video player: subtitle track support with a selector

Add /file-browser/subtitles (list text subtitle tracks) and /subtitle-vtt
(extract one as WebVTT on demand); the VideoRenderer fetches the list,
renders <track> 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 02:38:52 +00:00
co-authored by Claude Opus 4.8
parent b9eb78c919
commit 06bd8a5821
4 changed files with 162 additions and 5 deletions
@@ -52,7 +52,7 @@ export const FileViewerBody = () => {
) : fileType === 'image' ? (
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
) : fileType === 'video' ? (
<VideoRenderer src={videoSrc} fileName={fileName} />
<VideoRenderer src={videoSrc} fileName={fileName} filePath={filePath} root={root} />
) : fileType === 'audio' ? (
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
) : content !== null && editing && isJson ? (
@@ -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']) {
@@ -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<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout>>(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<SubtitleTrack[]>([]);
const [activeSub, setActiveSub] = useState<number | null>(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 <track>)
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) => (
<track key={t.id} kind="subtitles" src={getSubtitleVttUrl(filePath, t.id, root)} srcLang={t.lang} label={t.label} />
))}
</video>
{loaded && !playing && (
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
@@ -185,6 +234,38 @@ export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => {
<span className="text-[9px] font-mono text-white/30 uppercase tracking-wider">{getExt(fileName)}</span>
{subtitles.length > 0 && (
<div className="relative">
<button
onClick={() => setShowSubMenu((s) => !s)}
className={`p-1 transition-colors cursor-pointer ${activeSub !== null ? 'text-duck-teal' : 'text-white/70 hover:text-white'}`}
title="Subtitles"
aria-label="Subtitles"
>
<Captions className="h-4 w-4" />
</button>
{showSubMenu && (
<div className="absolute bottom-full right-0 mb-2 min-w-[140px] max-h-64 overflow-y-auto rounded-md bg-black/90 backdrop-blur-sm py-1 text-xs shadow-lg">
<button
onClick={() => selectSubtitle(null)}
className={`w-full text-left px-3 py-1.5 hover:bg-white/10 cursor-pointer ${activeSub === null ? 'text-duck-teal' : 'text-white/80'}`}
>
Off
</button>
{subtitles.map((t) => (
<button
key={t.id}
onClick={() => selectSubtitle(t.id)}
className={`w-full text-left px-3 py-1.5 hover:bg-white/10 cursor-pointer truncate ${activeSub === t.id ? 'text-duck-teal' : 'text-white/80'}`}
>
{t.label}
</button>
))}
</div>
)}
</div>
)}
<button
onClick={toggleFullscreen}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"