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
+69 -6
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, refreshFeeds,
cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
onRefreshProgress, refreshFeeds,
} from "./api";
import Player from "./components/Player";
import Settings from "./components/Settings";
@@ -15,7 +16,7 @@ import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed";
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
import {
QUALITIES, STREAM_QUALITIES,
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
} from "./types";
@@ -48,6 +49,14 @@ export default function App() {
const [showSettings, setShowSettings] = useState(false);
// Index into the current feed, so the player can step through it.
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
const [subLang, setSubLang] = useState(() => {
try {
const stored = localStorage.getItem("flighttube.subLang");
return SUB_LANGS.some((l) => l.value === stored) ? stored! : "off";
} catch {
return "off";
}
});
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
try {
const stored = localStorage.getItem("flighttube.streamQuality");
@@ -88,13 +97,14 @@ export default function App() {
localStorage.setItem("flighttube.view", view);
localStorage.setItem("flighttube.quality", quality);
localStorage.setItem("flighttube.streamQuality", streamQuality);
localStorage.setItem("flighttube.subLang", subLang);
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
} catch {
/* storage blocked */
}
}, [view, quality, streamQuality, downloadedOnly, hideShorts, sidebarHidden]);
}, [view, quality, streamQuality, subLang, downloadedOnly, hideShorts, sidebarHidden]);
// Offline, the only videos that can be played are the ones already on disk,
// so the feed collapses to those regardless of the toggle.
@@ -136,6 +146,10 @@ export default function App() {
[channels],
);
// yt-dlp wants a pattern; "en.*" catches "en" and "en-orig" and the
// auto-generated "en" track alike. English is always fetched as a fallback.
const subLangArg = subLang === "off" ? "en.*" : `${subLang}.*,en.*`;
const doRefresh = useCallback(async () => {
setRefreshing(true);
try {
@@ -204,6 +218,29 @@ export default function App() {
return () => clearInterval(id);
}, [online, refreshing, playingIndex, doRefresh]);
// Refresh once on launch, as soon as there is a connection and something to
// refresh, so the feed is current without anyone pressing anything.
const launched = useRef(false);
useEffect(() => {
if (launched.current || !online || channels.length === 0) return;
launched.current = true;
void doRefresh();
}, [online, channels.length, doRefresh]);
const [confirmWipe, setConfirmWipe] = useState(false);
const wipeDownloads = useCallback(async () => {
setConfirmWipe(false);
try {
const n = await deleteAllDownloads();
items.forEach((i) => clearLive(i.id));
await reload();
say(`Deleted ${n} download${n === 1 ? "" : "s"}`);
} catch (e) {
setFailure(String(e));
}
}, [items, clearLive, reload, say]);
const emptyMessage = () => {
if (loading) return "Loading…";
if (channels.length === 0)
@@ -274,6 +311,7 @@ export default function App() {
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
resultCount={items.length}
view={view} onView={setView}
onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined}
sidebarHidden={sidebarHidden}
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
titleBarInset={titleBarInset}
@@ -311,7 +349,7 @@ export default function App() {
online,
onOpen: () => openIndex(idx),
onDownload: () =>
downloadVideo(item.id, quality).catch((e) => setFailure(String(e))),
downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))),
onCancel: () =>
cancelDownload(item.id).catch((e) => setFailure(String(e))),
onDelete: () =>
@@ -339,6 +377,7 @@ export default function App() {
index={playingIndex}
total={items.length}
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
subLang={subLang}
titleBarInset={titleBarInset}
onPrev={
stepFrom(playingIndex, -1) != null
@@ -353,7 +392,7 @@ export default function App() {
onDownload={
playing.path === null
? () => {
downloadVideo(playing.item.id, quality).catch((e) => setFailure(String(e)));
downloadVideo(playing.item.id, quality, subLangArg).catch((e) => setFailure(String(e)));
say("Download started");
}
: undefined
@@ -361,7 +400,13 @@ export default function App() {
downloading={["queued", "running"].includes(
live[playing.item.id]?.state ?? playing.item.state ?? "",
)}
onClose={() => { setPlayingIndex(null); reload(); }}
onClose={() => {
setPlayingIndex(null);
// Coming back from a video is the natural moment to pick up
// whatever has been posted since.
if (online && !refreshing) void doRefresh();
else void reload();
}}
onDelete={async () => {
await deleteDownload(playing.item.id);
clearLive(playing.item.id);
@@ -381,6 +426,8 @@ export default function App() {
onQuality={setQuality}
streamQuality={streamQuality}
onStreamQuality={setStreamQuality}
subLang={subLang}
onSubLang={setSubLang}
onError={setFailure}
onImported={(n) => {
reload();
@@ -389,6 +436,22 @@ export default function App() {
/>
)}
{confirmWipe && (
<Dialog
title="Delete every download?"
onCancel={() => setConfirmWipe(false)}
onConfirm={wipeDownloads}
confirmLabel="Delete all"
destructive
>
<p>
All <b>{totals.downloaded}</b> downloaded video
{totals.downloaded === 1 ? "" : "s"} and their subtitles will be removed from
disk. Your subscriptions and the feed are untouched.
</p>
</Dialog>
)}
{failure && (
<Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
<p className="break-words">{failure}</p>