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:
+167
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user