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
+167 -2
View File
@@ -1,7 +1,172 @@
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="min-h-screen bg-ink text-white grid place-items-center">
<h1 className="text-3xl font-semibold tracking-tight">FlightTube</h1>
<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>
);
}