Download All, format filtering, playlist checkboxes, URL clear reset

Download All:
- 'Download All (N)' button appears above table when 2+ completed files
- Triggers individual browser downloads staggered by 300ms
- Toast notification shows count

Format picker filtering:
- FormatPicker accepts mediaType prop ('video' | 'audio')
- Video mode: shows Video+Audio and Video Only groups, hides Audio Only
- Audio mode: shows Audio Only group, hides video groups
- Switching media type live-updates the visible format list

Playlist entry selection:
- Checkboxes on each entry, all selected by default
- Select All / Deselect All toggle with partial state indicator
- Selected count displayed (e.g. '3 of 5 selected')
- Only selected entries are submitted for download
- Duration shown in parentheses after title

URL input clearing:
- Clearing or changing URL resets preview, formats, and selections
- Stale preview no longer persists when URL is edited
- URL watcher tracks the last fetched URL to avoid clearing on paste
This commit is contained in:
xpltd 2026-03-19 03:44:40 -05:00
parent 82786be485
commit 41c79bdfb2
3 changed files with 216 additions and 19 deletions

View file

@ -20,6 +20,28 @@ function showToast(message: string): void {
toastTimer = setTimeout(() => { toast.value = null }, 2000)
}
// Completed jobs with downloadable files
const completedWithFiles = computed(() =>
props.jobs.filter(j => j.status === 'completed' && j.filename)
)
function downloadAll(): void {
const jobs = completedWithFiles.value
if (!jobs.length) return
// Stagger downloads slightly to avoid browser blocking
jobs.forEach((job, i) => {
setTimeout(() => {
const a = document.createElement('a')
a.href = downloadUrl(job)
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}, i * 300)
})
showToast(`Downloading ${jobs.length} file${jobs.length > 1 ? 's' : ''}`)
}
// Sort state
type SortKey = 'name' | 'status' | 'progress' | 'speed' | 'eta'
const sortBy = ref<SortKey>('name')
@ -169,6 +191,11 @@ async function clearJob(jobId: string): Promise<void> {
<template>
<div class="download-table-wrap">
<div v-if="completedWithFiles.length > 1" class="table-toolbar">
<button class="btn-download-all" @click="downloadAll" title="Download all completed files">
Download All ({{ completedWithFiles.length }})
</button>
</div>
<table class="download-table">
<thead>
<tr>
@ -293,6 +320,28 @@ async function clearJob(jobId: string): Promise<void> {
-webkit-overflow-scrolling: touch;
}
.table-toolbar {
display: flex;
justify-content: flex-end;
padding: var(--space-xs) 0;
}
.btn-download-all {
font-size: var(--font-size-sm);
padding: var(--space-xs) var(--space-md);
background: var(--color-accent);
color: var(--color-bg);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
transition: background-color 0.15s;
}
.btn-download-all:hover {
background: var(--color-accent-hover);
}
.download-table {
width: 100%;
border-collapse: collapse;

View file

@ -4,6 +4,7 @@ import type { FormatInfo } from '@/api/types'
const props = defineProps<{
formats: FormatInfo[]
mediaType?: 'video' | 'audio'
}>()
const emit = defineEmits<{
@ -31,6 +32,10 @@ const audioFormats = computed(() =>
),
)
// Filter visibility based on mediaType prop
const showVideo = computed(() => !props.mediaType || props.mediaType === 'video')
const showAudio = computed(() => !props.mediaType || props.mediaType === 'audio')
function formatLabel(f: FormatInfo): string {
const parts: string[] = []
if (f.resolution) parts.push(f.resolution)
@ -60,7 +65,7 @@ function selectFormat(id: string | null): void {
<span class="format-hint">Let yt-dlp choose the best quality</span>
</div>
<template v-if="videoFormats.length > 0">
<template v-if="showVideo && videoFormats.length > 0">
<div class="format-group-label">Video + Audio</div>
<div
v-for="f in videoFormats"
@ -74,7 +79,7 @@ function selectFormat(id: string | null): void {
</div>
</template>
<template v-if="videoOnlyFormats.length > 0">
<template v-if="showVideo && videoOnlyFormats.length > 0">
<div class="format-group-label">Video only</div>
<div
v-for="f in videoOnlyFormats"
@ -88,7 +93,7 @@ function selectFormat(id: string | null): void {
</div>
</template>
<template v-if="audioFormats.length > 0">
<template v-if="showAudio && audioFormats.length > 0">
<div class="format-group-label">Audio only</div>
<div
v-for="f in audioFormats"

View file

@ -43,6 +43,33 @@ watch(outputFormat, (val) => {
localStorage.setItem('mediarip:outputFormat', val)
})
// Clear preview and formats when URL is emptied or changed manually
let lastFetchedUrl = ''
watch(url, (newUrl) => {
const trimmed = newUrl.trim()
if (!trimmed) {
// URL cleared reset everything
urlInfo.value = null
formats.value = []
selectedFormatId.value = null
extractError.value = null
audioLocked.value = false
showOptions.value = false
selectedEntries.value = new Set()
} else if (trimmed !== lastFetchedUrl) {
// URL changed to something different clear stale preview
urlInfo.value = null
formats.value = []
selectedFormatId.value = null
extractError.value = null
audioLocked.value = false
selectedEntries.value = new Set()
}
})
// Playlist entry selection state
const selectedEntries = ref<Set<number>>(new Set())
/** Whether formats have been fetched for the current URL. */
const formatsReady = computed(() => formats.value.length > 0)
@ -69,23 +96,37 @@ async function submitDownload(): Promise<void> {
if (!trimmed) return
try {
await store.submitDownload({
url: trimmed,
format_id: selectedFormatId.value,
quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null,
media_type: mediaType.value,
output_format: outputFormat.value === 'auto' ? null : outputFormat.value,
})
// If playlist with selection, submit only selected entries
if (urlInfo.value?.type === 'playlist' && urlInfo.value.entries.length > 0) {
const selected = urlInfo.value.entries.filter((_, i) => selectedEntries.value.has(i))
for (const entry of selected) {
await store.submitDownload({
url: entry.url,
format_id: selectedFormatId.value,
quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null,
media_type: mediaType.value,
output_format: outputFormat.value === 'auto' ? null : outputFormat.value,
})
}
} else {
await store.submitDownload({
url: trimmed,
format_id: selectedFormatId.value,
quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null,
media_type: mediaType.value,
output_format: outputFormat.value === 'auto' ? null : outputFormat.value,
})
}
// Reset form on success
url.value = ''
formats.value = []
showOptions.value = false
selectedFormatId.value = null
extractError.value = null
mediaType.value = 'video'
outputFormat.value = 'auto'
urlInfo.value = null
audioLocked.value = false
selectedEntries.value = new Set()
lastFetchedUrl = ''
} catch {
// Error already in store.submitError
}
@ -111,14 +152,20 @@ async function fetchUrlInfo(): Promise<void> {
isLoadingInfo.value = true
urlInfo.value = null
audioLocked.value = false
selectedEntries.value = new Set()
try {
const info = await api.getUrlInfo(trimmed)
urlInfo.value = info
lastFetchedUrl = trimmed
// Auto-switch to audio if the source is audio-only
if (info.is_audio_only) {
mediaType.value = 'audio'
audioLocked.value = true
}
// Select all playlist entries by default
if (info.type === 'playlist' && info.entries.length) {
selectedEntries.value = new Set(info.entries.map((_, i) => i))
}
} catch {
// Non-critical preview is optional
} finally {
@ -126,6 +173,30 @@ async function fetchUrlInfo(): Promise<void> {
}
}
function toggleEntry(index: number): void {
const s = new Set(selectedEntries.value)
if (s.has(index)) {
s.delete(index)
} else {
s.add(index)
}
selectedEntries.value = s
}
function selectAllEntries(): void {
if (!urlInfo.value) return
selectedEntries.value = new Set(urlInfo.value.entries.map((_, i) => i))
}
function selectNoneEntries(): void {
selectedEntries.value = new Set()
}
const allEntriesSelected = computed(() =>
urlInfo.value ? selectedEntries.value.size === urlInfo.value.entries.length : false
)
const someEntriesSelected = computed(() => selectedEntries.value.size > 0)
function formatDuration(seconds: number | null): string {
if (!seconds) return ''
const m = Math.floor(seconds / 60)
@ -222,22 +293,36 @@ function formatTooltip(fmt: string): string {
<div v-else-if="urlInfo" class="url-preview">
<div class="preview-header">
<span v-if="urlInfo.type === 'playlist'" class="preview-badge playlist">Playlist · {{ urlInfo.count }} items</span>
<span v-else class="preview-badge single">Single {{ audioLocked ? 'track' : 'video' }}</span>
<span v-else class="preview-badge single">{{ audioLocked ? 'Track' : 'Video' }}</span>
<span v-if="audioLocked" class="preview-badge audio-only">Audio only</span>
<span v-if="urlInfo.title" class="preview-title">{{ urlInfo.title }}</span>
<span v-if="urlInfo.type === 'single' && urlInfo.duration" class="preview-duration">({{ formatDuration(urlInfo.duration ?? null) }})</span>
</div>
<div v-if="urlInfo.type === 'playlist' && urlInfo.entries.length" class="preview-entries">
<div class="preview-controls">
<label class="select-all-check" @click.prevent="allEntriesSelected ? selectNoneEntries() : selectAllEntries()">
<span class="check-box" :class="{ checked: allEntriesSelected, partial: !allEntriesSelected && someEntriesSelected }">
{{ allEntriesSelected ? '✓' : (!allEntriesSelected && someEntriesSelected ? '' : '') }}
</span>
<span class="select-label">{{ allEntriesSelected ? 'Deselect all' : 'Select all' }}</span>
</label>
<span class="selected-count">{{ selectedEntries.size }} of {{ urlInfo.entries.length }} selected</span>
</div>
<div
v-for="(entry, i) in urlInfo.entries.slice(0, 10)"
v-for="(entry, i) in urlInfo.entries.slice(0, 20)"
:key="i"
class="preview-entry"
@click="toggleEntry(i)"
>
<span class="check-box" :class="{ checked: selectedEntries.has(i) }">
{{ selectedEntries.has(i) ? '✓' : '' }}
</span>
<span class="entry-num">{{ i + 1 }}.</span>
<span class="entry-title">{{ entry.title }}</span>
<span v-if="entry.duration" class="entry-duration">{{ formatDuration(entry.duration) }}</span>
<span v-if="entry.duration" class="entry-duration">({{ formatDuration(entry.duration) }})</span>
</div>
<div v-if="urlInfo.entries.length > 10" class="preview-more">
and {{ urlInfo.entries.length - 10 }} more
<div v-if="urlInfo.entries.length > 20" class="preview-more">
and {{ urlInfo.entries.length - 20 }} more
</div>
<div v-if="urlInfo.unavailable_count" class="preview-warning">
{{ urlInfo.unavailable_count }} private/unavailable item{{ urlInfo.unavailable_count > 1 ? 's' : '' }} skipped
@ -282,6 +367,7 @@ function formatTooltip(fmt: string): string {
<FormatPicker
v-if="formatsReady"
:formats="formats"
:media-type="mediaType"
@select="onFormatSelect"
/>
<div v-else-if="!isExtracting" class="options-hint">
@ -563,16 +649,73 @@ button:disabled {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 200px;
max-height: 260px;
overflow-y: auto;
}
.preview-controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xs) 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-xs);
}
.select-all-check {
display: flex;
align-items: center;
gap: var(--space-xs);
cursor: pointer;
user-select: none;
}
.select-label {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.selected-count {
font-size: 11px;
color: var(--color-text-muted);
}
.check-box {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 1.5px solid var(--color-border);
border-radius: 3px;
font-size: 11px;
color: var(--color-bg);
flex-shrink: 0;
transition: all 0.15s;
}
.check-box.checked {
background: var(--color-accent);
border-color: var(--color-accent);
}
.check-box.partial {
background: var(--color-text-muted);
border-color: var(--color-text-muted);
}
.preview-entry {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 2px 0;
padding: 3px 0;
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
.preview-entry:hover {
color: var(--color-text);
}
.entry-num {