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.
173 lines
6.4 KiB
TypeScript
173 lines
6.4 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress,
|
|
openExternal, refreshFeeds,
|
|
} from "./api";
|
|
import Player from "./components/Player";
|
|
import Settings from "./components/Settings";
|
|
import Sidebar from "./components/Sidebar";
|
|
import TopBar from "./components/TopBar";
|
|
import VideoRow from "./components/VideoRow";
|
|
import { useConnectivity } from "./hooks/useConnectivity";
|
|
import { useDownloads } from "./hooks/useDownloads";
|
|
import { useFeed } from "./hooks/useFeed";
|
|
import type { FeedFilter, FeedItem, RefreshProgress } from "./types";
|
|
|
|
export default function App() {
|
|
const [channelId, setChannelId] = useState<string | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
const [downloadedOnly, setDownloadedOnly] = useState(false);
|
|
const [hideShorts, setHideShorts] = useState(false);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
|
|
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
|
|
|
|
// Offline, the only videos that can be played are the ones already on disk,
|
|
// so the feed collapses to those regardless of the toggle.
|
|
const effectiveDownloadedOnly = downloadedOnly || !online;
|
|
|
|
const filter: FeedFilter = useMemo(
|
|
() => ({
|
|
channel_id: channelId,
|
|
search: search.trim() || null,
|
|
downloaded_only: effectiveDownloadedOnly,
|
|
hide_shorts: hideShorts,
|
|
limit: 500,
|
|
}),
|
|
[channelId, search, effectiveDownloadedOnly, hideShorts],
|
|
);
|
|
|
|
const { items, channels, loading, error, reload } = useFeed(filter);
|
|
const live = useDownloads(reload);
|
|
|
|
useEffect(() => {
|
|
let un: (() => void) | undefined;
|
|
onRefreshProgress(setRefreshProgress).then((u) => (un = u));
|
|
return () => un?.();
|
|
}, []);
|
|
|
|
const totalVideos = useMemo(
|
|
() => channels.reduce((n, c) => n + c.video_count, 0),
|
|
[channels],
|
|
);
|
|
|
|
const doRefresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
setNotice(null);
|
|
try {
|
|
const s = await refreshFeeds();
|
|
const failed = s.failures.length;
|
|
setNotice(
|
|
`Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}` +
|
|
(failed ? ` · ${failed} failed` : ""),
|
|
);
|
|
await reload();
|
|
} catch (e) {
|
|
setNotice(String(e));
|
|
} finally {
|
|
setRefreshing(false);
|
|
setRefreshProgress(null);
|
|
}
|
|
}, [reload]);
|
|
|
|
const openItem = useCallback(
|
|
async (item: FeedItem) => {
|
|
const path = live[item.id]?.path ?? item.path;
|
|
const done = (live[item.id]?.state ?? item.state) === "done";
|
|
if (done && path) {
|
|
setPlaying({ item, path });
|
|
} else if (online) {
|
|
await openExternal(`https://www.youtube.com/watch?v=${item.id}`);
|
|
} else {
|
|
setNotice("That video isn't downloaded, and you're offline.");
|
|
}
|
|
},
|
|
[live, online],
|
|
);
|
|
|
|
const emptyMessage = () => {
|
|
if (loading) return "Loading…";
|
|
if (channels.length === 0)
|
|
return "No subscriptions yet. Open Settings and import your Takeout subscriptions.csv.";
|
|
if (totalVideos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos.";
|
|
if (!online) return "You're offline, and nothing has been downloaded yet.";
|
|
if (effectiveDownloadedOnly) return "No downloaded videos match this filter.";
|
|
return "Nothing matches this filter.";
|
|
};
|
|
|
|
return (
|
|
<div className="h-screen flex bg-ink text-white overflow-hidden">
|
|
<Sidebar channels={channels} activeChannel={channelId} onSelect={setChannelId}
|
|
onOpenSettings={() => setShowSettings(true)} totalVideos={totalVideos} />
|
|
|
|
<div className="flex-1 min-w-0 flex flex-col">
|
|
<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}
|
|
/>
|
|
|
|
{!online && (
|
|
<div className="px-5 py-2 bg-amber-500/15 text-amber-200 text-xs border-b border-amber-500/25">
|
|
Offline — showing only videos you've downloaded.
|
|
{forcedOffline && " (Offline mode is forced on.)"}
|
|
</div>
|
|
)}
|
|
|
|
{(notice || error) && (
|
|
<div className="px-5 py-2 bg-surface text-xs text-muted border-b border-edge flex items-center justify-between gap-3">
|
|
<span className="truncate">{error ?? notice}</span>
|
|
<button onClick={() => setNotice(null)}
|
|
className="shrink-0 hover:text-white cursor-pointer">
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<main className="flex-1 overflow-y-auto px-3 py-3">
|
|
{items.length === 0 ? (
|
|
<div className="h-full grid place-items-center">
|
|
<p className="text-muted text-sm max-w-md text-center leading-relaxed">
|
|
{emptyMessage()}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="max-w-5xl mx-auto">
|
|
{items.map((item) => (
|
|
<VideoRow key={item.id} item={item} live={live[item.id]} online={online}
|
|
onOpen={() => openItem(item)}
|
|
onDownload={() => downloadVideo(item.id).catch((e) => setNotice(String(e)))}
|
|
onCancel={() => cancelDownload(item.id).catch((e) => setNotice(String(e)))}
|
|
onDelete={() =>
|
|
deleteDownload(item.id).then(reload).catch((e) => setNotice(String(e)))
|
|
} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{playing && (
|
|
<Player item={playing.item} path={playing.path}
|
|
onClose={() => setPlaying(null)}
|
|
onDelete={async () => {
|
|
await deleteDownload(playing.item.id);
|
|
setPlaying(null);
|
|
reload();
|
|
}} />
|
|
)}
|
|
|
|
{showSettings && (
|
|
<Settings onClose={() => setShowSettings(false)} onImported={() => reload()} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|