Harden audit findings: correctness, type-safety, Windows conventions & polish
Audit-pass over CODE-AUDIT.md (~48 items closed this pass; all verified — typecheck + 234 tests + eslint + prettier green). Correctness / bugs: - B3: match the release checksum to the asset's filename line (no wrong-hash verify) - B4: newline-safe metadata probe (one --print with a unit-separator delimiter) - B5 / L88: guard the meta event against canceled items; progress no longer promotes a queued item outside pump() - B7: cookie-login promise always resolves (handles destroy-without-close) - L146: trim parser rejects >2 colon-group times; M36: Library selection counts only actionable rows - L11 / L50 / L156 / L57 / L159 / L15 / L3: live queue count, empty-cookie message, schedule picker min, dead-code/comment cleanup Type safety: - Enable noUncheckedIndexedAccess + noFallthroughCasesInSwitch (15 real edge cases fixed) Resilience / Windows / metadata: - R5: settings write failure handled (no unhandled IPC rejection; reconciles to truth) - W1 / W5 / W6: min window size, seeded folder picker, parented sign-in window; L147 dead macOS branches removed - CL1: shared stdout markers; package/builder metadata (license, homepage, repository, copyright, tsbuildinfo glob) Copy / docs / tests: - M37 / SR9 dev-jargon cleanup in hints; M8 / M25 / M26 / L66 / L80 / L81 reconciled - New unit tests for L35 (isValidMediaItem) and L36 (compareVersions) This commit also checkpoints the previously-uncommitted feat/tray-background-clipboard work it builds on: background running + auto-download, library clipboard detection, tray, binary management & library scale, credential encryption at rest, the shared jsonStore and ui/ primitives, and the eslint/prettier tooling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,13 @@ import { TerminalView } from './components/TerminalView'
|
||||
import { SettingsView } from './components/SettingsView'
|
||||
import { Onboarding } from './components/Onboarding'
|
||||
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||
import { LiveRegion } from './components/LiveRegion'
|
||||
import { getTheme, pageBackground } from './theme'
|
||||
import { useSettings } from './store/settings'
|
||||
import { useDownloads } from './store/downloads'
|
||||
import { summarizeQueue } from './store/queueStats'
|
||||
import { useResolvedDark } from './store/systemTheme'
|
||||
import { logError } from './reportError'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
provider: {
|
||||
@@ -58,14 +60,19 @@ function App(): React.JSX.Element {
|
||||
function push(items: ReturnType<typeof useDownloads.getState>['items']): void {
|
||||
const s = summarizeQueue(items)
|
||||
const mode = s.active ? (s.failed > 0 ? 'error' : 'normal') : 'none'
|
||||
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode })
|
||||
window.api?.setTaskbarProgress?.({ fraction: s.progress, mode, badgeCount: s.downloading })
|
||||
}
|
||||
push(useDownloads.getState().items)
|
||||
return useDownloads.subscribe((st) => push(st.items))
|
||||
}, [])
|
||||
|
||||
const paletteActions: PaletteAction[] = [
|
||||
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
|
||||
{
|
||||
id: 'go-downloads',
|
||||
label: 'Go to Downloads',
|
||||
hint: 'Navigate',
|
||||
run: () => setTab('downloads')
|
||||
},
|
||||
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
|
||||
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
|
||||
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
|
||||
@@ -91,7 +98,7 @@ function App(): React.JSX.Element {
|
||||
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
|
||||
const [version, setVersion] = useState('')
|
||||
useEffect(() => {
|
||||
window.api?.getAppVersion?.().then(setVersion).catch(() => {})
|
||||
window.api?.getAppVersion?.().then(setVersion).catch(logError('getAppVersion'))
|
||||
}, [])
|
||||
|
||||
// Sidebar collapse, persisted across launches in localStorage.
|
||||
@@ -151,6 +158,7 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<LiveRegion />
|
||||
</div>
|
||||
</FluentProvider>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
|
||||
export interface PaletteAction {
|
||||
id: string
|
||||
@@ -35,7 +36,6 @@ const useStyles = makeStyles({
|
||||
input: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
padding: '14px 16px',
|
||||
fontSize: tokens.fontSizeBase400,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
@@ -91,6 +91,7 @@ export function CommandPalette({
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
const [q, setQ] = useState('')
|
||||
const [sel, setSel] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -133,7 +134,7 @@ export function CommandPalette({
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
className={mergeClasses(styles.input, focus.focusRing)}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -148,7 +149,11 @@ export function CommandPalette({
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
|
||||
className={mergeClasses(
|
||||
styles.item,
|
||||
i === sel && styles.itemActive,
|
||||
focus.focusRing
|
||||
)}
|
||||
onClick={() => {
|
||||
a.run()
|
||||
onClose()
|
||||
|
||||
@@ -33,7 +33,10 @@ import { sameVideo } from '../store/queueStats'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { Select } from './Select'
|
||||
import { Hint } from './Hint'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { IconButton } from './ui/IconButton'
|
||||
import { useClipboardLink, looksLikeUrl } from '../useClipboardLink'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
|
||||
function firstUrl(text: string): string | null {
|
||||
@@ -148,34 +151,6 @@ const useStyles = makeStyles({
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
},
|
||||
segmented: {
|
||||
display: 'inline-flex',
|
||||
width: 'fit-content',
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
overflow: 'hidden'
|
||||
},
|
||||
segment: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
padding: '7px 16px',
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
segmentActive: {
|
||||
backgroundColor: tokens.colorBrandBackground,
|
||||
color: tokens.colorNeutralForegroundOnBrand,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorBrandBackgroundHover
|
||||
}
|
||||
},
|
||||
quality: {
|
||||
minWidth: '220px'
|
||||
},
|
||||
@@ -224,24 +199,6 @@ const useStyles = makeStyles({
|
||||
flexGrow: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
plKindBtn: {
|
||||
flexShrink: 0,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
cursor: 'pointer',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
}
|
||||
},
|
||||
plItemLabel: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -296,6 +253,14 @@ const useStyles = makeStyles({
|
||||
}
|
||||
})
|
||||
|
||||
// Format a Date as the `YYYY-MM-DDTHH:mm` value a datetime-local input expects,
|
||||
// in LOCAL time — used as the picker's `min` so a past time can't be chosen and
|
||||
// then silently download immediately (L156/L57).
|
||||
function toLocalDatetimeValue(d: Date): string {
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
export function DownloadBar(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||
@@ -307,7 +272,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [kind, setKind] = useState<MediaKind>('video')
|
||||
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
|
||||
const [quality, setQuality] = useState<string>(QUALITY_OPTIONS.video[0])
|
||||
|
||||
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
|
||||
const [showTrim, setShowTrim] = useState(false)
|
||||
@@ -346,7 +311,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
const u = parseUrlFile(content)
|
||||
if (u) onUrlChange(u)
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(logError('dropped .url file read'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +349,10 @@ export function DownloadBar(): React.JSX.Element {
|
||||
dismiss: dismissSuggestion,
|
||||
offer: offerLink
|
||||
} = useClipboardLink(url)
|
||||
useEffect(() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')), [offerLink])
|
||||
useEffect(
|
||||
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
|
||||
[offerLink]
|
||||
)
|
||||
|
||||
function acceptSuggestion(): void {
|
||||
const link = acceptLink()
|
||||
@@ -393,7 +361,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
|
||||
const usingFormats = kind === 'video' && info !== null && info.formats.length > 0
|
||||
const selectedFormat: FormatOption | undefined = usingFormats
|
||||
? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
|
||||
? (info.formats.find((f) => f.id === formatId) ?? info.formats[0])
|
||||
: undefined
|
||||
|
||||
function clearProbe(): void {
|
||||
@@ -573,7 +541,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<div className={styles.urlRow}>
|
||||
<Input
|
||||
className={styles.url}
|
||||
input={{ id: 'aerofetch-url' }}
|
||||
input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }}
|
||||
value={url}
|
||||
onChange={(_, d) => onUrlChange(d.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
|
||||
@@ -716,19 +684,22 @@ export function DownloadBar(): React.JSX.Element {
|
||||
/>
|
||||
<Hint
|
||||
label={
|
||||
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
|
||||
effKind(e.index) === 'audio'
|
||||
? 'Audio — click for video'
|
||||
: 'Video — click for audio'
|
||||
}
|
||||
placement="top"
|
||||
align="end"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.plKindBtn}
|
||||
<IconButton
|
||||
size="sm"
|
||||
style={{ flexShrink: 0 }}
|
||||
icon={
|
||||
effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />
|
||||
}
|
||||
onClick={() => toggleItemKind(e.index)}
|
||||
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
|
||||
>
|
||||
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
|
||||
</button>
|
||||
/>
|
||||
</Hint>
|
||||
</div>
|
||||
))}
|
||||
@@ -781,6 +752,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
type="datetime-local"
|
||||
className={styles.dtInput}
|
||||
value={scheduleAt}
|
||||
min={toLocalDatetimeValue(new Date())}
|
||||
onChange={(e) => setScheduleAt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
@@ -792,20 +764,15 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.control}>
|
||||
<Caption1>Format</Caption1>
|
||||
<div className={styles.segmented} role="radiogroup" aria-label="Format">
|
||||
{(['video', 'audio'] as MediaKind[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={kind === k}
|
||||
className={mergeClasses(styles.segment, kind === k && styles.segmentActive)}
|
||||
onClick={() => onKindChange(k)}
|
||||
>
|
||||
{k === 'video' ? 'Video' : 'Audio'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl<MediaKind>
|
||||
value={kind}
|
||||
options={[
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'audio', label: 'Audio' }
|
||||
]}
|
||||
onChange={onKindChange}
|
||||
ariaLabel="Format"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.control}>
|
||||
|
||||
@@ -45,7 +45,7 @@ const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
|
||||
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
|
||||
sponsor: 'Sponsor',
|
||||
intro: 'Intro / intermission',
|
||||
outro: 'Endcards / credits',
|
||||
outro: 'End cards / credits',
|
||||
selfpromo: 'Self-promotion',
|
||||
preview: 'Preview / recap',
|
||||
filler: 'Filler / tangent',
|
||||
@@ -123,7 +123,10 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter.">
|
||||
<Field
|
||||
label="Preferred codec"
|
||||
hint="A preference when several formats match — not a strict filter."
|
||||
>
|
||||
<Select
|
||||
aria-label="Preferred video codec"
|
||||
value={value.preferredVideoCodec}
|
||||
@@ -144,7 +147,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
|
||||
<Field label="Embed subtitles" hint="Download subtitles and embed them in the video file.">
|
||||
<Switch
|
||||
checked={value.embedSubtitles}
|
||||
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
|
||||
@@ -232,6 +235,28 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
label={value.embedMetadata ? 'On' : 'Off'}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Metadata overrides"
|
||||
hint="Override specific tags before embedding. Leave blank to keep the extracted value. Automatically enables embed metadata."
|
||||
>
|
||||
<div className={styles.subGroup}>
|
||||
<Input
|
||||
placeholder="Title"
|
||||
value={value.metadataTitle ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataTitle', d.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Artist"
|
||||
value={value.metadataArtist ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataArtist', d.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Album"
|
||||
value={value.metadataAlbum ?? ''}
|
||||
onChange={(_, d) => setOpt('metadataAlbum', d.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
|
||||
<Switch
|
||||
checked={value.embedThumbnail}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Button,
|
||||
ProgressBar,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
@@ -14,6 +15,7 @@ import { summarizeQueue } from '../store/queueStats'
|
||||
import { DownloadBar } from './DownloadBar'
|
||||
import { QueueItem } from './QueueItem'
|
||||
import { VirtualList } from './VirtualList'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -70,6 +72,7 @@ const useStyles = makeStyles({
|
||||
|
||||
export function DownloadsView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const items = useDownloads((s) => s.items)
|
||||
const clearFinished = useDownloads((s) => s.clearFinished)
|
||||
const retryAll = useDownloads((s) => s.retryAll)
|
||||
@@ -78,9 +81,20 @@ export function DownloadsView(): React.JSX.Element {
|
||||
const hasFinished = items.some(
|
||||
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
|
||||
)
|
||||
// The header count is the live queue (work not yet finished), not the whole
|
||||
// list — completed/canceled/error rows linger until "Clear finished" (L11).
|
||||
const queueCount = items.filter(
|
||||
(i) =>
|
||||
i.status === 'downloading' ||
|
||||
i.status === 'queued' ||
|
||||
i.status === 'paused' ||
|
||||
i.status === 'saved'
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader title="Downloads" description="Fetch a video, playlist, or channel by URL." />
|
||||
|
||||
<DownloadBar />
|
||||
|
||||
{summary.active && (
|
||||
@@ -96,7 +110,7 @@ export function DownloadsView(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
<div className={styles.queueHeader}>
|
||||
<Subtitle2>Queue ({items.length})</Subtitle2>
|
||||
<Subtitle2>Queue ({queueCount})</Subtitle2>
|
||||
<div className={styles.headerActions}>
|
||||
{summary.failed > 0 && (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
interface State {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level renderer error boundary (M16). Before this, an exception thrown in
|
||||
* render by any view unmounted the whole shell to a blank white window with no
|
||||
* way out. This catches it, logs it (so it reaches the dev console / future log
|
||||
* sink), and shows a recover affordance.
|
||||
*
|
||||
* The fallback is intentionally dependency-free — no Fluent components, no theme
|
||||
* provider — because the error may have come from inside that very tree. It paints
|
||||
* its own full-window dark surface (matching the app's dark charcoal + toffee
|
||||
* accent) rather than reading theme tokens, so it renders identically regardless
|
||||
* of the active theme and uses only inline CSS that can't itself throw.
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
console.error('[AeroFetch] renderer crashed:', error, info.componentStack)
|
||||
}
|
||||
|
||||
private handleReload = (): void => {
|
||||
// A full reload re-runs the renderer from a clean state; persisted data
|
||||
// (settings/history/sources) lives in main, so nothing is lost.
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const { error } = this.state
|
||||
if (!error) return this.props.children
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
minHeight: '100vh',
|
||||
padding: 32,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Segoe UI, system-ui, sans-serif',
|
||||
color: '#c8c6c4',
|
||||
background: '#201f1e'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: '#f3f2f1' }}>Something went wrong</div>
|
||||
<div style={{ maxWidth: 440, fontSize: 13, lineHeight: 1.5 }}>
|
||||
AeroFetch hit an unexpected error and couldn’t continue. Your downloads and settings are
|
||||
saved — reloading the window should bring you back.
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
padding: '8px 12px',
|
||||
fontSize: 11,
|
||||
textAlign: 'left',
|
||||
color: '#d29ca0',
|
||||
background: '#2b2a29',
|
||||
borderRadius: 6
|
||||
}}
|
||||
>
|
||||
{error.message || String(error)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReload}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#1c1611',
|
||||
background: '#b5917d',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Reload AeroFetch
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Input,
|
||||
Body1,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from '@fluentui/react-icons'
|
||||
import type { HistoryEntry, MediaKind } from '@shared/ipc'
|
||||
import { useHistory } from '../store/history'
|
||||
import { formatWhen } from '../datetime'
|
||||
import { useResolvedDark } from '../store/systemTheme'
|
||||
import { useDownloads } from '../store/downloads'
|
||||
import { thumbColors } from '../theme'
|
||||
@@ -29,6 +31,8 @@ import { thumbUrl } from '../thumb'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
import { Hint } from './Hint'
|
||||
import { Select } from './Select'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -115,7 +119,7 @@ const useStyles = makeStyles({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
borderRadius: tokens.borderRadiusCircular,
|
||||
fontSize: '26px'
|
||||
},
|
||||
emptyHint: {
|
||||
@@ -134,19 +138,10 @@ const KIND_FILTER_OPTIONS = [
|
||||
{ value: 'audio', label: 'Audio' }
|
||||
]
|
||||
|
||||
function formatWhen(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
const dayMs = 1000 * 60 * 60 * 24
|
||||
if (ts >= startOfToday) return `Today, ${time}`
|
||||
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function HistoryView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const focus = useFocusStyles()
|
||||
const isDark = useResolvedDark()
|
||||
const tc = thumbColors[isDark ? 'dark' : 'light']
|
||||
const entries = useHistory((s) => s.entries)
|
||||
@@ -161,6 +156,8 @@ export function HistoryView(): React.JSX.Element {
|
||||
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
@@ -196,6 +193,7 @@ export function HistoryView(): React.JSX.Element {
|
||||
function exitSelectMode(): void {
|
||||
setSelectMode(false)
|
||||
setSelected(new Set())
|
||||
setConfirmDelete(false)
|
||||
}
|
||||
|
||||
function deleteSelected(): void {
|
||||
@@ -205,21 +203,25 @@ export function HistoryView(): React.JSX.Element {
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<div
|
||||
className={styles.emptyBadge}
|
||||
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
||||
>
|
||||
<HistoryRegular />
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||
<div className={styles.empty}>
|
||||
<div
|
||||
className={styles.emptyBadge}
|
||||
style={{ backgroundColor: tc.video.bg, color: tc.video.fg }}
|
||||
>
|
||||
<HistoryRegular />
|
||||
</div>
|
||||
<Body1>No downloads yet.</Body1>
|
||||
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
|
||||
</div>
|
||||
<Body1>No downloads yet.</Body1>
|
||||
<Caption1 className={styles.emptyHint}>Finished downloads will show up here.</Caption1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader title="History" description="Files you've finished downloading." />
|
||||
<div className={styles.header}>
|
||||
{selectMode ? (
|
||||
<>
|
||||
@@ -228,18 +230,44 @@ export function HistoryView(): React.JSX.Element {
|
||||
{allFilteredSelected ? 'Select none' : 'Select all'}
|
||||
</Button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={deleteSelected}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
Delete selected
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}>
|
||||
Cancel
|
||||
</Button>
|
||||
{confirmDelete ? (
|
||||
<>
|
||||
<Caption1 className={styles.count}>
|
||||
Delete {selected.size} {selected.size === 1 ? 'entry' : 'entries'}?
|
||||
</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
Delete selected
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DismissRegular />}
|
||||
onClick={exitSelectMode}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -248,6 +276,7 @@ export function HistoryView(): React.JSX.Element {
|
||||
</Caption1>
|
||||
<Input
|
||||
className={styles.search}
|
||||
input={{ 'aria-label': 'Search history' }}
|
||||
size="small"
|
||||
value={query}
|
||||
onChange={(_, d) => setQuery(d.value)}
|
||||
@@ -270,9 +299,36 @@ export function HistoryView(): React.JSX.Element {
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
|
||||
Clear history
|
||||
</Button>
|
||||
{confirmClear ? (
|
||||
<>
|
||||
<Caption1 className={styles.count}>
|
||||
Clear all {entries.length} {entries.length === 1 ? 'entry' : 'entries'}?
|
||||
</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => {
|
||||
clear()
|
||||
setConfirmClear(false)
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button size="small" appearance="subtle" onClick={() => setConfirmClear(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setConfirmClear(true)}
|
||||
>
|
||||
Clear history
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -280,10 +336,33 @@ export function HistoryView(): React.JSX.Element {
|
||||
{filtered.length === 0 ? (
|
||||
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<div
|
||||
className={styles.list}
|
||||
onKeyDown={(e) => {
|
||||
// W7: Ctrl/Cmd+A within the list selects every visible row (entering
|
||||
// select mode if needed). Scoped to the list, so it never hijacks
|
||||
// Ctrl+A in the search field, which lives in the header above.
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) {
|
||||
e.preventDefault()
|
||||
setSelectMode(true)
|
||||
setSelected(new Set(filtered.map((x) => x.id)))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filtered.map((h: HistoryEntry) => {
|
||||
return (
|
||||
<div key={h.id} className={styles.row}>
|
||||
<div
|
||||
key={h.id}
|
||||
className={mergeClasses(styles.row, focus.focusRing)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
// Delete removes the focused row (not when a child button is focused).
|
||||
if (e.key === 'Delete' && e.target === e.currentTarget) {
|
||||
e.preventDefault()
|
||||
remove(h.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectMode && (
|
||||
<Checkbox
|
||||
checked={selected.has(h.id)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
Text,
|
||||
@@ -33,10 +32,15 @@ import type { MediaItem, Source } from '@shared/ipc'
|
||||
import { useSources } from '../store/sources'
|
||||
import { useSettings } from '../store/settings'
|
||||
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 { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { StatusChip } from './ui/StatusChip'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -50,26 +54,13 @@ type ItemStatus = DownloadStatus | 'pending'
|
||||
* window cleanly (one virtualizer over a flat array, headers included).
|
||||
*/
|
||||
type LibRow =
|
||||
| { kind: 'header'; title: string; items: MediaItem[] }
|
||||
| { kind: 'item'; item: MediaItem }
|
||||
{ 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
|
||||
|
||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
pending: 'Pending',
|
||||
queued: 'Queued',
|
||||
downloading: 'Downloading',
|
||||
paused: 'Paused',
|
||||
saved: 'Saved',
|
||||
completed: 'Downloaded',
|
||||
error: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
|
||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
addRow: { display: 'flex', gap: '8px' },
|
||||
addInput: { flexGrow: 1 },
|
||||
@@ -198,27 +189,7 @@ const useStyles = makeStyles({
|
||||
textOverflow: 'ellipsis',
|
||||
color: tokens.colorNeutralForeground1
|
||||
},
|
||||
rowMeta: { color: tokens.colorNeutralForeground3 },
|
||||
pill: {
|
||||
flexShrink: 0,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
padding: '1px 8px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
pillDownloading: {
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2
|
||||
},
|
||||
pillCompleted: {
|
||||
backgroundColor: tokens.colorPaletteGreenBackground2,
|
||||
color: tokens.colorPaletteGreenForeground2
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: tokens.colorPaletteRedBackground2,
|
||||
color: tokens.colorPaletteRedForeground2
|
||||
}
|
||||
rowMeta: { color: tokens.colorNeutralForeground3 }
|
||||
})
|
||||
|
||||
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
||||
@@ -241,18 +212,10 @@ function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[
|
||||
return groups
|
||||
}
|
||||
|
||||
function relTime(ms?: number): string {
|
||||
if (!ms) return 'never'
|
||||
const mins = Math.round((Date.now() - ms) / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins} min ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs} h ago`
|
||||
return `${Math.round(hrs / 24)} d ago`
|
||||
}
|
||||
|
||||
export function LibraryView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const focus = useFocusStyles()
|
||||
const sources = useSources((s) => s.sources)
|
||||
const itemsBySource = useSources((s) => s.itemsBySource)
|
||||
const selectedSourceId = useSources((s) => s.selectedSourceId)
|
||||
@@ -271,6 +234,13 @@ export function LibraryView(): React.JSX.Element {
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null)
|
||||
|
||||
// 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))
|
||||
@@ -287,7 +257,10 @@ export function LibraryView(): React.JSX.Element {
|
||||
// Load the current scheduled-sync (Task Scheduler) state once.
|
||||
useEffect(() => {
|
||||
if (PREVIEW) return
|
||||
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
|
||||
window.api
|
||||
.getScheduledSync()
|
||||
.then((s) => setScheduled(s.enabled))
|
||||
.catch(logError('getScheduledSync'))
|
||||
}, [])
|
||||
|
||||
async function onCheckNew(): Promise<void> {
|
||||
@@ -322,8 +295,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
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)
|
||||
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[] = []
|
||||
@@ -385,6 +357,9 @@ export function LibraryView(): React.JSX.Element {
|
||||
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)
|
||||
}
|
||||
@@ -422,13 +397,6 @@ export function LibraryView(): React.JSX.Element {
|
||||
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)
|
||||
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
|
||||
return styles.pill
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -437,18 +405,19 @@ 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
|
||||
// "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}
|
||||
className={mergeClasses(styles.groupHead, focus.focusRing)}
|
||||
onClick={() => toggleGroupExpand(row.title)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)
|
||||
}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleGroupExpand(row.title)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
@@ -486,6 +455,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
<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}`}
|
||||
/>
|
||||
@@ -501,23 +471,22 @@ export function LibraryView(): React.JSX.Element {
|
||||
</span>
|
||||
{it.durationLabel && <Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>}
|
||||
</div>
|
||||
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
|
||||
<StatusChip status={status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Subtitle2>Library</Subtitle2>
|
||||
<Caption1 className={styles.sub}>
|
||||
Index a channel or playlist once, then download it into organized folders.
|
||||
</Caption1>
|
||||
</div>
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader
|
||||
title="Library"
|
||||
description="Index a channel or playlist once, then download it 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()}
|
||||
@@ -615,9 +584,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
styles={styles}
|
||||
source={src}
|
||||
expanded={selectedSourceId === src.id}
|
||||
onToggleExpand={() =>
|
||||
selectSource(selectedSourceId === src.id ? null : src.id)
|
||||
}
|
||||
onToggleExpand={() => selectSource(selectedSourceId === src.id ? null : src.id)}
|
||||
>
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.actionRow}>
|
||||
@@ -634,7 +601,11 @@ export function LibraryView(): React.JSX.Element {
|
||||
<div className={styles.actionSpacer} />
|
||||
{selected.size > 0 ? (
|
||||
<>
|
||||
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => setSelected(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
@@ -675,14 +646,38 @@ export function LibraryView(): React.JSX.Element {
|
||||
>
|
||||
Re-index
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => removeSource(src.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{confirmRemoveId === src.id ? (
|
||||
<>
|
||||
<Caption1 className={styles.sub}>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={styles.srcSub}>{batchNote}</Caption1>}
|
||||
@@ -695,7 +690,7 @@ export function LibraryView(): React.JSX.Element {
|
||||
items={flatRows}
|
||||
style={{ height: '58vh' }}
|
||||
overscan={10}
|
||||
estimateSize={(i) => (flatRows[i].kind === 'header' ? 40 : 46)}
|
||||
estimateSize={(i) => (flatRows[i]?.kind === 'header' ? 40 : 46)}
|
||||
getKey={(row) => rowKey(row)}
|
||||
renderItem={(row) => renderRow(row)}
|
||||
/>
|
||||
@@ -730,10 +725,11 @@ function SourceCard({
|
||||
onToggleExpand: () => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const focus = useFocusStyles()
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div
|
||||
className={styles.cardHead}
|
||||
className={mergeClasses(styles.cardHead, focus.focusRing)}
|
||||
onClick={onToggleExpand}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { makeStyles } from '@fluentui/react-components'
|
||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
// Visually hidden, but present for screen readers (the standard sr-only recipe).
|
||||
srOnly: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* A polite ARIA live region (W17) so Narrator announces download completions and
|
||||
* failures even when the user isn't on the Downloads tab. Subscribes to the store
|
||||
* directly and announces only terminal transitions (→ completed / error), so it
|
||||
* never chatters on progress ticks. Mounted once, near the app root.
|
||||
*/
|
||||
export function LiveRegion(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const [message, setMessage] = useState('')
|
||||
const prev = useRef<Map<string, DownloadStatus>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
function check(items: ReturnType<typeof useDownloads.getState>['items']): void {
|
||||
const announcements: string[] = []
|
||||
for (const it of items) {
|
||||
if (prev.current.get(it.id) !== it.status) {
|
||||
if (it.status === 'completed') announcements.push(`Finished downloading ${it.title}`)
|
||||
else if (it.status === 'error') announcements.push(`Download failed: ${it.title}`)
|
||||
}
|
||||
}
|
||||
prev.current = new Map(items.map((i) => [i.id, i.status]))
|
||||
if (announcements.length > 0) setMessage(announcements.join('. '))
|
||||
}
|
||||
// Seed the baseline without announcing the items already present on mount.
|
||||
prev.current = new Map(useDownloads.getState().items.map((i) => [i.id, i.status]))
|
||||
return useDownloads.subscribe((st) => check(st.items))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.srOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function MediaThumb({
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const isDark = useResolvedDark()
|
||||
const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video']
|
||||
const colors = thumbColors[isDark ? 'dark' : 'light'][kind]
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null)
|
||||
const showImg = !!src && failedSrc !== src
|
||||
|
||||
|
||||
@@ -112,8 +112,8 @@ 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.
|
||||
<strong>Documents\Audio</strong>. You can point each to a different folder any time in
|
||||
Settings.
|
||||
</Caption1>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
Caption1,
|
||||
Button,
|
||||
ProgressBar,
|
||||
Badge,
|
||||
Spinner,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
ErrorCircleFilled,
|
||||
EyeOffRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
|
||||
import { useDownloads, type DownloadItem } from '../store/downloads'
|
||||
import { thumbUrl } from '../thumb'
|
||||
import { fmtSchedule } from '../datetime'
|
||||
import { MediaThumb } from './MediaThumb'
|
||||
import { Hint } from './Hint'
|
||||
import { StatusChip } from './ui/StatusChip'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -93,34 +96,17 @@ const useStyles = makeStyles({
|
||||
}
|
||||
})
|
||||
|
||||
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
|
||||
queued: { label: 'Queued', color: 'subtle' },
|
||||
downloading: { label: 'Downloading', color: 'brand' },
|
||||
paused: { label: 'Paused', color: 'warning' },
|
||||
saved: { label: 'Saved', color: 'subtle' },
|
||||
completed: { label: 'Completed', color: 'success' },
|
||||
error: { label: 'Failed', color: 'danger' },
|
||||
canceled: { label: 'Canceled', color: 'warning' }
|
||||
}
|
||||
|
||||
function pct(progress: number): string {
|
||||
return `${Math.round(progress * 100)}%`
|
||||
}
|
||||
|
||||
function fmtSchedule(ms: number): string {
|
||||
try {
|
||||
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const QueueItem = memo(function QueueItem({
|
||||
item
|
||||
}: {
|
||||
item: DownloadItem
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
const cancel = useDownloads((s) => s.cancel)
|
||||
const pause = useDownloads((s) => s.pause)
|
||||
const resume = useDownloads((s) => s.resume)
|
||||
@@ -132,9 +118,19 @@ export const QueueItem = memo(function QueueItem({
|
||||
const openFile = useDownloads((s) => s.openFile)
|
||||
const showInFolder = useDownloads((s) => s.showInFolder)
|
||||
|
||||
const badge = STATUS_BADGE[item.status]
|
||||
const active = item.status === 'downloading' || item.status === 'queued'
|
||||
|
||||
// W7: Delete on a focused row removes it — cancelling first if it's still running,
|
||||
// since an in-flight download can't just be dropped from the list. Only when the
|
||||
// row itself holds focus, so Delete on one of its action buttons is unaffected.
|
||||
function onKeyDown(e: React.KeyboardEvent): void {
|
||||
if (e.key === 'Delete' && e.target === e.currentTarget) {
|
||||
e.preventDefault()
|
||||
if (active) cancel(item.id)
|
||||
else remove(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const metaParts = [
|
||||
item.channel,
|
||||
item.durationLabel,
|
||||
@@ -144,7 +140,7 @@ export const QueueItem = memo(function QueueItem({
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={mergeClasses(styles.root, focus.focusRing)} tabIndex={0} onKeyDown={onKeyDown}>
|
||||
<MediaThumb
|
||||
className={styles.thumb}
|
||||
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
|
||||
@@ -167,9 +163,7 @@ export const QueueItem = memo(function QueueItem({
|
||||
/>
|
||||
)}
|
||||
<Text className={styles.title}>{item.title}</Text>
|
||||
<Badge appearance="tint" color={badge.color}>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
<StatusChip status={item.status} />
|
||||
{item.incognito && (
|
||||
<Hint label="Private — not saved to history" placement="top" align="start">
|
||||
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
|
||||
@@ -181,11 +175,24 @@ export const QueueItem = memo(function QueueItem({
|
||||
|
||||
{item.status === 'downloading' && (
|
||||
<div className={styles.progressRow}>
|
||||
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
|
||||
{/* Indeterminate when finishing (the second stream / merge reads as
|
||||
"working" rather than a 0→100% restart, SR7) or when yt-dlp can't
|
||||
report a total size, so the bar never sits frozen at 0% (L137). */}
|
||||
<ProgressBar
|
||||
className={styles.progressBar}
|
||||
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
|
||||
thickness="large"
|
||||
/>
|
||||
<Caption1 className={styles.stats}>
|
||||
{pct(item.progress)}
|
||||
{item.speed ? ` • ${item.speed}` : ''}
|
||||
{item.eta ? ` • ${item.eta} left` : ''}
|
||||
{item.finishing ? (
|
||||
'Finishing…'
|
||||
) : (
|
||||
<>
|
||||
{item.sizeUnknown ? 'Downloading…' : pct(item.progress)}
|
||||
{item.speed ? ` • ${item.speed}` : ''}
|
||||
{item.eta ? ` • ${item.eta} left` : ''}
|
||||
</>
|
||||
)}
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
@@ -206,7 +213,9 @@ export const QueueItem = memo(function QueueItem({
|
||||
|
||||
{item.status === 'saved' && (
|
||||
<Caption1 className={styles.stats}>
|
||||
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
|
||||
{item.scheduledFor
|
||||
? `Scheduled for ${fmtSchedule(item.scheduledFor)}`
|
||||
: 'Saved for later'}
|
||||
</Caption1>
|
||||
)}
|
||||
|
||||
|
||||
@@ -61,14 +61,16 @@ import { useErrorLog } from '../store/errorlog'
|
||||
import { Select } from './Select'
|
||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||
import { TemplateManager } from './TemplateManager'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
import { useErrorTextStyles } from './ui/errorText'
|
||||
import { ACCENT_OPTIONS } from '../theme'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
maxWidth: '640px'
|
||||
gap: '16px'
|
||||
},
|
||||
card: {
|
||||
display: 'flex',
|
||||
@@ -117,7 +119,7 @@ const useStyles = makeStyles({
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
flexShrink: 0,
|
||||
...shorthands.borderRadius('50%'),
|
||||
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||
...shorthands.border('2px', 'solid', 'transparent'),
|
||||
padding: 0,
|
||||
cursor: 'pointer'
|
||||
@@ -148,11 +150,6 @@ const useStyles = makeStyles({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
errorRowText: {
|
||||
color: tokens.colorPaletteRedForeground1,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -176,7 +173,7 @@ const COOKIE_SOURCE_OPTIONS = [
|
||||
|
||||
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
|
||||
value: b,
|
||||
label: b[0].toUpperCase() + b.slice(1)
|
||||
label: b.charAt(0).toUpperCase() + b.slice(1)
|
||||
}))
|
||||
|
||||
const UPDATE_CHANNEL_OPTIONS = [
|
||||
@@ -186,6 +183,8 @@ const UPDATE_CHANNEL_OPTIONS = [
|
||||
|
||||
export function SettingsView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const errText = useErrorTextStyles()
|
||||
const screen = useScreenStyles()
|
||||
|
||||
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
|
||||
// filter by toggling each card's `display` based on whether its text matches.
|
||||
@@ -252,6 +251,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
||||
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
|
||||
const [mintingPot, setMintingPot] = useState(false)
|
||||
const [potHint, setPotHint] = useState<string | null>(null)
|
||||
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
|
||||
@@ -273,22 +274,23 @@ export function SettingsView(): React.JSX.Element {
|
||||
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
|
||||
const [confirmClearLog, setConfirmClearLog] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.api.cookiesStatus().then(setCookiesStatus)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getAppVersion().then(setAppVersion).catch(() => {})
|
||||
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
|
||||
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Show the current yt-dlp version without a manual click, and reflect a
|
||||
// background auto-update (which may run on launch) live in this panel.
|
||||
window.api.getYtdlpVersion().then(setVersion).catch(() => {})
|
||||
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
|
||||
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
|
||||
window.api.getFfmpegVersions().then(setFfmpeg).catch(() => {})
|
||||
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
|
||||
return window.api.onYtdlpAutoUpdateStatus((s) => {
|
||||
if (s.phase === 'checking') {
|
||||
setChecking(true)
|
||||
@@ -375,17 +377,14 @@ export function SettingsView(): React.JSX.Element {
|
||||
}
|
||||
|
||||
function copyErrorReport(): void {
|
||||
// The button is disabled when there are no entries, so `report` is always
|
||||
// non-empty here — no need for an unreachable "No errors logged." fallback (L159).
|
||||
const report = errorEntries
|
||||
.map((e) =>
|
||||
[
|
||||
new Date(e.occurredAt).toLocaleString(),
|
||||
e.title ?? e.url,
|
||||
e.url,
|
||||
e.error
|
||||
].join('\n')
|
||||
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
|
||||
)
|
||||
.join('\n\n---\n\n')
|
||||
navigator.clipboard.writeText(report || 'No errors logged.').catch(() => {})
|
||||
navigator.clipboard.writeText(report).catch(() => {})
|
||||
}
|
||||
|
||||
async function signIn(): Promise<void> {
|
||||
@@ -393,8 +392,14 @@ export function SettingsView(): React.JSX.Element {
|
||||
setLoginError(null)
|
||||
try {
|
||||
const result = await window.api.cookiesLogin(loginUrl)
|
||||
if (result.ok) setCookiesStatus(await window.api.cookiesStatus())
|
||||
else setLoginError(result.error ?? 'Sign-in failed.')
|
||||
if (result.ok && result.cookieCount === 0) {
|
||||
// Window closed without capturing anything — don't imply success (L50).
|
||||
setLoginError('No cookies were captured — did you sign in before closing the window?')
|
||||
} else if (result.ok) {
|
||||
setCookiesStatus(await window.api.cookiesStatus())
|
||||
} else {
|
||||
setLoginError(result.error ?? 'Sign-in failed.')
|
||||
}
|
||||
} catch (e) {
|
||||
setLoginError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
@@ -443,9 +448,16 @@ export function SettingsView(): React.JSX.Element {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
|
||||
<div data-settings-search>
|
||||
<ScreenHeader
|
||||
title="Settings"
|
||||
description="Folders, formats, network, cookies, and more."
|
||||
/>
|
||||
</div>
|
||||
<div data-settings-search>
|
||||
<Input
|
||||
input={{ 'aria-label': 'Search settings' }}
|
||||
value={search}
|
||||
onChange={(_, d) => setSearch(d.value)}
|
||||
placeholder="Search settings…"
|
||||
@@ -682,7 +694,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Use aria2c downloader"
|
||||
hint="Multi-connection downloads for faster speeds on supported sites. Requires aria2c.exe in resources/bin (see the README there) — falls back to yt-dlp's downloader if it's missing."
|
||||
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
|
||||
>
|
||||
<Switch
|
||||
checked={useAria2c}
|
||||
@@ -704,13 +716,47 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="YouTube PO token (advanced)"
|
||||
hint="A manually-obtained Proof-of-Origin token for YouTube's bot check, sent via --extractor-args. Usually cookies (above) are the easier fix; automatic minting is planned."
|
||||
hint={
|
||||
potHint ??
|
||||
'A token that helps get past YouTube\'s bot check. Click "Fetch" to grab one automatically, or paste it manually. Signing in with cookies (above) is usually the easier fix.'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={youtubePoToken}
|
||||
placeholder="(none)"
|
||||
onChange={(_, d) => update({ youtubePoToken: d.value })}
|
||||
/>
|
||||
<div className={styles.folderRow}>
|
||||
<Input
|
||||
style={{ flexGrow: 1 }}
|
||||
value={youtubePoToken}
|
||||
placeholder="(none)"
|
||||
onChange={(_, d) => {
|
||||
update({ youtubePoToken: d.value })
|
||||
setPotHint(null)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={mintingPot}
|
||||
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
|
||||
onClick={async () => {
|
||||
setMintingPot(true)
|
||||
setPotHint(null)
|
||||
try {
|
||||
const token = await window.api.mintPoToken()
|
||||
if (token) {
|
||||
setPotHint('Token saved.')
|
||||
} else {
|
||||
setPotHint(
|
||||
'Token not found — try signing into YouTube first via Cookies above.'
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
setPotHint('Failed to open YouTube window.')
|
||||
} finally {
|
||||
setMintingPot(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mintingPot ? 'Fetching…' : 'Fetch'}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
@@ -720,8 +766,8 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>Cookies</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Some sites only serve full quality, age-restricted, or members-only video to a
|
||||
logged-in session. Supply cookies so yt-dlp can act like one.
|
||||
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
|
||||
session. Supply cookies so yt-dlp can act like one.
|
||||
</Caption1>
|
||||
|
||||
<Field label="Cookie source">
|
||||
@@ -736,7 +782,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
{cookieSource === 'browser' && (
|
||||
<Field
|
||||
label="Browser"
|
||||
hint="Reads that browser's saved cookies directly. Close it first if yt-dlp can't open a locked cookie database."
|
||||
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
|
||||
>
|
||||
<Select
|
||||
aria-label="Browser"
|
||||
@@ -779,9 +825,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loginError && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>{loginError}</Caption1>
|
||||
)}
|
||||
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -872,8 +916,9 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>Backup & restore</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
Save your settings and custom-command templates to a JSON file, or restore them on
|
||||
another machine. Does not include download history.
|
||||
Save your settings and custom-command templates to a JSON file, or restore them on another
|
||||
machine. Does not include download history or credentials (proxy, API tokens — re-enter
|
||||
those after import).
|
||||
</Caption1>
|
||||
|
||||
<div className={styles.folderRow}>
|
||||
@@ -888,17 +933,13 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
|
||||
)}
|
||||
{exportResult && !exportResult.ok && exportResult.error && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
|
||||
{exportResult.error}
|
||||
</Caption1>
|
||||
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
|
||||
)}
|
||||
{importResult?.ok && (
|
||||
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
|
||||
)}
|
||||
{importResult && !importResult.ok && importResult.error && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
|
||||
{importResult.error}
|
||||
</Caption1>
|
||||
<Caption1 className={errText.error}>{importResult.error}</Caption1>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -920,9 +961,34 @@ export function SettingsView(): React.JSX.Element {
|
||||
>
|
||||
Copy full report
|
||||
</Button>
|
||||
<Button icon={<DeleteRegular />} onClick={clearErrorLog} disabled={errorEntries.length === 0}>
|
||||
Clear log
|
||||
</Button>
|
||||
{confirmClearLog ? (
|
||||
<>
|
||||
<Caption1>
|
||||
Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}?
|
||||
</Caption1>
|
||||
<Button
|
||||
icon={<DeleteRegular />}
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
clearErrorLog()
|
||||
setConfirmClearLog(false)
|
||||
}}
|
||||
>
|
||||
Clear log
|
||||
</Button>
|
||||
<Button appearance="subtle" onClick={() => setConfirmClearLog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => setConfirmClearLog(true)}
|
||||
disabled={errorEntries.length === 0}
|
||||
>
|
||||
Clear log
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorEntries.length === 0 ? (
|
||||
@@ -937,7 +1003,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
{new Date(e.occurredAt).toLocaleString()}
|
||||
</Caption1>
|
||||
</div>
|
||||
<Caption1 className={styles.errorRowText}>{e.error}</Caption1>
|
||||
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -968,12 +1034,12 @@ export function SettingsView(): React.JSX.Element {
|
||||
|
||||
<Field
|
||||
label="Update access token"
|
||||
hint="Only needed if the release server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only Gitea token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||
hint="Only needed if the update server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={updateToken}
|
||||
placeholder="Optional — Gitea access token"
|
||||
placeholder="Optional — access token"
|
||||
onChange={(_, d) => update({ updateToken: d.value })}
|
||||
contentBefore={<ArrowSyncRegular />}
|
||||
/>
|
||||
@@ -995,7 +1061,10 @@ export function SettingsView(): React.JSX.Element {
|
||||
{appDownloading ? 'Downloading…' : 'Update now'}
|
||||
</Button>
|
||||
{appUpd.htmlUrl && (
|
||||
<Button icon={<OpenRegular />} onClick={() => window.open(appUpd.htmlUrl, '_blank')}>
|
||||
<Button
|
||||
icon={<OpenRegular />}
|
||||
onClick={() => window.open(appUpd.htmlUrl, '_blank')}
|
||||
>
|
||||
View release
|
||||
</Button>
|
||||
)}
|
||||
@@ -1013,11 +1082,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Text>You're up to date — v{appUpd.currentVersion} is the latest.</Text>
|
||||
)}
|
||||
|
||||
{appUpdError && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
|
||||
{appUpdError}
|
||||
</Caption1>
|
||||
)}
|
||||
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
|
||||
</Card>
|
||||
|
||||
<Card className={styles.card}>
|
||||
@@ -1026,13 +1091,13 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Subtitle2>About</Subtitle2>
|
||||
</div>
|
||||
<Caption1 className={styles.hint}>
|
||||
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its
|
||||
own copy of yt-dlp, keeping it up to date automatically.
|
||||
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
|
||||
yt-dlp, keeping it up to date automatically.
|
||||
</Caption1>
|
||||
|
||||
<Field
|
||||
label="Keep yt-dlp updated automatically"
|
||||
hint="Checks once a day on launch and updates yt-dlp in the background, so YouTube changes don't start breaking downloads with 403 errors."
|
||||
hint="Checks once a day on launch and updates the downloader in the background, so site changes don't start breaking your downloads."
|
||||
>
|
||||
<Switch
|
||||
checked={autoUpdateYtdlp}
|
||||
@@ -1045,9 +1110,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
|
||||
)}
|
||||
{version && !version.ok && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
|
||||
{version.error}
|
||||
</Caption1>
|
||||
<Caption1 className={errText.errorPre}>{version.error}</Caption1>
|
||||
)}
|
||||
{ffmpeg && (
|
||||
<>
|
||||
@@ -1094,9 +1157,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
</Text>
|
||||
)}
|
||||
{updateResult && !updateResult.ok && (
|
||||
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
|
||||
{updateResult.error}
|
||||
</Caption1>
|
||||
<Caption1 className={errText.errorPre}>{updateResult.error}</Caption1>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
} from '@fluentui/react-icons'
|
||||
import type { ThemeMode } from '@shared/ipc'
|
||||
import { Hint } from './Hint'
|
||||
import { SegmentedControl } from './ui/SegmentedControl'
|
||||
import { IconButton } from './ui/IconButton'
|
||||
import { useFocusStyles } from './ui/focusRing'
|
||||
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
||||
|
||||
@@ -41,24 +44,6 @@ const useStyles = makeStyles({
|
||||
topBarCollapsed: {
|
||||
justifyContent: 'center'
|
||||
},
|
||||
iconBtn: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
}
|
||||
},
|
||||
brand: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -138,41 +123,6 @@ const useStyles = makeStyles({
|
||||
},
|
||||
spacer: {
|
||||
flexGrow: 1
|
||||
},
|
||||
// --- theme control (expanded): a 3-way Light / Dark / Auto segmented switch ---
|
||||
themeGroup: {
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
overflow: 'hidden'
|
||||
},
|
||||
themeSeg: {
|
||||
flex: 1,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '5px',
|
||||
padding: '7px 4px',
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
themeSegActive: {
|
||||
backgroundColor: tokens.colorBrandBackground,
|
||||
color: tokens.colorNeutralForegroundOnBrand,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorBrandBackgroundHover
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -215,11 +165,13 @@ export function Sidebar({
|
||||
onToggleCollapsed
|
||||
}: SidebarProps): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
|
||||
// Collapsed view shows one button that cycles Light → Dark → Auto.
|
||||
const order: ThemeMode[] = ['light', 'dark', 'system']
|
||||
function cycleTheme(): void {
|
||||
onSetTheme(order[(order.indexOf(theme) + 1) % order.length])
|
||||
const next = order[(order.indexOf(theme) + 1) % order.length]
|
||||
if (next) onSetTheme(next)
|
||||
}
|
||||
const themeIcon =
|
||||
theme === 'system' ? (
|
||||
@@ -235,15 +187,12 @@ export function Sidebar({
|
||||
<nav className={mergeClasses(styles.root, collapsed && styles.rootCollapsed)}>
|
||||
<div className={mergeClasses(styles.topBar, collapsed && styles.topBarCollapsed)}>
|
||||
<Hint label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
<IconButton
|
||||
icon={collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-pressed={collapsed}
|
||||
>
|
||||
{collapsed ? <PanelLeftExpandRegular /> : <PanelLeftContractRegular />}
|
||||
</button>
|
||||
/>
|
||||
</Hint>
|
||||
</div>
|
||||
|
||||
@@ -254,7 +203,12 @@ export function Sidebar({
|
||||
{!collapsed && (
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandName}>AeroFetch</span>
|
||||
<Caption1 className={styles.caption}>{version ? `v${version}` : 'yt-dlp frontend'}</Caption1>
|
||||
{/* Stable tagline so the caption never flips from text to a version
|
||||
string once it loads (L66); the version shows on hover and in
|
||||
Settings → About. */}
|
||||
<Caption1 className={styles.caption} title={version ? `Version ${version}` : undefined}>
|
||||
yt-dlp frontend
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -269,7 +223,8 @@ export function Sidebar({
|
||||
className={mergeClasses(
|
||||
styles.navItem,
|
||||
collapsed && styles.navItemCollapsed,
|
||||
active && styles.navItemActive
|
||||
active && styles.navItemActive,
|
||||
focus.focusRing
|
||||
)}
|
||||
style={
|
||||
active && !collapsed
|
||||
@@ -298,34 +253,20 @@ export function Sidebar({
|
||||
|
||||
{collapsed ? (
|
||||
<Hint label={`Theme: ${themeLabel}`} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
<IconButton
|
||||
icon={themeIcon}
|
||||
onClick={cycleTheme}
|
||||
aria-label={`Theme: ${themeLabel}. Click to change.`}
|
||||
>
|
||||
{themeIcon}
|
||||
</button>
|
||||
aria-label={`Change theme (currently ${themeLabel})`}
|
||||
/>
|
||||
</Hint>
|
||||
) : (
|
||||
<div className={styles.themeGroup} role="radiogroup" aria-label="Theme">
|
||||
{THEMES.map((t) => {
|
||||
const on = theme === t.value
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
className={mergeClasses(styles.themeSeg, on && styles.themeSegActive)}
|
||||
onClick={() => onSetTheme(t.value)}
|
||||
>
|
||||
<span className={styles.navIcon}>{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<SegmentedControl<ThemeMode>
|
||||
fitted
|
||||
value={theme}
|
||||
options={THEMES}
|
||||
onChange={onSetTheme}
|
||||
ariaLabel="Theme"
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
|
||||
@@ -9,9 +9,16 @@ import {
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { AddRegular, EditRegular, DeleteRegular, SaveRegular, DismissRegular } from '@fluentui/react-icons'
|
||||
import {
|
||||
AddRegular,
|
||||
EditRegular,
|
||||
DeleteRegular,
|
||||
SaveRegular,
|
||||
DismissRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { CommandTemplate } from '@shared/ipc'
|
||||
import { useTemplates } from '../store/templates'
|
||||
import { newId } from '../id'
|
||||
import { Hint } from './Hint'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
@@ -79,10 +86,6 @@ interface Draft {
|
||||
}
|
||||
const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline (no-modal — see the Hint/Select comments re: composited overlays
|
||||
* flickering on this dev machine's GPU) CRUD list + add/edit form for
|
||||
@@ -106,7 +109,7 @@ export function TemplateManager(): React.JSX.Element {
|
||||
if (!name) return
|
||||
const urlPattern = draft.urlPattern.trim()
|
||||
save({
|
||||
id: draft.id ?? newId(),
|
||||
id: draft.id ?? newId('tpl'),
|
||||
name,
|
||||
args: draft.args.trim(),
|
||||
...(urlPattern ? { urlPattern } : {})
|
||||
@@ -182,7 +185,11 @@ export function TemplateManager(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button appearance="subtle" icon={<AddRegular />} onClick={() => setDraft({ ...BLANK_DRAFT })}>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<AddRegular />}
|
||||
onClick={() => setDraft({ ...BLANK_DRAFT })}
|
||||
>
|
||||
Add template
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Textarea,
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { newId } from '../id'
|
||||
import { ScreenHeader, useScreenStyles } from './ui/Screen'
|
||||
|
||||
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
|
||||
interface Line {
|
||||
@@ -20,8 +22,6 @@ interface Line {
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
|
||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
gate: {
|
||||
padding: '12px 14px',
|
||||
backgroundColor: tokens.colorStatusWarningBackground1,
|
||||
@@ -54,10 +54,6 @@ const useStyles = makeStyles({
|
||||
empty: { color: tokens.colorNeutralForeground3 }
|
||||
})
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
|
||||
* binary, and watch its output stream. Gated on the customCommandEnabled consent
|
||||
@@ -65,6 +61,7 @@ function newId(): string {
|
||||
*/
|
||||
export function TerminalView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const screen = useScreenStyles()
|
||||
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
|
||||
|
||||
const [args, setArgs] = useState('')
|
||||
@@ -101,7 +98,7 @@ export function TerminalView(): React.JSX.Element {
|
||||
function run(): void {
|
||||
const a = args.trim()
|
||||
if (!a || running) return
|
||||
const id = newId()
|
||||
const id = newId('t')
|
||||
runId.current = id
|
||||
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
|
||||
setRunning(true)
|
||||
@@ -126,17 +123,25 @@ export function TerminalView(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const lineClass = (kind: LineKind): string | undefined =>
|
||||
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
|
||||
kind === 'cmd'
|
||||
? styles.cmd
|
||||
: kind === 'stderr'
|
||||
? styles.stderr
|
||||
: kind === 'sys'
|
||||
? styles.sys
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Subtitle2>Terminal</Subtitle2>
|
||||
<Caption1 className={styles.sub}>
|
||||
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
||||
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
||||
</Caption1>
|
||||
</div>
|
||||
<div className={mergeClasses(styles.root, screen.width)}>
|
||||
<ScreenHeader
|
||||
title="Terminal"
|
||||
description={
|
||||
<>
|
||||
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
|
||||
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!customCommandEnabled && (
|
||||
<Body1 className={styles.gate}>
|
||||
@@ -149,6 +154,7 @@ export function TerminalView(): React.JSX.Element {
|
||||
<div className={styles.inputCol}>
|
||||
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
|
||||
<Textarea
|
||||
textarea={{ 'aria-label': 'yt-dlp arguments' }}
|
||||
value={args}
|
||||
onChange={(_, d) => setArgs(d.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -42,28 +42,35 @@ export function VirtualList<T>({
|
||||
estimateSize,
|
||||
overscan,
|
||||
gap,
|
||||
getItemKey: (index) => getKey(items[index], index)
|
||||
getItemKey: (index) => {
|
||||
const it = items[index]
|
||||
return it !== undefined ? getKey(it, index) : index
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={className} style={{ overflowY: 'auto', ...style }}>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||
{virtualizer.getVirtualItems().map((vrow) => (
|
||||
<div
|
||||
key={vrow.key}
|
||||
data-index={vrow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vrow.start}px)`
|
||||
}}
|
||||
>
|
||||
{renderItem(items[vrow.index], vrow.index)}
|
||||
</div>
|
||||
))}
|
||||
{virtualizer.getVirtualItems().map((vrow) => {
|
||||
const item = items[vrow.index]
|
||||
if (item === undefined) return null
|
||||
return (
|
||||
<div
|
||||
key={vrow.key}
|
||||
data-index={vrow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vrow.start}px)`
|
||||
}}
|
||||
>
|
||||
{renderItem(item, vrow.index)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './focusRing'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
btn: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
color: tokens.colorNeutralForeground2
|
||||
},
|
||||
':disabled': {
|
||||
cursor: 'not-allowed',
|
||||
color: tokens.colorNeutralForegroundDisabled,
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
},
|
||||
md: { width: '32px', height: '32px', fontSize: '18px' },
|
||||
sm: { width: '28px', height: '28px', fontSize: '16px' }
|
||||
})
|
||||
|
||||
type IconButtonProps = {
|
||||
icon: React.JSX.Element
|
||||
size?: 'sm' | 'md'
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
/**
|
||||
* The recurring icon-only ghost button (UI15) — one implementation for the
|
||||
* sidebar's collapse/theme toggles and the playlist row's video/audio switch,
|
||||
* which were separate hand-rolled buttons with the same look. Carries the shared
|
||||
* focus ring (UI29). Extra props (onClick, aria-label, aria-pressed, disabled, …)
|
||||
* pass straight through to the underlying `<button>`.
|
||||
*/
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||
{ icon, size = 'md', className, type = 'button', ...rest },
|
||||
ref
|
||||
) {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={mergeClasses(styles.btn, styles[size], focus.focusRing, className)}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Subtitle2, Caption1, makeStyles, tokens } from '@fluentui/react-components'
|
||||
|
||||
/**
|
||||
* One shared content max-width for every screen (UI1).
|
||||
*
|
||||
* Settings used to be a 640px column while the list screens (Downloads, Library,
|
||||
* History, Terminal) were full-width, so on a wide window Settings read as an odd
|
||||
* narrow strip beside edge-to-edge siblings. This caps them all at the same
|
||||
* reading width and centers, so they line up. On typical window sizes the content
|
||||
* is already under the cap, so the list screens are visually unchanged — only the
|
||||
* wide-window upper bound is now shared.
|
||||
*
|
||||
* Merge `screen.width` onto each screen's existing root with `mergeClasses`.
|
||||
*/
|
||||
export const useScreenStyles = makeStyles({
|
||||
width: {
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
const useStyles = makeStyles({
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
titleBlock: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
minWidth: 0,
|
||||
flexGrow: 1
|
||||
},
|
||||
description: {
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The consistent screen header block (UI9/UI10): one page-title style on every
|
||||
* screen, with an optional one-line description and an optional right-aligned
|
||||
* action slot. Previously each screen rolled its own (Subtitle2 / Title2 / none),
|
||||
* and only Library had a description.
|
||||
*/
|
||||
export function ScreenHeader({
|
||||
title,
|
||||
description,
|
||||
actions
|
||||
}: {
|
||||
title: string
|
||||
description?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleBlock}>
|
||||
<Subtitle2>{title}</Subtitle2>
|
||||
{description && <Caption1 className={styles.description}>{description}</Caption1>}
|
||||
</div>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useRef } from 'react'
|
||||
import { makeStyles, mergeClasses, tokens } from '@fluentui/react-components'
|
||||
import { useFocusStyles } from './focusRing'
|
||||
|
||||
export interface SegmentOption<T extends string> {
|
||||
value: T
|
||||
label: string
|
||||
/** optional leading icon (used by the sidebar theme switch) */
|
||||
icon?: React.JSX.Element
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
group: {
|
||||
display: 'inline-flex',
|
||||
width: 'fit-content',
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Full-width variant: segments split the available width (the sidebar style).
|
||||
fitted: {
|
||||
display: 'flex',
|
||||
width: '100%'
|
||||
},
|
||||
segment: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '5px',
|
||||
padding: '7px 16px',
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
fontFamily: tokens.fontFamilyBase,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover
|
||||
}
|
||||
},
|
||||
segmentFitted: {
|
||||
flexGrow: 1,
|
||||
flexBasis: 0,
|
||||
padding: '7px 4px',
|
||||
fontSize: tokens.fontSizeBase200
|
||||
},
|
||||
segmentActive: {
|
||||
backgroundColor: tokens.colorBrandBackground,
|
||||
color: tokens.colorNeutralForegroundOnBrand,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
':hover': {
|
||||
backgroundColor: tokens.colorBrandBackgroundHover
|
||||
}
|
||||
},
|
||||
icon: {
|
||||
display: 'flex',
|
||||
fontSize: '16px',
|
||||
flexShrink: 0
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* One shared segmented control (UI14) replacing the two hand-rolled versions —
|
||||
* the DownloadBar's Video/Audio kind toggle and the Sidebar's Light/Dark/Auto
|
||||
* theme switch. Renders an ARIA radiogroup with roving-tabindex arrow-key
|
||||
* navigation (UI30) and the shared focus ring (UI29). Active treatment is solid
|
||||
* brand (matching both former implementations). `fitted` makes the segments split
|
||||
* the full width (sidebar); the default is fit-content (download bar).
|
||||
*/
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
fitted = false
|
||||
}: {
|
||||
value: T
|
||||
options: SegmentOption<T>[]
|
||||
onChange: (value: T) => void
|
||||
ariaLabel: string
|
||||
fitted?: boolean
|
||||
}): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const focus = useFocusStyles()
|
||||
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent, index: number): void {
|
||||
let next = -1
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % options.length
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
|
||||
next = (index - 1 + options.length) % options.length
|
||||
else if (e.key === 'Home') next = 0
|
||||
else if (e.key === 'End') next = options.length - 1
|
||||
else return
|
||||
const opt = options[next]
|
||||
if (!opt) return
|
||||
e.preventDefault()
|
||||
onChange(opt.value)
|
||||
refs.current[next]?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={mergeClasses(styles.group, fitted && styles.fitted)}
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{options.map((opt, i) => {
|
||||
const on = opt.value === value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
ref={(el) => {
|
||||
refs.current[i] = el
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
// Roving tabindex: only the selected segment is a tab stop; arrow keys
|
||||
// move within the group, as a radiogroup is expected to behave.
|
||||
tabIndex={on ? 0 : -1}
|
||||
className={mergeClasses(
|
||||
styles.segment,
|
||||
fitted && styles.segmentFitted,
|
||||
on && styles.segmentActive,
|
||||
focus.focusRing
|
||||
)}
|
||||
onClick={() => onChange(opt.value)}
|
||||
onKeyDown={(e) => onKeyDown(e, i)}
|
||||
>
|
||||
{opt.icon && <span className={styles.icon}>{opt.icon}</span>}
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Badge } from '@fluentui/react-components'
|
||||
import type { DownloadStatus } from '../../store/downloads'
|
||||
|
||||
/** The item-download status shown as a chip — the queue's live status plus the
|
||||
* library's 'pending' (catalogued but not yet downloaded). */
|
||||
export type ChipStatus = DownloadStatus | 'pending'
|
||||
|
||||
type ChipColor = 'brand' | 'success' | 'danger' | 'warning' | 'subtle'
|
||||
|
||||
/**
|
||||
* One label + color per status (UI18 / M8) — the single source of truth that
|
||||
* replaces QueueItem's Fluent `Badge` map and LibraryView's custom color `pill`
|
||||
* spans, so the same status concept looks and reads identically on both screens
|
||||
* (e.g. Library no longer says "Downloaded" where the queue says "Completed").
|
||||
*/
|
||||
const STATUS_CHIP: Record<ChipStatus, { label: string; color: ChipColor }> = {
|
||||
pending: { label: 'Pending', color: 'subtle' },
|
||||
queued: { label: 'Queued', color: 'subtle' },
|
||||
downloading: { label: 'Downloading', color: 'brand' },
|
||||
paused: { label: 'Paused', color: 'warning' },
|
||||
saved: { label: 'Saved', color: 'subtle' },
|
||||
completed: { label: 'Completed', color: 'success' },
|
||||
error: { label: 'Failed', color: 'danger' },
|
||||
canceled: { label: 'Canceled', color: 'warning' }
|
||||
}
|
||||
|
||||
/** Shared status chip used by the download queue and the library item list. */
|
||||
export function StatusChip({ status }: { status: ChipStatus }): React.JSX.Element {
|
||||
const { label, color } = STATUS_CHIP[status]
|
||||
return (
|
||||
<Badge appearance="tint" color={color}>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { makeStyles, tokens } from '@fluentui/react-components'
|
||||
|
||||
/**
|
||||
* Shared error-text styling (M12). The red `colorPaletteRedForeground1` was being
|
||||
* applied via inline `style={{ color: … }}` across the app; this hook is the one
|
||||
* place that owns it.
|
||||
*
|
||||
* - `error` — a single line of error copy (e.g. an inline validation/result message).
|
||||
* - `errorPre` — the same colour but preserving newlines/long tokens, for multi-line
|
||||
* yt-dlp/updater error output that would otherwise overflow.
|
||||
*/
|
||||
export const useErrorTextStyles = makeStyles({
|
||||
error: {
|
||||
color: tokens.colorPaletteRedForeground1
|
||||
},
|
||||
errorPre: {
|
||||
color: tokens.colorPaletteRedForeground1,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { makeStyles, tokens } from '@fluentui/react-components'
|
||||
|
||||
/**
|
||||
* One focus-visible ring for every hand-rolled interactive element (UI29).
|
||||
*
|
||||
* Fluent's own controls draw their own focus indicator; the app's bespoke
|
||||
* buttons, segmented controls, command-palette rows, and `role="button"` headers
|
||||
* previously fell back to the UA default (often invisible) or removed it
|
||||
* entirely. This gives them all the same visible, theme-aware keyboard focus.
|
||||
*
|
||||
* Apply with `mergeClasses(focus.focusRing, …)`. The ring is inset (negative
|
||||
* offset) so it never clips inside `overflow: hidden` containers and follows each
|
||||
* element's own border-radius; it shows only for `:focus-visible`, so pointer
|
||||
* clicks stay quiet.
|
||||
*/
|
||||
export const useFocusStyles = makeStyles({
|
||||
focusRing: {
|
||||
outlineStyle: 'none',
|
||||
':focus-visible': {
|
||||
outlineWidth: '2px',
|
||||
outlineStyle: 'solid',
|
||||
outlineColor: tokens.colorStrokeFocus2,
|
||||
outlineOffset: '-2px'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
// One home for the renderer's date/time formatters (M9). These were previously
|
||||
// three private functions — `relTime` (LibraryView), `formatWhen` (HistoryView)
|
||||
// and `fmtSchedule` (QueueItem) — each reimplementing date math inline.
|
||||
|
||||
/** Relative "time since" label, e.g. "just now" / "5 min ago" / "3 h ago" /
|
||||
* "2 d ago". Returns "never" for a missing timestamp. (Library last-indexed.) */
|
||||
export function relTime(ms?: number): string {
|
||||
if (!ms) return 'never'
|
||||
const mins = Math.round((Date.now() - ms) / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins} min ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs} h ago`
|
||||
return `${Math.round(hrs / 24)} d ago`
|
||||
}
|
||||
|
||||
/** Absolute "when" label that stays short for recent items: "Today, 3:04 PM" /
|
||||
* "Yesterday, 3:04 PM" / "Jun 5, 2025". (History rows.) */
|
||||
export function formatWhen(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
const dayMs = 1000 * 60 * 60 * 24
|
||||
if (ts >= startOfToday) return `Today, ${time}`
|
||||
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
/** Full date + time for a scheduled download, e.g. "Jun 5, 2025, 3:04 PM".
|
||||
* Returns '' if the timestamp can't be formatted. (Queue scheduled badge.) */
|
||||
export function fmtSchedule(ms: number): string {
|
||||
try {
|
||||
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Single id generator for queue items, terminal runs, and command templates.
|
||||
// Replaces three near-identical `newId()` copies that had divergent fallback
|
||||
// prefixes and could collide within a millisecond (M7 / L33 / L70).
|
||||
//
|
||||
// `crypto.randomUUID` is the real path in Electron/Node; the counter-suffixed
|
||||
// fallback is effectively unreachable but kept so the function is total in any
|
||||
// host, and the monotonic counter makes two ids minted in the same millisecond
|
||||
// distinct (the old `Date.now()`-only fallbacks could collide).
|
||||
let counter = 0
|
||||
|
||||
export function newId(prefix = 'id'): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return `${prefix}-${Date.now()}-${++counter}`
|
||||
}
|
||||
+55
-12
@@ -3,6 +3,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc'
|
||||
import App from './App'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
|
||||
// In the standalone UI preview (browser, no Electron preload) window.api isn't
|
||||
// injected. Stub the full surface so the UI renders and stays interactive during
|
||||
@@ -18,7 +19,7 @@ if (import.meta.env.DEV && !window.api) {
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
theme: 'system',
|
||||
accentColor: 'teal',
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||
@@ -75,7 +76,10 @@ if (import.meta.env.DEV && !window.api) {
|
||||
runAppUpdate: async () => ({ ok: true }),
|
||||
onAppUpdateProgress: () => () => {},
|
||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||
getFfmpegVersions: async () => ({ ffmpeg: '7.1-full_build (UI preview mock)', ffprobe: '7.1-full_build (UI preview mock)' }),
|
||||
getFfmpegVersions: async () => ({
|
||||
ffmpeg: '7.1-full_build (UI preview mock)',
|
||||
ffprobe: '7.1-full_build (UI preview mock)'
|
||||
}),
|
||||
probe: async (url: string) => {
|
||||
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
|
||||
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
|
||||
@@ -108,10 +112,38 @@ if (import.meta.env.DEV && !window.api) {
|
||||
thumbnail: undefined,
|
||||
formats: [
|
||||
{ id: '__best__', label: 'Best available', ext: 'mp4', hasAudio: true },
|
||||
{ id: '299', label: '1080p60 · mp4 · 248 MB', height: 1080, ext: 'mp4', filesizeLabel: '248 MB', hasAudio: false },
|
||||
{ id: '136', label: '720p · mp4 · 124 MB', height: 720, ext: 'mp4', filesizeLabel: '124 MB', hasAudio: false },
|
||||
{ id: '135', label: '480p · mp4 · 72 MB', height: 480, ext: 'mp4', filesizeLabel: '72 MB', hasAudio: false },
|
||||
{ id: '134', label: '360p · mp4 · 38 MB', height: 360, ext: 'mp4', filesizeLabel: '38 MB', hasAudio: false }
|
||||
{
|
||||
id: '299',
|
||||
label: '1080p60 · mp4 · 248 MB',
|
||||
height: 1080,
|
||||
ext: 'mp4',
|
||||
filesizeLabel: '248 MB',
|
||||
hasAudio: false
|
||||
},
|
||||
{
|
||||
id: '136',
|
||||
label: '720p · mp4 · 124 MB',
|
||||
height: 720,
|
||||
ext: 'mp4',
|
||||
filesizeLabel: '124 MB',
|
||||
hasAudio: false
|
||||
},
|
||||
{
|
||||
id: '135',
|
||||
label: '480p · mp4 · 72 MB',
|
||||
height: 480,
|
||||
ext: 'mp4',
|
||||
filesizeLabel: '72 MB',
|
||||
hasAudio: false
|
||||
},
|
||||
{
|
||||
id: '134',
|
||||
label: '360p · mp4 · 38 MB',
|
||||
height: 360,
|
||||
ext: 'mp4',
|
||||
filesizeLabel: '38 MB',
|
||||
hasAudio: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -119,7 +151,6 @@ if (import.meta.env.DEV && !window.api) {
|
||||
startDownload: async () => ({ ok: true }),
|
||||
cancelDownload: async () => {},
|
||||
pauseDownload: async () => {},
|
||||
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
|
||||
chooseFolder: async () => null,
|
||||
openPath: async () => '',
|
||||
showInFolder: async () => {},
|
||||
@@ -162,16 +193,25 @@ if (import.meta.env.DEV && !window.api) {
|
||||
},
|
||||
updateYtdlp: async (channel) => {
|
||||
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
|
||||
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
|
||||
return {
|
||||
ok: true,
|
||||
output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)`
|
||||
}
|
||||
},
|
||||
// No background updater in the browser preview — hand back an inert unsubscribe.
|
||||
onYtdlpAutoUpdateStatus: () => () => {},
|
||||
listErrorLog: async () => [],
|
||||
clearErrorLog: async () => [],
|
||||
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
|
||||
exportBackup: async () => ({
|
||||
ok: true,
|
||||
path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json'
|
||||
}),
|
||||
importBackup: async () => ({ ok: true }),
|
||||
onDownloadEvent: () => () => {},
|
||||
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
|
||||
getSystemTheme: async () => ({
|
||||
shouldUseDarkColors: false,
|
||||
shouldUseHighContrastColors: false
|
||||
}),
|
||||
onSystemThemeUpdate: () => () => {},
|
||||
openHighContrastSettings: async () => {},
|
||||
onExternalUrl: () => () => {},
|
||||
@@ -229,12 +269,15 @@ if (import.meta.env.DEV && !window.api) {
|
||||
runTerminal: async () => ({ ok: true }),
|
||||
cancelTerminal: async () => {},
|
||||
onTerminalOutput: () => () => {},
|
||||
setTaskbarProgress: () => {}
|
||||
setTaskbarProgress: () => {},
|
||||
mintPoToken: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Centralized handler for fire-and-forget IPC (and similar async) calls whose
|
||||
// failure we want recorded but can't surface to the user — the optimistic store
|
||||
// update has already happened, so there's nothing to roll back or prompt about.
|
||||
// Replaces the swallowed `.catch(() => {})` sites the audit flagged (M29) with a
|
||||
// single, consistent log format. `op` names the operation, e.g. 'addHistory'.
|
||||
//
|
||||
// Note: in production the DevTools console isn't reachable (the app menu is
|
||||
// suppressed), so this is primarily a development/`--inspect` diagnostic until a
|
||||
// persistent renderer log sink lands (CC8). It is still strictly better than
|
||||
// discarding the error.
|
||||
export function logError(op: string): (e: unknown) => void {
|
||||
return (e) => console.error(`[AeroFetch] ${op} failed:`, e)
|
||||
}
|
||||
@@ -1,19 +1,24 @@
|
||||
import { create } from 'zustand'
|
||||
import type { DownloadEvent, DownloadMeta, DownloadOptions, CollectionContext } from '@shared/ipc'
|
||||
import {
|
||||
VIDEO_QUALITY_OPTIONS,
|
||||
AUDIO_QUALITY_OPTIONS,
|
||||
type DownloadEvent,
|
||||
type DownloadMeta,
|
||||
type DownloadOptions,
|
||||
type CollectionContext,
|
||||
type MediaKind
|
||||
} from '@shared/ipc'
|
||||
import { useSettings } from './settings'
|
||||
import { useHistory } from './history'
|
||||
import { useSources } from './sources'
|
||||
import { newId } from '../id'
|
||||
|
||||
export type MediaKind = 'video' | 'audio'
|
||||
// Single-sourced from the IPC contract (L105) and re-exported so existing
|
||||
// `import { MediaKind } from '../store/downloads'` sites keep working.
|
||||
export type { MediaKind }
|
||||
|
||||
export type DownloadStatus =
|
||||
| 'queued'
|
||||
| 'downloading'
|
||||
| 'paused'
|
||||
| 'saved'
|
||||
| 'completed'
|
||||
| 'error'
|
||||
| 'canceled'
|
||||
'queued' | 'downloading' | 'paused' | 'saved' | 'completed' | 'error' | 'canceled'
|
||||
|
||||
/** An exact probed format chosen for a download (omitted for the preset path). */
|
||||
export interface ChosenFormat {
|
||||
@@ -37,6 +42,12 @@ export interface DownloadItem {
|
||||
speed?: string
|
||||
eta?: string
|
||||
sizeLabel?: string
|
||||
/** yt-dlp reported no total size — render the bar indeterminate, not 0% (L137) */
|
||||
sizeUnknown?: boolean
|
||||
/** true once the main download stream finished and yt-dlp is fetching the
|
||||
* remaining stream / merging / post-processing — the bar goes indeterminate
|
||||
* instead of visibly restarting 0→100% for the second stream (SR7) */
|
||||
finishing?: boolean
|
||||
filePath?: string
|
||||
error?: string
|
||||
/** exact format carried so retry re-downloads the same thing */
|
||||
@@ -60,10 +71,18 @@ export interface DownloadItem {
|
||||
mediaItemId?: string
|
||||
}
|
||||
|
||||
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
||||
video: ['Best available', '1080p', '720p', '480p', '360p'],
|
||||
audio: ['Best (MP3)', '320 kbps', '192 kbps', '128 kbps']
|
||||
}
|
||||
// `satisfies` (not an annotation) so the tuple element types survive: under
|
||||
// noUncheckedIndexedAccess a `readonly string[]` index yields `string | undefined`,
|
||||
// but the const tuples keep `QUALITY_OPTIONS[kind][0]` known-defined (L168).
|
||||
export const QUALITY_OPTIONS = {
|
||||
video: VIDEO_QUALITY_OPTIONS,
|
||||
audio: AUDIO_QUALITY_OPTIONS
|
||||
} satisfies Record<MediaKind, readonly string[]>
|
||||
|
||||
// Placeholder channel shown while metadata is still being fetched. Tracked as a
|
||||
// constant so the meta/error/done handlers can recognise and clear it instead of
|
||||
// letting it stick forever when a probe returns no channel (SR6).
|
||||
const RESOLVING = 'Resolving…'
|
||||
|
||||
/** Options accepted when enqueuing a URL (shared by addFromUrl + addMany). */
|
||||
export interface AddOptions {
|
||||
@@ -129,12 +148,6 @@ interface DownloadState {
|
||||
applyEvent: (ev: DownloadEvent) => void
|
||||
}
|
||||
|
||||
let idCounter = 0
|
||||
function newId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return `item-${++idCounter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh queued DownloadItem from a URL + options. Pure (no store access)
|
||||
* so addFromUrl and addMany share it. If the caller already probed the URL, its
|
||||
@@ -150,10 +163,10 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
|
||||
const scheduledFor =
|
||||
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
|
||||
return {
|
||||
id: newId(),
|
||||
id: newId('item'),
|
||||
url,
|
||||
title: opts?.title ?? titleFromUrl(url),
|
||||
channel: opts?.channel ?? 'Resolving…',
|
||||
channel: opts?.channel ?? RESOLVING,
|
||||
durationLabel: opts?.durationLabel,
|
||||
thumbnail: opts?.thumbnail,
|
||||
kind,
|
||||
@@ -196,7 +209,7 @@ function fmtEta(seconds: number): string {
|
||||
const seed: DownloadItem[] = PREVIEW
|
||||
? [
|
||||
{
|
||||
id: newId(),
|
||||
id: newId('item'),
|
||||
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
title: 'Building a Desktop App with Electron — Full Course',
|
||||
channel: 'DevChannel',
|
||||
@@ -210,7 +223,7 @@ const seed: DownloadItem[] = PREVIEW
|
||||
sizeLabel: '512 MB'
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
id: newId('item'),
|
||||
url: 'https://youtube.com/watch?v=abcd1234',
|
||||
title: 'Lo-fi beats to code to (1 hour mix)',
|
||||
channel: 'ChillStudio',
|
||||
@@ -221,7 +234,7 @@ const seed: DownloadItem[] = PREVIEW
|
||||
progress: 0
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
id: newId('item'),
|
||||
url: 'https://youtube.com/watch?v=zzz9999',
|
||||
title: 'How yt-dlp Works Under the Hood',
|
||||
channel: 'Open Source Weekly',
|
||||
@@ -234,7 +247,7 @@ const seed: DownloadItem[] = PREVIEW
|
||||
filePath: 'C:\\Users\\you\\Downloads\\How yt-dlp Works Under the Hood.mp4'
|
||||
},
|
||||
{
|
||||
id: newId(),
|
||||
id: newId('item'),
|
||||
url: 'https://youtube.com/watch?v=err0r',
|
||||
title: 'Private video',
|
||||
channel: undefined,
|
||||
@@ -376,7 +389,11 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
|
||||
addMany: (entries) => {
|
||||
if (entries.length === 0) return
|
||||
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts))
|
||||
// The queue is a newest-first array and pump() promotes the highest-index
|
||||
// (oldest) queued item first, so a batch must be stored last-entry-first for
|
||||
// the playlist/channel to download 1→N instead of N→1 (M32). Entry 1 then
|
||||
// sits at the bottom of the list — the same way repeated single adds stack.
|
||||
const built = entries.map((e) => buildItem(e.url, e.kind, e.quality, e.opts)).reverse()
|
||||
set((s) => ({ items: [...built, ...s.items] }))
|
||||
pump()
|
||||
},
|
||||
@@ -385,7 +402,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.id === id
|
||||
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
|
||||
? {
|
||||
...i,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
speed: undefined,
|
||||
eta: undefined
|
||||
}
|
||||
: i
|
||||
)
|
||||
}))
|
||||
@@ -396,7 +420,14 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.status === 'error'
|
||||
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
|
||||
? {
|
||||
...i,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
speed: undefined,
|
||||
eta: undefined
|
||||
}
|
||||
: i
|
||||
)
|
||||
}))
|
||||
@@ -435,11 +466,12 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
// item first (oldest-first over a newest-first array) — picks it next.
|
||||
set((s) => {
|
||||
const idx = s.items.findIndex((i) => i.id === id)
|
||||
if (idx < 0 || s.items[idx].status !== 'queued') return {}
|
||||
if (idx < 0 || s.items[idx]?.status !== 'queued') return {}
|
||||
const items = s.items.slice()
|
||||
const [it] = items.splice(idx, 1)
|
||||
if (!it) return {}
|
||||
let after = -1
|
||||
for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k
|
||||
for (let k = 0; k < items.length; k++) if (items[k]?.status === 'queued') after = k
|
||||
items.splice(after + 1, 0, it)
|
||||
return { items }
|
||||
})
|
||||
@@ -526,21 +558,35 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
if (i.id !== ev.id) return i
|
||||
switch (ev.type) {
|
||||
case 'meta':
|
||||
// A late meta event can arrive between cancel() and the child's
|
||||
// close — don't overwrite a canceled item's fields (B5; mirrors the
|
||||
// canceled guard on the done/error cases below).
|
||||
if (i.status === 'canceled') return i
|
||||
return {
|
||||
...i,
|
||||
title: ev.meta.title ?? i.title,
|
||||
channel: ev.meta.channel ?? i.channel,
|
||||
// Clear the 'Resolving…' placeholder even when the probe returns
|
||||
// no channel, so it can't stick forever (SR6); a real channel is
|
||||
// always preserved.
|
||||
channel: ev.meta.channel ?? (i.channel === RESOLVING ? undefined : i.channel),
|
||||
durationLabel: ev.meta.durationLabel ?? i.durationLabel
|
||||
}
|
||||
case 'progress':
|
||||
// Ignore late events once the item has left the active state.
|
||||
if (i.status !== 'downloading' && i.status !== 'queued') return i
|
||||
// Only a launched (downloading) item should take progress. Never
|
||||
// promote a 'queued' item here — pump() owns the queued→downloading
|
||||
// transition and the concurrency cap (L88); a stray progress event
|
||||
// for a non-downloading item is ignored.
|
||||
if (i.status !== 'downloading') return i
|
||||
return {
|
||||
...i,
|
||||
status: 'downloading',
|
||||
progress: ev.progress.progress,
|
||||
// Main owns the latch and resends it on every tick, so reflecting
|
||||
// it directly resets cleanly on a retry's fresh spawn (SR7).
|
||||
finishing: ev.progress.finishing,
|
||||
speed: ev.progress.speed,
|
||||
eta: ev.progress.eta,
|
||||
sizeUnknown: ev.progress.sizeUnknown,
|
||||
sizeLabel: ev.progress.sizeLabel ?? i.sizeLabel
|
||||
}
|
||||
case 'done':
|
||||
@@ -549,13 +595,23 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
...i,
|
||||
status: 'completed',
|
||||
progress: 1,
|
||||
finishing: false,
|
||||
speed: undefined,
|
||||
eta: undefined,
|
||||
channel: i.channel === RESOLVING ? undefined : i.channel,
|
||||
filePath: ev.filePath ?? i.filePath
|
||||
}
|
||||
case 'error':
|
||||
if (i.status === 'canceled') return i
|
||||
return { ...i, status: 'error', speed: undefined, eta: undefined, error: ev.error }
|
||||
return {
|
||||
...i,
|
||||
status: 'error',
|
||||
finishing: false,
|
||||
speed: undefined,
|
||||
eta: undefined,
|
||||
channel: i.channel === RESOLVING ? undefined : i.channel,
|
||||
error: ev.error
|
||||
}
|
||||
default:
|
||||
return i
|
||||
}
|
||||
@@ -595,7 +651,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
setInterval(() => {
|
||||
const st = useDownloads.getState()
|
||||
const now = Date.now()
|
||||
if (st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)) {
|
||||
if (
|
||||
st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)
|
||||
) {
|
||||
st.promoteDueScheduled()
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ErrorLogEntry } from '@shared/ipc'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -28,7 +29,7 @@ export const useErrorLog = create<ErrorLogState>((set) => ({
|
||||
|
||||
clear: () => {
|
||||
set({ entries: [] })
|
||||
if (!PREVIEW) window.api.clearErrorLog().catch(() => {})
|
||||
if (!PREVIEW) window.api.clearErrorLog().catch(logError('clearErrorLog'))
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import type { HistoryEntry } from '@shared/ipc'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -58,23 +59,23 @@ export const useHistory = create<HistoryState>((set, get) => ({
|
||||
|
||||
add: (entry) => {
|
||||
set((s) => ({ entries: [entry, ...s.entries.filter((e) => e.id !== entry.id)] }))
|
||||
if (!PREVIEW) window.api.addHistory(entry).catch(() => {})
|
||||
if (!PREVIEW) window.api.addHistory(entry).catch(logError('addHistory'))
|
||||
},
|
||||
|
||||
remove: (id) => {
|
||||
set((s) => ({ entries: s.entries.filter((e) => e.id !== id) }))
|
||||
if (!PREVIEW) window.api.removeHistory(id).catch(() => {})
|
||||
if (!PREVIEW) window.api.removeHistory(id).catch(logError('removeHistory'))
|
||||
},
|
||||
|
||||
removeMany: (ids) => {
|
||||
const remove = new Set(ids)
|
||||
set((s) => ({ entries: s.entries.filter((e) => !remove.has(e.id)) }))
|
||||
if (!PREVIEW) window.api.removeManyHistory(ids).catch(() => {})
|
||||
if (!PREVIEW) window.api.removeManyHistory(ids).catch(logError('removeManyHistory'))
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
set({ entries: [] })
|
||||
if (!PREVIEW) window.api.clearHistory().catch(() => {})
|
||||
if (!PREVIEW) window.api.clearHistory().catch(logError('clearHistory'))
|
||||
},
|
||||
|
||||
openFile: (id) => {
|
||||
|
||||
@@ -13,10 +13,10 @@ function parseSpeed(s?: string): number {
|
||||
if (!s) return 0
|
||||
const m = /([\d.]+)\s*([KMGT]?)i?B\/s/i.exec(s)
|
||||
if (!m) return 0
|
||||
const n = parseFloat(m[1])
|
||||
const n = parseFloat(m[1] ?? '')
|
||||
if (!isFinite(n)) return 0
|
||||
const mult: Record<string, number> = { '': 1, K: 1e3, M: 1e6, G: 1e9, T: 1e12 }
|
||||
return n * (mult[m[2].toUpperCase()] ?? 1)
|
||||
return n * (mult[(m[2] ?? '').toUpperCase()] ?? 1)
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number): string {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -12,7 +13,9 @@ const FALLBACK: Settings = {
|
||||
defaultAudioQuality: 'Best (MP3)',
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
// Mirror main's DEFAULTS (SR1): follow the OS theme until real settings load,
|
||||
// so there's no white-on-first-paint flash for a dark-mode user.
|
||||
theme: 'system',
|
||||
accentColor: 'teal',
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||
@@ -26,12 +29,12 @@ const FALLBACK: Settings = {
|
||||
restrictFilenames: false,
|
||||
downloadArchive: false,
|
||||
autoUpdateYtdlp: true,
|
||||
ytdlpChannel: 'nightly',
|
||||
ytdlpChannel: 'stable',
|
||||
ytdlpLastUpdateCheck: 0,
|
||||
customCommandEnabled: false,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
autoDownloadNew: false,
|
||||
// True in preview so design work isn't blocked behind the welcome screen;
|
||||
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
|
||||
hasCompletedOnboarding: PREVIEW,
|
||||
@@ -56,14 +59,31 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
|
||||
update: (partial) => {
|
||||
set(partial) // optimistic local update
|
||||
if (!PREVIEW) window.api.setSettings(partial).catch(() => {})
|
||||
if (PREVIEW) return
|
||||
// M34: reconcile with main's authoritative, validated result — for exactly the
|
||||
// keys we changed — so a value main clamps, sanitizes, or rejects (e.g. an
|
||||
// unsafe filenameTemplate, an out-of-range maxConcurrent, a malformed
|
||||
// rateLimit) stops showing as accepted in the UI until restart.
|
||||
window.api
|
||||
.setSettings(partial)
|
||||
.then((saved) => {
|
||||
const reconciled = Object.fromEntries(
|
||||
(Object.keys(partial) as (keyof Settings)[]).map((k) => [k, saved[k]])
|
||||
) as Partial<Settings>
|
||||
set(reconciled)
|
||||
})
|
||||
.catch(logError('setSettings'))
|
||||
},
|
||||
|
||||
chooseDir: (target) => {
|
||||
if (PREVIEW) return
|
||||
window.api.chooseFolder().then((dir) => {
|
||||
if (dir) get().update({ [target]: dir })
|
||||
})
|
||||
// Seed the OS picker with the folder this target currently points at (W5).
|
||||
window.api
|
||||
.chooseFolder(get()[target])
|
||||
.then((dir) => {
|
||||
if (dir) get().update({ [target]: dir })
|
||||
})
|
||||
.catch(logError('chooseFolder'))
|
||||
},
|
||||
|
||||
clearDir: (target) => get().update({ [target]: '' })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||
import type { Source, MediaItem, IndexProgress } from '@shared/ipc'
|
||||
import { useDownloads } from './downloads'
|
||||
import { useSettings } from './settings'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -113,7 +114,7 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
window.api
|
||||
.listSources()
|
||||
.then((sources) => set({ sources }))
|
||||
.catch(() => {})
|
||||
.catch(logError('listSources'))
|
||||
},
|
||||
|
||||
selectSource: (id) => {
|
||||
@@ -123,7 +124,7 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
window.api
|
||||
.listSourceItems(id)
|
||||
.then((items) => set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } })))
|
||||
.catch(() => {})
|
||||
.catch(logError('listSourceItems'))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -164,7 +165,8 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
const items = await window.api.listSourceItems(id)
|
||||
set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } }))
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
logError('reindexSource')(e)
|
||||
set({ indexing: { active: false } })
|
||||
}
|
||||
},
|
||||
@@ -174,7 +176,7 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
sources: s.sources.filter((x) => x.id !== id),
|
||||
selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
|
||||
}))
|
||||
if (!PREVIEW) window.api.removeSource(id).catch(() => {})
|
||||
if (!PREVIEW) window.api.removeSource(id).catch(logError('removeSource'))
|
||||
},
|
||||
|
||||
enqueueItems: (sourceId, items) => {
|
||||
@@ -220,12 +222,15 @@ export const useSources = create<SourcesState>((set, get) => ({
|
||||
}
|
||||
return changed ? { itemsBySource: next } : {}
|
||||
})
|
||||
if (!PREVIEW) window.api.markSourceItemDownloaded(itemId, filePath).catch(() => {})
|
||||
if (!PREVIEW)
|
||||
window.api
|
||||
.markSourceItemDownloaded(itemId, filePath)
|
||||
.catch(logError('markSourceItemDownloaded'))
|
||||
},
|
||||
|
||||
setWatched: (id, watched) => {
|
||||
set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
|
||||
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(() => {})
|
||||
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(logError('setSourceWatched'))
|
||||
},
|
||||
|
||||
syncWatched: async () => {
|
||||
@@ -274,7 +279,7 @@ if (!PREVIEW) {
|
||||
setTimeout(() => useSources.getState().syncWatched(), 1500)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(logError('startup listSources'))
|
||||
window.api.onIndexProgress((p: IndexProgress) => {
|
||||
useSources.setState({
|
||||
indexing: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import type { CommandTemplate } from '@shared/ipc'
|
||||
import { logError } from '../reportError'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -24,12 +25,12 @@ export const useTemplates = create<TemplatesState>((set) => ({
|
||||
|
||||
save: (template) => {
|
||||
set((s) => ({ templates: [template, ...s.templates.filter((t) => t.id !== template.id)] }))
|
||||
if (!PREVIEW) window.api.saveTemplate(template).catch(() => {})
|
||||
if (!PREVIEW) window.api.saveTemplate(template).catch(logError('saveTemplate'))
|
||||
},
|
||||
|
||||
remove: (id) => {
|
||||
set((s) => ({ templates: s.templates.filter((t) => t.id !== id) }))
|
||||
if (!PREVIEW) window.api.removeTemplate(id).catch(() => {})
|
||||
if (!PREVIEW) window.api.removeTemplate(id).catch(logError('removeTemplate'))
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function youtubeId(url: string | undefined): string | null {
|
||||
const v = u.searchParams.get('v')
|
||||
if (v) return v
|
||||
const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/i)
|
||||
if (m) return m[1]
|
||||
if (m?.[1]) return m[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user