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',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
padding: '6px 4px',
|
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 },
|
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
|
||||||
row: {
|
row: {
|
||||||
@@ -250,6 +253,9 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [batchNote, setBatchNote] = useState<string | null>(null)
|
const [batchNote, setBatchNote] = useState<string | null>(null)
|
||||||
const [syncNote, setSyncNote] = 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 [scheduled, setScheduled] = useState(false)
|
||||||
|
|
||||||
const watchedCount = sources.filter((s) => s.watched).length
|
const watchedCount = sources.filter((s) => s.watched).length
|
||||||
@@ -278,6 +284,7 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setBatchNote(null)
|
setBatchNote(null)
|
||||||
|
setExpandedGroups(new Set())
|
||||||
}, [selectedSourceId])
|
}, [selectedSourceId])
|
||||||
|
|
||||||
// Live per-URL queue status so a video row reflects its real download state.
|
// 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 items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
|
||||||
const groups = useMemo(() => groupByPlaylist(items), [items])
|
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.
|
// Flatten groups → [header, ...its items, header, ...] for the virtualized list.
|
||||||
const flatRows = useMemo<LibRow[]>(() => {
|
const flatRows = useMemo<LibRow[]>(() => {
|
||||||
const rows: LibRow[] = []
|
const rows: LibRow[] = []
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
rows.push({ kind: 'header', title: g.title, items: g.items })
|
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
|
return rows
|
||||||
}, [groups])
|
}, [groups, expandedGroups])
|
||||||
|
|
||||||
const effStatus = (it: MediaItem): ItemStatus =>
|
const effStatus = (it: MediaItem): ItemStatus =>
|
||||||
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
|
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 {
|
function toggleGroup(groupItems: MediaItem[], on: boolean): void {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -366,6 +388,16 @@ export function LibraryView(): React.JSX.Element {
|
|||||||
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
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 {
|
function pillClass(status: ItemStatus): string {
|
||||||
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
|
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
|
||||||
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
|
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 {
|
function renderRow(row: LibRow): React.JSX.Element {
|
||||||
if (row.kind === 'header') {
|
if (row.kind === 'header') {
|
||||||
const allOn = row.items.every((it) => selected.has(it.id))
|
const allOn = row.items.every((it) => selected.has(it.id))
|
||||||
|
const open = isGroupOpen(row.title)
|
||||||
|
const groupActionable = row.items.filter(actionable).length
|
||||||
return (
|
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 />
|
<AppsListRegular />
|
||||||
<span className={styles.groupTitle}>{row.title}</span>
|
<span className={styles.groupTitle}>{row.title}</span>
|
||||||
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
|
<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'}
|
{allOn ? 'None' : 'All'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<ArrowDownloadRegular />}
|
||||||
|
disabled={groupActionable === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
downloadGroup(row.items)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user