1468 lines
46 KiB
Vue
1468 lines
46 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<span
|
|
class="inline-flex size-11 shrink-0 items-center justify-center rounded-md border border-default bg-elevated/70 text-primary"
|
|
>
|
|
<UIcon :name="pageShell.icon" class="size-5" />
|
|
</span>
|
|
|
|
<div class="min-w-0 space-y-2">
|
|
<div
|
|
class="flex flex-wrap items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-toned"
|
|
>
|
|
<span>{{ pageShell.sectionLabel }}</span>
|
|
<span>/</span>
|
|
<span>{{ pageShell.pageLabel }}</span>
|
|
</div>
|
|
|
|
<p class="max-w-3xl text-sm text-toned">{{ pageShell.description }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex min-w-0 flex-wrap items-center justify-end gap-2 xl:justify-end">
|
|
<UButton
|
|
color="neutral"
|
|
:variant="toggleFilter ? 'soft' : 'outline'"
|
|
size="sm"
|
|
icon="i-lucide-filter"
|
|
@click="toggleFilter = !toggleFilter"
|
|
>
|
|
<span>Filter</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="false === config.paused"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-pause"
|
|
@click="() => void pauseDownload()"
|
|
>
|
|
<span>Pause</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-else
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-play"
|
|
@click="() => void resumeDownload()"
|
|
>
|
|
<span>Resume</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-plus"
|
|
@click="config.showForm = !config.showForm"
|
|
>
|
|
<span>Add</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
|
class="hidden sm:inline-flex"
|
|
@click="changeDisplay"
|
|
>
|
|
<span class="hidden sm:inline">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
|
</UButton>
|
|
|
|
<UInput
|
|
v-if="toggleFilter"
|
|
id="filter"
|
|
v-model.lazy="query"
|
|
type="search"
|
|
placeholder="Filter displayed content"
|
|
icon="i-lucide-filter"
|
|
size="sm"
|
|
class="order-last w-full sm:order-first sm:w-80"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="config.showForm" ref="formSection" class="page-form-wrap scroll-mt-24">
|
|
<NewDownload
|
|
:item="item_form"
|
|
@clear_form="item_form = {}"
|
|
@getInfo="
|
|
(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<UEmpty
|
|
v-if="!hasQueueContent"
|
|
icon="i-lucide-triangle-alert"
|
|
title="No active or queued downloads yet"
|
|
class="rounded-lg border border-dashed border-default bg-muted/10 py-10"
|
|
/>
|
|
|
|
<section v-else id="queue" class="scroll-mt-24 space-y-4">
|
|
<div class="w-full min-w-0 max-w-full space-y-4">
|
|
<div v-if="!socket.isConnected" class="flex justify-end">
|
|
<UButton
|
|
color="info"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-refresh-cw"
|
|
:loading="isRefreshing"
|
|
@click="() => void refreshQueue()"
|
|
>
|
|
Refresh
|
|
</UButton>
|
|
</div>
|
|
|
|
<div
|
|
v-if="hasItems"
|
|
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-default bg-default px-3 py-3"
|
|
>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
:icon="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
|
@click="toggleMasterSelection"
|
|
>
|
|
{{ masterSelectAll ? 'Unselect' : 'Select' }}
|
|
</UButton>
|
|
|
|
<UBadge v-if="selectedElms.length > 0" color="error" variant="soft" size="sm">
|
|
{{ selectedElms.length }}
|
|
</UBadge>
|
|
|
|
<UDropdownMenu :items="bulkActionGroups" :modal="false">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-list"
|
|
trailing-icon="i-lucide-chevron-down"
|
|
>
|
|
Actions
|
|
</UButton>
|
|
</UDropdownMenu>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UBadge color="neutral" variant="soft" size="sm">
|
|
<span class="inline-flex items-center gap-1.5">
|
|
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
|
<span>Total: {{ stateStore.count() }}</span>
|
|
</span>
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="'list' === contentStyle && hasItems"
|
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
|
>
|
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
|
<table class="min-w-210 table-fixed w-full text-sm">
|
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
|
<tr
|
|
class="text-center [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
|
|
>
|
|
<th class="w-12">
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer"
|
|
:aria-label="masterSelectAll ? 'Unselect all items' : 'Select all items'"
|
|
@click="toggleMasterSelection"
|
|
>
|
|
<UIcon
|
|
:name="masterSelectAll ? 'i-lucide-square' : 'i-lucide-square-check-big'"
|
|
class="size-4"
|
|
/>
|
|
</button>
|
|
</th>
|
|
<th class="w-full text-left">Video Title</th>
|
|
<th class="w-56">Progress</th>
|
|
<th class="w-32 whitespace-nowrap">Status</th>
|
|
<th class="w-36 whitespace-nowrap">Created</th>
|
|
<th class="w-80 whitespace-nowrap">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody class="divide-y divide-default">
|
|
<tr
|
|
v-for="item in displayedItems"
|
|
:key="item._id"
|
|
class="align-top transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
|
|
>
|
|
<td class="border-r border-default/60 px-3 py-3 text-center align-top">
|
|
<label class="inline-flex cursor-pointer items-center justify-center">
|
|
<input
|
|
:id="`checkbox-${item._id}`"
|
|
v-model="selectedElms"
|
|
class="completed-checkbox size-4 rounded border-default"
|
|
type="checkbox"
|
|
:value="item._id"
|
|
/>
|
|
</label>
|
|
</td>
|
|
|
|
<td class="border-r border-default/60 px-3 py-3 align-top">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<UTooltip :text="`[${item.preset}] - ${item.title}`">
|
|
<div class="truncate font-medium text-highlighted">
|
|
<a target="_blank" :href="item.url" class="hover:underline">
|
|
{{ item.title }}
|
|
</a>
|
|
</div>
|
|
</UTooltip>
|
|
</div>
|
|
|
|
<div
|
|
v-if="item.downloaded_bytes || show_popover"
|
|
class="flex shrink-0 items-center gap-2"
|
|
>
|
|
<UBadge
|
|
v-if="item.downloaded_bytes"
|
|
color="neutral"
|
|
variant="soft"
|
|
size="sm"
|
|
>
|
|
{{ formatBytes(item.downloaded_bytes) }}
|
|
</UBadge>
|
|
|
|
<UPopover
|
|
v-if="show_popover"
|
|
:content="{ side: 'bottom', align: 'end', sideOffset: 8 }"
|
|
>
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
icon="i-lucide-info"
|
|
square
|
|
/>
|
|
|
|
<template #content>
|
|
<UCard class="max-w-112.5" :ui="{ body: 'space-y-3 p-4' }">
|
|
<div class="space-y-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<p class="text-sm font-semibold text-highlighted">
|
|
{{ item.title }}
|
|
</p>
|
|
<UBadge color="info" variant="soft" size="sm">{{
|
|
item.preset
|
|
}}</UBadge>
|
|
</div>
|
|
|
|
<p v-if="item.extras?.duration" class="text-xs text-toned">
|
|
<span class="font-semibold text-default">Duration:</span>
|
|
{{ formatTime(item.extras.duration) }}
|
|
</p>
|
|
|
|
<p v-if="getItemPath(item)" class="text-xs text-toned">
|
|
<span class="font-semibold text-default">Path:</span>
|
|
{{ getItemPath(item) }}
|
|
</p>
|
|
</div>
|
|
|
|
<img
|
|
v-if="showThumbnails && getListImage(item)"
|
|
:src="getListImage(item)"
|
|
class="max-h-56 w-full rounded-md object-cover"
|
|
/>
|
|
|
|
<div
|
|
v-if="item.description"
|
|
class="max-h-40 overflow-y-auto rounded-md border border-default bg-muted/20 px-3 py-2 text-sm text-default"
|
|
>
|
|
{{ item.description }}
|
|
</div>
|
|
</UCard>
|
|
</template>
|
|
</UPopover>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td class="w-56 border-r border-default/60 px-3 py-3 align-top">
|
|
<div
|
|
class="queue-progress queue-progress--compact w-56 rounded-md border border-default bg-muted/20"
|
|
>
|
|
<div
|
|
class="queue-progress__bar bg-success/35"
|
|
:style="{ width: progressWidth(item) }"
|
|
></div>
|
|
<div class="queue-progress__label">
|
|
<template v-if="progressIcon(item)">
|
|
<UIcon
|
|
:name="progressIcon(item)"
|
|
:class="[
|
|
'mr-1 size-4',
|
|
['i-lucide-settings-2', 'i-lucide-loader-circle'].includes(
|
|
progressIcon(item),
|
|
)
|
|
? 'animate-spin'
|
|
: '',
|
|
]"
|
|
/>
|
|
</template>
|
|
<span>{{ progressText(item) }}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td class="border-r border-default/60 px-3 py-3 text-center align-top text-sm">
|
|
<div class="inline-flex items-center gap-2 text-default whitespace-nowrap">
|
|
<span class="inline-flex items-center">
|
|
<UIcon
|
|
:name="setIcon(item)"
|
|
:class="[setIconColor(item), setIconAnimation(item), 'size-4 shrink-0']"
|
|
/>
|
|
</span>
|
|
<span>{{ setStatus(item) }}</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td
|
|
class="border-r border-default/60 px-3 py-3 text-center align-top text-sm text-toned whitespace-nowrap"
|
|
>
|
|
<UTooltip :text="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')">
|
|
<span :data-datetime="item.datetime" v-rtime="item.datetime" />
|
|
</UTooltip>
|
|
</td>
|
|
|
|
<td class="w-80 px-3 py-3 align-top whitespace-nowrap">
|
|
<div class="flex items-center justify-end gap-1">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-circle-off"
|
|
@click="() => void confirmCancel(item)"
|
|
>
|
|
{{ item.is_live ? 'Stop Stream' : 'Cancel' }}
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="canStartItem(item)"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-circle-play"
|
|
@click="() => void startItem(item)"
|
|
>
|
|
Start
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="canPauseItem(item)"
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-pause"
|
|
@click="() => void pauseItem(item)"
|
|
>
|
|
Pause
|
|
</UButton>
|
|
|
|
<UDropdownMenu :items="itemActionGroups(item)" :modal="false">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-settings-2"
|
|
trailing-icon="i-lucide-chevron-down"
|
|
>
|
|
Actions
|
|
</UButton>
|
|
</UDropdownMenu>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="hasItems" class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
<LateLoader
|
|
v-for="item in displayedItems"
|
|
:key="item._id"
|
|
:unrender="true"
|
|
:min-height="showThumbnails ? 475 : 265"
|
|
class="min-h-0 min-w-0 w-full max-w-full"
|
|
>
|
|
<UCard
|
|
class="flex h-full min-w-0 w-full max-w-full flex-col overflow-hidden"
|
|
:ui="{
|
|
body: 'flex flex-1 flex-col gap-4 p-4',
|
|
footer: 'border-t border-default px-4 py-4',
|
|
header: 'p-4 pb-3',
|
|
root: 'bg-default border border-default',
|
|
}"
|
|
>
|
|
<template #header>
|
|
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<UTooltip :text="item.title">
|
|
<div class="min-w-0 text-sm font-semibold text-highlighted">
|
|
<a target="_blank" :href="item.url" class="block truncate hover:underline">
|
|
{{ item.title }}
|
|
</a>
|
|
</div>
|
|
</UTooltip>
|
|
</div>
|
|
|
|
<div class="flex max-w-full flex-wrap items-center justify-end gap-1 sm:shrink-0">
|
|
<UBadge v-if="item.extras?.duration" color="info" variant="soft" size="sm">
|
|
{{ formatTime(item.extras.duration) }}
|
|
</UBadge>
|
|
|
|
<UPopover
|
|
v-if="show_popover"
|
|
:content="{ side: 'bottom', align: 'end', sideOffset: 8 }"
|
|
>
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
icon="i-lucide-info"
|
|
square
|
|
/>
|
|
|
|
<template #content>
|
|
<UCard class="max-w-112.5" :ui="{ body: 'space-y-3 p-4' }">
|
|
<div class="space-y-2">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<p class="text-sm font-semibold text-highlighted">{{ item.title }}</p>
|
|
<UBadge color="info" variant="soft" size="sm">{{
|
|
item.preset
|
|
}}</UBadge>
|
|
</div>
|
|
|
|
<p v-if="getItemPath(item)" class="text-xs text-toned">
|
|
<span class="font-semibold text-default">Path:</span>
|
|
{{ getItemPath(item) }}
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
v-if="item.description"
|
|
class="max-h-40 overflow-y-auto rounded-md border border-default bg-muted/20 px-3 py-2 text-sm text-default"
|
|
>
|
|
{{ item.description }}
|
|
</div>
|
|
</UCard>
|
|
</template>
|
|
</UPopover>
|
|
|
|
<UButton
|
|
v-if="show_thumbnail"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
:icon="hideThumbnail ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
|
|
square
|
|
@click="hideThumbnail = !hideThumbnail"
|
|
/>
|
|
|
|
<label class="inline-flex cursor-pointer items-center justify-center px-1">
|
|
<input
|
|
:id="`checkbox-${item._id}`"
|
|
v-model="selectedElms"
|
|
class="completed-checkbox size-4 rounded border-default"
|
|
type="checkbox"
|
|
:value="item._id"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div
|
|
v-if="showThumbnails"
|
|
class="-mx-4 -mt-4 overflow-hidden border-b border-default bg-muted/20"
|
|
>
|
|
<figure :class="['relative w-full overflow-hidden', thumbnailRatioClass]">
|
|
<span
|
|
v-if="isEmbedable(item.url)"
|
|
class="play-overlay"
|
|
@click="embed_url = getEmbedable(item.url) as string"
|
|
>
|
|
<span class="play-icon embed-icon" aria-hidden="true">
|
|
<UIcon name="i-lucide-play" class="size-6 translate-x-px text-white" />
|
|
</span>
|
|
<img
|
|
v-if="getGridImage(item)"
|
|
:src="getGridImage(item)"
|
|
@load="pImg"
|
|
@error="onImgError"
|
|
/>
|
|
<img v-else src="/images/placeholder.png" />
|
|
</span>
|
|
|
|
<template v-else>
|
|
<img
|
|
v-if="getGridImage(item)"
|
|
:src="getGridImage(item)"
|
|
@load="pImg"
|
|
@error="onImgError"
|
|
/>
|
|
<img v-else src="/images/placeholder.png" />
|
|
</template>
|
|
</figure>
|
|
</div>
|
|
|
|
<div class="queue-progress rounded-md border border-default bg-muted/20">
|
|
<div
|
|
class="queue-progress__bar bg-success/35"
|
|
:style="{ width: progressWidth(item) }"
|
|
></div>
|
|
<div class="queue-progress__label">
|
|
<template v-if="progressIcon(item)">
|
|
<UIcon
|
|
:name="progressIcon(item)"
|
|
:class="[
|
|
'mr-1 size-4',
|
|
progressIcon(item) === 'i-lucide-settings-2' ? 'animate-spin' : '',
|
|
]"
|
|
/>
|
|
</template>
|
|
<span>{{ progressText(item) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 text-sm *:min-w-32 *:flex-1">
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-default transition hover:border-primary hover:text-default"
|
|
@click="toggleExpand(item._id, 'status')"
|
|
>
|
|
<span class="inline-flex w-full items-center justify-center gap-2">
|
|
<UIcon
|
|
:name="setIcon(item)"
|
|
:class="[setIconColor(item), setIconAnimation(item), 'size-4 shrink-0']"
|
|
/>
|
|
<span :class="['min-w-0 text-center', expandClass(item._id, 'status')]">
|
|
{{ setStatus(item) }}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-default transition hover:border-primary hover:text-default"
|
|
@click="toggleExpand(item._id, 'preset')"
|
|
>
|
|
<span class="inline-flex w-full items-center justify-center gap-2">
|
|
<UIcon name="i-lucide-sliders-horizontal" class="size-4 shrink-0 text-toned" />
|
|
<span :class="['min-w-0 text-center', expandClass(item._id, 'preset')]">
|
|
{{ item.preset }}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-toned transition hover:border-primary hover:text-default"
|
|
@click="toggleExpand(item._id, 'datetime')"
|
|
>
|
|
<UTooltip :text="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')">
|
|
<span class="inline-flex w-full items-center justify-center gap-2">
|
|
<UIcon name="i-lucide-clock-3" class="size-4 shrink-0 text-toned" />
|
|
<span
|
|
:class="['min-w-0 text-center', expandClass(item._id, 'datetime')]"
|
|
:data-datetime="item.datetime"
|
|
v-rtime="item.datetime"
|
|
/>
|
|
</span>
|
|
</UTooltip>
|
|
</button>
|
|
|
|
<button
|
|
v-if="item.downloaded_bytes"
|
|
type="button"
|
|
class="rounded-md border border-default bg-muted/20 px-3 py-2 text-toned transition hover:border-primary hover:text-default"
|
|
@click="toggleExpand(item._id, 'size')"
|
|
>
|
|
<span class="inline-flex w-full items-center justify-center gap-2">
|
|
<UIcon name="i-lucide-hard-drive" class="size-4 shrink-0 text-toned" />
|
|
<span :class="['min-w-0 text-center', expandClass(item._id, 'size')]">
|
|
{{ formatBytes(item.downloaded_bytes) }}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex flex-wrap gap-2 *:min-w-32 *:flex-1">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-circle-off"
|
|
class="w-full justify-center"
|
|
@click="() => void confirmCancel(item)"
|
|
>
|
|
{{ item.is_live ? 'Stop Stream' : 'Cancel' }}
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="canStartItem(item)"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-circle-play"
|
|
class="w-full justify-center"
|
|
@click="() => void startItem(item)"
|
|
>
|
|
Start
|
|
</UButton>
|
|
|
|
<UButton
|
|
v-if="canPauseItem(item)"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-pause"
|
|
class="w-full justify-center"
|
|
@click="() => void pauseItem(item)"
|
|
>
|
|
Pause
|
|
</UButton>
|
|
|
|
<UDropdownMenu :items="itemActionGroups(item)" :modal="false" class="w-full">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-settings-2"
|
|
trailing-icon="i-lucide-chevron-down"
|
|
class="w-full justify-center"
|
|
>
|
|
Actions
|
|
</UButton>
|
|
</UDropdownMenu>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</LateLoader>
|
|
</div>
|
|
|
|
<div v-if="!hasItems" class="space-y-4">
|
|
<UAlert
|
|
v-if="query"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-search"
|
|
title="Filter results"
|
|
>
|
|
<template #description>
|
|
<div class="space-y-3 text-sm text-default">
|
|
<p>
|
|
No results found for: <code>{{ query }}</code
|
|
>.
|
|
</p>
|
|
|
|
<p>
|
|
You can search using any value shown in the item's <code>Local Information</code>.
|
|
You can also do a targeted search using <code><u>key</u>:value</code>.
|
|
</p>
|
|
|
|
<div>
|
|
<p class="mb-1 font-medium">Examples:</p>
|
|
<ul class="list-disc space-y-1 pl-5">
|
|
<li><code>youtube.com</code> - items containing that text</li>
|
|
<li><code>is_live:true</code> - only live downloads</li>
|
|
<li><code>source_name:task_name</code> - items added by the specified task.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<UEmpty
|
|
v-else
|
|
icon="i-lucide-triangle-alert"
|
|
title="No items"
|
|
description="Download queue is empty."
|
|
class="rounded-lg border border-dashed border-default bg-muted/10 py-10"
|
|
/>
|
|
</div>
|
|
|
|
<UModal
|
|
v-if="embed_url"
|
|
:open="Boolean(embed_url)"
|
|
:dismissible="true"
|
|
title="Embedded player"
|
|
:ui="{ content: 'sm:max-w-5xl', body: 'p-0' }"
|
|
@update:open="(open) => !open && (embed_url = '')"
|
|
>
|
|
<template #body>
|
|
<EmbedPlayer :url="embed_url" @closeModel="embed_url = ''" />
|
|
</template>
|
|
</UModal>
|
|
</div>
|
|
</section>
|
|
|
|
<GetInfo
|
|
v-if="info_view.url"
|
|
:link="info_view.url"
|
|
:preset="info_view.preset"
|
|
:cli="info_view.cli"
|
|
:useUrl="info_view.useUrl"
|
|
@closeModel="close_info()"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import moment from 'moment';
|
|
import { useStorage } from '@vueuse/core';
|
|
import { useConfirm } from '~/composables/useConfirm';
|
|
import { useDialog } from '~/composables/useDialog';
|
|
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
|
import { useMediaQuery } from '~/composables/useMediaQuery';
|
|
import type { item_request } from '~/types/item';
|
|
import type { StoreItem } from '~/types/store';
|
|
import {
|
|
ag,
|
|
deepIncludes,
|
|
formatBytes,
|
|
formatTime,
|
|
getImage,
|
|
getPath,
|
|
request,
|
|
ucFirst,
|
|
} from '~/utils';
|
|
import { getEmbedable, isEmbedable } from '~/utils/embedable';
|
|
import { requirePageShell } from '~/utils/topLevelNavigation';
|
|
|
|
const config = useYtpConfig();
|
|
const stateStore = useQueueState();
|
|
const socket = useAppSocket();
|
|
const route = useRoute();
|
|
const toast = useNotification();
|
|
const box = useConfirm();
|
|
const { confirmDialog } = useDialog();
|
|
const { toggleExpand, expandClass } = useExpandableMeta();
|
|
const pendingDownloadFormItem = useState<item_request | Record<string, never>>(
|
|
'pending-download-form-item',
|
|
() => ({}),
|
|
);
|
|
|
|
const bg_enable = useStorage<boolean>('random_bg', true);
|
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95);
|
|
const display_style = useStorage<'grid' | 'list'>('queue_display_style', 'grid');
|
|
const show_thumbnail = useStorage<boolean>('show_thumbnail', true);
|
|
const hideThumbnail = useStorage<boolean>('hideThumbnailQueue', false);
|
|
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
|
|
const show_popover = useStorage<boolean>('show_popover', true);
|
|
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true);
|
|
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000);
|
|
const isMobile = useMediaQuery({ maxWidth: 639 });
|
|
|
|
const pageShell = requirePageShell('downloads');
|
|
const formSection = ref<HTMLElement | null>(null);
|
|
const info_view = ref<{ url: string; preset: string; cli: string; useUrl: boolean }>({
|
|
url: '',
|
|
preset: '',
|
|
cli: '',
|
|
useUrl: false,
|
|
});
|
|
const item_form = ref<item_request | object>({});
|
|
const query = ref('');
|
|
const toggleFilter = ref(false);
|
|
const selectedElms = ref<string[]>([]);
|
|
const masterSelectAll = ref(false);
|
|
const embed_url = ref('');
|
|
const isRefreshing = ref(false);
|
|
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const hasQueueContent = computed(() => stateStore.count() > 0 || query.value.trim().length > 0);
|
|
const contentStyle = computed<'grid' | 'list'>(() =>
|
|
isMobile.value ? 'grid' : display_style.value,
|
|
);
|
|
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value);
|
|
|
|
const displayedItems = computed<StoreItem[]>(() => {
|
|
const normalizedQuery = query.value.trim().toLowerCase();
|
|
const items = Object.values(stateStore.queue);
|
|
|
|
if (!normalizedQuery) {
|
|
return items;
|
|
}
|
|
|
|
return items.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet()));
|
|
});
|
|
|
|
const hasItems = computed(() => displayedItems.value.length > 0);
|
|
const hasSelected = computed(() => selectedElms.value.length > 0);
|
|
const displayedItemIds = computed(() => displayedItems.value.map((item) => item._id));
|
|
const hasManualStart = computed(() =>
|
|
Object.values(stateStore.queue).some((item) => !item.status && false === item.auto_start),
|
|
);
|
|
const hasPausable = computed(() =>
|
|
Object.values(stateStore.queue).some((item) => !item.status && true === item.auto_start),
|
|
);
|
|
const thumbnailRatioClass = computed(() =>
|
|
thumbnail_ratio.value === 'is-16by9' ? 'aspect-video' : 'aspect-[3/1]',
|
|
);
|
|
|
|
const refreshQueue = async (): Promise<void> => {
|
|
if (isRefreshing.value) {
|
|
return;
|
|
}
|
|
|
|
isRefreshing.value = true;
|
|
|
|
try {
|
|
await stateStore.loadQueue();
|
|
} catch {
|
|
toast.error('Failed to refresh queue');
|
|
} finally {
|
|
isRefreshing.value = false;
|
|
}
|
|
};
|
|
|
|
const startAutoRefresh = (): void => {
|
|
if (autoRefreshInterval.value) {
|
|
clearInterval(autoRefreshInterval.value);
|
|
}
|
|
|
|
if (!autoRefreshEnabled.value || socket.isConnected) {
|
|
return;
|
|
}
|
|
|
|
autoRefreshInterval.value = setInterval(async () => {
|
|
if (!socket.isConnected && autoRefreshEnabled.value) {
|
|
await refreshQueue();
|
|
}
|
|
}, autoRefreshDelay.value);
|
|
};
|
|
|
|
const stopAutoRefresh = (): void => {
|
|
if (!autoRefreshInterval.value) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(autoRefreshInterval.value);
|
|
autoRefreshInterval.value = null;
|
|
};
|
|
|
|
onMounted(async () => {
|
|
if (route.query?.simple !== undefined) {
|
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false);
|
|
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string);
|
|
await nextTick();
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('simple');
|
|
window.history.replaceState({}, '', url.toString());
|
|
}
|
|
|
|
if (Object.keys(pendingDownloadFormItem.value).length > 0) {
|
|
await toNewDownload(pendingDownloadFormItem.value);
|
|
pendingDownloadFormItem.value = {};
|
|
}
|
|
|
|
if (!socket.isConnected && autoRefreshEnabled.value) {
|
|
startAutoRefresh();
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => stopAutoRefresh());
|
|
|
|
watch(toggleFilter, () => {
|
|
if (!toggleFilter.value) {
|
|
query.value = '';
|
|
}
|
|
});
|
|
|
|
watch(
|
|
() => socket.isConnected,
|
|
(connected) => {
|
|
if (connected) {
|
|
stopAutoRefresh();
|
|
return;
|
|
}
|
|
|
|
if (autoRefreshEnabled.value) {
|
|
startAutoRefresh();
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(autoRefreshEnabled, (enabled) => {
|
|
if (enabled && !socket.isConnected) {
|
|
startAutoRefresh();
|
|
return;
|
|
}
|
|
|
|
stopAutoRefresh();
|
|
});
|
|
|
|
watch(autoRefreshDelay, () => {
|
|
if (autoRefreshEnabled.value && !socket.isConnected) {
|
|
startAutoRefresh();
|
|
}
|
|
});
|
|
|
|
watch(
|
|
displayedItemIds,
|
|
(ids) => {
|
|
const idSet = new Set(ids);
|
|
selectedElms.value = selectedElms.value.filter((id) => idSet.has(id));
|
|
|
|
if (masterSelectAll.value) {
|
|
selectedElms.value = [...ids];
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(selectedElms, (value) => {
|
|
const ids = displayedItemIds.value;
|
|
masterSelectAll.value = ids.length > 0 && ids.every((id) => value.includes(id));
|
|
});
|
|
|
|
watch(
|
|
() => info_view.value.url,
|
|
(value) => {
|
|
if (!bg_enable.value) {
|
|
return;
|
|
}
|
|
|
|
document
|
|
.querySelector('body')
|
|
?.setAttribute('style', `opacity: ${value ? 1 : bg_opacity.value}`);
|
|
},
|
|
);
|
|
|
|
watch(embed_url, (value) => {
|
|
if (!bg_enable.value) {
|
|
return;
|
|
}
|
|
|
|
document.querySelector('body')?.setAttribute('style', `opacity: ${value ? 1 : bg_opacity.value}`);
|
|
});
|
|
|
|
const toggleMasterSelection = (): void => {
|
|
if (masterSelectAll.value) {
|
|
selectedElms.value = [];
|
|
masterSelectAll.value = false;
|
|
return;
|
|
}
|
|
|
|
selectedElms.value = [...displayedItemIds.value];
|
|
masterSelectAll.value = true;
|
|
};
|
|
|
|
const resumeDownload = async (): Promise<void> => {
|
|
await request('/api/system/resume', { method: 'POST' });
|
|
};
|
|
|
|
const pauseDownload = async (): Promise<void> => {
|
|
const { status } = await confirmDialog({
|
|
title: 'Pause Downloads',
|
|
confirmText: 'Pause',
|
|
cancelText: 'Cancel',
|
|
confirmColor: 'warning',
|
|
message: 'Are you sure you want to pause all non-active downloads?',
|
|
});
|
|
|
|
if (!status) {
|
|
return;
|
|
}
|
|
|
|
await request('/api/system/pause', { method: 'POST' });
|
|
};
|
|
|
|
const close_info = (): void => {
|
|
info_view.value.url = '';
|
|
info_view.value.preset = '';
|
|
info_view.value.cli = '';
|
|
info_view.value.useUrl = false;
|
|
};
|
|
|
|
const view_info = (
|
|
url: string,
|
|
useUrl: boolean = false,
|
|
preset: string = '',
|
|
cli: string = '',
|
|
): void => {
|
|
info_view.value.url = url;
|
|
info_view.value.useUrl = useUrl;
|
|
info_view.value.preset = preset;
|
|
info_view.value.cli = cli;
|
|
};
|
|
|
|
const changeDisplay = (): void => {
|
|
display_style.value = display_style.value === 'grid' ? 'list' : 'grid';
|
|
};
|
|
|
|
const toNewDownload = async (item: item_request | Partial<StoreItem>): Promise<void> => {
|
|
if (!item) {
|
|
return;
|
|
}
|
|
|
|
if (config.showForm) {
|
|
config.showForm = false;
|
|
await nextTick();
|
|
}
|
|
|
|
item_form.value = item;
|
|
|
|
await nextTick();
|
|
config.showForm = true;
|
|
await nextTick();
|
|
formSection.value?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
};
|
|
|
|
const getItemPath = (item: StoreItem): string => getPath(config.app.download_path, item) || '';
|
|
const getListImage = (item: StoreItem): string =>
|
|
getImage(config.app.download_path, item, false) || '';
|
|
const getGridImage = (item: StoreItem): string => getImage(config.app.download_path, item) || '';
|
|
const canStartItem = (item: StoreItem): boolean => !item.auto_start && !item.status;
|
|
const canPauseItem = (item: StoreItem): boolean => item.auto_start && !item.status;
|
|
|
|
const bulkActionGroups = computed(() => {
|
|
const groups: Array<Array<Record<string, unknown>>> = [[]];
|
|
const selectedLiveOnly =
|
|
selectedElms.value.length > 0 &&
|
|
selectedElms.value.every((id) => {
|
|
const item = stateStore.get(id);
|
|
return Boolean(item?.is_live);
|
|
});
|
|
|
|
if (hasManualStart.value) {
|
|
groups[0]?.push({
|
|
label: 'Start',
|
|
icon: 'i-lucide-circle-play',
|
|
disabled: !hasSelected.value,
|
|
onSelect: () => void startItems(),
|
|
});
|
|
}
|
|
|
|
if (hasPausable.value) {
|
|
groups[0]?.push({
|
|
label: 'Pause',
|
|
icon: 'i-lucide-pause',
|
|
disabled: !hasSelected.value,
|
|
onSelect: () => void pauseSelected(),
|
|
});
|
|
}
|
|
|
|
groups[0]?.push({
|
|
label: selectedLiveOnly ? 'Stop' : 'Cancel',
|
|
icon: 'i-lucide-circle-off',
|
|
disabled: !hasSelected.value,
|
|
onSelect: () => void cancelSelected(),
|
|
});
|
|
|
|
return groups;
|
|
});
|
|
|
|
const itemActionGroups = (item: StoreItem): Array<Array<Record<string, unknown>>> => {
|
|
const groups: Array<Array<Record<string, unknown>>> = [];
|
|
const primaryActions: Array<Record<string, unknown>> = [];
|
|
|
|
if (isEmbedable(item.url)) {
|
|
primaryActions.push({
|
|
label: 'Play video',
|
|
icon: 'i-lucide-play',
|
|
onSelect: () => {
|
|
embed_url.value = getEmbedable(item.url) as string;
|
|
},
|
|
});
|
|
}
|
|
|
|
primaryActions.push({
|
|
label: item.is_live ? 'Stop Stream' : 'Cancel Download',
|
|
icon: 'i-lucide-circle-off',
|
|
onSelect: () => void confirmCancel(item),
|
|
});
|
|
|
|
if (canStartItem(item)) {
|
|
primaryActions.push({
|
|
label: 'Start Download',
|
|
icon: 'i-lucide-circle-play',
|
|
onSelect: () => void startItem(item),
|
|
});
|
|
}
|
|
|
|
if (canPauseItem(item)) {
|
|
primaryActions.push({
|
|
label: 'Pause Download',
|
|
icon: 'i-lucide-pause',
|
|
onSelect: () => void pauseItem(item),
|
|
});
|
|
}
|
|
|
|
groups.push(primaryActions);
|
|
|
|
groups.push([
|
|
{
|
|
label: 'yt-dlp Information',
|
|
icon: 'i-lucide-info',
|
|
onSelect: () => view_info(item.url, false, item.preset, item.cli),
|
|
},
|
|
{
|
|
label: 'Local Information',
|
|
icon: 'i-lucide-info',
|
|
onSelect: () => view_info(`/api/history/${item._id}`, true),
|
|
},
|
|
]);
|
|
|
|
return groups;
|
|
};
|
|
|
|
const setIcon = (item: StoreItem): string => {
|
|
if (!item.auto_start) {
|
|
return 'i-lucide-clock-3';
|
|
}
|
|
|
|
if ('downloading' === item.status && item.is_live) {
|
|
return 'i-lucide-globe';
|
|
}
|
|
|
|
if ('downloading' === item.status) {
|
|
return 'i-lucide-download';
|
|
}
|
|
|
|
if ('postprocessing' === item.status) {
|
|
return 'i-lucide-settings-2';
|
|
}
|
|
|
|
if (null === item.status && true === config.paused) {
|
|
return 'i-lucide-circle-pause';
|
|
}
|
|
|
|
if (!item.status) {
|
|
return 'i-lucide-circle-question-mark';
|
|
}
|
|
|
|
return 'i-lucide-loader-circle';
|
|
};
|
|
|
|
const setIconAnimation = (item: StoreItem): string => {
|
|
const icon = setIcon(item);
|
|
|
|
return ['i-lucide-globe', 'i-lucide-settings-2', 'i-lucide-loader-circle'].includes(icon)
|
|
? 'animate-spin'
|
|
: '';
|
|
};
|
|
|
|
const setStatus = (item: StoreItem): string => {
|
|
if (!item.auto_start) {
|
|
return 'Pending';
|
|
}
|
|
|
|
if (null === item.status && true === config.paused) {
|
|
return 'Paused';
|
|
}
|
|
|
|
if ('downloading' === item.status && item.is_live) {
|
|
return 'Streaming';
|
|
}
|
|
|
|
if ('started' === item.status) {
|
|
return 'Starting';
|
|
}
|
|
|
|
if ('preparing' === item.status) {
|
|
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..';
|
|
}
|
|
|
|
if (!item.status) {
|
|
return 'Unknown...';
|
|
}
|
|
|
|
return ucFirst(item.status);
|
|
};
|
|
|
|
const setIconColor = (item: StoreItem): string => {
|
|
if (['downloading', 'started'].includes(item.status || '')) {
|
|
return 'text-success';
|
|
}
|
|
|
|
if ('postprocessing' === item.status) {
|
|
return 'text-info';
|
|
}
|
|
|
|
if (!item.auto_start || (null === item.status && true === config.paused)) {
|
|
return 'text-warning';
|
|
}
|
|
|
|
return 'text-default';
|
|
};
|
|
|
|
const ETAPipe = (value: number | null): string => {
|
|
if (null === value || 0 === value) {
|
|
return 'Live';
|
|
}
|
|
|
|
if (value < 60) {
|
|
return `${Math.round(value)}s`;
|
|
}
|
|
|
|
if (value < 3600) {
|
|
return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`;
|
|
}
|
|
|
|
const hours = Math.floor(value / 3600);
|
|
const minutes = value % 3600;
|
|
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`;
|
|
};
|
|
|
|
const speedPipe = (value: number | null): string => {
|
|
if (null === value || 0 === value) {
|
|
return '0KB/s';
|
|
}
|
|
|
|
const k = 1024;
|
|
const dm = 2;
|
|
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
|
|
const i = Math.floor(Math.log(value) / Math.log(k));
|
|
return `${parseFloat((value / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
};
|
|
|
|
const percentPipe = (value: number | null): string => {
|
|
if (null === value || 0 === value) {
|
|
return '00.00';
|
|
}
|
|
|
|
return parseFloat(String(value)).toFixed(2);
|
|
};
|
|
|
|
const progressWidth = (item: StoreItem): string => {
|
|
if (!item.auto_start || (null === item.status && true === config.paused)) {
|
|
return '0%';
|
|
}
|
|
|
|
if (
|
|
'postprocessing' === item.status ||
|
|
'started' === item.status ||
|
|
'preparing' === item.status
|
|
) {
|
|
return '100%';
|
|
}
|
|
|
|
if (!item.percent || item.is_live) {
|
|
return '100%';
|
|
}
|
|
|
|
return `${percentPipe(item.percent)}%`;
|
|
};
|
|
|
|
const progressIcon = (item: StoreItem): string => {
|
|
if (null != item.status && item.is_live && !item.speed) {
|
|
return 'i-lucide-loader-circle';
|
|
}
|
|
|
|
if ('postprocessing' === item.status) {
|
|
return 'i-lucide-settings-2';
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
const progressText = (item: StoreItem): string => {
|
|
if (!item.auto_start) {
|
|
return 'Manual start';
|
|
}
|
|
|
|
if (null === item.status && true === config.paused) {
|
|
return 'Global Pause';
|
|
}
|
|
|
|
if ('started' === item.status) {
|
|
return 'Starting';
|
|
}
|
|
|
|
if ('postprocessing' === item.status) {
|
|
if (item.postprocessor) {
|
|
return `PP: ${item.postprocessor}`;
|
|
}
|
|
|
|
return 'Post-processors are running.';
|
|
}
|
|
|
|
if ('preparing' === item.status) {
|
|
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing';
|
|
}
|
|
|
|
if (null != item.status && item.is_live && !item.speed) {
|
|
return 'Recording live stream';
|
|
}
|
|
|
|
let value = '';
|
|
|
|
if (null != item.status) {
|
|
value += item.percent && !item.is_live ? `${percentPipe(item.percent)}%` : 'Live';
|
|
}
|
|
|
|
value += item.speed ? ` - ${speedPipe(item.speed)}` : ' - Waiting..';
|
|
|
|
if (null != item.status && item.eta) {
|
|
value += ` - ${ETAPipe(item.eta)}`;
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
const confirmCancel = async (item: StoreItem): Promise<boolean> => {
|
|
if (true !== (await box.confirm(`${item.is_live ? 'Stop' : 'Cancel'} '${item.title}'?`))) {
|
|
return false;
|
|
}
|
|
|
|
cancelItems(item._id);
|
|
return true;
|
|
};
|
|
|
|
const cancelSelected = async (): Promise<boolean> => {
|
|
const selectedLiveOnly =
|
|
selectedElms.value.length > 0 &&
|
|
selectedElms.value.every((id) => {
|
|
const item = stateStore.get(id);
|
|
return Boolean(item?.is_live);
|
|
});
|
|
|
|
if (
|
|
true !==
|
|
(await box.confirm(
|
|
`${selectedLiveOnly ? 'Stop' : 'Cancel'} '${selectedElms.value.length}' selected items?`,
|
|
))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
cancelItems(selectedElms.value);
|
|
selectedElms.value = [];
|
|
masterSelectAll.value = false;
|
|
return true;
|
|
};
|
|
|
|
const cancelItems = (item: string | string[]): void => {
|
|
const items = Array.isArray(item) ? [...item] : [item];
|
|
|
|
if (items.length < 1) {
|
|
return;
|
|
}
|
|
|
|
void stateStore.cancelItems(items);
|
|
};
|
|
|
|
const startItem = async (item: StoreItem): Promise<void> => await stateStore.startItems([item._id]);
|
|
const pauseItem = async (item: StoreItem): Promise<void> => await stateStore.pauseItems([item._id]);
|
|
|
|
const startItems = async (): Promise<void> => {
|
|
if (selectedElms.value.length < 1) {
|
|
return;
|
|
}
|
|
|
|
const eligible = selectedElms.value.filter((id) => {
|
|
const item = stateStore.get(id);
|
|
return Boolean(item && !item.auto_start && !item.status);
|
|
});
|
|
|
|
selectedElms.value = [];
|
|
|
|
if (eligible.length < 1) {
|
|
toast.error('No eligible items to start.');
|
|
return;
|
|
}
|
|
|
|
if (true !== (await box.confirm(`Start '${eligible.length}' selected items?`))) {
|
|
return;
|
|
}
|
|
|
|
await stateStore.startItems(eligible);
|
|
};
|
|
|
|
const pauseSelected = async (): Promise<void> => {
|
|
if (selectedElms.value.length < 1) {
|
|
return;
|
|
}
|
|
|
|
const eligible = selectedElms.value.filter((id) => {
|
|
const item = stateStore.get(id);
|
|
return Boolean(item && item.auto_start && !item.status);
|
|
});
|
|
|
|
selectedElms.value = [];
|
|
|
|
if (eligible.length < 1) {
|
|
toast.error('No eligible items to pause.');
|
|
return;
|
|
}
|
|
|
|
if (true !== (await box.confirm(`Pause '${eligible.length}' selected items?`))) {
|
|
return;
|
|
}
|
|
|
|
await stateStore.pauseItems(eligible);
|
|
};
|
|
|
|
const pImg = (event: Event): void => {
|
|
const target = event.target as HTMLImageElement;
|
|
|
|
if (target.naturalHeight > target.naturalWidth) {
|
|
target.classList.add('image-portrait');
|
|
}
|
|
};
|
|
|
|
const onImgError = (event: Event): void => {
|
|
const target = event.target as HTMLImageElement;
|
|
|
|
if (target.src.endsWith('/images/placeholder.png')) {
|
|
return;
|
|
}
|
|
|
|
target.src = '/images/placeholder.png';
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-form-wrap {
|
|
max-width: 100%;
|
|
}
|
|
|
|
.queue-progress {
|
|
position: relative;
|
|
min-height: 2.25rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.queue-progress__bar {
|
|
position: absolute;
|
|
inset: 0 auto 0 0;
|
|
}
|
|
|
|
.queue-progress__label {
|
|
position: relative;
|
|
z-index: 1;
|
|
display: flex;
|
|
min-height: 2.25rem;
|
|
min-width: 0;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-wrap: nowrap;
|
|
padding: 0.5rem 0.75rem;
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: var(--ui-text-highlighted);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.queue-progress--compact {
|
|
min-height: 1.875rem;
|
|
}
|
|
|
|
.queue-progress--compact .queue-progress__label {
|
|
min-height: 1.875rem;
|
|
padding: 0.375rem 0.75rem;
|
|
}
|
|
</style>
|