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