Format code with Prettier (8 modified files from H1 decomposition)
Aligns with CC2 enforcement: ESLint + Prettier + noImplicitAny now enforced. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { DownloadsView } from './components/DownloadsView'
|
||||
import { LibraryView } from './components/LibraryView'
|
||||
import { HistoryView } from './components/HistoryView'
|
||||
@@ -11,6 +11,7 @@ import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||
import { LiveRegion } from './components/LiveRegion'
|
||||
import { getTheme, pageBackground } from './theme'
|
||||
import { useSettings } from './store/settings'
|
||||
import { useNav } from './store/nav'
|
||||
import { useDownloads } from './store/downloads'
|
||||
// Eagerly load the sources store for its startup side-effects (load persisted
|
||||
// sources + the watched-source / scheduled `--sync` kickoff) and its cross-store
|
||||
@@ -22,6 +23,10 @@ import { summarizeQueue } from './store/queueStats'
|
||||
import { useResolvedDark } from './store/systemTheme'
|
||||
import { logError } from './reportError'
|
||||
|
||||
// How often to check whether a scheduled ('saved' + due) download should promote
|
||||
// to the queue. 15s is plenty for the minute-granularity times the picker offers.
|
||||
const SCHEDULE_TICK_MS = 15_000
|
||||
|
||||
const useStyles = makeStyles({
|
||||
provider: {
|
||||
height: '100vh'
|
||||
@@ -46,7 +51,8 @@ function App(): React.JSX.Element {
|
||||
const updateSettings = useSettings((s) => s.update)
|
||||
const showTerminal = useSettings((s) => s.customCommandEnabled)
|
||||
const isDark = useResolvedDark()
|
||||
const [tab, setTab] = useState<TabValue>('downloads')
|
||||
const tab = useNav((s) => s.tab)
|
||||
const setTab = useNav((s) => s.setTab)
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
// Track the element that was focused before the palette opened so we can restore it on close.
|
||||
const prePaletteRef = useRef<Element | null>(null)
|
||||
@@ -92,6 +98,27 @@ function App(): React.JSX.Element {
|
||||
return useDownloads.subscribe((st) => push(st.items))
|
||||
}, [])
|
||||
|
||||
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
|
||||
// Session-only: the queue isn't persisted, so a schedule only fires while the app
|
||||
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
|
||||
// Lives here (not at the downloads store's module load) so importing the store in
|
||||
// tests/preview doesn't start a stray timer, and the interval is cleared cleanly
|
||||
// on unmount (L1).
|
||||
useEffect(() => {
|
||||
const tick = setInterval(() => {
|
||||
const st = useDownloads.getState()
|
||||
const now = Date.now()
|
||||
if (
|
||||
st.items.some(
|
||||
(i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
|
||||
)
|
||||
) {
|
||||
st.promoteDueScheduled()
|
||||
}
|
||||
}, SCHEDULE_TICK_MS)
|
||||
return () => clearInterval(tick)
|
||||
}, [])
|
||||
|
||||
// Memoized so CommandPalette sees a stable array reference between renders --
|
||||
// only rebuilds when the theme label needs to change (L32).
|
||||
const paletteActions = useMemo<PaletteAction[]>(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MusicNote2Regular,
|
||||
ErrorCircleRegular,
|
||||
LinkRegular,
|
||||
LibraryRegular,
|
||||
DismissRegular,
|
||||
CutRegular,
|
||||
CalendarClockRegular,
|
||||
@@ -62,7 +63,8 @@ export function DownloadBar(): React.JSX.Element {
|
||||
selected,
|
||||
allSelected,
|
||||
suggestion,
|
||||
suggestionSource
|
||||
suggestionSource,
|
||||
channelHint
|
||||
} = bar
|
||||
|
||||
return (
|
||||
@@ -80,20 +82,23 @@ export function DownloadBar(): React.JSX.Element {
|
||||
onChange={(_, d) => bar.onUrlChange(d.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return
|
||||
if (info || probeError || playlist) bar.download()
|
||||
else void bar.fetchFormats()
|
||||
// Enter is "go": start the download (or add a fetched playlist). Preview
|
||||
// formats stays an explicit, optional click on the button (UX2).
|
||||
if (playlist) {
|
||||
if (selected.size > 0) bar.addPlaylist()
|
||||
} else bar.download()
|
||||
}}
|
||||
placeholder="Paste a video or playlist URL, then check…"
|
||||
placeholder="Paste a video or playlist URL, then press Enter to download"
|
||||
size="large"
|
||||
contentBefore={<ArrowDownloadRegular />}
|
||||
/>
|
||||
<Hint label="Check URL" placement="bottom" align="end">
|
||||
<Hint label="Preview available formats (optional)" placement="bottom" align="end">
|
||||
<Button
|
||||
size="large"
|
||||
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||
onClick={bar.fetchFormats}
|
||||
disabled={!url.trim() || probing}
|
||||
aria-label="Check URL for formats or playlist"
|
||||
aria-label="Preview available formats (optional)"
|
||||
/>
|
||||
</Hint>
|
||||
<Hint label="Paste from clipboard" placement="bottom" align="end">
|
||||
@@ -126,6 +131,26 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelHint && (
|
||||
<div className={styles.channelHint}>
|
||||
<LibraryRegular />
|
||||
<Caption1 className={styles.channelHintText}>
|
||||
This looks like a channel or playlist. Add it in the Library to download many videos at
|
||||
once.
|
||||
</Caption1>
|
||||
<Button size="small" appearance="primary" onClick={bar.openInLibrary}>
|
||||
Open in Library
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={bar.dismissChannelHint}
|
||||
aria-label="Dismiss Library suggestion"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dup && (
|
||||
<div className={styles.dupRow}>
|
||||
<WarningRegular />
|
||||
|
||||
@@ -120,11 +120,17 @@ export function DownloadOptionsForm({ value, onChange, kind }: Props): React.JSX
|
||||
</Field>
|
||||
)}
|
||||
{kind !== 'audio' && (
|
||||
<Field label="Video container" hint="The file format used when video and audio are combined into one file.">
|
||||
<Field
|
||||
label="Video container"
|
||||
hint="The file format used when video and audio are combined into one file."
|
||||
>
|
||||
<Select
|
||||
aria-label="Video container"
|
||||
value={value.videoContainer}
|
||||
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
|
||||
options={VIDEO_CONTAINERS.map((c) => ({
|
||||
value: c,
|
||||
label: VIDEO_CONTAINER_LABELS[c]
|
||||
}))}
|
||||
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import {
|
||||
SearchRegular,
|
||||
AddRegular,
|
||||
ArrowSyncRegular,
|
||||
DeleteRegular,
|
||||
ArrowDownloadRegular,
|
||||
@@ -31,6 +31,7 @@ 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'
|
||||
@@ -269,6 +270,16 @@ export function LibraryView(): React.JSX.Element {
|
||||
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(() => {
|
||||
@@ -376,7 +387,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
if (!u || indexing.active) return
|
||||
const res = await indexSource(u)
|
||||
if (res.ok) setUrl('')
|
||||
else setError(res.error ?? 'Could not index that link.')
|
||||
else setError(res.error ?? 'Could not add that link.')
|
||||
}
|
||||
|
||||
function toggle(id: string, on: boolean): void {
|
||||
@@ -469,11 +480,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
<span className={styles.groupTitle}>{row.title}</span>
|
||||
<Caption1 className={styles.srcSub}>{row.items.length}</Caption1>
|
||||
</button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => toggleGroup(row.items, !allOn)}
|
||||
>
|
||||
<Button size="small" appearance="subtle" onClick={() => toggleGroup(row.items, !allOn)}>
|
||||
{allOn ? 'None' : 'All'}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -519,7 +526,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader
|
||||
title="Library"
|
||||
description="Index a channel or playlist once, then download it into organized folders."
|
||||
description="Add a channel or playlist once, then download its videos into organized folders."
|
||||
/>
|
||||
|
||||
<div className={styles.addRow}>
|
||||
@@ -529,7 +536,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
value={url}
|
||||
onChange={(_, d) => setUrl(d.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
||||
placeholder="Paste a channel or playlist URL…"
|
||||
placeholder="Paste a channel or playlist URL to add it…"
|
||||
size="large"
|
||||
contentBefore={<LibraryRegular />}
|
||||
disabled={indexing.active}
|
||||
@@ -537,11 +544,11 @@ export function LibraryView(): React.JSX.Element {
|
||||
<Button
|
||||
size="large"
|
||||
appearance="primary"
|
||||
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||
icon={indexing.active ? <Spinner size="tiny" /> : <AddRegular />}
|
||||
onClick={onIndex}
|
||||
disabled={!url.trim() || indexing.active}
|
||||
>
|
||||
Index
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -603,7 +610,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
<div className={styles.progress}>
|
||||
<Spinner size="tiny" />
|
||||
<Text>
|
||||
{indexing.message ?? 'Indexing…'}
|
||||
{indexing.message ?? 'Adding…'}
|
||||
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -613,7 +620,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
{sources.length === 0 && !indexing.active ? (
|
||||
<div className={styles.empty}>
|
||||
<LibraryRegular fontSize={40} />
|
||||
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
|
||||
<Body1>No channels or playlists yet. Paste one above to add it.</Body1>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
@@ -692,7 +699,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
onClick={() => reindexSource(src.id)}
|
||||
disabled={indexing.active}
|
||||
>
|
||||
Re-index
|
||||
Refresh
|
||||
</Button>
|
||||
{confirmRemoveId === src.id ? (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Field,
|
||||
Button,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
@@ -14,7 +15,10 @@ import {
|
||||
ClipboardPasteRegular,
|
||||
HistoryRegular,
|
||||
OptionsRegular,
|
||||
RocketRegular
|
||||
RocketRegular,
|
||||
FolderRegular,
|
||||
MusicNote2Regular,
|
||||
VideoClipRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import { useSettings } from '../store/settings'
|
||||
|
||||
@@ -55,7 +59,41 @@ const useStyles = makeStyles({
|
||||
fontSize: '26px'
|
||||
},
|
||||
folderNote: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
color: tokens.colorNeutralForeground3,
|
||||
marginBottom: '8px'
|
||||
},
|
||||
folderRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '8px 10px',
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
||||
},
|
||||
folderRowGap: {
|
||||
marginTop: '8px'
|
||||
},
|
||||
folderIcon: {
|
||||
fontSize: '18px',
|
||||
flexShrink: 0,
|
||||
color: tokens.colorCompoundBrandForeground1
|
||||
},
|
||||
folderCol: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
flexGrow: 1
|
||||
},
|
||||
folderLabel: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorNeutralForeground1
|
||||
},
|
||||
folderPath: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
tips: {
|
||||
display: 'flex',
|
||||
@@ -93,6 +131,9 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
|
||||
export function Onboarding(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const update = useSettings((s) => s.update)
|
||||
const videoDir = useSettings((s) => s.videoDir)
|
||||
const audioDir = useSettings((s) => s.audioDir)
|
||||
const chooseDir = useSettings((s) => s.chooseDir)
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
@@ -111,10 +152,33 @@ export function Onboarding(): React.JSX.Element {
|
||||
|
||||
<Field label="Where downloads go">
|
||||
<Caption1 className={styles.folderNote}>
|
||||
Videos save to your <strong>Documents\Video</strong> folder and audio to{' '}
|
||||
<strong>Documents\Audio</strong>. You can point each to a different folder any time in
|
||||
Settings.
|
||||
Videos and audio save to your Documents folders by default. Pick a different folder now,
|
||||
or change it any time in Settings.
|
||||
</Caption1>
|
||||
<div className={styles.folderRow}>
|
||||
<VideoClipRegular className={styles.folderIcon} />
|
||||
<div className={styles.folderCol}>
|
||||
<Caption1 className={styles.folderLabel}>Video</Caption1>
|
||||
<Caption1 className={styles.folderPath} title={videoDir || 'Documents\\Video'}>
|
||||
{videoDir || 'Documents\\Video'}
|
||||
</Caption1>
|
||||
</div>
|
||||
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
|
||||
Choose…
|
||||
</Button>
|
||||
</div>
|
||||
<div className={mergeClasses(styles.folderRow, styles.folderRowGap)}>
|
||||
<MusicNote2Regular className={styles.folderIcon} />
|
||||
<div className={styles.folderCol}>
|
||||
<Caption1 className={styles.folderLabel}>Audio</Caption1>
|
||||
<Caption1 className={styles.folderPath} title={audioDir || 'Documents\\Audio'}>
|
||||
{audioDir || 'Documents\\Audio'}
|
||||
</Caption1>
|
||||
</div>
|
||||
<Button size="small" icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
|
||||
Choose…
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className={styles.tips}>
|
||||
|
||||
@@ -51,7 +51,10 @@ export function SettingsView(): React.JSX.Element {
|
||||
return (
|
||||
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
|
||||
<div data-settings-search>
|
||||
<ScreenHeader title="Settings" description="Folders, formats, network, cookies, and more." />
|
||||
<ScreenHeader
|
||||
title="Settings"
|
||||
description="Folders, formats, network, cookies, and more."
|
||||
/>
|
||||
</div>
|
||||
<div data-settings-search>
|
||||
<Input
|
||||
|
||||
@@ -36,6 +36,21 @@ export const useDownloadBarStyles = makeStyles({
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
// The channel/playlist nudge toward the Library (UX3): same brand-tinted banner
|
||||
// as the link suggestion, but its message wraps instead of truncating.
|
||||
channelHint: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 8px 8px 12px',
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge)
|
||||
},
|
||||
channelHintText: {
|
||||
flexGrow: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
url: {
|
||||
flexGrow: 1
|
||||
},
|
||||
|
||||
@@ -12,9 +12,11 @@ import { useDownloads, type MediaKind } from '../../store/downloads'
|
||||
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
||||
import { sameVideo } from '../../store/queueStats'
|
||||
import { useSettings } from '../../store/settings'
|
||||
import { useNav } from '../../store/nav'
|
||||
import {
|
||||
useClipboardLink,
|
||||
looksLikeUrl,
|
||||
looksLikeChannelOrPlaylist,
|
||||
firstUrlInText,
|
||||
type SuggestionSource
|
||||
} from '../../useClipboardLink'
|
||||
@@ -77,6 +79,10 @@ export interface DownloadBarController {
|
||||
suggestion: string | null
|
||||
suggestionSource: SuggestionSource
|
||||
dismissSuggestion: () => void
|
||||
// channel/playlist nudge → Library (UX3)
|
||||
channelHint: boolean
|
||||
openInLibrary: () => void
|
||||
dismissChannelHint: () => void
|
||||
// handlers
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
@@ -108,6 +114,7 @@ export interface DownloadBarController {
|
||||
export function useDownloadBar(): DownloadBarController {
|
||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||
const addMany = useDownloads((s) => s.addMany)
|
||||
const openLibraryWith = useNav((s) => s.openLibraryWith)
|
||||
const settingsLoaded = useSettings((s) => s.loaded)
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
@@ -128,6 +135,10 @@ export function useDownloadBar(): DownloadBarController {
|
||||
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
|
||||
// Channel/playlist nudge: a whole channel/playlist belongs in the Library, not
|
||||
// the one-off queue. Once dismissed for the current URL, stay quiet (UX3).
|
||||
const [channelHintDismissed, setChannelHintDismissed] = useState(false)
|
||||
|
||||
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
||||
const [dup, setDup] = useState<string | null>(null)
|
||||
const confirmDup = useRef(false)
|
||||
@@ -213,6 +224,16 @@ export function useDownloadBar(): DownloadBarController {
|
||||
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
|
||||
: undefined
|
||||
|
||||
// Show the "add this in the Library" nudge for a clear channel/playlist URL,
|
||||
// but only while the bar is still idle for it (nothing probed/resolved yet) and
|
||||
// the user hasn't waved it away (UX3).
|
||||
const channelHint =
|
||||
looksLikeChannelOrPlaylist(url) &&
|
||||
!channelHintDismissed &&
|
||||
info === null &&
|
||||
playlist === null &&
|
||||
probeError === null
|
||||
|
||||
function clearProbe(): void {
|
||||
setInfo(null)
|
||||
setProbeError(null)
|
||||
@@ -260,6 +281,8 @@ export function useDownloadBar(): DownloadBarController {
|
||||
if (dup) setDup(null)
|
||||
setCommandPreview(null)
|
||||
confirmDup.current = false
|
||||
// A new URL gets a fresh chance to nudge toward the Library.
|
||||
setChannelHintDismissed(false)
|
||||
}
|
||||
|
||||
function onKindChange(next: MediaKind): void {
|
||||
@@ -324,6 +347,22 @@ export function useDownloadBar(): DownloadBarController {
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the current channel/playlist URL to the Library tab, which pre-fills its
|
||||
// add field with it, and clear the bar (the URL now lives over there) (UX3).
|
||||
function openInLibrary(): void {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
openLibraryWith(trimmed)
|
||||
setUrl('')
|
||||
// Clear the URL-specific command preview too, so no stale command lingers over
|
||||
// the now-empty bar (the same invariant onUrlChange keeps).
|
||||
setCommandPreview(null)
|
||||
clearProbe()
|
||||
}
|
||||
function dismissChannelHint(): void {
|
||||
setChannelHintDismissed(true)
|
||||
}
|
||||
|
||||
// Selection helpers for the playlist panel.
|
||||
const allSelected = playlist !== null && selected.size === playlist.entries.length
|
||||
function toggleEntry(index: number, on: boolean): void {
|
||||
@@ -497,6 +536,10 @@ export function useDownloadBar(): DownloadBarController {
|
||||
suggestion,
|
||||
suggestionSource,
|
||||
dismissSuggestion,
|
||||
// channel/playlist nudge → Library (UX3)
|
||||
channelHint,
|
||||
openInLibrary,
|
||||
dismissChannelHint,
|
||||
// handlers
|
||||
onDragOver,
|
||||
onDrop,
|
||||
|
||||
@@ -42,7 +42,10 @@ export function AppearanceCard(): React.JSX.Element {
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={mergeClasses(styles.swatch, accentColor === opt.value && styles.swatchActive)}
|
||||
className={mergeClasses(
|
||||
styles.swatch,
|
||||
accentColor === opt.value && styles.swatchActive
|
||||
)}
|
||||
style={{ backgroundColor: opt.swatch }}
|
||||
onClick={() => update({ accentColor: opt.value as AccentColor })}
|
||||
aria-label={`${opt.label}${accentColor === opt.value ? ' (selected)' : ''}`}
|
||||
|
||||
@@ -21,8 +21,8 @@ export function CustomCommandsCard(): React.JSX.Element {
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
|
||||
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
|
||||
option, so a template flag can override a setting above it.
|
||||
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other option,
|
||||
so a template flag can override a setting above it.
|
||||
</Caption1>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -36,7 +36,11 @@ export function DiagnosticsCard(): React.JSX.Element {
|
||||
</Caption1>
|
||||
|
||||
<div className={styles.folderRow}>
|
||||
<Button icon={<CopyRegular />} onClick={copyErrorReport} disabled={errorEntries.length === 0}>
|
||||
<Button
|
||||
icon={<CopyRegular />}
|
||||
onClick={copyErrorReport}
|
||||
disabled={errorEntries.length === 0}
|
||||
>
|
||||
Copy full report
|
||||
</Button>
|
||||
{confirmClearLog ? (
|
||||
@@ -77,7 +81,9 @@ export function DiagnosticsCard(): React.JSX.Element {
|
||||
<div key={e.id} className={styles.errorRow}>
|
||||
<div className={styles.errorRowHeader}>
|
||||
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
|
||||
<Caption1 className={styles.hint}>{new Date(e.occurredAt).toLocaleString()}</Caption1>
|
||||
<Caption1 className={styles.hint}>
|
||||
{new Date(e.occurredAt).toLocaleString()}
|
||||
</Caption1>
|
||||
</div>
|
||||
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Field, Input, SpinButton, Switch, Button, Card, Subtitle2 } from '@fluentui/react-components'
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
SpinButton,
|
||||
Switch,
|
||||
Button,
|
||||
Card,
|
||||
Subtitle2
|
||||
} from '@fluentui/react-components'
|
||||
import { FolderRegular, ArrowDownloadRegular } from '@fluentui/react-icons'
|
||||
import { type MediaKind } from '@shared/ipc'
|
||||
import { useSettings } from '../../store/settings'
|
||||
|
||||
@@ -20,7 +20,10 @@ export function FilenamesCard(): React.JSX.Element {
|
||||
label="Filename template"
|
||||
hint="Controls how saved files are named. Use %(title)s for the video title, %(ext)s for the extension, and similar tokens."
|
||||
>
|
||||
<Input value={filenameTemplate} onChange={(_, d) => update({ filenameTemplate: d.value })} />
|
||||
<Input
|
||||
value={filenameTemplate}
|
||||
onChange={(_, d) => update({ filenameTemplate: d.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Caption1 className={styles.hint}>
|
||||
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
|
||||
|
||||
@@ -39,7 +39,11 @@ export function NetworkCard(): React.JSX.Element {
|
||||
label="Rate limit"
|
||||
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
|
||||
>
|
||||
<Input value={rateLimit} placeholder="2M" onChange={(_, d) => update({ rateLimit: d.value })} />
|
||||
<Input
|
||||
value={rateLimit}
|
||||
placeholder="2M"
|
||||
onChange={(_, d) => update({ rateLimit: d.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
|
||||
@@ -62,6 +62,34 @@ export function looksLikeSingleVideo(text: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* True for URLs that point at a whole channel or playlist rather than a single
|
||||
* video — the shapes that belong in the Library (index once, download in bulk),
|
||||
* not the one-off Downloads bar (UX3). Conservative on purpose: it only flags the
|
||||
* clear YouTube channel and dedicated-playlist shapes, so a plain `/watch?v=…`
|
||||
* video (even one carrying a `list=` context) is never flagged and keeps working
|
||||
* in the Downloads bar; a non-YouTube link is never flagged.
|
||||
*/
|
||||
export function looksLikeChannelOrPlaylist(text: string): boolean {
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(text.trim())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const host = u.hostname.replace(/^www\./, '').toLowerCase()
|
||||
const isYouTube =
|
||||
host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com'
|
||||
if (!isYouTube) return false
|
||||
// A dedicated playlist page (not a /watch video that merely carries a list).
|
||||
if (u.pathname === '/playlist' && u.searchParams.has('list')) return true
|
||||
// Channel shapes: /channel/UC…, /c/Name, /user/Name, /@handle — with an optional
|
||||
// /videos, /streams, /shorts, /playlists tab suffix.
|
||||
if (/^\/(channel|c|user)\/[^/]+/i.test(u.pathname)) return true
|
||||
if (/^\/@[^/]+/.test(u.pathname)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first http(s) URL from a multi-line blob of text.
|
||||
* Lines are trimmed; '#' comment lines are skipped.
|
||||
|
||||
@@ -402,18 +402,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
// no direct import from sources, so the two stores stay acyclic.
|
||||
on('enqueueDownloads', (entries) => useDownloads.getState().addMany(entries))
|
||||
|
||||
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
|
||||
// Session-only: the queue isn't persisted, so a schedule only fires while the app
|
||||
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
|
||||
setInterval(() => {
|
||||
const st = useDownloads.getState()
|
||||
const now = Date.now()
|
||||
if (
|
||||
st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)
|
||||
) {
|
||||
st.promoteDueScheduled()
|
||||
}
|
||||
}, 15_000)
|
||||
// The scheduled-download promoter tick lives in App (a useEffect), not here, so
|
||||
// importing this store in tests/preview doesn't start a stray never-cleared timer
|
||||
// on module load (L1). See SCHEDULE_TICK_MS / App.tsx.
|
||||
|
||||
// Wire main → renderer push events. (Output folder + cap live in the settings store.)
|
||||
if (!PREVIEW) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand'
|
||||
import type { TabValue } from '../components/Sidebar'
|
||||
|
||||
/**
|
||||
* The active screen, owned in a store so any component can navigate without
|
||||
* threading an `onTabChange` prop through the tree. It also carries a one-shot
|
||||
* URL handoff: the Downloads bar detects a channel/playlist link (which belongs
|
||||
* in the Library, not the one-off queue) and hands it here; the Library consumes
|
||||
* it once to pre-fill its add field (UX3).
|
||||
*/
|
||||
interface NavState {
|
||||
tab: TabValue
|
||||
setTab: (tab: TabValue) => void
|
||||
/** A channel/playlist URL handed from the Downloads bar to the Library. */
|
||||
pendingLibraryUrl: string | null
|
||||
/** Switch to the Library tab and stage `url` for its add field. */
|
||||
openLibraryWith: (url: string) => void
|
||||
/** Read and clear the staged Library URL (returns null if none is pending). */
|
||||
consumeLibraryUrl: () => string | null
|
||||
}
|
||||
|
||||
export const useNav = create<NavState>((set, get) => ({
|
||||
tab: 'downloads',
|
||||
setTab: (tab) => set({ tab }),
|
||||
pendingLibraryUrl: null,
|
||||
openLibraryWith: (url) => set({ tab: 'library', pendingLibraryUrl: url }),
|
||||
consumeLibraryUrl: () => {
|
||||
const url = get().pendingLibraryUrl
|
||||
if (url !== null) set({ pendingLibraryUrl: null })
|
||||
return url
|
||||
}
|
||||
}))
|
||||
@@ -149,7 +149,7 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
|
||||
reindexSource: async (id) => {
|
||||
const src = get().sources.find((s) => s.id === id)
|
||||
set({ indexing: { active: true, url: src?.url, message: 'Re-indexing…' } })
|
||||
set({ indexing: { active: true, url: src?.url, message: 'Refreshing…' } })
|
||||
if (PREVIEW) {
|
||||
await new Promise((r) => setTimeout(r, 700))
|
||||
set({ indexing: { active: false } })
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useSettings } from './store/settings'
|
||||
// Pure URL helpers extracted to a standalone module so they can be tested
|
||||
// without pulling in the Zustand store (L40). Re-exported here for existing
|
||||
// consumers (DownloadBar, LibraryView) without an import-path change.
|
||||
export { looksLikeUrl, looksLikeSingleVideo, firstUrlInText } from './lib/urlHelpers'
|
||||
export {
|
||||
looksLikeUrl,
|
||||
looksLikeSingleVideo,
|
||||
looksLikeChannelOrPlaylist,
|
||||
firstUrlInText
|
||||
} from './lib/urlHelpers'
|
||||
import { looksLikeUrl } from './lib/urlHelpers'
|
||||
|
||||
/** Where an offered link came from — drives the banner's wording. */
|
||||
|
||||
Reference in New Issue
Block a user