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>
+8 -2
View File
@@ -24,8 +24,14 @@ export const listFeed = (filter: FeedFilter) =>
export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
export const downloadVideo = (videoId: string, quality: Quality) =>
invoke<void>("download_video", { videoId, quality });
export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) =>
invoke<void>("download_video", { videoId, quality, subLangs });
export const deleteAllDownloads = () => invoke<number>("delete_all_downloads");
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
export const listSubtitles = (videoId: string) =>
invoke<Array<[string, string]>>("list_subtitles", { videoId });
export const savePlayback = (videoId: string, position: number, duration: number) =>
invoke<void>("save_playback", { videoId, position, duration });
+56 -15
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
import { fileUrl, listSubtitles, openExternal, resolveStream, savePlayback } from "../api";
import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format";
import PlayerControls from "./PlayerControls";
import { BTN, BTN_CHROME, Spinner } from "./ui";
import { BTN, Spinner } from "./ui";
interface Props {
item: FeedItem;
@@ -18,6 +18,8 @@ interface Props {
downloading?: boolean;
/** Max height for streaming, or null to let the player adapt. */
maxHeight: number | null;
/** Preferred subtitle language, or "off". */
subLang: string;
/** Position in the current feed, for the "3 of 180" readout. */
index: number;
total: number;
@@ -97,12 +99,28 @@ const RESUME_EDGE_S = 5;
*/
export default function Player({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
maxHeight, index, total, titleBarInset,
maxHeight, subLang, index, total, titleBarInset,
}: Props) {
const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
const [error, setError] = useState<string | null>(null);
const [buffering, setBuffering] = useState(true);
// WebVTT files yt-dlp saved next to a download, so subtitles work offline.
const [sidecars, setSidecars] = useState<Array<[string, string]>>([]);
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]);
// Controls and edge arrows fade away while you are just watching.
const [chromeVisible, setChromeVisible] = useState(true);
const hideTimer = useRef<number | undefined>(undefined);
@@ -236,17 +254,10 @@ export default function Player({
{item.channel_title}
</span>
{streaming ? (
{streaming && (
<span className="text-[11px] text-slate-400 dark:text-slate-500">
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"}
</span>
) : (
<button
onClick={onDelete}
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
>
Delete download
</button>
)}
</header>
@@ -312,7 +323,17 @@ export default function Player({
onPlaying={() => setBuffering(false)}
onSeeked={() => setBuffering(false)}
className="absolute inset-0 size-full object-contain"
/>
>
{sidecars.map(([lang, file]) => (
<track
key={file}
kind="subtitles"
srcLang={lang}
label={lang}
src={fileUrl(file)}
/>
))}
</video>
) : (
error && (
<div className="absolute inset-0 grid place-items-center px-6 text-center">
@@ -334,7 +355,12 @@ export default function Player({
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<PlayerControls videoRef={videoRef} stageRef={stageRef} onActivity={showChrome} />
<PlayerControls
videoRef={videoRef}
stageRef={stageRef}
onActivity={showChrome}
subLang={subLang}
/>
</div>
)}
</div>
@@ -362,11 +388,26 @@ export default function Player({
{downloading ? "Downloading…" : "Download"}
</button>
)}
{!streaming && (
<button
onClick={onDelete}
title="Delete this download"
className={`${navBtn} whitespace-nowrap hover:border-red-500! hover:text-red-600!
dark:hover:border-red-500! dark:hover:text-red-400!`}
>
Delete
</button>
)}
<button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${navBtn} whitespace-nowrap`}
title="Open on YouTube"
aria-label="Open on YouTube"
className={`${navBtn} w-[30px] p-0`}
>
Open on YouTube
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round"
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5" />
</svg>
</button>
</div>
</div>
+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(() => {
+25 -2
View File
@@ -4,7 +4,8 @@ import {
} from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import {
QUALITIES, STREAM_QUALITIES, type ImportPreview, type Prereqs, type Quality,
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
type ImportPreview, type Prereqs, type Quality,
} from "../types";
import TakeoutGuide from "./TakeoutGuide";
import {
@@ -20,6 +21,8 @@ interface Props {
onQuality: (q: Quality) => void;
streamQuality: Quality;
onStreamQuality: (q: Quality) => void;
subLang: string;
onSubLang: (l: string) => void;
onError: (message: string) => void;
}
@@ -44,7 +47,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality,
streamQuality, onStreamQuality, onError,
streamQuality, onStreamQuality, subLang, onSubLang, onError,
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
@@ -182,6 +185,26 @@ export default function Settings({
Streaming quality applies when you play something you have not downloaded.
Best lets the player adapt to your connection; a fixed height pins it.
</p>
<label className="mt-3 grid grid-cols-[92px_1fr] items-center gap-2">
<span className={LABEL}>Subtitles</span>
<select
value={subLang}
onChange={(e) => onSubLang(e.target.value)}
className={SELECT}
>
{SUB_LANGS.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</label>
<p className={`mt-2 ${HELP}`}>
Shown automatically when a video has subtitles in this language, including
YouTube's auto-generated ones, and saved alongside anything you download so
they work offline. You can still switch tracks from the player.
</p>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
+17 -1
View File
@@ -21,6 +21,8 @@ interface Props {
onView: (v: ViewMode) => void;
sidebarHidden: boolean;
onShowSidebar: () => void;
/** Present only when there is something to delete. */
onDeleteAll?: () => void;
/** Matches the sidebar's inset so the two headers share a baseline. */
titleBarInset: boolean;
}
@@ -58,7 +60,7 @@ export default function TopBar({
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
sidebarHidden, onShowSidebar, titleBarInset,
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
}: Props) {
const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100
@@ -105,6 +107,20 @@ export default function TopBar({
Downloaded only
</Toggle>
{downloadedOnly && onDeleteAll && (
<button
onClick={onDeleteAll}
title="Delete every download"
className={`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium
text-slate-500 hover:border-red-500 hover:text-red-600
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500
dark:hover:text-red-400`}
>
Delete all
</button>
)}
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
title="Hide Shorts from the feed">
Hide Shorts
+11
View File
@@ -3,6 +3,17 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
// The webview's own context menu offers Reload, Back and Inspect — page
// actions in something that is not meant to read as a page. Text fields keep
// theirs so copy and paste stay reachable.
document.addEventListener("contextmenu", (e) => {
const el = e.target as HTMLElement | null;
const editable =
el?.closest("input, textarea, [contenteditable='true']") !== null &&
el?.closest("input, textarea, [contenteditable='true']") !== undefined;
if (!editable) e.preventDefault();
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
+12
View File
@@ -110,3 +110,15 @@ export const STREAM_QUALITIES: Array<{ value: Quality; label: string }> = [
{ value: "720", label: "720p" },
{ value: "480", label: "480p" },
];
/** Preferred subtitle language: shown when available, and downloaded. */
export const SUB_LANGS: Array<{ value: string; label: string }> = [
{ value: "off", label: "None" },
{ value: "en", label: "English" },
{ value: "nl", label: "Nederlands" },
{ value: "de", label: "Deutsch" },
{ value: "fr", label: "Français" },
{ value: "es", label: "Español" },
{ value: "it", label: "Italiano" },
{ value: "pt", label: "Português" },
];