4c2b54a599
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>
172 lines
6.3 KiB
TypeScript
172 lines
6.3 KiB
TypeScript
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'
|
|
import { useDownloadBarStyles } from './styles'
|
|
|
|
interface PlaylistPanelProps {
|
|
playlist: PlaylistInfo
|
|
selected: Set<number>
|
|
allSelected: boolean
|
|
onToggleAll: () => void
|
|
onSetAllKinds: (k: MediaKind) => void
|
|
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({
|
|
playlist,
|
|
selected,
|
|
allSelected,
|
|
onToggleAll,
|
|
onSetAllKinds,
|
|
onToggleEntry,
|
|
effKind,
|
|
onToggleItemKind,
|
|
formatExpanded,
|
|
itemFormatOptions,
|
|
itemFormat,
|
|
formatProbing,
|
|
formatProbeError,
|
|
onToggleFormat,
|
|
onSetItemFormat
|
|
}: PlaylistPanelProps): React.JSX.Element {
|
|
const styles = useDownloadBarStyles()
|
|
const text = useTextStyles()
|
|
return (
|
|
<div className={styles.plPanel}>
|
|
<div className={styles.plHeader}>
|
|
<AppsListRegular />
|
|
<Caption1 className={mergeClasses(styles.plHeaderGrow, text.title)}>
|
|
{playlist.title}
|
|
{playlist.uploader ? `${META_SEP}${playlist.uploader}` : ''}
|
|
</Caption1>
|
|
<Caption1 className={text.muted}>
|
|
{selected.size} of {playlist.count} selected
|
|
</Caption1>
|
|
<Button size="small" appearance="subtle" onClick={onToggleAll}>
|
|
{allSelected ? 'Select none' : 'Select all'}
|
|
</Button>
|
|
<Button size="small" appearance="subtle" onClick={() => onSetAllKinds('video')}>
|
|
All video
|
|
</Button>
|
|
<Button size="small" appearance="subtle" onClick={() => onSetAllKinds('audio')}>
|
|
All audio
|
|
</Button>
|
|
</div>
|
|
<div className={styles.plList}>
|
|
{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>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|