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); }} > -