fix: the subtitle choice holds from one video to the next

Reproduced first: subtitles switched off on one video came back on at
the next one, and the same the other way.

The preference was applied once per set of tracks and then left alone.
That is not enough, because WebKit switches a newly added text track on
by itself, following the system's caption settings, and it does so
after the track has been added — after the one application had already
run. Nothing was watching, so its choice stood.

The decision is now held rather than applied: which track should be
showing is decided once per set of tracks, from the preference or from
a choice made in the menu, and re-asserted whenever a track's mode
changes behind us. Choosing from the menu records the decision first,
so holding enforces that choice instead of undoing it.

A cap on corrections, so that if something ever insists the two do not
sit there flipping a track at each other forever.

Verified in the running app both ways: off on 3/50 stayed off at 4/50,
English on at 4/50 stayed on at 5/50, and off survived a restart.
This commit is contained in:
vincent
2026-09-03 23:19:35 +02:00
parent c31bafa07a
commit 90f1eddb31
+56 -30
View File
@@ -106,8 +106,10 @@ export default function PlayerControls({
const [subs, setSubs] = useState<TextTrack[]>([]);
const [, bump] = useState(0);
const menuRef = useRef<HTMLDivElement>(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<TextTrack | null>(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";
}