, 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 && (
void;
+ /** Jump to this video's channel. */
+ onOpenChannel: () => void;
onDownload: () => void;
onCancel: () => void;
onDelete: () => void;
}
export default function VideoRow({
- 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);
@@ -70,9 +73,13 @@ export default function VideoRow({
{item.title}
-
+
{item.channel_title}
-
+
{[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,