diff --git a/src/App.tsx b/src/App.tsx index 6c3c478..a9f1e91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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; diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 3b35afd..8e2241a 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -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>([]); - - useEffect(() => { - if (!path) { - setSidecars([]); - 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>([]); + /** + * 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>([]); 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(() => { - setFetched([]); - if (subLang === "off" || sidecars.length > 0) return; - const urls: string[] = []; let cancelled = false; - setFetchingSubs(true); - fetchSubtitles(item.id, subLang) - .then((list) => { + const blobs: string[] = []; + setTracks([]); + setFetchingSubs(false); + + const load = async () => { + if (path) { + const local = await listSubtitles(item.id).catch(() => []); if (cancelled) return; - setFetched( + if (local.length > 0) { + setTracks(local.map(([lang, file]) => [lang, fileUrl(file)])); + return; + } + } + if (cancelled) return; + 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]) => ( - - ))} - {fetched.map(([lang, url]) => ( - + {tracks.map(([lang, url]) => ( + ))} ) : ( diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index 264499c..f1bd839 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -80,6 +80,8 @@ export default function PlayerControls({ const [subs, setSubs] = useState([]); const [, bump] = useState(0); const menuRef = useRef(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 diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 02323ad..56ea877 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -276,12 +276,12 @@ export default function Settings({

- 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. - None means no subtitles anywhere. 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. Off only means + nothing comes on by itself: whatever a video has is still listed in the + player's subtitle menu.

diff --git a/src/types.ts b/src/types.ts index f90d0e0..f3beb80 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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" },