diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index e1c93f9..c818b92 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -106,8 +106,10 @@ export default function PlayerControls({ const [subs, setSubs] = useState([]); const [, bump] = useState(0); const menuRef = useRef(null); - // Which video, and how many tracks, the preference was last applied to. - const applied = useRef(""); + // The track that should be showing, and the set of tracks that choice was + // made for. + const desired = useRef(null); + const decided = useRef(""); // Tracks arrive with the manifest, after metadata rather than on mount. useEffect(() => { @@ -116,37 +118,58 @@ export default function PlayerControls({ const read = () => setSubs(listSubs(v)); read(); - // Subtitles follow the language chosen in Settings and nothing else. - // WebKit will otherwise switch on whatever matches the system language, - // which is the same unwanted auto-selection as a dubbed audio track. - // Applied once per set of tracks. Re-applying on every poll would undo a - // choice made in the menu a second after it was made. - applied.current = ""; - const applyPreference = () => { - const tracks = Array.from(v.textTracks); + // Which track should be showing, or null for none. Decided once per set of + // tracks — from the preference, or from a choice made in the menu — and + // then held. + // + // Holding it is the point. Deciding once and walking away was not enough: + // WebKit switches a newly added track on by itself, following the system's + // caption settings, and it does so after the track is added. Off would come + // back on at the next video, and there was nothing watching to undo it. + decided.current = ""; + let corrections = 0; + + const enforce = () => { + // A cap, so that if something insists on its own choice the two do not + // sit there flipping it at each other forever. + if (corrections > 24) return; + let changed = false; + for (const t of Array.from(v.textTracks)) { + const want = t === desired.current ? "showing" : "disabled"; + if (t.mode !== want) { + t.mode = want; + changed = true; + } + } + if (changed) corrections++; + read(); + }; + + const decide = () => { // Keyed on the tracks themselves, not their number. Changing where - // subtitles sit re-cuts the same one track, and a count would not notice - // — leaving the fresh track disabled and the subtitles gone. + // subtitles sit re-cuts the same one track, and a count would not notice. const key = `${v.currentSrc}|${Array.from(v.querySelectorAll("track")) .map((el) => el.src) .join("|")}`; - if (key === applied.current) return; - applied.current = key; - const wanted = - subLang === "off" - ? undefined - : listSubs(v).find((t) => - (t.language || "").toLowerCase().startsWith(subLang.toLowerCase()), - ); - // Everything else off, in-band tracks included — otherwise the file's own - // copy renders underneath ours. - for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled"; - read(); + if (key !== decided.current) { + decided.current = key; + corrections = 0; + desired.current = + subLang === "off" + ? null + : (listSubs(v).find((t) => + (t.language || "").toLowerCase().startsWith(subLang.toLowerCase()), + ) ?? null); + } + enforce(); }; - applyPreference(); - v.addEventListener("loadedmetadata", applyPreference); - // HLS subtitle renditions arrive after metadata, so re-apply as they land. - v.textTracks.addEventListener?.("addtrack", applyPreference); + + decide(); + v.addEventListener("loadedmetadata", decide); + // HLS subtitle renditions arrive after metadata, so decide as they land. + v.textTracks.addEventListener?.("addtrack", decide); + // And hold that decision against anything that changes a mode behind us. + v.textTracks.addEventListener?.("change", enforce); v.addEventListener("loadedmetadata", read); v.textTracks.addEventListener?.("addtrack", read); @@ -154,8 +177,9 @@ export default function PlayerControls({ const id = setInterval(read, 1000); const stop = setTimeout(() => clearInterval(id), 8000); return () => { - v.removeEventListener("loadedmetadata", applyPreference); - v.textTracks.removeEventListener?.("addtrack", applyPreference); + v.removeEventListener("loadedmetadata", decide); + v.textTracks.removeEventListener?.("addtrack", decide); + v.textTracks.removeEventListener?.("change", enforce); v.removeEventListener("loadedmetadata", read); v.textTracks.removeEventListener?.("addtrack", read); clearInterval(id); @@ -176,6 +200,8 @@ export default function PlayerControls({ const chooseSub = (track: TextTrack | null) => { const v = videoRef.current; if (!v) return; + // Recorded first, so the holding above enforces this rather than undoing it. + desired.current = track; for (const t of Array.from(v.textTracks)) { t.mode = t === track ? "showing" : "disabled"; }