fix: a refresh no longer drags you off a channel; cap Download all
The auto-refresh could throw you back to All subscriptions and then refuse to let you back in. useFeed rebuilt reload() whenever the filter changed, so a refresh started before you picked a channel still held the old closure: half a minute later it re-fetched the unfiltered list and overwrote yours. The sidebar still showed the channel as selected, so clicking it changed nothing and there was no way back except via another channel. reload() now reads the filter at call time instead of capturing it, and carries a sequence number so a slow reply cannot overwrite a newer one. It is stable across filter changes, which also stops the auto-refresh interval being torn down and restarted every time you click a channel. Download all now works from All subscriptions too, capped by a new Settings limit (default 25, up to no limit). It takes the newest first and says what it did — "Queued the newest 25 of 500" — so a cap is never silent. Press it again for the next batch.
This commit is contained in:
+31
-10
@@ -16,7 +16,7 @@ import { useDownloads } from "./hooks/useDownloads";
|
||||
import { useFeed } from "./hooks/useFeed";
|
||||
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
|
||||
import {
|
||||
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||
BULK_LIMITS, DEFAULT_BULK_LIMIT, QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
|
||||
} from "./types";
|
||||
|
||||
@@ -87,6 +87,14 @@ export default function App() {
|
||||
return "best";
|
||||
}
|
||||
});
|
||||
const [bulkLimit, setBulkLimit] = useState(() => {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem("flighttube.bulkLimit"));
|
||||
return BULK_LIMITS.some((b) => b.value === stored) ? stored : DEFAULT_BULK_LIMIT;
|
||||
} catch {
|
||||
return DEFAULT_BULK_LIMIT;
|
||||
}
|
||||
});
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
@@ -118,6 +126,7 @@ export default function App() {
|
||||
try {
|
||||
localStorage.setItem("flighttube.view", view);
|
||||
localStorage.setItem("flighttube.quality", quality);
|
||||
localStorage.setItem("flighttube.bulkLimit", String(bulkLimit));
|
||||
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
||||
localStorage.setItem("flighttube.subLang", subLang);
|
||||
localStorage.setItem("flighttube.browser", browser);
|
||||
@@ -127,7 +136,8 @@ export default function App() {
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}, [view, quality, streamQuality, subLang, browser, downloadedOnly, hideShorts, sidebarHidden]);
|
||||
}, [view, quality, bulkLimit, streamQuality, subLang, browser, 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.
|
||||
@@ -255,13 +265,23 @@ export default function App() {
|
||||
// Queues the lot in one go. The backend runs two at a time and the rest wait
|
||||
// their turn, so this is a queue rather than a stampede; a video that fails
|
||||
// reports it on its own row instead of throwing a dialog for each one.
|
||||
const bulkTargets = useMemo(
|
||||
() => (bulkLimit > 0 ? pendingDownloads.slice(0, bulkLimit) : pendingDownloads),
|
||||
[pendingDownloads, bulkLimit],
|
||||
);
|
||||
|
||||
const downloadAll = useCallback(() => {
|
||||
if (pendingDownloads.length === 0) return;
|
||||
say(`Queued ${pendingDownloads.length} video${pendingDownloads.length === 1 ? "" : "s"}`);
|
||||
for (const i of pendingDownloads) {
|
||||
if (bulkTargets.length === 0) return;
|
||||
const capped = bulkTargets.length < pendingDownloads.length;
|
||||
say(
|
||||
capped
|
||||
? `Queued the newest ${bulkTargets.length} of ${pendingDownloads.length} — limit set in Settings`
|
||||
: `Queued ${bulkTargets.length} video${bulkTargets.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
for (const i of bulkTargets) {
|
||||
downloadVideo(i.id, quality, subLangArg).catch(() => {});
|
||||
}
|
||||
}, [pendingDownloads, quality, subLangArg, say]);
|
||||
}, [bulkTargets, pendingDownloads.length, quality, subLangArg, say]);
|
||||
|
||||
// Ids on screen still lacking a length, newest first. Joined into a string
|
||||
// so the effect below only re-runs when the set actually changes.
|
||||
@@ -396,10 +416,9 @@ export default function App() {
|
||||
resultCount={items.length}
|
||||
view={view} onView={setView}
|
||||
onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined}
|
||||
onDownloadAll={
|
||||
channelId && online && pendingDownloads.length > 0 ? downloadAll : undefined
|
||||
}
|
||||
downloadAllCount={pendingDownloads.length}
|
||||
onDownloadAll={online && bulkTargets.length > 0 ? downloadAll : undefined}
|
||||
downloadAllCount={bulkTargets.length}
|
||||
downloadAllTotal={pendingDownloads.length}
|
||||
sidebarHidden={sidebarHidden}
|
||||
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
|
||||
titleBarInset={titleBarInset}
|
||||
@@ -525,6 +544,8 @@ export default function App() {
|
||||
onAppearance={setMode}
|
||||
quality={quality}
|
||||
onQuality={setQuality}
|
||||
bulkLimit={bulkLimit}
|
||||
onBulkLimit={setBulkLimit}
|
||||
streamQuality={streamQuality}
|
||||
onStreamQuality={setStreamQuality}
|
||||
subLang={subLang}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||
import {
|
||||
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||
BULK_LIMITS,
|
||||
type ImportPreview, type Prereqs, type Quality, type UpdateStatus,
|
||||
} from "../types";
|
||||
import TakeoutGuide from "./TakeoutGuide";
|
||||
@@ -21,6 +22,8 @@ interface Props {
|
||||
onAppearance: (a: Appearance) => void;
|
||||
quality: Quality;
|
||||
onQuality: (q: Quality) => void;
|
||||
bulkLimit: number;
|
||||
onBulkLimit: (n: number) => void;
|
||||
streamQuality: Quality;
|
||||
onStreamQuality: (q: Quality) => void;
|
||||
subLang: string;
|
||||
@@ -53,6 +56,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
|
||||
|
||||
export default function Settings({
|
||||
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
||||
bulkLimit, onBulkLimit,
|
||||
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
|
||||
browser, onBrowser, onError,
|
||||
}: Props) {
|
||||
@@ -218,6 +222,26 @@ export default function Settings({
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||
<span className={LABEL}>Download all</span>
|
||||
<select
|
||||
value={bulkLimit}
|
||||
onChange={(e) => onBulkLimit(Number(e.target.value))}
|
||||
className={SELECT}
|
||||
>
|
||||
{BULK_LIMITS.map((b) => (
|
||||
<option key={b.value} value={b.value}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={`mt-2 ${HELP}`}>
|
||||
The cap on one <b>Download all</b>. It takes the newest first, so from
|
||||
All subscriptions you get the latest across every channel rather than
|
||||
several hundred videos at once. Press it again for the next batch.
|
||||
</p>
|
||||
|
||||
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||
<span className={LABEL}>Streaming</span>
|
||||
<select
|
||||
|
||||
@@ -23,7 +23,9 @@ interface Props {
|
||||
onDeleteAll?: () => void;
|
||||
/** Present only on a channel page with videos still to fetch. */
|
||||
onDownloadAll?: () => void;
|
||||
/** How many this press would queue, and how many are listed in all. */
|
||||
downloadAllCount?: number;
|
||||
downloadAllTotal?: number;
|
||||
/** Matches the sidebar's inset so the two headers share a baseline. */
|
||||
titleBarInset: boolean;
|
||||
}
|
||||
@@ -62,7 +64,7 @@ export default function TopBar({
|
||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||
sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0,
|
||||
titleBarInset,
|
||||
downloadAllTotal = 0, titleBarInset,
|
||||
}: Props) {
|
||||
const pct = refreshProgress && refreshProgress.total > 0
|
||||
? (refreshProgress.done / refreshProgress.total) * 100
|
||||
@@ -112,9 +114,14 @@ export default function TopBar({
|
||||
{onDownloadAll && (
|
||||
<button
|
||||
onClick={onDownloadAll}
|
||||
title={`Download all ${downloadAllCount} video${
|
||||
downloadAllCount === 1 ? "" : "s"
|
||||
} listed here, one after another`}
|
||||
title={
|
||||
downloadAllCount < downloadAllTotal
|
||||
? `Download the newest ${downloadAllCount} of the ${downloadAllTotal} listed here, ` +
|
||||
"one after another — raise the limit in Settings"
|
||||
: `Download all ${downloadAllCount} video${
|
||||
downloadAllCount === 1 ? "" : "s"
|
||||
} listed here, one after another`
|
||||
}
|
||||
aria-label="Download all listed videos"
|
||||
className={`${ICON_BTN} border border-slate-300 text-slate-500 hover:border-sky-500
|
||||
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
|
||||
|
||||
+20
-8
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listChannels, listFeed } from "../api";
|
||||
import type { ChannelWithCount, FeedFilter, FeedItem } from "../types";
|
||||
|
||||
@@ -8,24 +8,36 @@ export function useFeed(filter: FeedFilter) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// A refresh can take half a minute, and the filter may well change while it
|
||||
// runs. Reading the filter at call time rather than capturing it keeps a
|
||||
// reload from asking for the list the user has already moved on from.
|
||||
const latest = useRef(filter);
|
||||
latest.current = filter;
|
||||
// Only the newest load may write; a slower earlier one is dropped, so an
|
||||
// out-of-order reply cannot put a stale list back on screen.
|
||||
const seq = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [feed, chans] = await Promise.all([listFeed(filter), listChannels()]);
|
||||
const [feed, chans] = await Promise.all([listFeed(latest.current), listChannels()]);
|
||||
if (mine !== seq.current) return;
|
||||
setItems(feed);
|
||||
setChannels(chans);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
if (mine === seq.current) setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mine === seq.current) setLoading(false);
|
||||
}
|
||||
// Filter is a plain object rebuilt each render; compare by value.
|
||||
}, [JSON.stringify(filter)]);
|
||||
}, []);
|
||||
|
||||
// Filter is a plain object rebuilt each render; compare by value.
|
||||
const key = JSON.stringify(filter);
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
void reload();
|
||||
}, [key, reload]);
|
||||
|
||||
return { items, channels, loading, error, reload };
|
||||
}
|
||||
|
||||
@@ -113,6 +113,20 @@ export const STREAM_QUALITIES: Array<{ value: Quality; label: string }> = [
|
||||
{ value: "480", label: "480p" },
|
||||
];
|
||||
|
||||
/**
|
||||
* How many videos one "Download all" may queue. All subscriptions can be
|
||||
* hundreds of videos, which is not something to set going by accident.
|
||||
*/
|
||||
export const BULK_LIMITS: Array<{ value: number; label: string }> = [
|
||||
{ value: 5, label: "5 videos" },
|
||||
{ value: 10, label: "10 videos" },
|
||||
{ value: 25, label: "25 videos" },
|
||||
{ value: 50, label: "50 videos" },
|
||||
{ value: 100, label: "100 videos" },
|
||||
{ value: 0, label: "No limit" },
|
||||
];
|
||||
export const DEFAULT_BULK_LIMIT = 25;
|
||||
|
||||
/** Preferred subtitle language: shown when available, and downloaded. */
|
||||
export const SUB_LANGS: Array<{ value: string; label: string }> = [
|
||||
{ value: "off", label: "None" },
|
||||
|
||||
Reference in New Issue
Block a user