71f0b2b68f
Contained fixes: - L158: drag-highlight flicker. useDownloadBar tracks a dragDepth counter — onDragEnter increments, onDragLeave decrements, the highlight clears only when depth returns to 0 (the drag truly left the card). onDragOver just preventDefaults; onDrop resets. No more blink when crossing child elements. - L92: preload method names aligned with main — markSourceItemDownloaded → setMediaItemDownloaded, syncSources → syncWatchedSources (callers + mock updated; compiler-enforced via the derived Api type). - L149: the three-sentence "Keep running in the tray" hint is now one line. - L171: one MOCK_CURRENT_VERSION backs both the preview mock's getAppVersion and checkForAppUpdate.currentVersion, so they agree. - L5 (representative): the About card's repeated monospace inline styles → shared mono/monoBlock/monoPre classes; convention set (makeStyles for static, inline style only for data-driven values). Resolved by verification/convention (CODE-AUDIT.md): L21 (moot after the H1 SettingsView decomposition), L31 (dual theme switcher is width-driven), L130 (lowercase fragments vs shown-raw sentences), L152 (search sizes are role- appropriate), UI22 (brand/entity/section icon-emphasis set now defined). Deferred with rationale: L51/L64 (features), L93 (CC13 view-model pass), L104 (moved to Batch 14 perf), L109/UI21 (visual), L161/UI23 (layout/live-verify). Verified: typecheck (node+web) + 279 tests + eslint + production build green; touched files prettier-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
|
import {
|
|
parseUrlShortcutContent,
|
|
type MediaInfo,
|
|
type FormatOption,
|
|
type PlaylistInfo,
|
|
type DownloadOptions,
|
|
DEFAULT_DOWNLOAD_OPTIONS,
|
|
type CommandPreviewResult
|
|
} from '@shared/ipc'
|
|
import { useDownloads, type MediaKind } from '../../store/downloads'
|
|
import { useHistory } from '../../store/history'
|
|
import { QUALITY_OPTIONS } from '../../qualityOptions'
|
|
import { sameVideo } from '../../store/queueStats'
|
|
import { useSettings } from '../../store/settings'
|
|
import { useNav } from '../../store/nav'
|
|
import {
|
|
useClipboardLink,
|
|
looksLikeUrl,
|
|
looksLikeChannelOrPlaylist,
|
|
firstUrlInText,
|
|
type SuggestionSource
|
|
} from '../../useClipboardLink'
|
|
import { logError } from '../../reportError'
|
|
|
|
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
|
|
function parseUrlFile(content: string): string | null {
|
|
const url = parseUrlShortcutContent(content)
|
|
return url && looksLikeUrl(url) ? url : null
|
|
}
|
|
|
|
// 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).
|
|
export 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())}`
|
|
}
|
|
|
|
/** Everything the DownloadBar render needs — the hook's public surface. */
|
|
export interface DownloadBarController {
|
|
// form state
|
|
url: string
|
|
kind: MediaKind
|
|
quality: string
|
|
// trim / schedule
|
|
showTrim: boolean
|
|
setShowTrim: Dispatch<SetStateAction<boolean>>
|
|
trim: string
|
|
showSchedule: boolean
|
|
setShowSchedule: Dispatch<SetStateAction<boolean>>
|
|
scheduleAt: string
|
|
setScheduleAt: Dispatch<SetStateAction<string>>
|
|
// drag / dup
|
|
dragActive: boolean
|
|
setDragActive: Dispatch<SetStateAction<boolean>>
|
|
dup: string | null
|
|
setDup: Dispatch<SetStateAction<string | null>>
|
|
/** true when the duplicate was found in history (already downloaded) vs the live queue (L144). */
|
|
dupInHistory: boolean
|
|
// advanced / preview
|
|
showAdvanced: boolean
|
|
setShowAdvanced: Dispatch<SetStateAction<boolean>>
|
|
downloadOptions: DownloadOptions
|
|
incognito: boolean
|
|
setIncognito: Dispatch<SetStateAction<boolean>>
|
|
commandPreview: CommandPreviewResult | null
|
|
previewLoading: boolean
|
|
// probe (single video)
|
|
info: MediaInfo | null
|
|
thumbFailed: string | null
|
|
setThumbFailed: Dispatch<SetStateAction<string | null>>
|
|
probing: boolean
|
|
probeError: string | null
|
|
usingFormats: boolean
|
|
selectedFormat: FormatOption | undefined
|
|
// playlist
|
|
playlist: PlaylistInfo | null
|
|
selected: Set<number>
|
|
allSelected: boolean
|
|
// suggestion banner
|
|
suggestion: string | null
|
|
suggestionSource: SuggestionSource
|
|
dismissSuggestion: () => void
|
|
// channel/playlist nudge → Library (UX3)
|
|
channelHint: boolean
|
|
openInLibrary: () => void
|
|
dismissChannelHint: () => void
|
|
// handlers
|
|
onDragEnter: (e: React.DragEvent) => void
|
|
onDragOver: (e: React.DragEvent) => void
|
|
onDragLeave: () => void
|
|
onDrop: (e: React.DragEvent) => void
|
|
onUrlChange: (next: string) => void
|
|
onKindChange: (next: MediaKind) => void
|
|
onQualityChange: (v: string) => void
|
|
onFormatIdChange: (v: string) => void
|
|
onTrimChange: (v: string) => void
|
|
onDownloadOptionsChange: (next: DownloadOptions) => void
|
|
toggleCommandPreview: () => Promise<void>
|
|
fetchFormats: () => Promise<void>
|
|
paste: () => Promise<void>
|
|
acceptSuggestion: () => void
|
|
download: () => void
|
|
downloadAnyway: () => void
|
|
addPlaylist: () => void
|
|
toggleEntry: (index: number, on: boolean) => void
|
|
toggleAll: () => void
|
|
effKind: (index: number) => MediaKind
|
|
toggleItemKind: (index: number) => void
|
|
setAllKinds: (k: MediaKind) => void
|
|
}
|
|
|
|
/**
|
|
* All state and behaviour for the DownloadBar. Extracted from the component so
|
|
* the ~17 interdependent state hooks and their handlers live in one place and
|
|
* the component is render-only.
|
|
*/
|
|
export function useDownloadBar(): DownloadBarController {
|
|
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
|
const addMany = useDownloads((s) => s.addMany)
|
|
const openLibraryWith = useNav((s) => s.openLibraryWith)
|
|
const settingsLoaded = useSettings((s) => s.loaded)
|
|
const defaultKind = useSettings((s) => s.defaultKind)
|
|
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
|
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
|
|
|
|
const [url, setUrl] = useState('')
|
|
const [kind, setKind] = useState<MediaKind>('video')
|
|
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)
|
|
const [trim, setTrim] = useState('')
|
|
|
|
// Optional schedule: a datetime-local value; a future time parks the download.
|
|
const [showSchedule, setShowSchedule] = useState(false)
|
|
const [scheduleAt, setScheduleAt] = useState('')
|
|
|
|
// Drag-and-drop: highlight while a link / .url file hovers the card.
|
|
const [dragActive, setDragActive] = useState(false)
|
|
|
|
// Channel/playlist nudge: a whole channel/playlist belongs in the Library, not
|
|
// the one-off queue. Once dismissed for the current URL, stay quiet (UX3).
|
|
const [channelHintDismissed, setChannelHintDismissed] = useState(false)
|
|
|
|
// Duplicate guard: warn before enqueuing a URL already in the active queue.
|
|
const [dup, setDup] = useState<string | null>(null)
|
|
// Whether the duplicate matched a history entry (already downloaded) rather than
|
|
// a live queue item (L144) — the banner wording differs.
|
|
const [dupInHistory, setDupInHistory] = useState(false)
|
|
const confirmDup = useRef(false)
|
|
|
|
// Per-download options (M5/M6/UX1): advanced features.
|
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
const [downloadOptions, setDownloadOptions] = useState<DownloadOptions>(DEFAULT_DOWNLOAD_OPTIONS)
|
|
const [incognito, setIncognito] = useState(false)
|
|
const [commandPreview, setCommandPreview] = useState<CommandPreviewResult | null>(null)
|
|
const [previewLoading, setPreviewLoading] = useState(false)
|
|
|
|
// Probe state for the single-video format picker.
|
|
const [info, setInfo] = useState<MediaInfo | null>(null)
|
|
const [thumbFailed, setThumbFailed] = useState<string | null>(null)
|
|
const [probing, setProbing] = useState(false)
|
|
const [probeError, setProbeError] = useState<string | null>(null)
|
|
const [formatId, setFormatId] = useState<string>('')
|
|
|
|
// Probe state for a playlist URL.
|
|
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
|
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
|
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
|
|
// bar's global kind. Lets a playlist mix video and audio downloads.
|
|
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
|
|
|
|
// Drag highlight is gated on a depth counter, not raw enter/leave (L158): a
|
|
// dragleave fires every time the cursor crosses onto a child element, so toggling
|
|
// on leave made the dashed outline blink. Count enters vs leaves and only clear
|
|
// the highlight when the drag has actually left the whole card (depth back to 0).
|
|
const dragDepth = useRef(0)
|
|
function onDragEnter(e: React.DragEvent): void {
|
|
e.preventDefault()
|
|
dragDepth.current += 1
|
|
if (!dragActive) setDragActive(true)
|
|
}
|
|
function onDragOver(e: React.DragEvent): void {
|
|
// Required so the element is a valid drop target; the highlight is owned by
|
|
// onDragEnter/onDragLeave.
|
|
e.preventDefault()
|
|
}
|
|
function onDragLeave(): void {
|
|
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
|
if (dragDepth.current === 0) setDragActive(false)
|
|
}
|
|
function onDrop(e: React.DragEvent): void {
|
|
e.preventDefault()
|
|
dragDepth.current = 0
|
|
setDragActive(false)
|
|
const dt = e.dataTransfer
|
|
const fromText = firstUrlInText(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
|
|
if (fromText) {
|
|
onUrlChange(fromText)
|
|
return
|
|
}
|
|
// A dropped Windows .url Internet Shortcut -- read its URL= line.
|
|
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
|
|
if (file) {
|
|
file
|
|
.text()
|
|
.then((content) => {
|
|
const u = parseUrlFile(content)
|
|
if (u) onUrlChange(u)
|
|
})
|
|
.catch(logError('dropped .url file read'))
|
|
}
|
|
}
|
|
|
|
// When settings load or change, update kind/quality -- but only while the bar
|
|
// is idle (URL empty) so an in-progress setup isn't unexpectedly reset.
|
|
useEffect(() => {
|
|
if (!settingsLoaded || url.trim()) return
|
|
setKind(defaultKind)
|
|
setQuality(defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality)
|
|
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality, url])
|
|
|
|
// Clipboard auto-detect and links handed to AeroFetch from outside (the
|
|
// aerofetch:// protocol or a "Send to" .url file) share one suggestion banner,
|
|
// driven by the same hook the library's add-source field uses. An external link
|
|
// always takes priority over a clipboard guess -- it's a direct request.
|
|
const {
|
|
suggestion,
|
|
source: suggestionSource,
|
|
accept: acceptLink,
|
|
dismiss: dismissSuggestion,
|
|
offer: offerLink
|
|
} = useClipboardLink(url)
|
|
useEffect(
|
|
() => window.api.onExternalUrl((incoming) => offerLink(incoming, 'external')),
|
|
[offerLink]
|
|
)
|
|
|
|
function acceptSuggestion(): void {
|
|
const link = acceptLink()
|
|
if (link) onUrlChange(link)
|
|
}
|
|
|
|
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])
|
|
: undefined
|
|
|
|
// Show the "add this in the Library" nudge for a clear channel/playlist URL,
|
|
// but only while the bar is still idle for it (nothing probed/resolved yet) and
|
|
// the user hasn't waved it away (UX3).
|
|
const channelHint =
|
|
looksLikeChannelOrPlaylist(url) &&
|
|
!channelHintDismissed &&
|
|
info === null &&
|
|
playlist === null &&
|
|
probeError === null
|
|
|
|
function clearProbe(): void {
|
|
setInfo(null)
|
|
setProbeError(null)
|
|
setFormatId('')
|
|
setPlaylist(null)
|
|
setSelected(new Set())
|
|
setItemKinds({})
|
|
}
|
|
|
|
// Reset the whole bar after a download / playlist is enqueued: clear the URL +
|
|
// probe and drop the one-shot overrides (trim / schedule / advanced / incognito /
|
|
// per-download options) so the next download starts from the global defaults.
|
|
// Shared by download() and addPlaylist() so a playlist add resets the same state
|
|
// a single download does (L132 — addPlaylist previously left trim/schedule open).
|
|
function resetForm(): void {
|
|
setUrl('')
|
|
setTrim('')
|
|
setShowTrim(false)
|
|
setScheduleAt('')
|
|
setShowSchedule(false)
|
|
setShowAdvanced(false)
|
|
setCommandPreview(null)
|
|
setIncognito(false)
|
|
setDownloadOptions(DEFAULT_DOWNLOAD_OPTIONS)
|
|
clearProbe()
|
|
}
|
|
|
|
// Fetch (or toggle off) the exact yt-dlp command line for the current form
|
|
// state. The options mirror what download() sends so the preview matches the
|
|
// command that will actually run -- including a probe-selected format and any
|
|
// trim spec (M5).
|
|
async function toggleCommandPreview(): Promise<void> {
|
|
if (commandPreview) {
|
|
setCommandPreview(null)
|
|
return
|
|
}
|
|
const trimmed = url.trim()
|
|
if (!trimmed) return
|
|
setPreviewLoading(true)
|
|
try {
|
|
const result = await window.api.previewCommand({
|
|
id: 'preview',
|
|
url: trimmed,
|
|
kind: usingFormats ? 'video' : kind,
|
|
quality: usingFormats && selectedFormat ? selectedFormat.label : quality,
|
|
formatId: usingFormats ? selectedFormat?.id : undefined,
|
|
formatHasAudio: usingFormats ? selectedFormat?.hasAudio : undefined,
|
|
options: downloadOptions,
|
|
trim: trim.trim() || undefined
|
|
})
|
|
setCommandPreview(result)
|
|
} catch (e) {
|
|
setCommandPreview({ ok: false, error: (e as Error).message })
|
|
} finally {
|
|
setPreviewLoading(false)
|
|
}
|
|
}
|
|
|
|
function onUrlChange(next: string): void {
|
|
setUrl(next)
|
|
// Any probed info -- or a duplicate warning -- is stale once the URL changes.
|
|
if (info || probeError || playlist) clearProbe()
|
|
if (dup) setDup(null)
|
|
setCommandPreview(null)
|
|
confirmDup.current = false
|
|
// A new URL gets a fresh chance to nudge toward the Library.
|
|
setChannelHintDismissed(false)
|
|
}
|
|
|
|
function onKindChange(next: MediaKind): void {
|
|
setKind(next)
|
|
setQuality(QUALITY_OPTIONS[next][0])
|
|
// The command changes with kind/quality, so any shown preview is now stale.
|
|
setCommandPreview(null)
|
|
}
|
|
|
|
// Any change to quality / format / trim / options invalidates a shown preview.
|
|
function onQualityChange(v: string): void {
|
|
setQuality(v)
|
|
setCommandPreview(null)
|
|
}
|
|
function onFormatIdChange(v: string): void {
|
|
setFormatId(v)
|
|
setCommandPreview(null)
|
|
}
|
|
function onTrimChange(v: string): void {
|
|
setTrim(v)
|
|
setCommandPreview(null)
|
|
}
|
|
function onDownloadOptionsChange(next: DownloadOptions): void {
|
|
setDownloadOptions(next)
|
|
setCommandPreview(null)
|
|
}
|
|
|
|
async function fetchFormats(): Promise<void> {
|
|
const trimmed = url.trim()
|
|
if (!trimmed || probing) return
|
|
setProbing(true)
|
|
setProbeError(null)
|
|
setInfo(null)
|
|
setPlaylist(null)
|
|
// Probing may switch the URL to the format-picker path, changing the command.
|
|
setCommandPreview(null)
|
|
try {
|
|
const res = await window.api.probe(trimmed)
|
|
if (res.ok && res.kind === 'playlist' && res.playlist) {
|
|
setPlaylist(res.playlist)
|
|
// Pre-select every entry; the user can trim the list.
|
|
setSelected(new Set(res.playlist.entries.map((e) => e.index)))
|
|
} else if (res.ok && res.info) {
|
|
setInfo(res.info)
|
|
setFormatId(res.info.formats[0]?.id ?? '')
|
|
} else {
|
|
setProbeError(res.error ?? 'Could not fetch video info.')
|
|
}
|
|
} catch (e) {
|
|
setProbeError(String(e))
|
|
} finally {
|
|
setProbing(false)
|
|
}
|
|
}
|
|
|
|
async function paste(): Promise<void> {
|
|
try {
|
|
const text = (await window.api?.readClipboard?.()) ?? ''
|
|
if (text) onUrlChange(text.trim())
|
|
} catch {
|
|
/* ignore in preview */
|
|
}
|
|
}
|
|
|
|
// Hand the current channel/playlist URL to the Library tab, which pre-fills its
|
|
// add field with it, and clear the bar (the URL now lives over there) (UX3).
|
|
function openInLibrary(): void {
|
|
const trimmed = url.trim()
|
|
if (!trimmed) return
|
|
openLibraryWith(trimmed)
|
|
setUrl('')
|
|
// Clear the URL-specific command preview too, so no stale command lingers over
|
|
// the now-empty bar (the same invariant onUrlChange keeps).
|
|
setCommandPreview(null)
|
|
clearProbe()
|
|
}
|
|
function dismissChannelHint(): void {
|
|
setChannelHintDismissed(true)
|
|
}
|
|
|
|
// Selection helpers for the playlist panel.
|
|
const allSelected = playlist !== null && selected.size === playlist.entries.length
|
|
function toggleEntry(index: number, on: boolean): void {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (on) next.add(index)
|
|
else next.delete(index)
|
|
return next
|
|
})
|
|
}
|
|
function toggleAll(): void {
|
|
if (!playlist) return
|
|
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
|
}
|
|
|
|
// Per-entry kind: the override if set, else the bar's global kind.
|
|
function effKind(index: number): MediaKind {
|
|
return itemKinds[index] ?? kind
|
|
}
|
|
function toggleItemKind(index: number): void {
|
|
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
|
|
}
|
|
function setAllKinds(k: MediaKind): void {
|
|
if (!playlist) return
|
|
const all: Record<number, MediaKind> = {}
|
|
for (const e of playlist.entries) all[e.index] = k
|
|
setItemKinds(all)
|
|
}
|
|
|
|
function download(): void {
|
|
const trimmed = url.trim()
|
|
if (!trimmed) return
|
|
|
|
// Duplicate guard: if this URL is already in the queue (and the user hasn't
|
|
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
|
|
// Canceled/failed items don't count -- re-adding those is a legitimate retry.
|
|
if (!confirmDup.current) {
|
|
const existing = useDownloads
|
|
.getState()
|
|
.items.find(
|
|
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
|
|
)
|
|
if (existing) {
|
|
// Use the URL as fallback if the title is still the placeholder generated
|
|
// by titleFromUrl() before metadata loads (L76).
|
|
const isPlaceholder =
|
|
/video \(\w+\)$/.test(existing.title) ||
|
|
existing.title.endsWith(' download') ||
|
|
existing.title === 'New download'
|
|
setDup(isPlaceholder ? existing.url : existing.title)
|
|
setDupInHistory(false)
|
|
return
|
|
}
|
|
// Not in the queue, but maybe already downloaded earlier (L144): warn so a
|
|
// re-download is a deliberate choice, not an accidental duplicate.
|
|
const downloaded = useHistory.getState().entries.find((h) => sameVideo(h.url, trimmed))
|
|
if (downloaded) {
|
|
setDup(downloaded.title || downloaded.url)
|
|
setDupInHistory(true)
|
|
return
|
|
}
|
|
}
|
|
confirmDup.current = false
|
|
setDup(null)
|
|
|
|
const meta = info
|
|
? {
|
|
title: info.title,
|
|
channel: info.channel,
|
|
durationLabel: info.durationLabel,
|
|
thumbnail: info.thumbnail
|
|
}
|
|
: {}
|
|
|
|
const trimSpec = trim.trim() || undefined
|
|
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
|
|
|
|
if (usingFormats && selectedFormat) {
|
|
addFromUrl(trimmed, 'video', selectedFormat.label, {
|
|
...meta,
|
|
trim: trimSpec,
|
|
scheduledFor,
|
|
options: downloadOptions,
|
|
incognito,
|
|
format: {
|
|
id: selectedFormat.id,
|
|
hasAudio: selectedFormat.hasAudio,
|
|
label: selectedFormat.label
|
|
}
|
|
})
|
|
} else {
|
|
addFromUrl(trimmed, kind, quality, {
|
|
...meta,
|
|
trim: trimSpec,
|
|
scheduledFor,
|
|
options: downloadOptions,
|
|
incognito
|
|
})
|
|
}
|
|
|
|
resetForm()
|
|
}
|
|
|
|
function downloadAnyway(): void {
|
|
confirmDup.current = true
|
|
download()
|
|
}
|
|
|
|
function addPlaylist(): void {
|
|
if (!playlist) return
|
|
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
|
// Each entry uses its own kind override; quality falls back to that kind's
|
|
// default unless the entry matches the bar's current kind/quality.
|
|
addMany(
|
|
chosen.map((e) => {
|
|
const k = effKind(e.index)
|
|
return {
|
|
url: e.url,
|
|
kind: k,
|
|
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
|
|
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
|
|
}
|
|
})
|
|
)
|
|
resetForm()
|
|
}
|
|
|
|
return {
|
|
// form state
|
|
url,
|
|
kind,
|
|
quality,
|
|
// trim / schedule
|
|
showTrim,
|
|
setShowTrim,
|
|
trim,
|
|
showSchedule,
|
|
setShowSchedule,
|
|
scheduleAt,
|
|
setScheduleAt,
|
|
// drag / dup
|
|
dupInHistory,
|
|
dragActive,
|
|
setDragActive,
|
|
dup,
|
|
setDup,
|
|
// advanced / preview
|
|
showAdvanced,
|
|
setShowAdvanced,
|
|
downloadOptions,
|
|
incognito,
|
|
setIncognito,
|
|
commandPreview,
|
|
previewLoading,
|
|
// probe (single video)
|
|
info,
|
|
thumbFailed,
|
|
setThumbFailed,
|
|
probing,
|
|
probeError,
|
|
usingFormats,
|
|
selectedFormat,
|
|
// playlist
|
|
playlist,
|
|
selected,
|
|
allSelected,
|
|
// suggestion banner
|
|
suggestion,
|
|
suggestionSource,
|
|
dismissSuggestion,
|
|
// channel/playlist nudge → Library (UX3)
|
|
channelHint,
|
|
openInLibrary,
|
|
dismissChannelHint,
|
|
// handlers
|
|
onDragEnter,
|
|
onDragOver,
|
|
onDragLeave,
|
|
onDrop,
|
|
onUrlChange,
|
|
onKindChange,
|
|
onQualityChange,
|
|
onFormatIdChange,
|
|
onTrimChange,
|
|
onDownloadOptionsChange,
|
|
toggleCommandPreview,
|
|
fetchFormats,
|
|
paste,
|
|
acceptSuggestion,
|
|
download,
|
|
downloadAnyway,
|
|
addPlaylist,
|
|
toggleEntry,
|
|
toggleAll,
|
|
effKind,
|
|
toggleItemKind,
|
|
setAllKinds
|
|
}
|
|
}
|