From 06bd8a5821e2bd1ce0c65e83dc604694872abc0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 21 Jul 2026 02:38:52 +0000 Subject: [PATCH] video player: subtitle track support with a selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /file-browser/subtitles (list text subtitle tracks) and /subtitle-vtt (extract one as WebVTT on demand); the VideoRenderer fetches the list, renders elements, and shows a CC selector to switch/turn off subs. Track labels prefer title, then handler_name (mp4), then a real language, falling back to Track N — so untagged tracks aren't shown as "und". Co-Authored-By: Claude Opus 4.8 --- src/servers/api/file-browser/router.ts | 62 +++++++++++++ .../src/apps/FileViewer/FileViewerBody.tsx | 2 +- .../src/apps/FileViewer/file-types.ts | 14 +++ .../FileViewer/renderers/VideoRenderer.tsx | 89 ++++++++++++++++++- 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index e0ab8fcc..f5a6b10a 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -261,6 +261,68 @@ router.get('/raw', async (ctx) => { }); }); +// List a video's text-based subtitle tracks (for the in-browser player's selector) +const TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'webvtt', 'text', 'subviewer', 'microdvd']); + +router.get('/subtitles', async (ctx) => { + const user = ctx.get('user'); + const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); + const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); + if (!relPath) throw errors.BAD_REQUEST('path is required'); + const absPath = resolveUserPath(rootDir, relPath); + + const proc = Bun.spawn( + ['ffprobe', '-v', 'error', '-select_streams', 's', '-show_entries', 'stream=codec_name:stream_tags=language,title,handler_name', '-of', 'json', absPath], + { stdout: 'pipe', stderr: 'ignore' }, + ); + const out = await new Response(proc.stdout).text(); + await proc.exited; + + type ProbeStream = { codec_name?: string; tags?: { language?: string; title?: string; handler_name?: string } }; + let streams: ProbeStream[] = []; + try { + streams = (JSON.parse(out).streams as ProbeStream[]) ?? []; + } catch { + streams = []; + } + + // mkv stores the track name in `title`; mp4/mov stores it in `handler_name` (default names like + // "SubtitleHandler" are generic and ignored). + const trackName = (tags: ProbeStream['tags']) => { + const handler = tags?.handler_name ?? ''; + return tags?.title || (handler && !/Handler$/.test(handler) ? handler : ''); + }; + + // `id` is the subtitle-relative index among ALL subtitle streams (what `-map 0:s:id` expects), + // so it is assigned before filtering out image-based tracks that can't become WebVTT. + const tracks = streams + .map((s, id) => ({ id, s })) + .filter(({ s }) => TEXT_SUBTITLE_CODECS.has((s.codec_name ?? '').toLowerCase())) + .map(({ id, s }) => ({ id, codec: s.codec_name ?? '', lang: s.tags?.language ?? '', title: trackName(s.tags) })); + + return ctx.json(tracks); +}); + +// Extract one subtitle track as WebVTT for a element +router.get('/subtitle-vtt', async (ctx) => { + const user = ctx.get('user'); + const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); + const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); + if (!relPath) throw errors.BAD_REQUEST('path is required'); + const track = parseInt(ctx.req.query('track') ?? '', 10); + if (!Number.isInteger(track) || track < 0) throw errors.BAD_REQUEST('valid track is required'); + const absPath = resolveUserPath(rootDir, relPath); + + const proc = Bun.spawn(['ffmpeg', '-v', 'error', '-i', absPath, '-map', `0:s:${track}`, '-f', 'webvtt', 'pipe:1'], { + stdout: 'pipe', + stderr: 'ignore', + }); + + return new Response(proc.stdout as ReadableStream, { + headers: { 'Content-Type': 'text/vtt; charset=utf-8' }, + }); +}); + // Save a cached result (ocr/tts/transcriptions/audio) next to the original file const CACHE_PREFIXES = ['cache/ocr/', 'cache/tts/', 'cache/transcriptions/', 'cache/audio/']; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx index e82624f1..0033f077 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx @@ -52,7 +52,7 @@ export const FileViewerBody = () => { ) : fileType === 'image' ? ( ) : fileType === 'video' ? ( - + ) : fileType === 'audio' ? ( ) : content !== null && editing && isJson ? ( diff --git a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts index 8a5730aa..d226f6fb 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts @@ -131,6 +131,20 @@ export function getRawUrl(filePath: string, root?: string): string { return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`; } +export function getSubtitlesUrl(filePath: string, root?: string): string { + const headers = getHeaders(); + const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; + const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; + return `${API_URL}/file-browser/subtitles?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`; +} + +export function getSubtitleVttUrl(filePath: string, track: number, root?: string): string { + const headers = getHeaders(); + const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; + const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; + return `${API_URL}/file-browser/subtitle-vtt?path=${encodeURIComponent(filePath)}&track=${track}&token=${encodeURIComponent(token)}${rootParam}`; +} + export function getArchiveBaseName(name: string): string { const lower = name.toLowerCase(); for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) { diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx index d984eec5..6dbe2d71 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx @@ -1,14 +1,18 @@ import { useState, useEffect, useRef } from 'react'; -import { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX } from 'lucide-react'; -import { getExt, formatTime } from '../file-types'; +import { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX, Captions } from 'lucide-react'; +import { getExt, formatTime, getSubtitlesUrl, getSubtitleVttUrl } from '../file-types'; import { useSeekBar, SeekBar } from './SeekBar'; type VideoRendererProps = { src: string; fileName: string; + filePath: string; + root?: string; }; -export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => { +type SubtitleTrack = { id: number; lang: string; label: string }; + +export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererProps) => { const videoRef = useRef(null); const containerRef = useRef(null); const hideTimer = useRef>(null); @@ -21,6 +25,9 @@ export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => { const [error, setError] = useState(false); const [showControls, setShowControls] = useState(true); const [isFullscreen, setIsFullscreen] = useState(false); + const [subtitles, setSubtitles] = useState([]); + const [activeSub, setActiveSub] = useState(null); + const [showSubMenu, setShowSubMenu] = useState(false); const { barRef, onSeekDown } = useSeekBar(videoRef, duration); useEffect(() => { @@ -51,6 +58,32 @@ export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => { }; }, []); + // Discover embedded subtitle tracks (served on demand as WebVTT via ) + useEffect(() => { + let cancelled = false; + setSubtitles([]); + setActiveSub(null); + fetch(getSubtitlesUrl(filePath, root)) + .then((res) => (res.ok ? res.json() : [])) + .then((list: Array<{ id: number; lang: string; title: string }>) => { + if (cancelled || !Array.isArray(list)) return; + setSubtitles( + list.map((t) => { + const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : ''; + return { + id: t.id, + lang: t.lang || 'und', + label: t.title || realLang || `Track ${t.id + 1}`, + }; + }), + ); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [filePath, root]); + useEffect(() => { const onChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onChange); @@ -98,6 +131,18 @@ export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => { else c.requestFullscreen(); }; + // Only one text track shows at a time; null turns them all off. + const selectSubtitle = (id: number | null) => { + const v = videoRef.current; + if (v) { + for (let i = 0; i < v.textTracks.length; i++) { + v.textTracks[i]!.mode = i === id ? 'showing' : 'disabled'; + } + } + setActiveSub(id); + setShowSubMenu(false); + }; + const pct = duration > 0 ? (currentTime / duration) * 100 : 0; if (error) { @@ -124,7 +169,11 @@ export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => { playsInline className="max-w-full max-h-full" onClick={togglePlay} - /> + > + {subtitles.map((t) => ( + + ))} + {loaded && !playing && ( + {showSubMenu && ( +
+ + {subtitles.map((t) => ( + + ))} +
+ )} + + )} +