fix: default to original audio, and pick tracks from the transport bar

YouTube marks an AI-dubbed audio track DEFAULT=YES on some videos and
AVFoundation obeys the manifest, so a synthetic voice played over the
original. The app now always serves its own rewritten master playlist —
previously only when a quality cap was set — and marks the track whose
language matches the video's own as the default, falling back to
whichever track does not describe itself as dubbed. Every track is still
listed, so switching remains possible.

Adds an audio and subtitle picker to the transport bar, reading the
video element's audioTracks and textTracks. Subtitles start off for the
same reason the dub does: nothing translated is forced on.

Also collapses the player's own title-bar strip in window fullscreen,
which was leaving a stray bar above the player header.
This commit is contained in:
vincent
2026-08-29 12:54:05 +02:00
parent e556534459
commit 73f12cfac1
4 changed files with 452 additions and 75 deletions
+181 -1
View File
@@ -1,4 +1,32 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
/** WebKit exposes HLS alternate audio renditions here; the DOM lib omits it. */
interface AudioTrackLike {
id: string;
label: string;
language: string;
enabled: boolean;
}
interface AudioTrackListLike {
length: number;
[index: number]: AudioTrackLike;
addEventListener?: (t: string, fn: () => void) => void;
removeEventListener?: (t: string, fn: () => void) => void;
}
type VideoWithTracks = HTMLVideoElement & { audioTracks?: AudioTrackListLike };
function listAudio(v: HTMLVideoElement | null): AudioTrackLike[] {
const list = (v as VideoWithTracks | null)?.audioTracks;
if (!list) return [];
return Array.from({ length: list.length }, (_, i) => list[i]);
}
function listSubs(v: HTMLVideoElement | null): TextTrack[] {
if (!v) return [];
return Array.from(v.textTracks).filter(
(t) => t.kind === "subtitles" || t.kind === "captions",
);
}
interface Props {
videoRef: React.RefObject<HTMLVideoElement | null>;
@@ -39,6 +67,78 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
const [muted, setMuted] = useState(false);
const [pip, setPip] = useState(false);
const [full, setFull] = useState(false);
const [menu, setMenu] = useState(false);
const [audio, setAudio] = useState<AudioTrackLike[]>([]);
const [subs, setSubs] = useState<TextTrack[]>([]);
const [, bump] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
// Tracks arrive with the manifest, after metadata rather than on mount.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const read = () => {
setAudio(listAudio(v));
setSubs(listSubs(v));
};
read();
// Nothing translated gets forced on. YouTube marks its subtitle track
// AUTOSELECT=YES and WebKit will switch it on when it matches the system
// language; that is the same unwanted auto-selection as a dubbed audio
// track, so subtitles start off and stay a deliberate choice.
const silenceSubs = () => {
for (const t of Array.from(v.textTracks)) t.mode = "disabled";
read();
};
v.addEventListener("loadedmetadata", silenceSubs);
v.addEventListener("loadedmetadata", read);
const at = (v as VideoWithTracks).audioTracks;
at?.addEventListener?.("addtrack", read);
v.textTracks.addEventListener?.("addtrack", read);
// Manifests can take a moment to surface renditions.
const id = setInterval(read, 1000);
const stop = setTimeout(() => clearInterval(id), 8000);
return () => {
v.removeEventListener("loadedmetadata", silenceSubs);
v.removeEventListener("loadedmetadata", read);
at?.removeEventListener?.("addtrack", read);
v.textTracks.removeEventListener?.("addtrack", read);
clearInterval(id);
clearTimeout(stop);
};
}, [videoRef]);
// Close the menu on any outside click.
useEffect(() => {
if (!menu) return;
const onDown = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node)) setMenu(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [menu]);
const chooseAudio = (i: number) => {
const v = videoRef.current;
const list = (v as VideoWithTracks | null)?.audioTracks;
if (!list) return;
// Exactly one enabled, or WebKit mixes them.
for (let k = 0; k < list.length; k++) list[k].enabled = k === i;
bump((n) => n + 1);
onActivity?.();
};
const chooseSub = (track: TextTrack | null) => {
const v = videoRef.current;
if (!v) return;
for (const t of Array.from(v.textTracks)) {
t.mode = t === track ? "showing" : "disabled";
}
bump((n) => n + 1);
onActivity?.();
};
// Mirror the element's state rather than assuming ours is authoritative —
// playback can change from the keyboard, the system, or the video ending.
@@ -212,6 +312,86 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
}}
/>
{(audio.length > 1 || subs.length > 0) && (
<div ref={menuRef} className="relative shrink-0">
<button
onClick={() => { setMenu((m) => !m); onActivity?.(); }}
className={btn}
title="Audio and subtitles"
>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path strokeLinecap="round" d="M7 15h4M14 15h3" />
</svg>
</button>
{menu && (
<div
className="absolute bottom-full right-0 mb-2 max-h-72 w-60 overflow-y-auto rounded-lg
border border-slate-700 bg-slate-900/95 p-1 shadow-2xl backdrop-blur"
>
{audio.length > 1 && (
<>
<div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Audio
</div>
{audio.map((t, i) => (
<button
key={t.id || `${t.language}-${i}`}
onClick={() => chooseAudio(i)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
text-[12px] cursor-pointer hover:bg-slate-800 ${
t.enabled ? "text-white" : "text-slate-300"
}`}
>
<span className="w-3 shrink-0 text-sky-400">{t.enabled ? "✓" : ""}</span>
<span className="truncate">{t.label || t.language || `Track ${i + 1}`}</span>
</button>
))}
</>
)}
{subs.length > 0 && (
<>
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Subtitles
</div>
<button
onClick={() => chooseSub(null)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
text-[12px] cursor-pointer hover:bg-slate-800 ${
subs.every((t) => t.mode !== "showing")
? "text-white"
: "text-slate-300"
}`}
>
<span className="w-3 shrink-0 text-sky-400">
{subs.every((t) => t.mode !== "showing") ? "✓" : ""}
</span>
Off
</button>
{subs.map((t, i) => (
<button
key={t.id || `${t.language}-${i}`}
onClick={() => chooseSub(t)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
text-[12px] cursor-pointer hover:bg-slate-800 ${
t.mode === "showing" ? "text-white" : "text-slate-300"
}`}
>
<span className="w-3 shrink-0 text-sky-400">
{t.mode === "showing" ? "✓" : ""}
</span>
<span className="truncate">{t.label || t.language || `Subtitles ${i + 1}`}</span>
</button>
))}
</>
)}
</div>
)}
</div>
)}
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" />