mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
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:
parent
82786be485
commit
41c79bdfb2
3 changed files with 216 additions and 19 deletions
|
|
@ -20,6 +20,28 @@ function showToast(message: string): void {
|
||||||
toastTimer = setTimeout(() => { toast.value = null }, 2000)
|
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
|
// Sort state
|
||||||
type SortKey = 'name' | 'status' | 'progress' | 'speed' | 'eta'
|
type SortKey = 'name' | 'status' | 'progress' | 'speed' | 'eta'
|
||||||
const sortBy = ref<SortKey>('name')
|
const sortBy = ref<SortKey>('name')
|
||||||
|
|
@ -169,6 +191,11 @@ async function clearJob(jobId: string): Promise<void> {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="download-table-wrap">
|
<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">
|
<table class="download-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -293,6 +320,28 @@ async function clearJob(jobId: string): Promise<void> {
|
||||||
-webkit-overflow-scrolling: touch;
|
-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 {
|
.download-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { FormatInfo } from '@/api/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
formats: FormatInfo[]
|
formats: FormatInfo[]
|
||||||
|
mediaType?: 'video' | 'audio'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 {
|
function formatLabel(f: FormatInfo): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (f.resolution) parts.push(f.resolution)
|
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>
|
<span class="format-hint">Let yt-dlp choose the best quality</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="videoFormats.length > 0">
|
<template v-if="showVideo && videoFormats.length > 0">
|
||||||
<div class="format-group-label">Video + Audio</div>
|
<div class="format-group-label">Video + Audio</div>
|
||||||
<div
|
<div
|
||||||
v-for="f in videoFormats"
|
v-for="f in videoFormats"
|
||||||
|
|
@ -74,7 +79,7 @@ function selectFormat(id: string | null): void {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="videoOnlyFormats.length > 0">
|
<template v-if="showVideo && videoOnlyFormats.length > 0">
|
||||||
<div class="format-group-label">Video only</div>
|
<div class="format-group-label">Video only</div>
|
||||||
<div
|
<div
|
||||||
v-for="f in videoOnlyFormats"
|
v-for="f in videoOnlyFormats"
|
||||||
|
|
@ -88,7 +93,7 @@ function selectFormat(id: string | null): void {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="audioFormats.length > 0">
|
<template v-if="showAudio && audioFormats.length > 0">
|
||||||
<div class="format-group-label">Audio only</div>
|
<div class="format-group-label">Audio only</div>
|
||||||
<div
|
<div
|
||||||
v-for="f in audioFormats"
|
v-for="f in audioFormats"
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,33 @@ watch(outputFormat, (val) => {
|
||||||
localStorage.setItem('mediarip: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. */
|
/** Whether formats have been fetched for the current URL. */
|
||||||
const formatsReady = computed(() => formats.value.length > 0)
|
const formatsReady = computed(() => formats.value.length > 0)
|
||||||
|
|
||||||
|
|
@ -69,6 +96,19 @@ async function submitDownload(): Promise<void> {
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 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({
|
await store.submitDownload({
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
format_id: selectedFormatId.value,
|
format_id: selectedFormatId.value,
|
||||||
|
|
@ -76,16 +116,17 @@ async function submitDownload(): Promise<void> {
|
||||||
media_type: mediaType.value,
|
media_type: mediaType.value,
|
||||||
output_format: outputFormat.value === 'auto' ? null : outputFormat.value,
|
output_format: outputFormat.value === 'auto' ? null : outputFormat.value,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
// Reset form on success
|
// Reset form on success
|
||||||
url.value = ''
|
url.value = ''
|
||||||
formats.value = []
|
formats.value = []
|
||||||
showOptions.value = false
|
showOptions.value = false
|
||||||
selectedFormatId.value = null
|
selectedFormatId.value = null
|
||||||
extractError.value = null
|
extractError.value = null
|
||||||
mediaType.value = 'video'
|
|
||||||
outputFormat.value = 'auto'
|
|
||||||
urlInfo.value = null
|
urlInfo.value = null
|
||||||
audioLocked.value = false
|
audioLocked.value = false
|
||||||
|
selectedEntries.value = new Set()
|
||||||
|
lastFetchedUrl = ''
|
||||||
} catch {
|
} catch {
|
||||||
// Error already in store.submitError
|
// Error already in store.submitError
|
||||||
}
|
}
|
||||||
|
|
@ -111,14 +152,20 @@ async function fetchUrlInfo(): Promise<void> {
|
||||||
isLoadingInfo.value = true
|
isLoadingInfo.value = true
|
||||||
urlInfo.value = null
|
urlInfo.value = null
|
||||||
audioLocked.value = false
|
audioLocked.value = false
|
||||||
|
selectedEntries.value = new Set()
|
||||||
try {
|
try {
|
||||||
const info = await api.getUrlInfo(trimmed)
|
const info = await api.getUrlInfo(trimmed)
|
||||||
urlInfo.value = info
|
urlInfo.value = info
|
||||||
|
lastFetchedUrl = trimmed
|
||||||
// Auto-switch to audio if the source is audio-only
|
// Auto-switch to audio if the source is audio-only
|
||||||
if (info.is_audio_only) {
|
if (info.is_audio_only) {
|
||||||
mediaType.value = 'audio'
|
mediaType.value = 'audio'
|
||||||
audioLocked.value = true
|
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 {
|
} catch {
|
||||||
// Non-critical — preview is optional
|
// Non-critical — preview is optional
|
||||||
} finally {
|
} 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 {
|
function formatDuration(seconds: number | null): string {
|
||||||
if (!seconds) return ''
|
if (!seconds) return ''
|
||||||
const m = Math.floor(seconds / 60)
|
const m = Math.floor(seconds / 60)
|
||||||
|
|
@ -222,22 +293,36 @@ function formatTooltip(fmt: string): string {
|
||||||
<div v-else-if="urlInfo" class="url-preview">
|
<div v-else-if="urlInfo" class="url-preview">
|
||||||
<div class="preview-header">
|
<div class="preview-header">
|
||||||
<span v-if="urlInfo.type === 'playlist'" class="preview-badge playlist">Playlist · {{ urlInfo.count }} items</span>
|
<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="audioLocked" class="preview-badge audio-only">Audio only</span>
|
||||||
<span v-if="urlInfo.title" class="preview-title">{{ urlInfo.title }}</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>
|
||||||
<div v-if="urlInfo.type === 'playlist' && urlInfo.entries.length" class="preview-entries">
|
<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
|
<div
|
||||||
v-for="(entry, i) in urlInfo.entries.slice(0, 10)"
|
v-for="(entry, i) in urlInfo.entries.slice(0, 20)"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="preview-entry"
|
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-num">{{ i + 1 }}.</span>
|
||||||
<span class="entry-title">{{ entry.title }}</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>
|
||||||
<div v-if="urlInfo.entries.length > 10" class="preview-more">
|
<div v-if="urlInfo.entries.length > 20" class="preview-more">
|
||||||
…and {{ urlInfo.entries.length - 10 }} more
|
…and {{ urlInfo.entries.length - 20 }} more
|
||||||
</div>
|
</div>
|
||||||
<div v-if="urlInfo.unavailable_count" class="preview-warning">
|
<div v-if="urlInfo.unavailable_count" class="preview-warning">
|
||||||
⚠ {{ urlInfo.unavailable_count }} private/unavailable item{{ urlInfo.unavailable_count > 1 ? 's' : '' }} skipped
|
⚠ {{ urlInfo.unavailable_count }} private/unavailable item{{ urlInfo.unavailable_count > 1 ? 's' : '' }} skipped
|
||||||
|
|
@ -282,6 +367,7 @@ function formatTooltip(fmt: string): string {
|
||||||
<FormatPicker
|
<FormatPicker
|
||||||
v-if="formatsReady"
|
v-if="formatsReady"
|
||||||
:formats="formats"
|
:formats="formats"
|
||||||
|
:media-type="mediaType"
|
||||||
@select="onFormatSelect"
|
@select="onFormatSelect"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="!isExtracting" class="options-hint">
|
<div v-else-if="!isExtracting" class="options-hint">
|
||||||
|
|
@ -563,16 +649,73 @@ button:disabled {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
max-height: 200px;
|
max-height: 260px;
|
||||||
overflow-y: auto;
|
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 {
|
.preview-entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
padding: 2px 0;
|
padding: 3px 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-entry:hover {
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-num {
|
.entry-num {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue