import { useEffect, useMemo, useState } from 'react' import './App.css' type 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 } type 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)) } type VideoInfoCacheEntry = { url: string status: 'pending' | 'ready' | 'error' fetchedAt: number info?: VideoInfo error?: string } type VideoGroup = { label: string height: number formats: VideoFormat[] } const loadCachedInfo = async (url: string): Promise => { const data = await browser.storage.local.get('videoInfoCacheByUrl') const map = data.videoInfoCacheByUrl as Record | 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(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [currentUrl, setCurrentUrl] = useState('') const [retryTrigger, setRetryTrigger] = useState(0) useEffect(() => { let active = true const targetState = { url: '' } const handleStorageChange = ( changes: Record, areaName: string ) => { if (!active || areaName !== 'local') return const change = changes.videoInfoCacheByUrl if (!change?.newValue) return const map = change.newValue as Record 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 = {} 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 (
Working ) if (error) return (
Error ) if (info) return (
Ready ) return (
Idle ) } return (

VidBee

{renderStatus()}
{loading && (
Analyzing video...
)} {!loading && error && (

{errorTitle}

{errorDescription}

{error}
{isClientConnectionError ? (

Client installed

Start VidBee and keep it running, then we will retry automatically.

Need the app?

Download VidBee once, install it, then come back here to try again.

Download VidBee
) : (

Try again

{isInvalidPageError ? 'Open a supported video page, then retry.' : 'Retry after a moment.'}

)}
)} {!loading && !error && info && ( <>

{info.title || 'Untitled video'}

{formatDuration(info.duration)} {formats.length} formats
{info.thumbnail && }
{groupedVideoFormats.map((group) => (
{group.label}
{group.formats.map((f) => ( ))}
ID Ext Size
{f.format_id || '-'} {f.ext || '-'} {formatBytes(f.filesize || f.filesize_approx)}
))} {groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
No compatible formats.
)} {groupedFormats.audio.length > 0 && (
Audio Only
{groupedFormats.audio.map((f) => ( ))}
ID Ext Size
{f.format_id || '-'} {f.ext || '-'} {formatBytes(f.filesize || f.filesize_approx)}
)}
)}
) } export default App