feat: subtitles, delete-all, auto refresh, and menu cleanup

Subtitles: a language preference in Settings drives what is shown and
what is downloaded. yt-dlp saves WebVTT sidecars next to each download,
including YouTube's auto-generated track, and the player attaches them
as <track> elements so they work offline. Sidecars are removed with
their video, and by Delete all.

WebVTT rather than muxed subtitle streams because WebKit reads a <track>
reliably and largely ignores subtitle tracks inside an MP4.

Downloads in progress now appear under the downloaded-only filter, so a
download you just started does not vanish from the list you are watching
it in. That view also gains a Delete all, behind a confirmation naming
what goes.

The feed refreshes on launch and whenever the player closes, so the
Refresh button is only for staleness.

Player: Delete moved to the footer and shortened, Open on YouTube is now
an external-link icon.

Removes the Edit and Help menus. Cut/Copy/Paste move to the app menu,
without which their shortcuts would stop working in the search field.
The webview context menu is suppressed outside text fields — its Reload
and Back items act on a page the app does not present as one.

Settings now warns that 4K AV1 plays back with artefacts: the files
decode cleanly in ffmpeg, so it is the built-in decoder, not the
download.
This commit is contained in:
vincent
2026-08-29 13:25:17 +02:00
parent 73f12cfac1
commit 557467baad
12 changed files with 458 additions and 43 deletions
+22 -10
View File
@@ -34,6 +34,8 @@ interface Props {
stageRef: React.RefObject<HTMLDivElement | null>;
/** Nudges the auto-hide timer whenever the user does something. */
onActivity?: () => void;
/** Preferred subtitle language, or "off" to start with none. */
subLang: string;
}
const SKIP_S = 10;
@@ -59,7 +61,7 @@ const btn =
* Owning the bar is the only way to get every control into one strip along the
* bottom, so the native ones are switched off entirely.
*/
export default function PlayerControls({ videoRef, stageRef, onActivity }: Props) {
export default function PlayerControls({ videoRef, stageRef, onActivity, subLang }: Props) {
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
@@ -83,15 +85,24 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
};
read();
// Nothing translated gets forced on. YouTube marks its subtitle track
// AUTOSELECT=YES and WebKit will switch it on when it matches the system
// language; that is the same unwanted auto-selection as a dubbed audio
// track, so subtitles start off and stay a deliberate choice.
const silenceSubs = () => {
for (const t of Array.from(v.textTracks)) t.mode = "disabled";
// 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.
const applyPreference = () => {
const tracks = Array.from(v.textTracks);
const wanted =
subLang === "off"
? undefined
: tracks.find((t) =>
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
);
for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled";
read();
};
v.addEventListener("loadedmetadata", silenceSubs);
applyPreference();
v.addEventListener("loadedmetadata", applyPreference);
// HLS subtitle renditions arrive after metadata, so re-apply as they land.
v.textTracks.addEventListener?.("addtrack", applyPreference);
v.addEventListener("loadedmetadata", read);
const at = (v as VideoWithTracks).audioTracks;
@@ -101,14 +112,15 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
const id = setInterval(read, 1000);
const stop = setTimeout(() => clearInterval(id), 8000);
return () => {
v.removeEventListener("loadedmetadata", silenceSubs);
v.removeEventListener("loadedmetadata", applyPreference);
v.textTracks.removeEventListener?.("addtrack", applyPreference);
v.removeEventListener("loadedmetadata", read);
at?.removeEventListener?.("addtrack", read);
v.textTracks.removeEventListener?.("addtrack", read);
clearInterval(id);
clearTimeout(stop);
};
}, [videoRef]);
}, [videoRef, subLang]);
// Close the menu on any outside click.
useEffect(() => {