diff --git a/ROADMAP.md b/ROADMAP.md index 53b262e..da5cbe3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 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 - default). Per-item exact-format selection (needs probing each entry) left as a further - extension.* + default).* +- [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 ` ({ value: o.id, label: o.label }))} + onChange={(v) => onSetItemFormat(e.index, v)} + /> + ) : ( + No selectable formats. )} - - } - /> - - : } - onClick={() => onToggleItemKind(e.index)} - aria-label={`Download type for ${e.title}: ${effKind(e.index)}`} - /> - - - ))} + + )} + + ) + })} ) diff --git a/src/renderer/src/components/downloadBar/playlistEntries.ts b/src/renderer/src/components/downloadBar/playlistEntries.ts new file mode 100644 index 0000000..81d9f6d --- /dev/null +++ b/src/renderer/src/components/downloadBar/playlistEntries.ts @@ -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 +} + +/** + * 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 } : {}) + } + } + }) +} diff --git a/src/renderer/src/components/downloadBar/styles.ts b/src/renderer/src/components/downloadBar/styles.ts index 3a16a8a..0c8ad97 100644 --- a/src/renderer/src/components/downloadBar/styles.ts +++ b/src/renderer/src/components/downloadBar/styles.ts @@ -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', diff --git a/src/renderer/src/components/downloadBar/useDownloadBar.ts b/src/renderer/src/components/downloadBar/useDownloadBar.ts index b39084c..e6ec2f4 100644 --- a/src/renderer/src/components/downloadBar/useDownloadBar.ts +++ b/src/renderer/src/components/downloadBar/useDownloadBar.ts @@ -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 allSelected: boolean + // per-item format picker (video rows only) + formatExpanded: Set + itemFormatOptions: Record + itemFormat: (index: number) => ChosenFormat | undefined + formatProbing: Set + formatProbeError: Record // 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 + 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>({}) + // 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>(new Set()) + const [itemFormatOptions, setItemFormatOptions] = useState>({}) + const [itemFormats, setItemFormats] = useState>({}) + const [formatProbing, setFormatProbing] = useState>(new Set()) + const [formatProbeError, setFormatProbeError] = useState>({}) + // 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 @@ -258,13 +281,26 @@ export function useDownloadBar(): DownloadBarController { playlist === null && probeError === null + // Per-entry format state is keyed by playlist position, so it must be dropped + // any time the playlist itself is dropped or re-fetched -- otherwise a stale + // exact-format pick from a prior probe can silently reattach to a different + // video that lands at the same index in the new result. + function resetPlaylistItemState(): void { + setSelected(new Set()) + setItemKinds({}) + setFormatExpanded(new Set()) + setItemFormatOptions({}) + setItemFormats({}) + setFormatProbing(new Set()) + setFormatProbeError({}) + } + function clearProbe(): void { setInfo(null) setProbeError(null) setFormatId('') setPlaylist(null) - setSelected(new Set()) - setItemKinds({}) + resetPlaylistItemState() } // Reset the whole bar after a download / playlist is enqueued: clear the URL + @@ -359,6 +395,7 @@ export function useDownloadBar(): DownloadBarController { setProbeError(null) setInfo(null) setPlaylist(null) + resetPlaylistItemState() // Probing may switch the URL to the format-picker path, changing the command. setCommandPreview(null) try { @@ -425,13 +462,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 = {} 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 { + 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 +636,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 +685,12 @@ export function useDownloadBar(): DownloadBarController { playlist, selected, allSelected, + // per-item format picker + formatExpanded, + itemFormatOptions, + itemFormat, + formatProbing, + formatProbeError, // suggestion banner suggestion, suggestionSource, @@ -601,6 +721,8 @@ export function useDownloadBar(): DownloadBarController { toggleAll, effKind, toggleItemKind, - setAllKinds + setAllKinds, + toggleItemFormat, + setItemFormat } } diff --git a/test/playlistEntries.test.ts b/test/playlistEntries.test.ts new file mode 100644 index 0000000..b7bbdf5 --- /dev/null +++ b/test/playlistEntries.test.ts @@ -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 { + 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 + formats?: Record +}) { + 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') + }) +})