mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 10:54:00 -06:00
Best quality format: - Synthetic 'bestvideo+bestaudio/best' entry added at top of format list when the best separate video stream exceeds the best pre-muxed format. Shows as 'Best quality (1920x1080)' in Video+Audio group. - YouTube typically only has 360p pre-muxed but 1080p+ as separate streams — users can now select full quality with auto-merge. - Only appears when there's actually a quality advantage vs pre-muxed. Password change UX: - Enter key on confirm password field submits the change - Auto-logout 1.5s after successful password change - User sees '✓ Password changed' before being redirected home Mobile table: - Status column hidden on mobile (<640px) alongside Progress - Only Name + Actions columns shown — clean two-column layout - Removed mobile-specific status badge font tweaks (column gone)
592 lines
17 KiB
Vue
592 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useDownloadsStore } from '@/stores/downloads'
|
|
import ProgressBar from './ProgressBar.vue'
|
|
import type { Job } from '@/api/types'
|
|
|
|
const props = defineProps<{
|
|
jobs: Job[]
|
|
}>()
|
|
|
|
const store = useDownloadsStore()
|
|
|
|
// Toast notification
|
|
const toast = ref<string | null>(null)
|
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function showToast(message: string): void {
|
|
toast.value = message
|
|
if (toastTimer) clearTimeout(toastTimer)
|
|
toastTimer = setTimeout(() => { toast.value = null }, 2000)
|
|
}
|
|
|
|
// Sort state
|
|
type SortKey = 'name' | 'status' | 'progress' | 'speed' | 'eta'
|
|
const sortBy = ref<SortKey>('name')
|
|
const sortDir = ref<'asc' | 'desc'>('asc')
|
|
|
|
function toggleSort(key: SortKey): void {
|
|
if (sortBy.value === key) {
|
|
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
|
} else {
|
|
sortBy.value = key
|
|
sortDir.value = key === 'progress' ? 'desc' : 'asc'
|
|
}
|
|
}
|
|
|
|
function sortIndicator(key: SortKey): string {
|
|
if (sortBy.value !== key) return ''
|
|
return sortDir.value === 'asc' ? ' ▲' : ' ▼'
|
|
}
|
|
|
|
const sortedJobs = computed<Job[]>(() => {
|
|
const list = [...props.jobs]
|
|
const dir = sortDir.value === 'asc' ? 1 : -1
|
|
|
|
list.sort((a, b) => {
|
|
switch (sortBy.value) {
|
|
case 'name':
|
|
return dir * displayName(a).localeCompare(displayName(b))
|
|
case 'status':
|
|
return dir * a.status.localeCompare(b.status)
|
|
case 'progress':
|
|
return dir * (a.progress_percent - b.progress_percent)
|
|
case 'speed':
|
|
return dir * (parseSpeed(a.speed) - parseSpeed(b.speed))
|
|
case 'eta':
|
|
return dir * (parseEta(a.eta) - parseEta(b.eta))
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
return list
|
|
})
|
|
|
|
function displayName(job: Job): string {
|
|
if (job.filename) {
|
|
const parts = job.filename.replace(/\\/g, '/').split('/')
|
|
const name = parts[parts.length - 1]
|
|
// Ensure extension is visible: if name is long, truncate the middle
|
|
if (name.length > 60) {
|
|
const ext = name.lastIndexOf('.')
|
|
if (ext > 0) {
|
|
const extension = name.slice(ext)
|
|
const base = name.slice(0, 55 - extension.length)
|
|
return `${base}…${extension}`
|
|
}
|
|
}
|
|
return name
|
|
}
|
|
try {
|
|
const u = new URL(job.url)
|
|
return `${u.hostname}${u.pathname}`.slice(0, 80)
|
|
} catch {
|
|
return job.url.slice(0, 80)
|
|
}
|
|
}
|
|
|
|
function parseSpeed(speed: string | null): number {
|
|
if (!speed) return 0
|
|
const match = speed.match(/([\d.]+)\s*(B|KiB|MiB|GiB)/)
|
|
if (!match) return 0
|
|
const val = parseFloat(match[1])
|
|
const unit = match[2]
|
|
const multipliers: Record<string, number> = { B: 1, KiB: 1024, MiB: 1048576, GiB: 1073741824 }
|
|
return val * (multipliers[unit] || 1)
|
|
}
|
|
|
|
function parseEta(eta: string | null): number {
|
|
if (!eta) return Infinity
|
|
// yt-dlp formats ETA as HH:MM:SS or MM:SS or SS
|
|
const parts = eta.split(':').map(Number)
|
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
if (parts.length === 2) return parts[0] * 60 + parts[1]
|
|
return parts[0] || Infinity
|
|
}
|
|
|
|
function isActive(job: Job): boolean {
|
|
return !store.isTerminal(job.status)
|
|
}
|
|
|
|
function isCompleted(job: Job): boolean {
|
|
return job.status === 'completed'
|
|
}
|
|
|
|
/** Infer whether the job is audio or video from quality/filename. */
|
|
function isAudioJob(job: Job): boolean {
|
|
if (job.quality === 'bestaudio') return true
|
|
if (job.filename) {
|
|
const ext = job.filename.split('.').pop()?.toLowerCase() || ''
|
|
if (['mp3', 'wav', 'flac', 'opus', 'm4a', 'aac', 'ogg', 'wma'].includes(ext)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// File download URL — filename is relative to the output directory
|
|
// (normalized by the backend). May contain subdirectories for source templates.
|
|
function downloadUrl(job: Job): string {
|
|
if (!job.filename) return '#'
|
|
const normalized = job.filename.replace(/\\/g, '/')
|
|
// Encode each path segment separately to preserve directory structure
|
|
return `/api/downloads/${normalized.split('/').map(encodeURIComponent).join('/')}`
|
|
}
|
|
|
|
// Copy download link to clipboard
|
|
async function copyLink(job: Job): Promise<void> {
|
|
const url = `${window.location.origin}${downloadUrl(job)}`
|
|
try {
|
|
await navigator.clipboard.writeText(url)
|
|
showToast('Link copied to clipboard')
|
|
} catch {
|
|
// Fallback — clipboard API may fail in non-secure contexts
|
|
showToast('Copy failed — clipboard unavailable')
|
|
}
|
|
}
|
|
|
|
const cancelling = ref<Set<string>>(new Set())
|
|
|
|
async function cancel(jobId: string): Promise<void> {
|
|
if (cancelling.value.has(jobId)) return
|
|
cancelling.value.add(jobId)
|
|
try {
|
|
await store.cancelDownload(jobId)
|
|
} catch (err) {
|
|
console.error('[DownloadTable] Cancel failed:', err)
|
|
} finally {
|
|
cancelling.value.delete(jobId)
|
|
}
|
|
}
|
|
|
|
async function clearJob(jobId: string): Promise<void> {
|
|
try {
|
|
await store.cancelDownload(jobId)
|
|
} catch {
|
|
// If DELETE fails (already gone), just remove locally
|
|
store.handleJobRemoved(jobId)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="download-table-wrap">
|
|
<table class="download-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-name sortable" @click="toggleSort('name')">
|
|
Name{{ sortIndicator('name') }}
|
|
</th>
|
|
<th class="col-status sortable" @click="toggleSort('status')">
|
|
Status{{ sortIndicator('status') }}
|
|
</th>
|
|
<th class="col-progress sortable" @click="toggleSort('progress')">
|
|
Progress{{ sortIndicator('progress') }}
|
|
</th>
|
|
<th class="col-speed sortable hide-mobile" @click="toggleSort('speed')">
|
|
Speed{{ sortIndicator('speed') }}
|
|
</th>
|
|
<th class="col-eta sortable hide-mobile" @click="toggleSort('eta')">
|
|
ETA{{ sortIndicator('eta') }}
|
|
</th>
|
|
<th class="col-actions">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<TransitionGroup name="table-row" tag="tbody">
|
|
<tr v-for="job in sortedJobs" :key="job.id" :class="'row-' + job.status">
|
|
<td class="col-name" :title="job.url">
|
|
<span class="name-with-icon">
|
|
<!-- Media type icon -->
|
|
<svg v-if="isAudioJob(job)" class="media-icon audio-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
<svg v-else class="media-icon video-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
|
<span class="name-text">{{ displayName(job) }}</span>
|
|
</span>
|
|
</td>
|
|
<td class="col-status">
|
|
<span class="status-badge" :class="'badge-' + job.status">
|
|
{{ job.status }}
|
|
</span>
|
|
</td>
|
|
<td class="col-progress">
|
|
<ProgressBar
|
|
v-if="job.status === 'downloading' || job.status === 'extracting'"
|
|
:percent="job.progress_percent"
|
|
/>
|
|
<span v-else-if="isCompleted(job)" class="progress-done">Done</span>
|
|
<span v-else-if="job.error_message" class="progress-error" :title="job.error_message">
|
|
{{ job.error_message.slice(0, 40) }}
|
|
</span>
|
|
<span v-else class="progress-na">—</span>
|
|
</td>
|
|
<td class="col-speed hide-mobile">
|
|
<span v-if="job.speed" class="mono">{{ job.speed }}</span>
|
|
<span v-else class="text-muted">—</span>
|
|
</td>
|
|
<td class="col-eta hide-mobile">
|
|
<span v-if="job.eta" class="mono">{{ job.eta }}</span>
|
|
<span v-else class="text-muted">—</span>
|
|
</td>
|
|
<td class="col-actions">
|
|
<div class="action-group">
|
|
<!-- Completed: download, copy link, clear -->
|
|
<template v-if="isCompleted(job)">
|
|
<a
|
|
:href="downloadUrl(job)"
|
|
class="action-btn action-download"
|
|
title="Download file"
|
|
download
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
</a>
|
|
<button
|
|
class="action-btn action-copy"
|
|
title="Copy download link"
|
|
@click="copyLink(job)"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
</button>
|
|
<button
|
|
class="action-btn action-clear"
|
|
title="Remove from list"
|
|
@click="clearJob(job.id)"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</template>
|
|
<!-- Active: cancel -->
|
|
<template v-else-if="isActive(job)">
|
|
<button
|
|
class="action-btn action-cancel"
|
|
:disabled="cancelling.has(job.id)"
|
|
title="Cancel download"
|
|
@click.stop="cancel(job.id)"
|
|
>
|
|
<svg v-if="!cancelling.has(job.id)" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
<span v-else class="spinner-sm"></span>
|
|
</button>
|
|
</template>
|
|
<!-- Failed/expired: clear -->
|
|
<template v-else>
|
|
<button
|
|
class="action-btn action-clear"
|
|
title="Remove from list"
|
|
@click="clearJob(job.id)"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</TransitionGroup>
|
|
</table>
|
|
<!-- Toast notification -->
|
|
<Transition name="toast">
|
|
<div v-if="toast" class="toast-notification">
|
|
{{ toast }}
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.download-table-wrap {
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.download-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.download-table thead {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
.download-table th {
|
|
padding: var(--space-sm) var(--space-md);
|
|
text-align: left;
|
|
font-weight: 600;
|
|
font-size: var(--font-size-sm);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--color-text-muted);
|
|
background: var(--color-surface);
|
|
border-bottom: 2px solid var(--color-border);
|
|
white-space: nowrap;
|
|
user-select: none;
|
|
}
|
|
|
|
.download-table th.sortable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.download-table th.sortable:hover {
|
|
color: var(--color-accent);
|
|
}
|
|
|
|
.download-table td {
|
|
padding: var(--space-sm) var(--space-md);
|
|
border-bottom: 1px solid var(--color-border);
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.download-table tbody tr {
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.download-table tbody tr:hover {
|
|
background: var(--color-surface-hover);
|
|
}
|
|
|
|
/* Column widths */
|
|
.col-name {
|
|
min-width: 200px;
|
|
max-width: 400px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.name-with-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.media-icon {
|
|
flex-shrink: 0;
|
|
color: var(--color-text-muted);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.name-text {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.col-status { width: 100px; }
|
|
.col-progress { width: 180px; min-width: 120px; }
|
|
.col-speed { width: 100px; }
|
|
.col-eta { width: 80px; }
|
|
.col-actions { width: 110px; }
|
|
|
|
/* Status badges */
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 2px var(--space-sm);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge-queued {
|
|
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
|
color: var(--color-text-muted);
|
|
}
|
|
.badge-extracting {
|
|
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
|
color: var(--color-warning);
|
|
}
|
|
.badge-downloading {
|
|
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
color: var(--color-accent);
|
|
}
|
|
.badge-completed {
|
|
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
|
color: var(--color-success);
|
|
}
|
|
.badge-failed {
|
|
background: color-mix(in srgb, var(--color-error) 15%, transparent);
|
|
color: var(--color-error);
|
|
}
|
|
.badge-expired {
|
|
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
/* Progress cell */
|
|
.progress-done {
|
|
color: var(--color-success);
|
|
font-weight: 500;
|
|
}
|
|
.progress-error {
|
|
color: var(--color-error);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
.progress-na {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
/* Utility */
|
|
.mono {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
.text-muted {
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
/* Actions */
|
|
.action-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-xs);
|
|
}
|
|
|
|
.action-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 30px;
|
|
height: 30px;
|
|
min-width: 30px;
|
|
min-height: 30px;
|
|
padding: 0;
|
|
background: transparent;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
text-decoration: none;
|
|
box-sizing: border-box;
|
|
line-height: 1;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: var(--color-surface-hover);
|
|
}
|
|
|
|
.action-download:hover {
|
|
color: var(--color-success);
|
|
border-color: var(--color-success);
|
|
}
|
|
|
|
.action-copy:hover {
|
|
color: var(--color-accent);
|
|
border-color: var(--color-accent);
|
|
}
|
|
|
|
.action-cancel:hover,
|
|
.action-clear:hover {
|
|
color: var(--color-error);
|
|
border-color: var(--color-error);
|
|
}
|
|
|
|
.action-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.spinner-sm {
|
|
display: inline-block;
|
|
width: 12px;
|
|
height: 12px;
|
|
border: 2px solid var(--color-border);
|
|
border-top-color: var(--color-accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Table row transitions */
|
|
.table-row-enter-active,
|
|
.table-row-leave-active {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.table-row-enter-from {
|
|
opacity: 0;
|
|
transform: translateY(-8px);
|
|
}
|
|
|
|
.table-row-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* Mobile: hide speed, ETA, progress, and status columns */
|
|
@media (max-width: 639px) {
|
|
.hide-mobile {
|
|
display: none;
|
|
}
|
|
|
|
.col-name {
|
|
min-width: 100px;
|
|
max-width: none;
|
|
}
|
|
|
|
.col-status {
|
|
display: none;
|
|
}
|
|
|
|
.col-progress {
|
|
display: none;
|
|
}
|
|
|
|
.col-actions {
|
|
width: auto;
|
|
}
|
|
|
|
.download-table th,
|
|
.download-table td {
|
|
padding: var(--space-xs);
|
|
}
|
|
|
|
.action-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
min-width: 28px;
|
|
min-height: 28px;
|
|
}
|
|
|
|
.action-group {
|
|
gap: 2px;
|
|
}
|
|
}
|
|
|
|
/* Toast notification */
|
|
.toast-notification {
|
|
position: fixed;
|
|
bottom: calc(var(--header-height) + var(--space-md));
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--color-surface);
|
|
color: var(--color-text);
|
|
border: 1px solid var(--color-border);
|
|
padding: var(--space-sm) var(--space-lg);
|
|
border-radius: var(--radius-md);
|
|
font-size: var(--font-size-sm);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
z-index: 1000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.toast-enter-active {
|
|
transition: all 0.2s ease-out;
|
|
}
|
|
|
|
.toast-leave-active {
|
|
transition: all 0.15s ease-in;
|
|
}
|
|
|
|
.toast-enter-from {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(8px);
|
|
}
|
|
|
|
.toast-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(-4px);
|
|
}
|
|
</style>
|