Files
wehub-resource-sync d18ada4ee7
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:24:41 +08:00

501 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import './App.css'
interface VideoFormat {
format_id?: string
ext?: string
format_note?: string
resolution?: string
width?: number
height?: number
fps?: number
vcodec?: string
acodec?: string
filesize?: number
filesize_approx?: number
tbr?: number
}
interface VideoInfo {
title?: string
thumbnail?: string
duration?: number
formats?: VideoFormat[]
}
const CACHE_TTL_MS = 60 * 60 * 1000
const isValidHttpUrl = (value?: string): boolean => {
if (!value) {
return false
}
return value.startsWith('http://') || value.startsWith('https://')
}
const formatDuration = (value?: number): string => {
if (!value || value <= 0) {
return 'Unknown'
}
const totalSeconds = Math.round(value)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const paddedSeconds = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`
}
const formatBytes = (value?: number): string => {
if (!value || value <= 0) {
return '-'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
const isVideoFormat = (format: VideoFormat): boolean => {
if (format.vcodec && format.vcodec !== 'none') {
return true
}
return Boolean(format.resolution || format.width || format.height)
}
const isAudioFormat = (format: VideoFormat): boolean => {
return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))
}
interface VideoInfoCacheEntry {
url: string
status: 'pending' | 'ready' | 'error'
fetchedAt: number
info?: VideoInfo
error?: string
}
interface VideoGroup {
label: string
height: number
formats: VideoFormat[]
}
const loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {
const data = await browser.storage.local.get('videoInfoCacheByUrl')
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
if (!map) {
return null
}
const cached = map[url]
if (!cached) {
return null
}
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) {
return null
}
return cached
}
const sanitizeError = (error: string): string => {
const message = error.toLowerCase()
if (
message.includes('localhost') ||
message.includes('fetch') ||
message.includes('network') ||
message.includes('connect') ||
message.includes('failed to request')
) {
return 'Client connection failed'
}
return error
}
function App() {
const [info, setInfo] = useState<VideoInfo | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string>('')
const [retryTrigger, setRetryTrigger] = useState(0)
useEffect(() => {
let active = true
const targetState = { url: '' }
const handleStorageChange = (
changes: Record<string, { newValue?: unknown }>,
areaName: string
) => {
if (!active || areaName !== 'local') {
return
}
const change = changes.videoInfoCacheByUrl
if (!change?.newValue) {
return
}
const map = change.newValue as Record<string, VideoInfoCacheEntry>
const next = map[targetState.url]
if (!next) {
return
}
if (next.status === 'ready' && next.info) {
setInfo(next.info)
setError(null)
setLoading(false)
} else if (next.status === 'error' && next.error) {
setError(sanitizeError(next.error))
setInfo(null)
setLoading(false)
} else if (next.status === 'pending') {
setLoading(true)
}
}
browser.storage.onChanged.addListener(handleStorageChange)
const loadInfo = async () => {
setLoading(true)
setError(null)
setInfo(null)
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
if (!isValidHttpUrl(tab?.url)) {
setError('Please open a valid video page first.')
setLoading(false)
return
}
const targetUrl = tab.url as string
targetState.url = targetUrl
setCurrentUrl(targetUrl)
const cached = await loadCachedInfo(targetUrl)
const shouldBypassCache = retryTrigger > 0
if (cached && !shouldBypassCache) {
if (cached.status === 'ready' && cached.info) {
setInfo(cached.info)
setLoading(false)
return
}
if (cached.status === 'error' && cached.error) {
setError(sanitizeError(cached.error))
setLoading(false)
return
}
}
try {
await browser.runtime.sendMessage({
type: 'video-info:fetch',
url: targetUrl
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to request video info.'
setError(sanitizeError(message))
setLoading(false)
}
const latest = await loadCachedInfo(targetUrl)
if (latest && latest.status === 'ready' && latest.info) {
setInfo(latest.info)
setError(null)
setLoading(false)
} else if (latest && latest.status === 'error' && latest.error) {
setError(sanitizeError(latest.error))
setInfo(null)
setLoading(false)
}
}
void loadInfo()
return () => {
active = false
browser.storage.onChanged.removeListener(handleStorageChange)
}
}, [retryTrigger])
const formats = useMemo(() => info?.formats ?? [], [info])
const groupedFormats = useMemo(() => {
const video: VideoFormat[] = []
const audio: VideoFormat[] = []
const other: VideoFormat[] = []
for (const format of formats) {
if (isVideoFormat(format)) {
video.push(format)
} else if (isAudioFormat(format)) {
audio.push(format)
} else {
other.push(format)
}
}
return { video, audio, other }
}, [formats])
const groupedVideoFormats = useMemo(() => {
const raw = groupedFormats.video
if (!raw.length) {
return []
}
const groups: Record<number, VideoFormat[]> = {}
const noHeight: VideoFormat[] = []
for (const f of raw) {
const h = f.height || f.resolution?.match(/x(\d+)/)?.[1]
const heightVal = h ? Number(h) : 0
if (heightVal > 0) {
if (!groups[heightVal]) {
groups[heightVal] = []
}
groups[heightVal].push(f)
} else {
noHeight.push(f)
}
}
const sortedLabels = Object.keys(groups)
.map(Number)
.sort((a, b) => b - a)
const result: VideoGroup[] = sortedLabels.map((h) => ({
label: `${h}p`,
height: h,
formats: groups[h].sort((a, b) => {
const sa = a.filesize || a.filesize_approx || 0
const sb = b.filesize || b.filesize_approx || 0
return sb - sa
})
}))
if (noHeight.length > 0) {
result.push({
label: 'Other',
height: 0,
formats: noHeight
})
}
return result
}, [groupedFormats.video])
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const clientLaunchDelayMs = 2000
const openClientApp = async () => {
window.location.href = 'vidbee://'
await wait(clientLaunchDelayMs)
}
const handleOpenClient = () => {
if (!currentUrl) {
return
}
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
window.location.href = deepLink
}
const handleOpenClientAndRetry = async () => {
await openClientApp()
setRetryTrigger((count) => count + 1)
}
const handleRetry = () => {
setRetryTrigger((count) => count + 1)
}
const isInvalidPageError = error === 'Please open a valid video page first.'
const isClientConnectionError = Boolean(error?.includes('Client connection failed'))
const errorTitle = isInvalidPageError
? 'Open a video page'
: isClientConnectionError
? 'Connect the VidBee app'
: 'Something went wrong'
const errorDescription = isInvalidPageError
? 'Navigate to a supported video page, then try again.'
: isClientConnectionError
? 'The extension needs the VidBee desktop app to be running.'
: 'Try again in a moment.'
const renderStatus = () => {
if (loading) {
return (
<span className="status-indicator">
<div className="status-dot loading" /> Working
</span>
)
}
if (error) {
return (
<span className="status-indicator">
<div className="status-dot error" /> Error
</span>
)
}
if (info) {
return (
<span className="status-indicator">
<div className="status-dot ok" /> Ready
</span>
)
}
return (
<span className="status-indicator">
<div className="status-dot" /> Idle
</span>
)
}
return (
<div className="app">
<header>
<h1>VidBee</h1>
{renderStatus()}
</header>
{loading && (
<div className="loading-container">
<div className="spinner" />
<div className="loading-text">Analyzing video...</div>
</div>
)}
{!loading && error && (
<div className="error-container">
<div className="error-header">
<h2 className="error-title">{errorTitle}</h2>
<p className="error-description">{errorDescription}</p>
</div>
<div className="error-banner">{error}</div>
{isClientConnectionError ? (
<div className="action-grid">
<div className="action-card">
<p className="action-title">Client installed</p>
<p className="action-text">
Start VidBee and keep it running, then we will retry automatically.
</p>
<button
className="secondary-button"
onClick={handleOpenClientAndRetry}
type="button"
>
Open Client
</button>
</div>
<div className="action-card">
<p className="action-title">Need the app?</p>
<p className="action-text">
Download VidBee once, install it, then come back here to try again.
</p>
<a
className="secondary-button"
href="https://vidbee.app"
rel="noopener noreferrer"
target="_blank"
>
Download VidBee
</a>
</div>
</div>
) : (
<div className="action-card">
<p className="action-title">Try again</p>
<p className="action-text">
{isInvalidPageError
? 'Open a supported video page, then retry.'
: 'Retry after a moment.'}
</p>
<button className="secondary-button" onClick={handleRetry} type="button">
Retry
</button>
</div>
)}
</div>
)}
{!(loading || error) && info && (
<>
<section className="video-info">
<div className="video-details">
<h2>{info.title || 'Untitled video'}</h2>
<div className="meta-row">
<span>{formatDuration(info.duration)}</span>
<span></span>
<span>{formats.length} formats</span>
</div>
</div>
{info.thumbnail && <img alt="" className="thumbnail" src={info.thumbnail} />}
</section>
<button className="primary-button" onClick={handleOpenClient} type="button">
Download with VidBee
</button>
<section className="formats-section">
{groupedVideoFormats.map((group) => (
<div className="format-group" key={group.label}>
<div className="group-title sticky-title">{group.label}</div>
<table className="format-table">
<thead>
<tr>
<th className="col-id">ID</th>
<th className="col-ext">Ext</th>
<th className="col-size">Size</th>
</tr>
</thead>
<tbody>
{group.formats.map((f) => (
<tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>
<td className="col-id">{f.format_id || '-'}</td>
<td className="col-ext">{f.ext || '-'}</td>
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
<div className="empty-state">No compatible formats.</div>
)}
{groupedFormats.audio.length > 0 && (
<div className="format-group">
<div className="group-title">Audio Only</div>
<table className="format-table">
<thead>
<tr>
<th className="col-id">ID</th>
<th className="col-ext">Ext</th>
<th className="col-size">Size</th>
</tr>
</thead>
<tbody>
{groupedFormats.audio.map((f) => (
<tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>
<td className="col-id">{f.format_id || '-'}</td>
<td className="col-ext">{f.ext || '-'}</td>
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
)
}
export default App