feat: feed UI, in-app player, offline mode, settings

Adds an end-to-end integration test that runs the real pipeline against
live YouTube Atom feeds, verifying the merged feed is newest-first
across channels.
This commit is contained in:
vincent
2026-08-29 02:34:14 +02:00
parent a30423e4b5
commit e75d896933
16 changed files with 1059 additions and 2 deletions
+44
View File
@@ -0,0 +1,44 @@
import { useCallback, useEffect, useState } from "react";
import { getConnectivity } from "../api";
const POLL_MS = 30_000;
/**
* Real reachability, not `navigator.onLine` — which reports link state and is
* true on a plane's captive-portal wifi with no actual internet. The manual
* override exists so offline mode can be exercised without touching wifi.
*/
export function useConnectivity() {
const [reachable, setReachable] = useState(true);
const [forcedOffline, setForcedOffline] = useState(false);
const probe = useCallback(async () => {
try {
setReachable(await getConnectivity());
} catch {
setReachable(false);
}
}, []);
useEffect(() => {
probe();
const id = setInterval(probe, POLL_MS);
const onUp = () => probe();
const onDown = () => setReachable(false);
window.addEventListener("online", onUp);
window.addEventListener("offline", onDown);
return () => {
clearInterval(id);
window.removeEventListener("online", onUp);
window.removeEventListener("offline", onDown);
};
}, [probe]);
return {
online: reachable && !forcedOffline,
reachable,
forcedOffline,
setForcedOffline,
probe,
};
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { onDownloadProgress, onDownloadState } from "../api";
import type { DownloadState } from "../types";
export interface LiveDownload {
state: DownloadState;
pct: number | null;
speed: number | null;
eta: number | null;
error: string | null;
path: string | null;
}
/**
* Live download state keyed by video id, driven by backend events. Overlays the
* snapshot the feed query returns, so progress updates don't require refetching.
*/
export function useDownloads(onFinished: () => void) {
const [live, setLive] = useState<Record<string, LiveDownload>>({});
useEffect(() => {
const unlisteners: Array<() => void> = [];
onDownloadProgress((p) => {
setLive((prev) => ({
...prev,
[p.video_id]: {
state: "running",
pct: p.pct,
speed: p.speed,
eta: p.eta,
error: null,
path: prev[p.video_id]?.path ?? null,
},
}));
}).then((u) => unlisteners.push(u));
onDownloadState((s) => {
setLive((prev) => ({
...prev,
[s.video_id]: {
state: s.state,
pct: s.state === "done" ? 100 : (prev[s.video_id]?.pct ?? null),
speed: null,
eta: null,
error: s.error,
path: s.path,
},
}));
if (s.state === "done" || s.state === "failed" || s.state === "cancelled") {
onFinished();
}
}).then((u) => unlisteners.push(u));
return () => unlisteners.forEach((u) => u());
}, [onFinished]);
return live;
}
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useEffect, useState } from "react";
import { listChannels, listFeed } from "../api";
import type { ChannelWithCount, FeedFilter, FeedItem } from "../types";
export function useFeed(filter: FeedFilter) {
const [items, setItems] = useState<FeedItem[]>([]);
const [channels, setChannels] = useState<ChannelWithCount[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
try {
const [feed, chans] = await Promise.all([listFeed(filter), listChannels()]);
setItems(feed);
setChannels(chans);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
// Filter is a plain object rebuilt each render; compare by value.
}, [JSON.stringify(filter)]);
useEffect(() => {
reload();
}, [reload]);
return { items, channels, loading, error, reload };
}