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 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 09:13:08 -04:00
parent 8e161fe9ce
commit 28237bdaa2
+67 -4
View File
@@ -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<Set<string>>(new Set())
const [batchNote, setBatchNote] = useState<string | null>(null)
const [syncNote, setSyncNote] = useState<string | null>(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<Set<string>>(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<LibRow[]>(() => {
const rows: LibRow[] = []
for (const g of groups) {
rows.push({ kind: 'header', title: g.title, items: g.items })
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 (
<div className={styles.groupHead}>
<div
className={styles.groupHead}
onClick={() => toggleGroupExpand(row.title)}
role="button"
tabIndex={0}
onKeyDown={(e) =>
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
}
aria-expanded={open}
>
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
<AppsListRegular />
<span className={styles.groupTitle}>{row.title}</span>
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
<Button
size="small"
appearance="subtle"
onClick={(e) => {
e.stopPropagation()
toggleGroup(row.items, !allOn)
}}
>
{allOn ? 'None' : 'All'}
</Button>
<Button
size="small"
appearance="subtle"
icon={<ArrowDownloadRegular />}
disabled={groupActionable === 0}
onClick={(e) => {
e.stopPropagation()
downloadGroup(row.items)
}}
>
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
</Button>
</div>
)
}