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:
+69
-6
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user