feat(playlist): exact-format-per-item selection
Each video row in the playlist panel gets an exact-format button that lazily probes just that entry's URL (reusing the existing single-video probe IPC, no new main-process code) and shows a native Select of its real formats. The choice flows through addMany -> formatId per queue item. Probing is on-demand per row, never all up front, since a playlist can hold thousands of entries and per-entry probes are rate-limited. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+8
-2
@@ -363,8 +363,14 @@ per-item `options`) but not the surfaces. typecheck + test + `npm run build` cle
|
|||||||
- [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a
|
- [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a
|
||||||
per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist`
|
per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist`
|
||||||
enqueues via `addMany` with each entry's own kind (quality falls back to that kind's
|
enqueues via `addMany` with each entry's own kind (quality falls back to that kind's
|
||||||
default). Per-item exact-format selection (needs probing each entry) left as a further
|
default).*
|
||||||
extension.*
|
- [x] **Per-playlist exact-format-per-item.** *Done: each video row in the playlist panel has an
|
||||||
|
**exact-format** button that lazily probes just that entry's URL (reusing the single-video
|
||||||
|
`probe` IPC — no new main code) and reveals a native `<Select>` of that entry's real formats;
|
||||||
|
the pick is carried as a `ChosenFormat` and flows through `addMany` → `formatId` per queue
|
||||||
|
item. Probing is on-demand per row (never all up front — playlists can be huge / rate-limited).
|
||||||
|
Flipping a row to audio drops its format. Pure mapping in `downloadBar/playlistEntries.ts`,
|
||||||
|
unit-tested (`test/playlistEntries.test.ts`); verified end-to-end in the preview harness.*
|
||||||
- [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on
|
- [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on
|
||||||
`DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S <value>`, overriding the
|
`DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S <value>`, overriding the
|
||||||
codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
|
codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
|
||||||
|
|||||||
@@ -239,6 +239,13 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
onToggleEntry={bar.toggleEntry}
|
onToggleEntry={bar.toggleEntry}
|
||||||
effKind={bar.effKind}
|
effKind={bar.effKind}
|
||||||
onToggleItemKind={bar.toggleItemKind}
|
onToggleItemKind={bar.toggleItemKind}
|
||||||
|
formatExpanded={bar.formatExpanded}
|
||||||
|
itemFormatOptions={bar.itemFormatOptions}
|
||||||
|
itemFormat={bar.itemFormat}
|
||||||
|
formatProbing={bar.formatProbing}
|
||||||
|
formatProbeError={bar.formatProbeError}
|
||||||
|
onToggleFormat={bar.toggleItemFormat}
|
||||||
|
onSetItemFormat={bar.setItemFormat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Button, Checkbox, Caption1, mergeClasses } from '@fluentui/react-components'
|
import { Button, Checkbox, Caption1, Spinner, mergeClasses } from '@fluentui/react-components'
|
||||||
import { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
|
import {
|
||||||
import { type PlaylistInfo } from '@shared/ipc'
|
AppsListRegular,
|
||||||
|
VideoClipRegular,
|
||||||
|
MusicNote2Regular,
|
||||||
|
OptionsRegular
|
||||||
|
} from '@fluentui/react-icons'
|
||||||
|
import { BEST_FORMAT_ID, type FormatOption, type PlaylistInfo } from '@shared/ipc'
|
||||||
import { type MediaKind } from '../../store/downloads'
|
import { type MediaKind } from '../../store/downloads'
|
||||||
|
import { type ChosenFormat } from '../../store/downloadTypes'
|
||||||
import { Hint } from '../Hint'
|
import { Hint } from '../Hint'
|
||||||
|
import { Select } from '../Select'
|
||||||
import { IconButton } from '../ui/IconButton'
|
import { IconButton } from '../ui/IconButton'
|
||||||
import { useTextStyles } from '../ui/text'
|
import { useTextStyles } from '../ui/text'
|
||||||
import { META_SEP } from '../ui/tokens'
|
import { META_SEP } from '../ui/tokens'
|
||||||
@@ -17,6 +24,14 @@ interface PlaylistPanelProps {
|
|||||||
onToggleEntry: (index: number, on: boolean) => void
|
onToggleEntry: (index: number, on: boolean) => void
|
||||||
effKind: (index: number) => MediaKind
|
effKind: (index: number) => MediaKind
|
||||||
onToggleItemKind: (index: number) => void
|
onToggleItemKind: (index: number) => void
|
||||||
|
// per-item exact-format picker (video rows only)
|
||||||
|
formatExpanded: Set<number>
|
||||||
|
itemFormatOptions: Record<number, FormatOption[]>
|
||||||
|
itemFormat: (index: number) => ChosenFormat | undefined
|
||||||
|
formatProbing: Set<number>
|
||||||
|
formatProbeError: Record<number, string>
|
||||||
|
onToggleFormat: (index: number) => void
|
||||||
|
onSetItemFormat: (index: number, formatId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistPanel({
|
export function PlaylistPanel({
|
||||||
@@ -27,7 +42,14 @@ export function PlaylistPanel({
|
|||||||
onSetAllKinds,
|
onSetAllKinds,
|
||||||
onToggleEntry,
|
onToggleEntry,
|
||||||
effKind,
|
effKind,
|
||||||
onToggleItemKind
|
onToggleItemKind,
|
||||||
|
formatExpanded,
|
||||||
|
itemFormatOptions,
|
||||||
|
itemFormat,
|
||||||
|
formatProbing,
|
||||||
|
formatProbeError,
|
||||||
|
onToggleFormat,
|
||||||
|
onSetItemFormat
|
||||||
}: PlaylistPanelProps): React.JSX.Element {
|
}: PlaylistPanelProps): React.JSX.Element {
|
||||||
const styles = useDownloadBarStyles()
|
const styles = useDownloadBarStyles()
|
||||||
const text = useTextStyles()
|
const text = useTextStyles()
|
||||||
@@ -53,44 +75,96 @@ export function PlaylistPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.plList}>
|
<div className={styles.plList}>
|
||||||
{playlist.entries.map((e) => (
|
{playlist.entries.map((e) => {
|
||||||
<div key={e.index} className={styles.plItemRow}>
|
const isVideo = effKind(e.index) === 'video'
|
||||||
<Checkbox
|
const chosen = itemFormat(e.index)
|
||||||
className={styles.plItem}
|
const expanded = formatExpanded.has(e.index)
|
||||||
checked={selected.has(e.index)}
|
const probing = formatProbing.has(e.index)
|
||||||
onChange={(_, d) => onToggleEntry(e.index, !!d.checked)}
|
const error = formatProbeError[e.index]
|
||||||
label={
|
const options = itemFormatOptions[e.index]
|
||||||
<span className={styles.plItemLabel}>
|
return (
|
||||||
<span className={text.truncate}>
|
<div key={e.index} className={styles.plItemGroup}>
|
||||||
{e.index}. {e.title}
|
<div className={styles.plItemRow}>
|
||||||
</span>
|
<Checkbox
|
||||||
{(e.durationLabel || e.uploader) && (
|
className={styles.plItem}
|
||||||
<Caption1 className={text.muted}>
|
checked={selected.has(e.index)}
|
||||||
{[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
|
onChange={(_, d) => onToggleEntry(e.index, !!d.checked)}
|
||||||
|
label={
|
||||||
|
<span className={styles.plItemLabel}>
|
||||||
|
<span className={text.truncate}>
|
||||||
|
{e.index}. {e.title}
|
||||||
|
</span>
|
||||||
|
{(e.durationLabel || e.uploader || chosen) && (
|
||||||
|
<Caption1 className={text.muted}>
|
||||||
|
{[e.durationLabel, e.uploader, chosen?.label]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(META_SEP)}
|
||||||
|
</Caption1>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isVideo && (
|
||||||
|
<Hint
|
||||||
|
label={expanded ? 'Hide format' : 'Choose exact format'}
|
||||||
|
placement="top"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
className={chosen ? styles.plFormatBtnActive : undefined}
|
||||||
|
aria-pressed={expanded}
|
||||||
|
icon={<OptionsRegular />}
|
||||||
|
onClick={() => onToggleFormat(e.index)}
|
||||||
|
aria-label={`Choose format for ${e.title}`}
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
)}
|
||||||
|
<Hint
|
||||||
|
label={
|
||||||
|
effKind(e.index) === 'audio'
|
||||||
|
? 'Audio -- click for video'
|
||||||
|
: 'Video -- click for audio'
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
icon={
|
||||||
|
effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />
|
||||||
|
}
|
||||||
|
onClick={() => onToggleItemKind(e.index)}
|
||||||
|
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
|
||||||
|
/>
|
||||||
|
</Hint>
|
||||||
|
</div>
|
||||||
|
{isVideo && expanded && (
|
||||||
|
<div className={styles.plFormatPanel}>
|
||||||
|
{probing ? (
|
||||||
|
<Caption1 className={styles.plFormatStatus}>
|
||||||
|
<Spinner size="tiny" /> Fetching formats…
|
||||||
</Caption1>
|
</Caption1>
|
||||||
|
) : error ? (
|
||||||
|
<Caption1 className={styles.plFormatError}>{error}</Caption1>
|
||||||
|
) : options && options.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
className={styles.plFormatSelect}
|
||||||
|
aria-label={`Format for ${e.title}`}
|
||||||
|
value={chosen?.id ?? BEST_FORMAT_ID}
|
||||||
|
options={options.map((o) => ({ value: o.id, label: o.label }))}
|
||||||
|
onChange={(v) => onSetItemFormat(e.index, v)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Caption1 className={text.muted}>No selectable formats.</Caption1>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
}
|
)}
|
||||||
/>
|
</div>
|
||||||
<Hint
|
)
|
||||||
label={
|
})}
|
||||||
effKind(e.index) === 'audio'
|
|
||||||
? 'Audio -- click for video'
|
|
||||||
: 'Video -- click for audio'
|
|
||||||
}
|
|
||||||
placement="top"
|
|
||||||
align="end"
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="sm"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
icon={effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
|
|
||||||
onClick={() => onToggleItemKind(e.index)}
|
|
||||||
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
|
|
||||||
/>
|
|
||||||
</Hint>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { type PlaylistEntry } from '@shared/ipc'
|
||||||
|
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
|
||||||
|
import { type MediaKind } from '../../store/downloads'
|
||||||
|
import { type AddEntry, type ChosenFormat } from '../../store/downloadTypes'
|
||||||
|
|
||||||
|
/** The download-bar state a playlist add reads to build its per-entry queue rows. */
|
||||||
|
export interface PlaylistAddContext {
|
||||||
|
/** the bar's global kind (an entry with no override inherits this) */
|
||||||
|
barKind: MediaKind
|
||||||
|
/** the bar's global quality preset (used when an entry's kind matches barKind) */
|
||||||
|
barQuality: string
|
||||||
|
/** effective kind for an entry index: its override, else barKind */
|
||||||
|
effKind: (index: number) => MediaKind
|
||||||
|
/** exact per-item format the user probed + picked (video rows only) */
|
||||||
|
itemFormats: Record<number, ChosenFormat>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map selected playlist entries to queue rows for a single `addMany` batch.
|
||||||
|
* Each entry uses its own kind override; a video entry the user gave an exact
|
||||||
|
* probed format downloads that format, otherwise quality falls back to the bar's
|
||||||
|
* preset (when the kind matches) or that kind's default. Kept pure so the mapping
|
||||||
|
* is unit-testable apart from the React hook.
|
||||||
|
*/
|
||||||
|
export function buildPlaylistAddEntries(
|
||||||
|
chosen: PlaylistEntry[],
|
||||||
|
ctx: PlaylistAddContext
|
||||||
|
): AddEntry[] {
|
||||||
|
return chosen.map((e) => {
|
||||||
|
const k = ctx.effKind(e.index)
|
||||||
|
// A per-item exact format only makes sense for a video row (audio is a preset).
|
||||||
|
const fmt = k === 'video' ? ctx.itemFormats[e.index] : undefined
|
||||||
|
return {
|
||||||
|
url: e.url,
|
||||||
|
kind: k,
|
||||||
|
quality: fmt ? fmt.label : k === ctx.barKind ? ctx.barQuality : QUALITY_OPTIONS[k][0],
|
||||||
|
opts: {
|
||||||
|
title: e.title,
|
||||||
|
channel: e.uploader,
|
||||||
|
durationLabel: e.durationLabel,
|
||||||
|
...(fmt ? { format: fmt } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -131,6 +131,35 @@ export const useDownloadBarStyles = makeStyles({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
minWidth: 0
|
minWidth: 0
|
||||||
},
|
},
|
||||||
|
// Column wrapper per entry: the checkbox/toggle row plus its (optional) format panel.
|
||||||
|
plItemGroup: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
},
|
||||||
|
// The lazily-probed per-item format picker, indented under its entry row.
|
||||||
|
plFormatPanel: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
// Line up under the entry title (past the checkbox), leave room below.
|
||||||
|
padding: '0 0 6px 28px'
|
||||||
|
},
|
||||||
|
plFormatSelect: {
|
||||||
|
maxWidth: '260px'
|
||||||
|
},
|
||||||
|
// Brand-tint the per-item format button once an exact format is chosen for that row.
|
||||||
|
plFormatBtnActive: {
|
||||||
|
color: tokens.colorBrandForeground1
|
||||||
|
},
|
||||||
|
plFormatStatus: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
color: tokens.colorNeutralForeground3
|
||||||
|
},
|
||||||
|
plFormatError: {
|
||||||
|
color: tokens.colorPaletteRedForeground1
|
||||||
|
},
|
||||||
// --- trim / schedule panels ---
|
// --- trim / schedule panels ---
|
||||||
trimBlock: {
|
trimBlock: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
|
||||||
import {
|
import {
|
||||||
parseUrlShortcutContent,
|
parseUrlShortcutContent,
|
||||||
|
BEST_FORMAT_ID,
|
||||||
type MediaInfo,
|
type MediaInfo,
|
||||||
type FormatOption,
|
type FormatOption,
|
||||||
type PlaylistInfo,
|
type PlaylistInfo,
|
||||||
@@ -9,6 +10,8 @@ import {
|
|||||||
type CommandPreviewResult
|
type CommandPreviewResult
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
import { useDownloads, type MediaKind } from '../../store/downloads'
|
import { useDownloads, type MediaKind } from '../../store/downloads'
|
||||||
|
import { type ChosenFormat } from '../../store/downloadTypes'
|
||||||
|
import { buildPlaylistAddEntries } from './playlistEntries'
|
||||||
import { useHistory } from '../../store/history'
|
import { useHistory } from '../../store/history'
|
||||||
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
|
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
|
||||||
import { sameVideo } from '../../lib/queueStats'
|
import { sameVideo } from '../../lib/queueStats'
|
||||||
@@ -78,6 +81,12 @@ export interface DownloadBarController {
|
|||||||
playlist: PlaylistInfo | null
|
playlist: PlaylistInfo | null
|
||||||
selected: Set<number>
|
selected: Set<number>
|
||||||
allSelected: boolean
|
allSelected: boolean
|
||||||
|
// per-item format picker (video rows only)
|
||||||
|
formatExpanded: Set<number>
|
||||||
|
itemFormatOptions: Record<number, FormatOption[]>
|
||||||
|
itemFormat: (index: number) => ChosenFormat | undefined
|
||||||
|
formatProbing: Set<number>
|
||||||
|
formatProbeError: Record<number, string>
|
||||||
// suggestion banner
|
// suggestion banner
|
||||||
suggestion: string | null
|
suggestion: string | null
|
||||||
suggestionSource: SuggestionSource
|
suggestionSource: SuggestionSource
|
||||||
@@ -109,6 +118,8 @@ export interface DownloadBarController {
|
|||||||
effKind: (index: number) => MediaKind
|
effKind: (index: number) => MediaKind
|
||||||
toggleItemKind: (index: number) => void
|
toggleItemKind: (index: number) => void
|
||||||
setAllKinds: (k: MediaKind) => void
|
setAllKinds: (k: MediaKind) => void
|
||||||
|
toggleItemFormat: (index: number) => Promise<void>
|
||||||
|
setItemFormat: (index: number, formatId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,6 +183,18 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
// bar's global kind. Lets a playlist mix video and audio downloads.
|
// bar's global kind. Lets a playlist mix video and audio downloads.
|
||||||
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
|
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
|
||||||
|
|
||||||
|
// Per-entry exact-format selection (video rows only). A playlist probe is flat —
|
||||||
|
// it carries no per-entry formats — so each row's formats are fetched lazily on
|
||||||
|
// first expand (never all up front: a playlist can hold thousands of entries and
|
||||||
|
// per-entry probes are rate-limited). `formatExpanded` = rows showing the picker;
|
||||||
|
// `itemFormatOptions` caches a row's probed formats; `itemFormats` = the chosen
|
||||||
|
// exact format per row (absent = use the kind/quality preset path).
|
||||||
|
const [formatExpanded, setFormatExpanded] = useState<Set<number>>(new Set())
|
||||||
|
const [itemFormatOptions, setItemFormatOptions] = useState<Record<number, FormatOption[]>>({})
|
||||||
|
const [itemFormats, setItemFormats] = useState<Record<number, ChosenFormat>>({})
|
||||||
|
const [formatProbing, setFormatProbing] = useState<Set<number>>(new Set())
|
||||||
|
const [formatProbeError, setFormatProbeError] = useState<Record<number, string>>({})
|
||||||
|
|
||||||
// Drag highlight is gated on a depth counter, not raw enter/leave (L158): a
|
// 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
|
// 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
|
// on leave made the dashed outline blink. Count enters vs leaves and only clear
|
||||||
@@ -265,6 +288,11 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
setPlaylist(null)
|
setPlaylist(null)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setItemKinds({})
|
setItemKinds({})
|
||||||
|
setFormatExpanded(new Set())
|
||||||
|
setItemFormatOptions({})
|
||||||
|
setItemFormats({})
|
||||||
|
setFormatProbing(new Set())
|
||||||
|
setFormatProbeError({})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the whole bar after a download / playlist is enqueued: clear the URL +
|
// Reset the whole bar after a download / playlist is enqueued: clear the URL +
|
||||||
@@ -425,13 +453,96 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
return itemKinds[index] ?? kind
|
return itemKinds[index] ?? kind
|
||||||
}
|
}
|
||||||
function toggleItemKind(index: number): void {
|
function toggleItemKind(index: number): void {
|
||||||
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
|
const next = effKind(index) === 'audio' ? 'video' : 'audio'
|
||||||
|
setItemKinds((m) => ({ ...m, [index]: next }))
|
||||||
|
// An exact format is a video-only choice; drop it (and the picker) when the
|
||||||
|
// row switches to audio so the audio download uses the quality preset.
|
||||||
|
if (next === 'audio') dropItemFormat(index)
|
||||||
}
|
}
|
||||||
function setAllKinds(k: MediaKind): void {
|
function setAllKinds(k: MediaKind): void {
|
||||||
if (!playlist) return
|
if (!playlist) return
|
||||||
const all: Record<number, MediaKind> = {}
|
const all: Record<number, MediaKind> = {}
|
||||||
for (const e of playlist.entries) all[e.index] = k
|
for (const e of playlist.entries) all[e.index] = k
|
||||||
setItemKinds(all)
|
setItemKinds(all)
|
||||||
|
// Same reason as toggleItemKind: an "All audio" flip clears every per-item format.
|
||||||
|
if (k === 'audio') {
|
||||||
|
setItemFormats({})
|
||||||
|
setFormatExpanded(new Set())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-entry exact-format helpers.
|
||||||
|
function itemFormat(index: number): ChosenFormat | undefined {
|
||||||
|
return itemFormats[index]
|
||||||
|
}
|
||||||
|
function dropItemFormat(index: number): void {
|
||||||
|
setItemFormats((m) => {
|
||||||
|
const { [index]: _drop, ...rest } = m
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
setFormatExpanded((s) => {
|
||||||
|
const next = new Set(s)
|
||||||
|
next.delete(index)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand/collapse a row's format picker. On first expand, lazily probe just that
|
||||||
|
// entry's URL — the existing single-video probe returns its full format list — and
|
||||||
|
// cache it so re-expanding is instant.
|
||||||
|
async function toggleItemFormat(index: number): Promise<void> {
|
||||||
|
if (formatExpanded.has(index)) {
|
||||||
|
setFormatExpanded((s) => {
|
||||||
|
const next = new Set(s)
|
||||||
|
next.delete(index)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFormatExpanded((s) => new Set(s).add(index))
|
||||||
|
if (itemFormatOptions[index] || formatProbing.has(index)) return // already have (or fetching) formats
|
||||||
|
const entry = playlist?.entries.find((e) => e.index === index)
|
||||||
|
if (!entry) return
|
||||||
|
setFormatProbing((s) => new Set(s).add(index))
|
||||||
|
setFormatProbeError((m) => {
|
||||||
|
const { [index]: _drop, ...rest } = m
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await window.api.probe(entry.url)
|
||||||
|
if (res.ok && res.kind === 'video' && res.info) {
|
||||||
|
setItemFormatOptions((m) => ({ ...m, [index]: res.info!.formats }))
|
||||||
|
} else {
|
||||||
|
setFormatProbeError((m) => ({ ...m, [index]: res.error ?? 'Could not fetch formats.' }))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setFormatProbeError((m) => ({ ...m, [index]: String(e) }))
|
||||||
|
} finally {
|
||||||
|
setFormatProbing((s) => {
|
||||||
|
const next = new Set(s)
|
||||||
|
next.delete(index)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick an exact format for a row (or clear back to the preset for "Best available").
|
||||||
|
function setItemFormat(index: number, formatId: string): void {
|
||||||
|
const opt = itemFormatOptions[index]?.find((o) => o.id === formatId)
|
||||||
|
if (!opt || opt.id === BEST_FORMAT_ID) {
|
||||||
|
// "Best available" is the auto/preset path — carry no exact format.
|
||||||
|
setItemFormats((m) => {
|
||||||
|
const { [index]: _drop, ...rest } = m
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setItemFormats((m) => ({
|
||||||
|
...m,
|
||||||
|
[index]: { id: opt.id, hasAudio: opt.hasAudio, label: opt.label }
|
||||||
|
}))
|
||||||
|
// Choosing a video format implies this row is a video download.
|
||||||
|
if (effKind(index) === 'audio') setItemKinds((m) => ({ ...m, [index]: 'video' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function download(): void {
|
function download(): void {
|
||||||
@@ -516,18 +627,12 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
function addPlaylist(): void {
|
function addPlaylist(): void {
|
||||||
if (!playlist) return
|
if (!playlist) return
|
||||||
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||||
// Each entry uses its own kind override; quality falls back to that kind's
|
// Each entry uses its own kind override and, for a video row the user gave an
|
||||||
// default unless the entry matches the bar's current kind/quality.
|
// exact probed format, that format; otherwise quality falls back to the bar's
|
||||||
|
// current preset (when the kind matches) or that kind's default (see the pure
|
||||||
|
// buildPlaylistAddEntries for the mapping).
|
||||||
addMany(
|
addMany(
|
||||||
chosen.map((e) => {
|
buildPlaylistAddEntries(chosen, { barKind: kind, barQuality: quality, effKind, itemFormats })
|
||||||
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()
|
resetForm()
|
||||||
}
|
}
|
||||||
@@ -571,6 +676,12 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
playlist,
|
playlist,
|
||||||
selected,
|
selected,
|
||||||
allSelected,
|
allSelected,
|
||||||
|
// per-item format picker
|
||||||
|
formatExpanded,
|
||||||
|
itemFormatOptions,
|
||||||
|
itemFormat,
|
||||||
|
formatProbing,
|
||||||
|
formatProbeError,
|
||||||
// suggestion banner
|
// suggestion banner
|
||||||
suggestion,
|
suggestion,
|
||||||
suggestionSource,
|
suggestionSource,
|
||||||
@@ -601,6 +712,8 @@ export function useDownloadBar(): DownloadBarController {
|
|||||||
toggleAll,
|
toggleAll,
|
||||||
effKind,
|
effKind,
|
||||||
toggleItemKind,
|
toggleItemKind,
|
||||||
setAllKinds
|
setAllKinds,
|
||||||
|
toggleItemFormat,
|
||||||
|
setItemFormat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import type { PlaylistEntry } from '../src/shared/ipc'
|
||||||
|
import { buildPlaylistAddEntries } from '../src/renderer/src/components/downloadBar/playlistEntries'
|
||||||
|
import type { ChosenFormat } from '../src/renderer/src/store/downloadTypes'
|
||||||
|
import type { MediaKind } from '../src/renderer/src/store/downloads'
|
||||||
|
|
||||||
|
function entry(index: number, over: Partial<PlaylistEntry> = {}): PlaylistEntry {
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
id: `v${index}`,
|
||||||
|
title: `Item ${index}`,
|
||||||
|
url: `https://youtube.com/watch?v=v${index}`,
|
||||||
|
durationLabel: '10:00',
|
||||||
|
uploader: 'Chan',
|
||||||
|
...over
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the ctx the way useDownloadBar does, from per-entry override maps. */
|
||||||
|
function ctx(opts: {
|
||||||
|
barKind?: MediaKind
|
||||||
|
barQuality?: string
|
||||||
|
kinds?: Record<number, MediaKind>
|
||||||
|
formats?: Record<number, ChosenFormat>
|
||||||
|
}) {
|
||||||
|
const { barKind = 'video', barQuality = '1080p', kinds = {}, formats = {} } = opts
|
||||||
|
return {
|
||||||
|
barKind,
|
||||||
|
barQuality,
|
||||||
|
effKind: (i: number): MediaKind => kinds[i] ?? barKind,
|
||||||
|
itemFormats: formats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildPlaylistAddEntries', () => {
|
||||||
|
it('uses the bar preset when an entry matches the bar kind and has no override', () => {
|
||||||
|
const [row] = buildPlaylistAddEntries([entry(1)], ctx({ barKind: 'video', barQuality: '720p' }))
|
||||||
|
expect(row.kind).toBe('video')
|
||||||
|
expect(row.quality).toBe('720p')
|
||||||
|
expect(row.opts?.format).toBeUndefined()
|
||||||
|
// metadata is forwarded so the queue row shows a title before probing
|
||||||
|
expect(row.opts?.title).toBe('Item 1')
|
||||||
|
expect(row.opts?.channel).toBe('Chan')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to that kind default when an entry override differs from the bar kind', () => {
|
||||||
|
const [row] = buildPlaylistAddEntries(
|
||||||
|
[entry(1)],
|
||||||
|
ctx({ barKind: 'video', barQuality: '720p', kinds: { 1: 'audio' } })
|
||||||
|
)
|
||||||
|
expect(row.kind).toBe('audio')
|
||||||
|
// audio's first quality option, not the bar's video '720p'
|
||||||
|
expect(row.quality).toBe('Best')
|
||||||
|
expect(row.opts?.format).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries an exact per-item format on a video row', () => {
|
||||||
|
const fmt: ChosenFormat = { id: '299', hasAudio: false, label: '1080p60 · mp4' }
|
||||||
|
const [row] = buildPlaylistAddEntries([entry(1)], ctx({ formats: { 1: fmt } }))
|
||||||
|
expect(row.kind).toBe('video')
|
||||||
|
expect(row.opts?.format).toEqual(fmt)
|
||||||
|
// the chosen format's label doubles as the queue-row quality text
|
||||||
|
expect(row.quality).toBe('1080p60 · mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a stored format on a row that is set to audio', () => {
|
||||||
|
const fmt: ChosenFormat = { id: '299', hasAudio: false, label: '1080p60 · mp4' }
|
||||||
|
const [row] = buildPlaylistAddEntries(
|
||||||
|
[entry(1)],
|
||||||
|
ctx({ kinds: { 1: 'audio' }, formats: { 1: fmt } })
|
||||||
|
)
|
||||||
|
expect(row.kind).toBe('audio')
|
||||||
|
expect(row.opts?.format).toBeUndefined()
|
||||||
|
expect(row.quality).toBe('Best')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps a mixed batch independently per entry', () => {
|
||||||
|
const fmt: ChosenFormat = { id: '136', hasAudio: false, label: '720p · mp4' }
|
||||||
|
const rows = buildPlaylistAddEntries(
|
||||||
|
[entry(1), entry(2), entry(3)],
|
||||||
|
ctx({
|
||||||
|
barKind: 'video',
|
||||||
|
barQuality: '1080p',
|
||||||
|
kinds: { 2: 'audio' },
|
||||||
|
formats: { 3: fmt }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(rows.map((r) => r.kind)).toEqual(['video', 'audio', 'video'])
|
||||||
|
expect(rows[0].opts?.format).toBeUndefined()
|
||||||
|
expect(rows[0].quality).toBe('1080p')
|
||||||
|
expect(rows[1].quality).toBe('Best')
|
||||||
|
expect(rows[2].opts?.format).toEqual(fmt)
|
||||||
|
expect(rows[2].quality).toBe('720p · mp4')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user