661a4e7572
A new in-app toast surface is the batch's spine, making the silent-failure and
global-status gaps fixable:
- Toast infra: store/toasts.ts (useToasts + toast(); auto-dismiss) + ui/Toaster.tsx
(non-portal bottom-center stack, avoids the Fluent-portal GPU flicker). Unit-
tested (test/toasts.test.ts). Mounted at the app root beside LiveRegion.
- UX6: shared renderer reveal.ts revealFile('open'|'folder') awaits the IPC result
and toasts the main-process reason instead of silently no-oping on a moved/typed-
out file. safeShowInFolder upgraded to return an error string like safeOpenPath
(preload/mock/Api types updated).
- UX9 / UI25: a Fluent CounterBadge on the sidebar Downloads nav item shows the
active (downloading + queued) count from any tab, via a count-only App selector
that doesn't re-render on progress ticks.
- UX15: cancelAll store action + a "Cancel all (N)" header button.
- UX24: the disabled "Check watched" button uses disabledFocusable + a Hint that
explains it needs a watched source.
- UX12: the Terminal gate gains an inline "Enable custom commands" button, so it's
no longer a dead-end pointing at another screen.
- L144: the DownloadBar duplicate guard now also checks history — a URL downloaded
earlier warns "Already downloaded: …" (dupInHistory) vs "Already in your queue: …".
Deferred with rationale (CODE-AUDIT.md): UX7/UX8 (Settings IA redesign — visual),
UX18/UX26 (subjective/visual polish), L131 (arguably by-design, needs live taskbar),
L142 (history/templates/sources IPC-return reconciliation — a focused own PR).
Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
794 lines
28 KiB
TypeScript
794 lines
28 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
Caption1,
|
|
Text,
|
|
Input,
|
|
Button,
|
|
Checkbox,
|
|
Switch,
|
|
Spinner,
|
|
makeStyles,
|
|
mergeClasses,
|
|
tokens,
|
|
shorthands
|
|
} from '@fluentui/react-components'
|
|
import {
|
|
AddRegular,
|
|
ArrowSyncRegular,
|
|
DeleteRegular,
|
|
ArrowDownloadRegular,
|
|
ChevronDownRegular,
|
|
ChevronRightRegular,
|
|
AppsListRegular,
|
|
VideoClipMultipleRegular,
|
|
AlertRegular,
|
|
LibraryRegular
|
|
} from '@fluentui/react-icons'
|
|
import type { MediaItem, Source, MediaKind } from '@shared/ipc'
|
|
import { isPreview as PREVIEW } from '../isPreview'
|
|
import { useSources } from '../store/sources'
|
|
import { useSettings } from '../store/settings'
|
|
import { useNav } from '../store/nav'
|
|
import { useClipboardLink, looksLikeSingleVideo } from '../useClipboardLink'
|
|
import { logError } from '../reportError'
|
|
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
|
import { thumbUrl } from '../thumb'
|
|
import { relTime } from '../datetime'
|
|
import { MediaThumb } from './MediaThumb'
|
|
import { VirtualList } from './VirtualList'
|
|
import { Hint } from './Hint'
|
|
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
|
import { StatusChip } from './ui/StatusChip'
|
|
import { SegmentedControl } from './ui/SegmentedControl'
|
|
import { EmptyState } from './ui/EmptyState'
|
|
import { LinkSuggestion } from './ui/LinkSuggestion'
|
|
import { useFocusStyles } from './ui/focusRing'
|
|
import { useTextStyles } from './ui/text'
|
|
import { SPACE, RADIUS, ICON, META_SEP } from './ui/tokens'
|
|
import { THUMB_XS } from '../thumbSizes'
|
|
|
|
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
|
|
type ItemStatus = DownloadStatus | 'pending'
|
|
|
|
/**
|
|
* A flattened row for the virtualized item list: either a playlist header or a
|
|
* single video. Flattening the groups into one list lets a 1000s-video source
|
|
* window cleanly (one virtualizer over a flat array, headers included).
|
|
*/
|
|
type LibRow =
|
|
{ kind: 'header'; title: string; items: MediaItem[] } | { kind: 'item'; item: MediaItem }
|
|
|
|
/** Above this many flattened rows, the item list switches to a virtualized panel. */
|
|
const VIRTUALIZE_AT = 100
|
|
|
|
// When a URL appears more than once in the queue ("Download anyway" duplicates),
|
|
// the library row should reflect the most meaningful state: a finished copy wins,
|
|
// then anything in flight, then terminal failures (M17).
|
|
const STATUS_PRIORITY: Record<DownloadStatus, number> = {
|
|
completed: 6,
|
|
downloading: 5,
|
|
queued: 4,
|
|
saved: 3,
|
|
paused: 2,
|
|
error: 1,
|
|
canceled: 0
|
|
}
|
|
|
|
const useStyles = makeStyles({
|
|
root: { display: 'flex', flexDirection: 'column', gap: SPACE.section },
|
|
addRow: { display: 'flex', gap: '8px' },
|
|
addInput: { flexGrow: 1 },
|
|
toolbar: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '14px',
|
|
flexWrap: 'wrap',
|
|
paddingTop: '2px'
|
|
},
|
|
toolbarSpacer: { flexGrow: 1 },
|
|
switchRow: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '2px',
|
|
color: tokens.colorNeutralForeground2
|
|
},
|
|
progress: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
color: tokens.colorNeutralForeground2,
|
|
fontSize: tokens.fontSizeBase200
|
|
},
|
|
error: { color: tokens.colorPaletteRedForeground1 },
|
|
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
|
|
card: {
|
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
|
backgroundColor: tokens.colorNeutralBackground1,
|
|
overflow: 'hidden'
|
|
},
|
|
cardHead: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
padding: '12px 14px',
|
|
width: '100%',
|
|
border: 'none',
|
|
backgroundColor: 'transparent',
|
|
// A native <button> doesn't inherit color; set it so the chevron (currentColor)
|
|
// matches the page foreground in dark mode instead of UA ButtonText.
|
|
color: tokens.colorNeutralForeground1,
|
|
fontFamily: tokens.fontFamilyBase,
|
|
textAlign: 'left',
|
|
cursor: 'pointer',
|
|
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
|
},
|
|
srcIcon: {
|
|
width: '34px',
|
|
height: '34px',
|
|
flexShrink: 0,
|
|
borderRadius: tokens.borderRadiusMedium,
|
|
backgroundColor: tokens.colorBrandBackground2,
|
|
color: tokens.colorBrandForeground2,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
// Source tile glyph, snapped to the nearest ICON tier (UI11 — no literal px).
|
|
fontSize: `${ICON.control}px`
|
|
},
|
|
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
|
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
|
|
srcTitle: { fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1 },
|
|
watchBadge: {
|
|
flexShrink: 0,
|
|
fontSize: tokens.fontSizeBase100,
|
|
padding: '0 7px',
|
|
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
|
backgroundColor: tokens.colorBrandBackground2,
|
|
color: tokens.colorBrandForeground2
|
|
},
|
|
detail: {
|
|
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
|
padding: '12px 14px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '12px'
|
|
},
|
|
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
|
actionSpacer: { flexGrow: 1 },
|
|
groupHead: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
padding: '2px 4px'
|
|
},
|
|
groupToggle: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
flexGrow: 1,
|
|
minWidth: 0,
|
|
padding: '4px 4px',
|
|
border: 'none',
|
|
backgroundColor: 'transparent',
|
|
color: tokens.colorNeutralForeground2,
|
|
cursor: 'pointer',
|
|
fontFamily: tokens.fontFamilyBase,
|
|
fontSize: tokens.fontSizeBase300,
|
|
textAlign: 'left',
|
|
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
|
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
|
},
|
|
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
|
|
row: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '10px',
|
|
padding: '5px 4px 5px 18px'
|
|
},
|
|
plainList: { display: 'flex', flexDirection: 'column' },
|
|
rowThumb: {
|
|
width: `${THUMB_XS.w}px`,
|
|
height: `${THUMB_XS.h}px`,
|
|
// One thumbnail radius app-wide (UI6): control tier / Medium.
|
|
...shorthands.borderRadius(RADIUS.control)
|
|
},
|
|
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }
|
|
})
|
|
|
|
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
|
function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[] }[] {
|
|
const map = new Map<string, MediaItem[]>()
|
|
for (const it of items) {
|
|
const arr = map.get(it.playlistTitle) ?? []
|
|
arr.push(it)
|
|
map.set(it.playlistTitle, arr)
|
|
}
|
|
const groups = [...map.entries()].map(([title, its]) => ({
|
|
title,
|
|
items: [...its].sort((a, b) => a.playlistIndex - b.playlistIndex)
|
|
}))
|
|
groups.sort(
|
|
(a, b) =>
|
|
(a.title === 'Uploads' ? 1 : 0) - (b.title === 'Uploads' ? 1 : 0) ||
|
|
a.title.localeCompare(b.title)
|
|
)
|
|
return groups
|
|
}
|
|
|
|
export function LibraryView(): React.JSX.Element {
|
|
const styles = useStyles()
|
|
const screen = useScreenStyles()
|
|
const focus = useFocusStyles()
|
|
const text = useTextStyles()
|
|
const sources = useSources((s) => s.sources)
|
|
const itemsBySource = useSources((s) => s.itemsBySource)
|
|
const selectedSourceId = useSources((s) => s.selectedSourceId)
|
|
const indexing = useSources((s) => s.indexing)
|
|
const selectSource = useSources((s) => s.selectSource)
|
|
const indexSource = useSources((s) => s.indexSource)
|
|
const reindexSource = useSources((s) => s.reindexSource)
|
|
const removeSource = useSources((s) => s.removeSource)
|
|
const enqueueItems = useSources((s) => s.enqueueItems)
|
|
const setWatched = useSources((s) => s.setWatched)
|
|
const syncWatched = useSources((s) => s.syncWatched)
|
|
const syncing = useSources((s) => s.syncing)
|
|
const downloadItems = useDownloads((s) => s.items)
|
|
const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
|
|
const updateSettings = useSettings((s) => s.update)
|
|
|
|
const [url, setUrl] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
|
|
|
|
// A channel/playlist URL handed over from the Downloads bar (UX3): pre-fill the
|
|
// add field with it once, so the user lands here ready to add it.
|
|
const pendingLibraryUrl = useNav((s) => s.pendingLibraryUrl)
|
|
const consumeLibraryUrl = useNav((s) => s.consumeLibraryUrl)
|
|
useEffect(() => {
|
|
if (pendingLibraryUrl === null) return
|
|
const handed = consumeLibraryUrl()
|
|
if (handed) setUrl(handed)
|
|
}, [pendingLibraryUrl, consumeLibraryUrl])
|
|
|
|
// Reset any pending Remove confirmation when the expanded source changes so a
|
|
// stale confirm can't reappear after navigating between sources.
|
|
useEffect(() => {
|
|
setConfirmRemoveId(null)
|
|
}, [selectedSourceId])
|
|
// Offer a freshly-copied link the way the Downloads tab does, but skip single
|
|
// videos -- a library source is a channel/playlist to sync, not a one-off.
|
|
const clip = useClipboardLink(url, (u) => !looksLikeSingleVideo(u))
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
// Per-batch video/audio choice for queueing items, seeded from the global
|
|
// default so behavior is unchanged until the user flips it (M27).
|
|
const [enqueueKind, setEnqueueKind] = useState<MediaKind>(
|
|
() => useSettings.getState().defaultKind
|
|
)
|
|
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
|
|
|
|
// Load the current scheduled-sync (Task Scheduler) state once.
|
|
useEffect(() => {
|
|
if (PREVIEW) return
|
|
window.api
|
|
.getScheduledSync()
|
|
.then((s) => setScheduled(s.enabled))
|
|
.catch(logError('getScheduledSync'))
|
|
}, [])
|
|
|
|
async function onCheckNew(): Promise<void> {
|
|
setSyncNote(null)
|
|
const n = await syncWatched()
|
|
setSyncNote(n > 0 ? `Found ${n} new video${n === 1 ? '' : 's'}.` : 'No new videos.')
|
|
}
|
|
|
|
async function toggleScheduled(next: boolean): Promise<void> {
|
|
setScheduled(next) // optimistic
|
|
if (PREVIEW) return
|
|
const res = await window.api.setScheduledSync(next)
|
|
setScheduled(res.enabled)
|
|
if (res.error) setError(res.error)
|
|
}
|
|
|
|
// Reset the selection (and any batch note) whenever the expanded source changes.
|
|
useEffect(() => {
|
|
setSelected(new Set())
|
|
setBatchNote(null)
|
|
setExpandedGroups(new Set())
|
|
}, [selectedSourceId])
|
|
|
|
// Live per-URL queue status so a video row reflects its real download state.
|
|
const statusByUrl = useMemo(() => {
|
|
const m = new Map<string, DownloadStatus>()
|
|
for (const d of downloadItems) {
|
|
const prev = m.get(d.url)
|
|
// Keep the highest-priority status when a URL has duplicate queue entries.
|
|
if (prev === undefined || STATUS_PRIORITY[d.status] > STATUS_PRIORITY[prev]) {
|
|
m.set(d.url, d.status)
|
|
}
|
|
}
|
|
return m
|
|
}, [downloadItems])
|
|
|
|
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, expandedGroups])
|
|
|
|
const effStatus = (it: MediaItem): ItemStatus =>
|
|
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
|
|
|
|
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
|
|
|
|
// 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/
|
|
// downloading items keeps "Select all → Download" from enqueuing duplicates,
|
|
// since addFromUrl doesn't dedupe by URL.
|
|
const actionable = (it: MediaItem): boolean => {
|
|
const st = effStatus(it)
|
|
return st === 'pending' || st === 'error' || st === 'canceled'
|
|
}
|
|
const actionableItems = items.filter(actionable)
|
|
const selectedActionable = actionableItems.filter((it) => selected.has(it.id))
|
|
const allActionableSelected =
|
|
actionableItems.length > 0 && actionableItems.every((it) => selected.has(it.id))
|
|
|
|
async function onIndex(): Promise<void> {
|
|
setError(null)
|
|
const u = url.trim()
|
|
if (!u || indexing.active) return
|
|
const res = await indexSource(u)
|
|
if (res.ok) setUrl('')
|
|
else setError(res.error ?? 'Could not add that link.')
|
|
}
|
|
|
|
function toggle(id: string, on: boolean): void {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (on) next.add(id)
|
|
else next.delete(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
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)
|
|
for (const it of groupItems) {
|
|
// Only actionable rows are selectable, so the selection count can never
|
|
// exceed what "Download N selected" will actually queue (M36).
|
|
if (!actionable(it)) continue
|
|
if (on) next.add(it.id)
|
|
else next.delete(it.id)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Select / clear every downloadable item across all groups in this source.
|
|
function toggleAll(on: boolean): void {
|
|
setSelected(on ? new Set(actionableItems.map((it) => it.id)) : new Set())
|
|
}
|
|
|
|
// One click queues every chosen item -- maxConcurrent gates how many actually
|
|
// run, the rest wait in the queue. Selection clears since nothing is held back.
|
|
function downloadSelected(): void {
|
|
if (!selectedSourceId || selectedActionable.length === 0) return
|
|
const n = enqueueItems(selectedSourceId, selectedActionable, enqueueKind)
|
|
setSelected(new Set())
|
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
|
}
|
|
|
|
function downloadPending(): void {
|
|
if (!selectedSourceId || pendingItems.length === 0) return
|
|
const n = enqueueItems(selectedSourceId, pendingItems, enqueueKind)
|
|
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, enqueueKind)
|
|
setBatchNote(`Queued ${n} download${n === 1 ? '' : 's'}.`)
|
|
}
|
|
|
|
// One row of the item list -- a playlist header or a video -- shared by the
|
|
// inline (small source) and virtualized (large source) render paths.
|
|
function rowKey(row: LibRow): string {
|
|
return row.kind === 'header' ? `h:${row.title}` : row.item.id
|
|
}
|
|
|
|
function renderRow(row: LibRow): React.JSX.Element {
|
|
if (row.kind === 'header') {
|
|
const open = isGroupOpen(row.title)
|
|
// "All on" is judged over the ACTIONABLE rows only -- downloaded rows aren't
|
|
// selectable, so they must not keep the group from reading as fully selected (M36).
|
|
const groupActionableItems = row.items.filter(actionable)
|
|
const groupActionable = groupActionableItems.length
|
|
const allOn = groupActionable > 0 && groupActionableItems.every((it) => selected.has(it.id))
|
|
return (
|
|
<div className={styles.groupHead}>
|
|
<button
|
|
type="button"
|
|
className={mergeClasses(styles.groupToggle, focus.focusRing)}
|
|
onClick={() => toggleGroupExpand(row.title)}
|
|
aria-expanded={open}
|
|
aria-label={`${row.title}, ${row.items.length} items`}
|
|
>
|
|
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
|
<AppsListRegular />
|
|
<Text className={styles.groupTitle}>{row.title}</Text>
|
|
<Caption1 className={text.muted}>{row.items.length}</Caption1>
|
|
</button>
|
|
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
|
|
{allOn ? 'None' : 'All'}
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<ArrowDownloadRegular />}
|
|
disabled={groupActionable === 0}
|
|
onClick={() => downloadGroup(row.items)}
|
|
>
|
|
Download{groupActionable > 0 ? ` ${groupActionable}` : ''}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
const it = row.item
|
|
const status = effStatus(it)
|
|
return (
|
|
<div className={styles.row}>
|
|
<Checkbox
|
|
checked={selected.has(it.id)}
|
|
disabled={!actionable(it)}
|
|
onChange={(_, d) => toggle(it.id, !!d.checked)}
|
|
aria-label={`Select ${it.title}`}
|
|
/>
|
|
<MediaThumb
|
|
className={styles.rowThumb}
|
|
src={thumbUrl({ url: it.url, videoId: it.videoId })}
|
|
kind="video"
|
|
iconSize={16}
|
|
/>
|
|
<div className={styles.rowMain}>
|
|
<Text className={text.truncate}>
|
|
{it.playlistIndex}. {it.title}
|
|
</Text>
|
|
{it.durationLabel && <Caption1 className={text.muted}>{it.durationLabel}</Caption1>}
|
|
</div>
|
|
<StatusChip status={status} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={mergeClasses(styles.root, screen.width)}>
|
|
<ScreenHeader
|
|
title="Library"
|
|
description="Add a channel or playlist once, then download its videos into organized folders."
|
|
/>
|
|
|
|
<div className={styles.addRow}>
|
|
<Input
|
|
className={styles.addInput}
|
|
input={{ 'aria-label': 'Channel or playlist URL' }}
|
|
value={url}
|
|
onChange={(_, d) => setUrl(d.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
|
placeholder="Paste a channel or playlist URL to add it…"
|
|
size="large"
|
|
contentBefore={<LibraryRegular />}
|
|
disabled={indexing.active}
|
|
/>
|
|
<Button
|
|
size="large"
|
|
appearance="primary"
|
|
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
|
|
onClick={onIndex}
|
|
disabled={!url.trim() || indexing.active}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
|
|
{clip.suggestion && (
|
|
<LinkSuggestion
|
|
prefix="Use copied link? "
|
|
link={clip.suggestion}
|
|
onAccept={() => {
|
|
const link = clip.accept()
|
|
if (link) setUrl(link)
|
|
}}
|
|
onDismiss={clip.dismiss}
|
|
dismissLabel="Dismiss suggested link"
|
|
/>
|
|
)}
|
|
|
|
<div className={styles.toolbar}>
|
|
<Hint
|
|
label={
|
|
watchedCount === 0
|
|
? 'Turn on "Watch" for a channel or playlist below first, then this checks them for new uploads.'
|
|
: 'Check your watched sources for new uploads'
|
|
}
|
|
placement="top"
|
|
align="start"
|
|
>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={syncing ? <Spinner size="tiny" /> : <ArrowSyncRegular />}
|
|
onClick={onCheckNew}
|
|
// disabledFocusable (not disabled) when nothing is watched, so the
|
|
// button still shows its explanatory tooltip on hover/focus (UX24).
|
|
disabled={syncing}
|
|
disabledFocusable={watchedCount === 0}
|
|
>
|
|
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
|
</Button>
|
|
</Hint>
|
|
{syncNote && <Caption1 className={text.muted}>{syncNote}</Caption1>}
|
|
<div className={styles.toolbarSpacer} />
|
|
<span className={styles.switchRow}>
|
|
<Caption1>Auto-download new</Caption1>
|
|
<Switch
|
|
checked={autoDownloadNew}
|
|
onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
|
|
aria-label="Auto-download new uploads"
|
|
/>
|
|
</span>
|
|
<span className={styles.switchRow}>
|
|
<Caption1>Daily sync</Caption1>
|
|
<Switch
|
|
checked={scheduled}
|
|
onChange={(_, d) => toggleScheduled(d.checked)}
|
|
aria-label="Daily background sync"
|
|
/>
|
|
</span>
|
|
</div>
|
|
|
|
{indexing.active && (
|
|
<div className={styles.progress}>
|
|
<Spinner size="tiny" />
|
|
<Text>
|
|
{indexing.message ?? 'Adding…'}
|
|
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
{error && <Caption1 className={styles.error}>{error}</Caption1>}
|
|
|
|
{sources.length === 0 && !indexing.active ? (
|
|
<EmptyState
|
|
icon={<LibraryRegular fontSize={ICON.hero} />}
|
|
message="No channels or playlists yet. Paste one above to add it."
|
|
/>
|
|
) : (
|
|
<div className={styles.list}>
|
|
{sources.map((src) => (
|
|
<SourceCard
|
|
key={src.id}
|
|
styles={styles}
|
|
source={src}
|
|
expanded={selectedSourceId === src.id}
|
|
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
|
|
>
|
|
<div className={styles.detail}>
|
|
<div className={styles.actionRow}>
|
|
<Checkbox
|
|
checked={allActionableSelected ? true : selected.size > 0 ? 'mixed' : false}
|
|
onChange={(_, d) => toggleAll(!!d.checked)}
|
|
label="Select all"
|
|
disabled={actionableItems.length === 0}
|
|
/>
|
|
<Caption1 className={text.muted}>
|
|
{items.length} videos{META_SEP}
|
|
{pendingItems.length} pending{META_SEP}indexed {relTime(src.lastIndexedAt)}
|
|
</Caption1>
|
|
<div className={styles.actionSpacer} />
|
|
<SegmentedControl<MediaKind>
|
|
value={enqueueKind}
|
|
options={[
|
|
{ value: 'video', label: 'Video' },
|
|
{ value: 'audio', label: 'Audio' }
|
|
]}
|
|
onChange={setEnqueueKind}
|
|
ariaLabel="Download as video or audio"
|
|
/>
|
|
{selected.size > 0 ? (
|
|
<>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
onClick={() => setSelected(new Set())}
|
|
>
|
|
Clear
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
appearance="primary"
|
|
icon={<ArrowDownloadRegular />}
|
|
onClick={downloadSelected}
|
|
disabled={selectedActionable.length === 0}
|
|
>
|
|
Download {selectedActionable.length} selected
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
size="small"
|
|
appearance="primary"
|
|
icon={<ArrowDownloadRegular />}
|
|
onClick={downloadPending}
|
|
disabled={pendingItems.length === 0}
|
|
>
|
|
Download {pendingItems.length} pending
|
|
</Button>
|
|
)}
|
|
<span className={styles.switchRow}>
|
|
<Caption1>Watch</Caption1>
|
|
<Switch
|
|
checked={!!src.watched}
|
|
onChange={(_, d) => setWatched(src.id, !!d.checked)}
|
|
aria-label={`Watch ${src.title} for new uploads`}
|
|
/>
|
|
</span>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<ArrowSyncRegular />}
|
|
onClick={() => reindexSource(src.id)}
|
|
disabled={indexing.active}
|
|
>
|
|
Refresh
|
|
</Button>
|
|
{confirmRemoveId === src.id ? (
|
|
<>
|
|
<Caption1 className={text.muted}>Remove {src.title}?</Caption1>
|
|
<Button
|
|
size="small"
|
|
appearance="primary"
|
|
icon={<DeleteRegular />}
|
|
onClick={() => {
|
|
removeSource(src.id)
|
|
setConfirmRemoveId(null)
|
|
}}
|
|
>
|
|
Remove
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
onClick={() => setConfirmRemoveId(null)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<DeleteRegular />}
|
|
onClick={() => setConfirmRemoveId(src.id)}
|
|
>
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{batchNote && <Caption1 className={text.muted}>{batchNote}</Caption1>}
|
|
|
|
{flatRows.length > VIRTUALIZE_AT ? (
|
|
// Big source: a fixed-height, internally-scrolling virtualized
|
|
// panel so 1000s of rows stay light. A definite height (not
|
|
// max-height) gives the virtualizer a viewport to measure.
|
|
<VirtualList
|
|
items={flatRows}
|
|
style={{ height: '58vh' }}
|
|
overscan={10}
|
|
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
|
|
getKey={(row) => rowKey(row)}
|
|
renderItem={(row) => renderRow(row)}
|
|
/>
|
|
) : (
|
|
// Small source: render inline so the card grows naturally.
|
|
<div className={styles.plainList}>
|
|
{flatRows.map((row) => (
|
|
<div key={rowKey(row)}>{renderRow(row)}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SourceCard>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** One collapsible source card (header always shown; children render when expanded). */
|
|
function SourceCard({
|
|
styles,
|
|
source,
|
|
expanded,
|
|
onToggleExpand,
|
|
children
|
|
}: {
|
|
styles: ReturnType<typeof useStyles>
|
|
source: Source
|
|
expanded: boolean
|
|
onToggleExpand: () => void
|
|
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>
|
|
{expanded && children}
|
|
</div>
|
|
)
|
|
}
|