From 8c9bb9a68e9a03f8c3012ef11597b500fa3cd7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 21 Jul 2026 04:04:47 +0000 Subject: [PATCH] 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 --- src/servers/api/file-browser/router.ts | 96 ++++++++++++- .../src/apps/FileViewer/file-types.ts | 12 +- .../FileViewer/renderers/VideoRenderer.tsx | 127 ++++++++++++++---- 3 files changed, 207 insertions(+), 28 deletions(-) diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index f5a6b10a..15501c9a 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -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>(); + +async function ensureAudioRemux(email: string, absPath: string, relPath: string, track: number): Promise { + 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/']; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts index d226f6fb..61810bdc 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts @@ -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 { diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx index 6dbe2d71..94169dba 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx @@ -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(null); const containerRef = useRef(null); const hideTimer = useRef>(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([]); const [activeSub, setActiveSub] = useState(null); const [showSubMenu, setShowSubMenu] = useState(false); + const [audioTracks, setAudioTracks] = useState([]); + 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 ) 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); }} > -