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:
2026-07-02 16:55:58 -04:00
parent 5c88d9c8e0
commit 4c2b54a599
7 changed files with 424 additions and 55 deletions
@@ -239,6 +239,13 @@ export function DownloadBar(): React.JSX.Element {
onToggleEntry={bar.toggleEntry}
effKind={bar.effKind}
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 { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import { type PlaylistInfo } from '@shared/ipc'
import { Button, Checkbox, Caption1, Spinner, mergeClasses } from '@fluentui/react-components'
import {
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 ChosenFormat } from '../../store/downloadTypes'
import { Hint } from '../Hint'
import { Select } from '../Select'
import { IconButton } from '../ui/IconButton'
import { useTextStyles } from '../ui/text'
import { META_SEP } from '../ui/tokens'
@@ -17,6 +24,14 @@ interface PlaylistPanelProps {
onToggleEntry: (index: number, on: boolean) => void
effKind: (index: number) => MediaKind
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({
@@ -27,7 +42,14 @@ export function PlaylistPanel({
onSetAllKinds,
onToggleEntry,
effKind,
onToggleItemKind
onToggleItemKind,
formatExpanded,
itemFormatOptions,
itemFormat,
formatProbing,
formatProbeError,
onToggleFormat,
onSetItemFormat
}: PlaylistPanelProps): React.JSX.Element {
const styles = useDownloadBarStyles()
const text = useTextStyles()
@@ -53,44 +75,96 @@ export function PlaylistPanel({
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<div key={e.index} className={styles.plItemRow}>
<Checkbox
className={styles.plItem}
checked={selected.has(e.index)}
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) && (
<Caption1 className={text.muted}>
{[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
{playlist.entries.map((e) => {
const isVideo = effKind(e.index) === 'video'
const chosen = itemFormat(e.index)
const expanded = formatExpanded.has(e.index)
const probing = formatProbing.has(e.index)
const error = formatProbeError[e.index]
const options = itemFormatOptions[e.index]
return (
<div key={e.index} className={styles.plItemGroup}>
<div className={styles.plItemRow}>
<Checkbox
className={styles.plItem}
checked={selected.has(e.index)}
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>
) : 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>
}
/>
<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',
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 ---
trimBlock: {
display: 'flex',
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import {
parseUrlShortcutContent,
BEST_FORMAT_ID,
type MediaInfo,
type FormatOption,
type PlaylistInfo,
@@ -9,6 +10,8 @@ import {
type CommandPreviewResult
} from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads'
import { type ChosenFormat } from '../../store/downloadTypes'
import { buildPlaylistAddEntries } from './playlistEntries'
import { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { sameVideo } from '../../lib/queueStats'
@@ -78,6 +81,12 @@ export interface DownloadBarController {
playlist: PlaylistInfo | null
selected: Set<number>
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: string | null
suggestionSource: SuggestionSource
@@ -109,6 +118,8 @@ export interface DownloadBarController {
effKind: (index: number) => MediaKind
toggleItemKind: (index: number) => 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.
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
// 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
@@ -265,6 +288,11 @@ export function useDownloadBar(): DownloadBarController {
setPlaylist(null)
setSelected(new Set())
setItemKinds({})
setFormatExpanded(new Set())
setItemFormatOptions({})
setItemFormats({})
setFormatProbing(new Set())
setFormatProbeError({})
}
// 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
}
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 {
if (!playlist) return
const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k
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 {
@@ -516,18 +627,12 @@ export function useDownloadBar(): DownloadBarController {
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.
// Each entry uses its own kind override and, for a video row the user gave an
// 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(
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 }
}
})
buildPlaylistAddEntries(chosen, { barKind: kind, barQuality: quality, effKind, itemFormats })
)
resetForm()
}
@@ -571,6 +676,12 @@ export function useDownloadBar(): DownloadBarController {
playlist,
selected,
allSelected,
// per-item format picker
formatExpanded,
itemFormatOptions,
itemFormat,
formatProbing,
formatProbeError,
// suggestion banner
suggestion,
suggestionSource,
@@ -601,6 +712,8 @@ export function useDownloadBar(): DownloadBarController {
toggleAll,
effKind,
toggleItemKind,
setAllKinds
setAllKinds,
toggleItemFormat,
setItemFormat
}
}