video player: server-side audio track selector

Add /file-browser/audio-tracks (list) and raw?audio=N (serve the video with
a chosen audio track via a cached -c copy remux, byte-range seekable). The
player shows an always-visible selector outside the controls; switching
reloads the source and restores the playhead. Works in Chromium, which
doesn't expose the audioTracks API.

Temp remux file keeps the real extension (a .tmp suffix breaks ffmpeg's
muxer selection); concurrent requests for a track share one remux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 04:04:47 +00:00
co-authored by Claude Opus 4.8
parent 921413ca4d
commit 8c9bb9a68e
3 changed files with 207 additions and 28 deletions
@@ -124,11 +124,19 @@ export function formatTime(s: number): string {
return `${m}:${sec.toString().padStart(2, '0')}`;
}
export function getRawUrl(filePath: string, root?: string): string {
export function getRawUrl(filePath: string, root?: string, audioTrack?: number): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`;
const audioParam = audioTrack && audioTrack > 0 ? `&audio=${audioTrack}` : '';
return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}${audioParam}`;
}
export function getAudioTracksUrl(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/audio-tracks?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getSubtitlesUrl(filePath: string, root?: string): string {
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX, Captions } from 'lucide-react';
import { getExt, formatTime, getSubtitlesUrl, getSubtitleVttUrl } from '../file-types';
import { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX, Captions, Languages } from 'lucide-react';
import { getExt, formatTime, getRawUrl, getSubtitlesUrl, getSubtitleVttUrl, getAudioTracksUrl } from '../file-types';
import { useSeekBar, SeekBar } from './SeekBar';
type VideoRendererProps = {
@@ -11,11 +11,14 @@ type VideoRendererProps = {
};
type SubtitleTrack = { id: number; lang: string; label: string };
type AudioOption = { id: number; 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);
// When we swap the source to switch audio track, restore the playhead + play state after it loads.
const pendingSeek = useRef<{ time: number; play: boolean } | null>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
@@ -25,23 +28,48 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
const [error, setError] = useState(false);
const [showControls, setShowControls] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [videoSrc, setVideoSrc] = useState(src);
const [subtitles, setSubtitles] = useState<SubtitleTrack[]>([]);
const [activeSub, setActiveSub] = useState<number | null>(null);
const [showSubMenu, setShowSubMenu] = useState(false);
const [audioTracks, setAudioTracks] = useState<AudioOption[]>([]);
const [activeAudio, setActiveAudio] = useState(0);
const [showAudioMenu, setShowAudioMenu] = useState(false);
const { barRef, onSeekDown } = useSeekBar(videoRef, duration);
// New file → reset to its default source / audio track.
useEffect(() => {
setVideoSrc(src);
setActiveAudio(0);
pendingSeek.current = null;
}, [src]);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onLoaded = () => {
setDuration(v.duration);
setLoaded(true);
const ps = pendingSeek.current;
if (ps) {
pendingSeek.current = null;
try {
v.currentTime = ps.time;
} catch {
/* ignore */
}
if (ps.play) v.play().catch(() => {});
}
};
const onTime = () => setCurrent(v.currentTime);
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onEnded = () => setPlaying(false);
const onError = () => setError(true);
const onError = () => {
// Aborts happen when we swap the source to switch audio — don't treat those as fatal.
const err = v.error;
if (err && err.code !== err.MEDIA_ERR_ABORTED) setError(true);
};
v.addEventListener('loadedmetadata', onLoaded);
v.addEventListener('timeupdate', onTime);
v.addEventListener('play', onPlay);
@@ -58,6 +86,15 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
};
}, []);
// Reload cleanly whenever the source changes (default ↔ an audio-switched remux).
useEffect(() => {
const v = videoRef.current;
if (!v) return;
setError(false);
setLoaded(false);
v.load();
}, [videoSrc]);
// Discover embedded subtitle tracks (served on demand as WebVTT via <track>)
useEffect(() => {
let cancelled = false;
@@ -70,11 +107,28 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
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}`,
};
return { id: t.id, lang: t.lang || 'und', label: t.title || realLang || `Track ${t.id + 1}` };
}),
);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [filePath, root]);
// Discover audio tracks (switched server-side by reloading the source with the chosen track)
useEffect(() => {
let cancelled = false;
setAudioTracks([]);
fetch(getAudioTracksUrl(filePath, root))
.then((res) => (res.ok ? res.json() : []))
.then((list: Array<{ id: number; lang: string; title: string }>) => {
if (cancelled || !Array.isArray(list)) return;
setAudioTracks(
list.map((t) => {
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
return { id: t.id, label: t.title || realLang || `Track ${t.id + 1}` };
}),
);
})
@@ -143,6 +197,17 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
setShowSubMenu(false);
};
// Switch audio track by reloading the source (track 0 = the raw file; others are remuxed server-side).
const selectAudio = (track: number) => {
setShowAudioMenu(false);
if (track === activeAudio) return;
const v = videoRef.current;
if (v) pendingSeek.current = { time: v.currentTime, play: !v.paused };
setActiveAudio(track);
setLoaded(false);
setVideoSrc(track === 0 ? src : getRawUrl(filePath, root, track));
};
const pct = duration > 0 ? (currentTime / duration) * 100 : 0;
if (error) {
@@ -162,19 +227,39 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
if (playing) setShowControls(false);
}}
>
<video
ref={videoRef}
src={src}
preload="metadata"
playsInline
className="max-w-full max-h-full"
onClick={togglePlay}
>
<video ref={videoRef} src={videoSrc} preload="metadata" 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>
{/* Audio-track selector — outside the auto-hiding controls, always visible */}
{audioTracks.length > 1 && (
<div className="absolute top-2 left-2 z-10">
<button
onClick={() => setShowAudioMenu((s) => !s)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/50 backdrop-blur-sm text-xs text-white/90 hover:bg-black/70 transition-colors cursor-pointer"
title="Audio track"
>
<Languages className="h-3.5 w-3.5" />
<span className="max-w-[140px] truncate">{audioTracks[activeAudio]?.label ?? 'Audio'}</span>
</button>
{showAudioMenu && (
<div className="absolute top-full left-0 mt-1 min-w-[160px] max-h-64 overflow-y-auto rounded-md bg-black/90 backdrop-blur-sm py-1 text-xs shadow-lg">
{audioTracks.map((t, i) => (
<button
key={t.id}
onClick={() => selectAudio(i)}
className={`w-full text-left px-3 py-1.5 hover:bg-white/10 cursor-pointer truncate ${activeAudio === i ? 'text-duck-teal' : 'text-white/80'}`}
>
{t.label}
</button>
))}
</div>
)}
</div>
)}
{loaded && !playing && (
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
<div className="w-16 h-16 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center">
@@ -209,10 +294,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
</button>
<div className="flex items-center gap-1.5">
<button
onClick={toggleMute}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
>
<button onClick={toggleMute} className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer">
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
@@ -266,10 +348,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
</div>
)}
<button
onClick={toggleFullscreen}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
>
<button onClick={toggleFullscreen} className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer">
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>