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:
@@ -82,6 +82,56 @@ function resolveUserPath(rootDir: string, relPath: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// mkv `title` / mp4 `handler_name` hold a track's name; the generic "…Handler" defaults are ignored.
|
||||
function trackName(tags?: { title?: string; handler_name?: string }): string {
|
||||
const handler = tags?.handler_name ?? '';
|
||||
return tags?.title || (handler && !/Handler$/.test(handler) ? handler : '');
|
||||
}
|
||||
|
||||
// Serve a video with a chosen audio track selected: fast `-c copy` remux (video untouched, other
|
||||
// audio dropped) cached under the user's data dir, so it streams with byte-range seeking like /raw.
|
||||
// In-flight remuxes are shared so concurrent requests for the same track don't race on the temp file.
|
||||
const audioRemuxInFlight = new Map<string, Promise<string>>();
|
||||
|
||||
async function ensureAudioRemux(email: string, absPath: string, relPath: string, track: number): Promise<string> {
|
||||
const parsed = parsePath(relPath);
|
||||
const ext = (parsed.ext.slice(1) || 'mp4').toLowerCase();
|
||||
const sub = parsed.dir ? `${parsed.dir}/` : '';
|
||||
// ffmpeg picks the output muxer from the file extension, so both the final and temp names must
|
||||
// keep the real extension (a ".tmp" suffix makes ffmpeg fail with "unable to choose format").
|
||||
const base = resolve(getUserDataDir(email), `cache/audio/${sub}${parsed.name}.a${track}`);
|
||||
const cacheAbs = `${base}.${ext}`;
|
||||
if (existsSync(cacheAbs)) return cacheAbs;
|
||||
|
||||
const pending = audioRemuxInFlight.get(cacheAbs);
|
||||
if (pending) return pending;
|
||||
|
||||
const job = (async () => {
|
||||
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||
const tmp = `${base}.tmp.${ext}`;
|
||||
const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : [];
|
||||
const proc = Bun.spawn(
|
||||
['ffmpeg', '-v', 'error', '-i', absPath, '-map', '0:v', '-map', `0:a:${track}`, '-c', 'copy', '-dn', '-sn', ...movflags, '-y', tmp],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
);
|
||||
const code = await proc.exited;
|
||||
if (code !== 0) {
|
||||
const err = await new Response(proc.stderr).text();
|
||||
await rm(tmp, { force: true }).catch(() => {});
|
||||
throw errors.BAD_REQUEST(err.trim() || 'Audio track remux failed');
|
||||
}
|
||||
await rename(tmp, cacheAbs);
|
||||
return cacheAbs;
|
||||
})();
|
||||
|
||||
audioRemuxInFlight.set(cacheAbs, job);
|
||||
try {
|
||||
return await job;
|
||||
} finally {
|
||||
audioRemuxInFlight.delete(cacheAbs);
|
||||
}
|
||||
}
|
||||
|
||||
// List directory entries
|
||||
router.get('/ls', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -227,9 +277,17 @@ router.get('/raw', async (ctx) => {
|
||||
const s = await stat(absPath);
|
||||
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot serve a directory');
|
||||
|
||||
const file = Bun.file(absPath);
|
||||
// Optional: serve with a specific audio track selected (track 0 is the default → serve raw).
|
||||
let fileAbs = absPath;
|
||||
const audioParam = ctx.req.query('audio');
|
||||
if (audioParam) {
|
||||
const track = parseInt(audioParam, 10);
|
||||
if (Number.isInteger(track) && track > 0) fileAbs = await ensureAudioRemux(user.email, absPath, relPath, track);
|
||||
}
|
||||
|
||||
const file = Bun.file(fileAbs);
|
||||
const contentType = file.type || 'application/octet-stream';
|
||||
const total = s.size;
|
||||
const total = file.size;
|
||||
|
||||
const rangeHeader = ctx.req.header('range');
|
||||
if (rangeHeader) {
|
||||
@@ -323,6 +381,40 @@ router.get('/subtitle-vtt', async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
// List a video's audio tracks (for the external audio-track selector; served via raw?audio=N)
|
||||
router.get('/audio-tracks', 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', 'a', '-show_entries', 'stream=channels,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 ProbeAudio = { channels?: number; codec_name?: string; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
let streams: ProbeAudio[] = [];
|
||||
try {
|
||||
streams = (JSON.parse(out).streams as ProbeAudio[]) ?? [];
|
||||
} catch {
|
||||
streams = [];
|
||||
}
|
||||
|
||||
const tracks = streams.map((s, id) => ({
|
||||
id,
|
||||
codec: s.codec_name ?? '',
|
||||
channels: s.channels ?? 0,
|
||||
lang: s.tags?.language ?? '',
|
||||
title: trackName(s.tags),
|
||||
}));
|
||||
|
||||
return ctx.json(tracks);
|
||||
});
|
||||
|
||||
// Save a cached result (ocr/tts/transcriptions/audio) next to the original file
|
||||
const CACHE_PREFIXES = ['cache/ocr/', 'cache/tts/', 'cache/transcriptions/', 'cache/audio/'];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user