feat(extension): integrate local video info API (#67)

* feat(extension): integrate local video info API

* chore(repo): clean up popup styles

* feat(extension): revamp popup UI

* chore(repo): revert docs and drop content script
This commit is contained in:
Nexmoe
2026-01-11 00:05:46 +08:00
committed by GitHub
parent e332ec4ddc
commit d2806164a2
28 changed files with 4919 additions and 0 deletions

View File

@@ -0,0 +1,291 @@
:root {
--bg: #ffffff;
--fg: #111111;
--fg-secondary: #757575;
--border: #f0f0f0;
--accent: #000000;
--error: #e00000;
--success: #00c853;
--warning: #ffd600;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: var(--bg);
color: var(--fg);
}
#root {
width: 360px;
min-height: 200px;
padding: 24px;
box-sizing: border-box;
}
.app {
display: flex;
flex-direction: column;
gap: 24px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 13px;
font-weight: 600;
margin: 0;
letter-spacing: -0.01em;
color: var(--fg);
}
.status-indicator {
font-size: 11px;
font-weight: 500;
color: var(--fg-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--border);
}
.status-dot.loading { background-color: var(--warning); box-shadow: 0 0 4px var(--warning); }
.status-dot.ok { background-color: var(--success); }
.status-dot.error { background-color: var(--error); }
.video-info {
display: grid;
grid-template-columns: 1fr 90px;
gap: 20px;
align-items: start;
}
.video-details h2 {
font-size: 15px;
font-weight: 500;
line-height: 1.4;
margin: 0 0 8px 0;
color: var(--fg);
}
.meta-row {
font-size: 12px;
color: var(--fg-secondary);
margin: 0 0 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.thumbnail {
width: 90px;
height: 50px;
background: var(--border);
object-fit: cover;
border-radius: 6px;
display: block;
}
.formats-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.format-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Sticky headers for long lists */
.sticky-title {
position: sticky;
top: 0;
background: var(--bg);
padding: 4px 0;
z-index: 10;
}
.group-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-secondary);
font-weight: 600;
border-bottom: 2px solid var(--border);
padding-bottom: 4px;
}
.format-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.format-table th {
text-align: left;
font-weight: 400;
color: var(--fg-secondary);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
font-size: 10px;
text-transform: uppercase;
padding-top: 6px;
}
.format-table td {
padding: 6px 0;
border-bottom: 1px solid var(--border);
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.format-table tr:last-child td {
border-bottom: none;
}
.col-id { width: 50px; color: var(--fg-secondary); }
.col-ext { width: 50px; }
.col-size { width: 60px; text-align: right; }
.format-table th.col-size { text-align: right; }
.empty-state {
font-size: 12px;
color: var(--fg-secondary);
padding: 12px 0;
font-style: italic;
text-align: center;
}
.error-banner {
font-size: 12px;
color: var(--error);
line-height: 1.5;
background: rgba(224, 0, 0, 0.05);
padding: 12px;
border-radius: 6px;
}
.primary-button {
background: var(--fg);
color: var(--bg);
border: none;
border-radius: 8px;
padding: 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
width: 100%;
transition: opacity 0.2s;
}
.primary-button:hover {
opacity: 0.9;
}
.primary-button:active {
transform: scale(0.98);
}
.error-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.troubleshoot-box {
background: var(--border);
padding: 12px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 8px;
}
.troubleshoot-title {
font-size: 11px;
font-weight: 600;
color: var(--fg-secondary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.troubleshoot-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.link-button {
background: none;
border: none;
padding: 0;
color: var(--accent);
text-decoration: none;
font-weight: 500;
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
.link-button:hover {
text-decoration: underline;
}
.divider {
color: var(--fg-secondary);
}
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
gap: 16px;
flex: 1;
width: 100%;
}
.spinner {
width: 24px;
height: 24px;
border: 2.5px solid var(--border);
border-top-color: var(--fg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
font-size: 13px;
font-weight: 500;
color: var(--fg-secondary);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}

View File

@@ -0,0 +1,415 @@
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<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, browser.storage.StorageChange>,
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 handleOpenClient = () => {
if (!currentUrl) return
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
window.location.href = deepLink
}
const renderStatus = () => {
// if (loading) return null
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-banner">{error}</div>
<div className="troubleshoot-box">
<p className="troubleshoot-title">Having trouble?</p>
<div className="troubleshoot-actions">
<button
type="button"
className="link-button"
onClick={() => {
window.location.href = 'vidbee://'
setTimeout(() => setRetryTrigger((c) => c + 1), 100)
}}
>
Open Client
</button>
<span className="divider"></span>
<a
href="https://vidbee.app"
target="_blank"
rel="noopener noreferrer"
className="link-button"
>
Download
</a>
</div>
</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 className="thumbnail" src={info.thumbnail} alt="" />}
</section>
<button type="button" className="primary-button" onClick={handleOpenClient}>
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

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
const root = document.getElementById('root')
if (root) {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
}