feat(library): add per-row "Download new" for watched sources

Queuing a watched channel's not-yet-downloaded videos previously meant
expanding the source and using its "Download N pending" button. Watched
source rows now carry a one-click "Download new" that loads the source's
items (even while collapsed) and queues everything not already
downloaded or in the queue, using the source's resolved kind/profile.

Extracts the shared, tested selectUndownloaded selector (also now backs
the expanded panel's pending list) and adds ensureSourceItems to load a
collapsed source's items without expanding it.
This commit is contained in:
2026-07-05 17:49:39 -04:00
parent 27cae28e12
commit cb25262a2d
4 changed files with 176 additions and 33 deletions
+16
View File
@@ -0,0 +1,16 @@
import type { MediaItem } from '@shared/ipc'
/**
* Which of a source's items are worth queuing for a one-click "Download new":
* everything not already downloaded AND not already represented in the download
* queue. `queuedUrls` is the set of URLs that currently have any queue entry, so
* an item that's downloading, queued, completed, failed, or canceled is left
* alone — matching the expanded panel's "Download N pending" semantics (a failed
* item is retried via its own Retry, not bulk-swept here).
*
* Pure so the choice is unit-tested without the store; the impure enqueue lives
* in the caller (LibraryView).
*/
export function selectUndownloaded(items: MediaItem[], queuedUrls: Set<string>): MediaItem[] {
return items.filter((it) => !it.downloaded && !queuedUrls.has(it.url))
}
+17 -1
View File
@@ -39,7 +39,8 @@ const seedSources: Source[] =
channel: 'DevChannel',
addedAt: Date.now() - 86_400_000,
lastIndexedAt: Date.now() - 3_600_000,
itemCount: 9
itemCount: 9,
watched: true
},
{
id: 'src-mix',
@@ -99,6 +100,11 @@ interface SourcesState {
* enqueued.
*/
enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => number
/**
* Load a source's items (lazily fetching + caching once) WITHOUT expanding it,
* so a per-row action can act on a collapsed source. Resolves with the items.
*/
ensureSourceItems: (sourceId: string) => Promise<MediaItem[]>
/** mark an item downloaded once its queue download completes (called by the downloads store) */
markDownloaded: (itemId: string, filePath?: string, quality?: string) => void
/** toggle a source's watched-for-new-uploads flag (Phase J) */
@@ -300,6 +306,16 @@ export const useSources = create<SourcesState>((set, get) => ({
return items.length
},
ensureSourceItems: async (sourceId) => {
const cached = get().itemsBySource[sourceId]
if (cached) return cached
// Preview has no IPC — fall back to whatever seed items exist for the source.
if (PREVIEW) return get().itemsBySource[sourceId] ?? []
const items = await window.api.listSourceItems(sourceId)
set((s) => ({ itemsBySource: { ...s.itemsBySource, [sourceId]: items } }))
return items
},
markDownloaded: (itemId, filePath, quality) => {
// Which loaded source holds this item — also the reconcile key, so overlapping
// completions in different sources never suppress each other's response.
+100 -32
View File
@@ -33,6 +33,7 @@ import { Select } from '../components/Select'
import { useNav } from '../store/nav'
import { useClipboardLink, looksLikeSingleVideo } from '../lib/useClipboardLink'
import { useDownloads, type DownloadStatus } from '../store/downloads'
import { selectUndownloaded } from '../store/librarySelect'
import { thumbUrl } from '../lib/thumb'
import { relTime } from '../lib/datetime'
import { MediaThumb } from '../components/MediaThumb'
@@ -109,13 +110,27 @@ const useStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground1,
overflow: 'hidden'
},
// Row wrapping the expand button + an optional trailing action (e.g. "Download
// new"). The action is a sibling of the button, not nested inside it (nested
// <button>s are invalid), so both stay independently focusable.
cardHeadRow: {
display: 'flex',
alignItems: 'center'
},
cardHeadAction: {
flexShrink: 0,
paddingRight: SPACE.cozy
},
cardHead: {
display: 'flex',
alignItems: 'center',
gap: '12px',
// List-row padding tier (UI3): cozy on both axes (was 12/14).
padding: SPACE.cozy,
width: '100%',
// Fill the row, but shrink to make room for the trailing action (minWidth:0
// lets the title ellipsize instead of pushing the action off the edge).
flexGrow: 1,
minWidth: 0,
border: 'none',
backgroundColor: 'transparent',
// A native <button> doesn't inherit color; set it so the chevron (currentColor)
@@ -235,6 +250,7 @@ export function LibraryView(): React.JSX.Element {
const cancelIndexing = useSources((s) => s.cancelIndexing)
const removeSource = useSources((s) => s.removeSource)
const enqueueItems = useSources((s) => s.enqueueItems)
const ensureSourceItems = useSources((s) => s.ensureSourceItems)
const setWatched = useSources((s) => s.setWatched)
const setProfile = useSources((s) => s.setProfile)
const profiles = useProfiles((s) => s.profiles)
@@ -279,6 +295,10 @@ export function LibraryView(): React.JSX.Element {
)
const [batchNote, setBatchNote] = useState<string | null>(null)
const [syncNote, setSyncNote] = useState<string | null>(null)
// The source whose per-row "Download new" is currently loading items + queuing,
// so its button shows a busy state (the action is async: it may fetch a
// collapsed source's items before enqueuing).
const [busySourceId, setBusySourceId] = 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())
@@ -355,7 +375,11 @@ export function LibraryView(): React.JSX.Element {
const effStatus = (it: MediaItem): ItemStatus =>
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
// Every URL that already has a queue entry (any status), so a source's
// not-downloaded-and-not-queued items can be picked with the shared selector —
// used both here for the expanded panel and by the per-row "Download new".
const queuedUrls = useMemo(() => new Set(statusByUrl.keys()), [statusByUrl])
const pendingItems = selectUndownloaded(items, queuedUrls)
// An item is worth (re)queuing when it isn't already downloaded or in flight:
// pending, or a previous failure/cancel to retry. Skipping completed/queued/
@@ -431,6 +455,30 @@ export function LibraryView(): React.JSX.Element {
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
}
// Per source-row action: queue every not-yet-downloaded, not-yet-queued video
// of a (possibly collapsed) watched source in one click — loading its items
// first if they aren't cached. Uses the source's own resolved kind (no
// kindOverride), and reports the outcome in the shared sync note.
async function downloadNew(src: Source): Promise<void> {
if (busySourceId) return
setBusySourceId(src.id)
setSyncNote(null)
try {
const its = await ensureSourceItems(src.id)
const queueable = selectUndownloaded(its, queuedUrls)
if (queueable.length === 0) {
setSyncNote(`Nothing new to download for ${src.title}.`)
return
}
const n = enqueueItems(src.id, queueable)
setSyncNote(`Queued ${n} from ${src.title}.`)
} catch {
setSyncNote(`Couldn't load videos for ${src.title}.`)
} finally {
setBusySourceId(null)
}
}
// 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 {
@@ -644,6 +692,19 @@ export function LibraryView(): React.JSX.Element {
source={src}
expanded={selectedSourceId === src.id}
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
rowAction={
src.watched ? (
<Button
size="small"
appearance="subtle"
icon={busySourceId === src.id ? <Spinner size="tiny" /> : <ArrowDownloadRegular />}
onClick={() => downloadNew(src)}
disabled={busySourceId !== null}
>
Download new
</Button>
) : undefined
}
>
<div className={styles.detail}>
<div className={styles.actionRow}>
@@ -802,48 +863,55 @@ function SourceCard({
source,
expanded,
onToggleExpand,
rowAction,
children
}: {
styles: ReturnType<typeof useStyles>
source: Source
expanded: boolean
onToggleExpand: () => void
/** an optional action rendered beside the header, OUTSIDE the expand button
* (nesting a button inside the header button is invalid) — e.g. "Download new" */
rowAction?: React.ReactNode
children: React.ReactNode
}): React.JSX.Element {
const focus = useFocusStyles()
const text = useTextStyles()
return (
<div className={styles.card}>
<button
type="button"
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand}
aria-expanded={expanded}
aria-label={source.title}
>
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}>
<VideoClipMultipleRegular />
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
{META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1>
</div>
</button>
<div className={styles.cardHeadRow}>
<button
type="button"
className={mergeClasses(styles.cardHead, focus.focusRing)}
onClick={onToggleExpand}
aria-expanded={expanded}
aria-label={source.title}
>
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
<div className={styles.srcIcon}>
<VideoClipMultipleRegular />
</div>
<div className={styles.srcMeta}>
<span className={styles.srcTitleRow}>
<Text className={styles.srcTitle}>{source.title}</Text>
{source.watched && (
<span className={styles.watchBadge}>
<AlertRegular fontSize={11} /> Watching
</span>
)}
</span>
<Caption1 className={text.muted}>
{source.kind === 'channel' ? 'Channel' : 'Playlist'}
{META_SEP}
{source.itemCount} videos
{source.channel && source.channel !== source.title
? `${META_SEP}${source.channel}`
: ''}
</Caption1>
</div>
</button>
{rowAction && <div className={styles.cardHeadAction}>{rowAction}</div>}
</div>
{expanded && children}
</div>
)