fix: subtitles no longer depend on the Settings preference

Verified in the running app: captions render on a downloaded video and
on a stream, and the player's menu lists them under SUBTITLES beside
AUDIO.

Three things were wrong.

The preference decided whether subtitles were fetched at all, so "Off"
— the old default, which nobody had to choose — silently emptied the
subtitle menu as well. Fetching now happens regardless; the preference
only says which language switches itself on.

Choosing a track from the menu moved the preference from "off" to that
language, which re-ran the fetch, tore down the tracks and stalled the
stream mid-play. The fetch keys on the language it would ask for, not
on the preference, and that value does not change when "off" becomes
the language it already stood for.

The preference was re-applied on every poll of the track list, so a
choice made in the menu was undone a second later. It now applies once
per set of tracks, which also makes "Off" mean off.

Anyone still carrying the old "off" default is moved to English once.
An Off chosen deliberately after this is left alone.
This commit is contained in:
vincent
2026-08-29 16:46:34 +02:00
parent 967bcdde93
commit 62110b28fc
5 changed files with 76 additions and 62 deletions
+8
View File
@@ -67,6 +67,14 @@ export default function App() {
const [subLang, setSubLang] = useState(() => {
try {
const stored = localStorage.getItem("flighttube.subLang");
// "Off" used to be the default, so anyone who never opened Settings had
// subtitles silently switched off everywhere. Correct that once; a
// deliberate Off chosen after this is left alone.
if (stored === "off" && !localStorage.getItem("flighttube.subLangFixed")) {
localStorage.setItem("flighttube.subLangFixed", "1");
return DEFAULT_SUB_LANG;
}
localStorage.setItem("flighttube.subLangFixed", "1");
return SUB_LANGS.some((l) => l.value === stored) ? stored! : DEFAULT_SUB_LANG;
} catch {
return DEFAULT_SUB_LANG;
+54 -55
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream, savePlayback,
} from "../api";
import type { FeedItem } from "../types";
import { DEFAULT_SUB_LANG, type FeedItem } from "../types";
import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls";
import { Badge, BTN, Spinner } from "./ui";
@@ -113,56 +113,70 @@ export default function Player({
// the player switches rendition, so it is read from the element rather than
// assumed from the setting.
const [height, setHeight] = useState(0);
// WebVTT files yt-dlp saved next to a download, so subtitles work offline.
const [sidecars, setSidecars] = useState<Array<[string, string]>>([]);
/**
* Subtitle tracks as (language, URL), from whichever source has them:
* WebVTT files yt-dlp saved beside a download, or a fetch.
*
* A stream has no other source — YouTube's HLS manifest carries a dozen
* audio renditions and no subtitles at all — and a download saved before
* subtitles were switched on has nothing beside it either.
*
* Fetching does not depend on the Settings preference. That preference says
* which language is switched on by itself; it must not decide whether
* subtitles exist to be chosen at all, or "None" would quietly empty the
* player's subtitle menu as well.
*
* Fetched cues become blob URLs, which share the document's origin — a
* file:// or 127.0.0.1 track would not.
*/
const [tracks, setTracks] = useState<Array<[string, string]>>([]);
const [fetchingSubs, setFetchingSubs] = useState(false);
// The language to fetch, which is NOT the preference: turning subtitles on
// from the player's menu moves the preference from "off" to that language,
// and refetching then would tear down the tracks — and the stream with them —
// the instant one is chosen.
const wantLang = subLang === "off" ? DEFAULT_SUB_LANG : subLang;
useEffect(() => {
if (!path) {
setSidecars([]);
let cancelled = false;
const blobs: string[] = [];
setTracks([]);
setFetchingSubs(false);
const load = async () => {
if (path) {
const local = await listSubtitles(item.id).catch(() => []);
if (cancelled) return;
if (local.length > 0) {
setTracks(local.map(([lang, file]) => [lang, fileUrl(file)]));
return;
}
let cancelled = false;
listSubtitles(item.id)
.then((s) => !cancelled && setSidecars(s))
.catch(() => !cancelled && setSidecars([]));
return () => {
cancelled = true;
};
}, [item.id, path]);
// Captions fetched on demand, as (language, blob URL). YouTube's HLS
// manifest carries a dozen audio renditions and no subtitles at all, so a
// stream has nothing to show without this; a download saved before subtitles
// were switched on is in the same position. Blob URLs share the document's
// origin, which a file:// or 127.0.0.1 track would not.
const [fetched, setFetched] = useState<Array<[string, string]>>([]);
const [fetchingSubs, setFetchingSubs] = useState(false);
useEffect(() => {
setFetched([]);
if (subLang === "off" || sidecars.length > 0) return;
const urls: string[] = [];
let cancelled = false;
setFetchingSubs(true);
fetchSubtitles(item.id, subLang)
.then((list) => {
}
if (cancelled) return;
setFetched(
setFetchingSubs(true);
try {
const list = await fetchSubtitles(item.id, wantLang);
if (cancelled) return;
setTracks(
list.map(([lang, text]) => {
const url = URL.createObjectURL(new Blob([text], { type: "text/vtt" }));
urls.push(url);
blobs.push(url);
return [lang, url] as [string, string];
}),
);
})
.catch(() => {
/* no captions in this language is an ordinary outcome */
})
.finally(() => !cancelled && setFetchingSubs(false));
} catch {
/* a video with no captions in this language is an ordinary outcome */
} finally {
if (!cancelled) setFetchingSubs(false);
}
};
void load();
return () => {
cancelled = true;
for (const u of urls) URL.revokeObjectURL(u);
for (const u of blobs) URL.revokeObjectURL(u);
};
}, [item.id, subLang, sidecars.length]);
}, [item.id, path, wantLang]);
// Controls and edge arrows fade away while you are just watching.
const [chromeVisible, setChromeVisible] = useState(true);
@@ -394,23 +408,8 @@ export default function Player({
onLoadedData={measure}
className="absolute inset-0 size-full object-contain"
>
{sidecars.map(([lang, file]) => (
<track
key={file}
kind="subtitles"
srcLang={lang}
label={subtitleLabel(lang)}
src={fileUrl(file)}
/>
))}
{fetched.map(([lang, url]) => (
<track
key={url}
kind="subtitles"
srcLang={lang}
label={subtitleLabel(lang)}
src={url}
/>
{tracks.map(([lang, url]) => (
<track key={url} kind="subtitles" srcLang={lang} label={subtitleLabel(lang)} src={url} />
))}
</video>
) : (
+7
View File
@@ -80,6 +80,8 @@ export default function PlayerControls({
const [subs, setSubs] = useState<TextTrack[]>([]);
const [, bump] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
// How many text tracks the preference was last applied to.
const applied = useRef(-1);
// Tracks arrive with the manifest, after metadata rather than on mount.
useEffect(() => {
@@ -94,8 +96,13 @@ export default function PlayerControls({
// 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 = -1;
const applyPreference = () => {
const tracks = Array.from(v.textTracks);
if (tracks.length === applied.current) return;
applied.current = tracks.length;
const wanted =
subLang === "off"
? undefined
+6 -6
View File
@@ -276,12 +276,12 @@ export default function Settings({
</select>
</label>
<p className={`mt-2 ${HELP}`}>
Shown whenever a video has subtitles in this language, including YouTube's
auto-generated ones on most videos those are the only ones there are.
Downloads keep them beside the file for offline use; streams fetch them
separately, because YouTube's live manifest carries no subtitles at all.
<b> None means no subtitles anywhere.</b> You can still switch tracks from
the player.
Which language switches itself on, including YouTube's auto-generated
captions on most videos those are the only ones there are. Downloads keep
them beside the file for offline use; streams fetch them separately, since
YouTube's live manifest carries no subtitles at all. <b>Off</b> only means
nothing comes on by itself: whatever a video has is still listed in the
player's subtitle menu.
</p>
</section>
+1 -1
View File
@@ -132,7 +132,7 @@ export const DEFAULT_BULK_LIMIT = 25;
export const DEFAULT_SUB_LANG = "en";
export const SUB_LANGS: Array<{ value: string; label: string }> = [
{ value: "off", label: "None" },
{ value: "off", label: "Off — pick per video" },
{ value: "en", label: "English" },
{ value: "nl", label: "Nederlands" },
{ value: "de", label: "Deutsch" },