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:
@@ -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 <track> 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/'];
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user