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)
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,6 +96,19 @@ async function submitDownload(): Promise<void> {
|
|||
if (!trimmed) return
|
||||
|
||||
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({
|
||||
url: trimmed,
|
||||
format_id: selectedFormatId.value,
|
||||
|
|
@ -76,16 +116,17 @@ async function submitDownload(): Promise<void> {
|
|||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue