diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b6aeb53..3e539bb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1056,6 +1056,16 @@ pub async fn download_video( .await .map_err(|e| format!("Download queue closed: {e}"))?; + // A whole channel can be enqueued at once, so the wait for a slot can be + // long. Cancelling during that wait has to actually stop the download + // rather than have it start later anyway. + { + let db = state.db.lock().await; + if db.download_state(&video_id)?.as_deref() != Some(DownloadState::Queued.as_str()) { + return Ok(()); + } + } + let out_template = library .join(downloader::OUTPUT_TEMPLATE) .to_string_lossy() diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index e8a2cc9..0c09c86 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -561,6 +561,22 @@ impl Db { Ok(()) } + /// The recorded state, or None if the video was never queued. + pub fn download_state(&self, video_id: &str) -> Result, String> { + self.conn + .query_row( + "SELECT state FROM downloads WHERE video_id = ?1", + params![video_id], + |r| r.get::<_, String>(0), + ) + .map(Some) + .or_else(|e| match e { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(other), + }) + .map_err(|e| e.to_string()) + } + pub fn get_download_path(&self, video_id: &str) -> Result, String> { self.conn .query_row( @@ -990,6 +1006,20 @@ mod tests { assert_eq!(a.state, Some(DownloadState::Running)); } + #[test] + fn download_state_is_readable_and_absent_until_queued() { + let db = seeded(); + assert_eq!(db.download_state("a").unwrap(), None); + db.set_download_state("a", DownloadState::Queued, None).unwrap(); + assert_eq!(db.download_state("a").unwrap().as_deref(), Some("queued")); + // What a waiting download checks before it claims a slot. + db.set_download_state("a", DownloadState::Cancelled, None).unwrap(); + assert_ne!( + db.download_state("a").unwrap().as_deref(), + Some(DownloadState::Queued.as_str()) + ); + } + #[test] fn clearing_a_download_makes_it_undownloaded_again() { let db = seeded(); diff --git a/src/App.tsx b/src/App.tsx index 1f29fc6..0f0856c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -242,6 +242,27 @@ export default function App() { return () => clearInterval(id); }, [online, refreshing, playingIndex, doRefresh]); + // Everything listed that is not already here or on its way. + const pendingDownloads = useMemo( + () => + items.filter((i) => { + const state = live[i.id]?.state ?? i.state; + return state !== "done" && state !== "queued" && state !== "running"; + }), + [items, live], + ); + + // Queues the lot in one go. The backend runs two at a time and the rest wait + // their turn, so this is a queue rather than a stampede; a video that fails + // reports it on its own row instead of throwing a dialog for each one. + const downloadAll = useCallback(() => { + if (pendingDownloads.length === 0) return; + say(`Queued ${pendingDownloads.length} video${pendingDownloads.length === 1 ? "" : "s"}`); + for (const i of pendingDownloads) { + downloadVideo(i.id, quality, subLangArg).catch(() => {}); + } + }, [pendingDownloads, quality, subLangArg, say]); + // Ids on screen still lacking a length, newest first. Joined into a string // so the effect below only re-runs when the set actually changes. const missingDurations = useMemo( @@ -375,6 +396,10 @@ export default function App() { resultCount={items.length} view={view} onView={setView} onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined} + onDownloadAll={ + channelId && online && pendingDownloads.length > 0 ? downloadAll : undefined + } + downloadAllCount={pendingDownloads.length} sidebarHidden={sidebarHidden} onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }} titleBarInset={titleBarInset} @@ -422,6 +447,7 @@ export default function App() { live: live[item.id], online, onOpen: () => openIndex(idx), + onOpenChannel: () => { setChannelId(item.channel_id); setSearch(""); }, onDownload: () => downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))), onCancel: () => diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index c336a3e..cd4f3ff 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -21,6 +21,9 @@ interface Props { onShowSidebar: () => void; /** Present only when there is something to delete. */ onDeleteAll?: () => void; + /** Present only on a channel page with videos still to fetch. */ + onDownloadAll?: () => void; + downloadAllCount?: number; /** Matches the sidebar's inset so the two headers share a baseline. */ titleBarInset: boolean; } @@ -58,7 +61,8 @@ export default function TopBar({ search, onSearch, downloadedOnly, onDownloadedOnly, online, reachable, forcedOffline, onToggleForcedOffline, onRefresh, refreshing, refreshProgress, resultCount, view, onView, - sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset, + sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0, + titleBarInset, }: Props) { const pct = refreshProgress && refreshProgress.total > 0 ? (refreshProgress.done / refreshProgress.total) * 100 @@ -105,6 +109,26 @@ export default function TopBar({ Local + {onDownloadAll && ( + + )} + {downloadedOnly && onDeleteAll && ( -
+
+
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
diff --git a/src/components/VideoTile.tsx b/src/components/VideoTile.tsx index cacdf71..19834cd 100644 --- a/src/components/VideoTile.tsx +++ b/src/components/VideoTile.tsx @@ -4,12 +4,15 @@ import type { FeedItem } from "../types"; import DownloadButton from "./DownloadButton"; import WatchBar from "./WatchBar"; import { clockDuration, compactViews, relativeTime } from "./format"; +import { CHANNEL_LINK } from "./ui"; interface Props { item: FeedItem; live?: LiveDownload; online: boolean; onOpen: () => void; + /** Jump to this video's channel. */ + onOpenChannel: () => void; onDownload: () => void; onCancel: () => void; onDelete: () => void; @@ -17,7 +20,7 @@ interface Props { /** Grid cell: thumbnail on top, metadata beneath — the familiar YouTube shape. */ export default function VideoTile({ - item, live, online, onOpen, onDownload, onCancel, onDelete, + item, live, online, onOpen, onOpenChannel, onDownload, onCancel, onDelete, }: Props) { const downloaded = (live?.state ?? item.state) === "done"; const src = thumbSrc(item, online); @@ -74,12 +77,13 @@ export default function VideoTile({ {item.title} -
{item.channel_title} -
+
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 1e80ebb..07e3fc6 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -145,6 +145,11 @@ export function Toast({ message }: { message: string | null }) { ); } +/** A channel name that takes you to its channel. Quiet until hovered. */ +export const CHANNEL_LINK = + "block w-full cursor-pointer truncate text-left text-[12px] text-slate-500 " + + "transition-colors hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"; + /** States a fact in passing — a quality, a status. Never a control. */ export function Badge({ children,