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:
@@ -106,8 +106,10 @@ export default function PlayerControls({
|
|||||||
const [subs, setSubs] = useState<TextTrack[]>([]);
|
const [subs, setSubs] = useState<TextTrack[]>([]);
|
||||||
const [, bump] = useState(0);
|
const [, bump] = useState(0);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
// Which video, and how many tracks, the preference was last applied to.
|
// The track that should be showing, and the set of tracks that choice was
|
||||||
const applied = useRef("");
|
// made for.
|
||||||
|
const desired = useRef<TextTrack | null>(null);
|
||||||
|
const decided = useRef("");
|
||||||
|
|
||||||
// Tracks arrive with the manifest, after metadata rather than on mount.
|
// Tracks arrive with the manifest, after metadata rather than on mount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,37 +118,58 @@ export default function PlayerControls({
|
|||||||
const read = () => setSubs(listSubs(v));
|
const read = () => setSubs(listSubs(v));
|
||||||
read();
|
read();
|
||||||
|
|
||||||
// Subtitles follow the language chosen in Settings and nothing else.
|
// Which track should be showing, or null for none. Decided once per set of
|
||||||
// WebKit will otherwise switch on whatever matches the system language,
|
// tracks — from the preference, or from a choice made in the menu — and
|
||||||
// which is the same unwanted auto-selection as a dubbed audio track.
|
// then held.
|
||||||
// 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.
|
// Holding it is the point. Deciding once and walking away was not enough:
|
||||||
applied.current = "";
|
// WebKit switches a newly added track on by itself, following the system's
|
||||||
const applyPreference = () => {
|
// caption settings, and it does so after the track is added. Off would come
|
||||||
const tracks = Array.from(v.textTracks);
|
// 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
|
// Keyed on the tracks themselves, not their number. Changing where
|
||||||
// subtitles sit re-cuts the same one track, and a count would not notice
|
// subtitles sit re-cuts the same one track, and a count would not notice.
|
||||||
// — leaving the fresh track disabled and the subtitles gone.
|
|
||||||
const key = `${v.currentSrc}|${Array.from(v.querySelectorAll("track"))
|
const key = `${v.currentSrc}|${Array.from(v.querySelectorAll("track"))
|
||||||
.map((el) => el.src)
|
.map((el) => el.src)
|
||||||
.join("|")}`;
|
.join("|")}`;
|
||||||
if (key === applied.current) return;
|
if (key !== decided.current) {
|
||||||
applied.current = key;
|
decided.current = key;
|
||||||
const wanted =
|
corrections = 0;
|
||||||
|
desired.current =
|
||||||
subLang === "off"
|
subLang === "off"
|
||||||
? undefined
|
? null
|
||||||
: listSubs(v).find((t) =>
|
: (listSubs(v).find((t) =>
|
||||||
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
|
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
|
||||||
);
|
) ?? null);
|
||||||
// Everything else off, in-band tracks included — otherwise the file's own
|
}
|
||||||
// copy renders underneath ours.
|
enforce();
|
||||||
for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled";
|
|
||||||
read();
|
|
||||||
};
|
};
|
||||||
applyPreference();
|
|
||||||
v.addEventListener("loadedmetadata", applyPreference);
|
decide();
|
||||||
// HLS subtitle renditions arrive after metadata, so re-apply as they land.
|
v.addEventListener("loadedmetadata", decide);
|
||||||
v.textTracks.addEventListener?.("addtrack", applyPreference);
|
// 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.addEventListener("loadedmetadata", read);
|
||||||
v.textTracks.addEventListener?.("addtrack", read);
|
v.textTracks.addEventListener?.("addtrack", read);
|
||||||
@@ -154,8 +177,9 @@ export default function PlayerControls({
|
|||||||
const id = setInterval(read, 1000);
|
const id = setInterval(read, 1000);
|
||||||
const stop = setTimeout(() => clearInterval(id), 8000);
|
const stop = setTimeout(() => clearInterval(id), 8000);
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener("loadedmetadata", applyPreference);
|
v.removeEventListener("loadedmetadata", decide);
|
||||||
v.textTracks.removeEventListener?.("addtrack", applyPreference);
|
v.textTracks.removeEventListener?.("addtrack", decide);
|
||||||
|
v.textTracks.removeEventListener?.("change", enforce);
|
||||||
v.removeEventListener("loadedmetadata", read);
|
v.removeEventListener("loadedmetadata", read);
|
||||||
v.textTracks.removeEventListener?.("addtrack", read);
|
v.textTracks.removeEventListener?.("addtrack", read);
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
@@ -176,6 +200,8 @@ export default function PlayerControls({
|
|||||||
const chooseSub = (track: TextTrack | null) => {
|
const chooseSub = (track: TextTrack | null) => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
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)) {
|
for (const t of Array.from(v.textTracks)) {
|
||||||
t.mode = t === track ? "showing" : "disabled";
|
t.mode = t === track ? "showing" : "disabled";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user