feat: channel failures, updater, and a tidier Settings
Failing channels are visible instead of buried in a toast count. Each refresh records its outcome per channel, the sidebar marks the failures and counts them in its heading, and selecting one explains why above its videos. Yours turn out to be three channels returning HTTP 404 — removed or renamed on YouTube. Video lengths now fill for what is on screen. Filling the newest across all subscriptions meant a channel's videos stayed blank forever, since the global newest always won the queue. yt-dlp can be updated from Settings, which also says whether it is current. It breaks whenever YouTube changes something, so it lands in app data and takes precedence over the bundled copy; ffmpeg is stable and ships with each release, so it is shown but not updated. Also: 'Downloaded only' is now 'Local'; Hide Shorts moved to Settings; the seven-step Takeout guide moved behind a button, since it dominated the panel; the player names the height it is actually streaming, which changes as an adaptive stream switches rendition; the player's delete is an icon; and the window minimum drops to 1080 now that the control row has one fewer button, while still never wrapping.
This commit is contained in:
+36
-7
@@ -24,11 +24,12 @@ const TOAST_MS = 2400;
|
||||
/** How often to pull new videos while online, so the feed stays live. */
|
||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* Video lengths trickle in. This was once every 12s and it got the whole IP
|
||||
* challenged by YouTube, which broke playback and downloads too — the feed
|
||||
* being fully annotated is not worth that.
|
||||
* Video lengths trickle in, four at a time, for whatever is on screen. An
|
||||
* early version fetched two pages a second across the whole feed and got the
|
||||
* IP challenged by YouTube, breaking playback and downloads too — so this stays
|
||||
* slow on purpose.
|
||||
*/
|
||||
const DURATION_FILL_MS = 5 * 60 * 1000;
|
||||
const DURATION_FILL_MS = 30 * 1000;
|
||||
|
||||
function remembered(key: string): boolean {
|
||||
try {
|
||||
@@ -241,6 +242,14 @@ export default function App() {
|
||||
return () => clearInterval(id);
|
||||
}, [online, refreshing, playingIndex, doRefresh]);
|
||||
|
||||
// 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.
|
||||
const missingDurations = useMemo(
|
||||
() => items.filter((i) => i.duration == null).slice(0, 40).map((i) => i.id),
|
||||
[items],
|
||||
);
|
||||
const missingKey = missingDurations.join(",");
|
||||
|
||||
// The Atom feed carries no duration, so lengths are looked up a batch at a
|
||||
// time in the background and cached. Paused while the player is open.
|
||||
useEffect(() => {
|
||||
@@ -252,7 +261,7 @@ export default function App() {
|
||||
const tick = async () => {
|
||||
if (stop || refused || playingIndex != null) return;
|
||||
try {
|
||||
if ((await fetchDurations()) > 0 && !stop) await reload();
|
||||
if ((await fetchDurations(missingDurations)) > 0 && !stop) await reload();
|
||||
} catch {
|
||||
refused = true;
|
||||
}
|
||||
@@ -263,7 +272,10 @@ export default function App() {
|
||||
stop = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [online, playingIndex, reload]);
|
||||
// Re-runs when the visible set changes, so switching channel fills that
|
||||
// channel rather than whatever is newest overall.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [online, playingIndex, missingKey]);
|
||||
|
||||
// Refresh once on launch, as soon as there is a connection and something to
|
||||
// refresh, so the feed is current without anyone pressing anything.
|
||||
@@ -288,6 +300,11 @@ export default function App() {
|
||||
}
|
||||
}, [items, clearLive, reload, say]);
|
||||
|
||||
const activeChannelError =
|
||||
channelId == null
|
||||
? null
|
||||
: (channels.find((c) => c.id === channelId)?.last_error ?? null);
|
||||
|
||||
const emptyMessage = () => {
|
||||
if (loading) return "Loading…";
|
||||
if (channels.length === 0)
|
||||
@@ -352,7 +369,6 @@ export default function App() {
|
||||
<TopBar
|
||||
search={search} onSearch={setSearch}
|
||||
downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
|
||||
hideShorts={hideShorts} onHideShorts={setHideShorts}
|
||||
online={online} reachable={reachable} forcedOffline={forcedOffline}
|
||||
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
|
||||
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
|
||||
@@ -364,6 +380,17 @@ export default function App() {
|
||||
titleBarInset={titleBarInset}
|
||||
/>
|
||||
|
||||
{activeChannelError && (
|
||||
<div
|
||||
className="border-b border-red-500/30 bg-red-500/10 px-4 py-2 text-[11px]
|
||||
leading-snug text-red-700 dark:text-red-300"
|
||||
>
|
||||
<b>This channel failed to refresh.</b>{" "}
|
||||
{activeChannelError.replace(/\.?$/, ".")} Anything listed below is from the
|
||||
last successful check.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!online && (
|
||||
<div
|
||||
className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[11px]
|
||||
@@ -476,6 +503,8 @@ export default function App() {
|
||||
onStreamQuality={setStreamQuality}
|
||||
subLang={subLang}
|
||||
onSubLang={setSubLang}
|
||||
hideShorts={hideShorts}
|
||||
onHideShorts={setHideShorts}
|
||||
browser={browser}
|
||||
onBrowser={setBrowser}
|
||||
onError={setFailure}
|
||||
|
||||
Reference in New Issue
Block a user