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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user