From 28237bdaa204ef039ffce3a0de853b97df1f9e56 Mon Sep 17 00:00:00 2001 From: debont80 Date: Fri, 26 Jun 2026 09:13:08 -0400 Subject: [PATCH] feat: collapsible playlist groups + per-playlist download in Library Library channel cards now collapse their playlist groups by default (single-group sources auto-expand), and each group header has a Download button that queues just that playlist's pending videos. Co-Authored-By: Claude Opus 4.8 --- src/renderer/src/components/LibraryView.tsx | 73 +++++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index 5cca864..0929d72 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -152,7 +152,10 @@ const useStyles = makeStyles({ alignItems: 'center', gap: '8px', padding: '6px 4px', - color: tokens.colorNeutralForeground2 + color: tokens.colorNeutralForeground2, + cursor: 'pointer', + ...shorthands.borderRadius(tokens.borderRadiusMedium), + ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover } }, groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 }, row: { @@ -250,6 +253,9 @@ export function LibraryView(): React.JSX.Element { const [selected, setSelected] = useState>(new Set()) const [batchNote, setBatchNote] = useState(null) const [syncNote, setSyncNote] = useState(null) + // Which playlist groups are expanded. Empty = all collapsed (the default), so + // an expanded source shows just its group headers until one is opened. + const [expandedGroups, setExpandedGroups] = useState>(new Set()) const [scheduled, setScheduled] = useState(false) const watchedCount = sources.filter((s) => s.watched).length @@ -278,6 +284,7 @@ export function LibraryView(): React.JSX.Element { useEffect(() => { setSelected(new Set()) setBatchNote(null) + setExpandedGroups(new Set()) }, [selectedSourceId]) // Live per-URL queue status so a video row reflects its real download state. @@ -289,15 +296,21 @@ export function LibraryView(): React.JSX.Element { const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : [] const groups = useMemo(() => groupByPlaylist(items), [items]) + // A group is shown when toggled open, or auto-expanded when it's the only group + // (a single "Uploads" channel shouldn't need a second click to reach its videos). + const isGroupOpen = (title: string): boolean => + groups.length === 1 || expandedGroups.has(title) // Flatten groups → [header, ...its items, header, ...] for the virtualized list. const flatRows = useMemo(() => { const rows: LibRow[] = [] for (const g of groups) { rows.push({ kind: 'header', title: g.title, items: g.items }) - for (const it of g.items) rows.push({ kind: 'item', item: it }) + if (groups.length === 1 || expandedGroups.has(g.title)) { + for (const it of g.items) rows.push({ kind: 'item', item: it }) + } } return rows - }, [groups]) + }, [groups, expandedGroups]) const effStatus = (it: MediaItem): ItemStatus => statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending') @@ -335,6 +348,15 @@ export function LibraryView(): React.JSX.Element { }) } + function toggleGroupExpand(title: string): void { + setExpandedGroups((prev) => { + const next = new Set(prev) + if (next.has(title)) next.delete(title) + else next.add(title) + return next + }) + } + function toggleGroup(groupItems: MediaItem[], on: boolean): void { setSelected((prev) => { const next = new Set(prev) @@ -366,6 +388,16 @@ export function LibraryView(): React.JSX.Element { setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) } + // Queue every actionable (pending/failed/canceled) video in one playlist group, + // so "download this whole playlist" is a single click. + function downloadGroup(groupItems: MediaItem[]): void { + if (!selectedSourceId) return + const toQueue = groupItems.filter(actionable) + if (toQueue.length === 0) return + const n = enqueueItems(selectedSourceId, toQueue) + setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`) + } + function pillClass(status: ItemStatus): string { if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading) if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted) @@ -382,14 +414,45 @@ export function LibraryView(): React.JSX.Element { function renderRow(row: LibRow): React.JSX.Element { if (row.kind === 'header') { const allOn = row.items.every((it) => selected.has(it.id)) + const open = isGroupOpen(row.title) + const groupActionable = row.items.filter(actionable).length return ( -
+
toggleGroupExpand(row.title)} + role="button" + tabIndex={0} + onKeyDown={(e) => + (e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title) + } + aria-expanded={open} + > + {open ? : } {row.title} {row.items.length} - +
) }