ytptube/ui/app/components/Queue.vue
Copilot b9c5b0b7ad
Decouple queue from config endpoint; load via paginated API with correct ordering (#2)
* Initial plan

* Refactor: remove queue from /api/system/configuration, use existing paginated /api/history endpoint for queue data

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Remove accidentally committed package-lock.json and add to gitignore

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Simplify loadMoreQueue to use loadNextPage for reduced duplication

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Order in-progress downloads oldest-to-newest (order of processing)

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Fix queue ordering: remove client-side timestamp sort, rely on backend created_at ASC

Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>

* Revert "Fix queue ordering: remove client-side timestamp sort, rely on backend created_at ASC"

This reverts commit 9b53112e0050a45e4db95c85064577d22ed47a50. Changes not needed

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbatesy <51190172+jbatesy@users.noreply.github.com>
Co-authored-by: Jesse Bate <noreply@idump.me>
2026-03-09 15:05:22 +10:30

903 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
v-if="!socket.isConnected"
>
<div class="column is-narrow">
<div class="field is-grouped">
<p class="control">
<button
type="button"
class="button is-info"
@click="refreshQueue"
:class="{ 'is-loading': isRefreshing }"
v-tooltip.bottom="'Refresh queue data'"
>
<span class="icon">
<i class="fa-solid fa-refresh" />
</span>
<span>Refresh</span>
</button>
</p>
</div>
</div>
</div>
<div
class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
v-if="filteredItems.length > 0"
>
<div class="column is-narrow">
<button
type="button"
class="button"
@click="masterSelectAll = !masterSelectAll"
:class="{ 'has-text-primary': !masterSelectAll, 'has-text-danger': masterSelectAll }"
>
<span class="icon">
<i :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span>
<span v-if="!masterSelectAll">Select</span>
<span v-else>Unselect</span>
<span v-if="selectedElms.length > 0">
&nbsp;(<u class="has-text-danger">{{ selectedElms.length }}</u
>)
</span>
</button>
</div>
<div class="column is-2-tablet is-5-mobile">
<Dropdown label="Actions" icons="fa-solid fa-list">
<a
v-if="hasManualStart"
class="dropdown-item has-text-success"
@click="hasSelected ? startItems() : null"
:style="{
opacity: !hasSelected ? 0.5 : 1,
cursor: !hasSelected ? 'not-allowed' : 'pointer',
}"
>
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span>
</a>
<a
v-if="hasPausable"
class="dropdown-item has-text-warning"
@click="hasSelected ? pauseSelected() : null"
:style="{
opacity: !hasSelected ? 0.5 : 1,
cursor: !hasSelected ? 'not-allowed' : 'pointer',
}"
>
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span>
</a>
<a
class="dropdown-item has-text-warning"
@click="hasSelected ? cancelSelected() : null"
:style="{
opacity: !hasSelected ? 0.5 : 1,
cursor: !hasSelected ? 'not-allowed' : 'pointer',
}"
>
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span>
</a>
</Dropdown>
</div>
</div>
<div class="columns is-multiline" v-if="'list' === display_style">
<div class="column is-12" v-if="filteredItems.length > 0">
<div class="table-container">
<table
class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 1300px; table-layout: fixed"
>
<thead>
<tr class="has-text-centered is-unselectable">
<th width="5%" v-tooltip="masterSelectAll ? 'Unselect all' : 'Select all'">
<a href="#" @click.prevent="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block">
<span class="icon">
<i
class="fa-regular"
:class="{
'fa-square-check': !masterSelectAll,
'fa-square': masterSelectAll,
}"
/>
</span>
</span>
</a>
</th>
<th width="40%">Video Title</th>
<th width="15%">Status</th>
<th width="20%">Progress</th>
<th width="15%">Created</th>
<th width="10%">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item._id">
<td class="has-text-centered is-vcentered">
<label class="checkbox is-block">
<input
class="completed-checkbox"
type="checkbox"
v-model="selectedElms"
:id="'checkbox-' + item._id"
:value="item._id"
/>
</label>
</td>
<td class="is-text-overflow is-vcentered">
<div class="is-inline is-pulled-right" v-if="item.downloaded_bytes || show_popover">
<span class="tag" v-if="item.downloaded_bytes">{{
formatBytes(item.downloaded_bytes)
}}</span>
<Popover :showDelay="400" :maxWidth="450" v-if="show_popover">
<template #trigger>
<span class="icon is-pointer"><i class="fa-solid fa-info-circle" /></span>
</template>
<template #title>
<strong>
{{ item.title }}
<span class="tag is-info is-unselectable">{{ item.preset }}</span>
</strong>
</template>
<p v-if="item.extras?.duration">
<b>Duration</b>: {{ formatTime(item.extras.duration) }}
</p>
<p v-if="getPath(config.app.download_path, item)">
<b>Path:</b> {{ getPath(config.app.download_path, item) }}
</p>
<hr
v-if="
(showThumbnails && getImage(config.app.download_path, item, false)) ||
item.description
"
/>
<img
v-if="showThumbnails && getImage(config.app.download_path, item, false)"
:src="getImage(config.app.download_path, item, false)"
class="card-image mt-2 mb-2"
/>
<p v-if="item.description">{{ item.description }}</p>
</Popover>
</div>
<div class="is-text-overflow" v-tooltip="`[${item.preset}] - ${item.title}`">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
</td>
<td>
<div class="progress-bar is-unselectable">
<div class="progress-percentage" v-html="updateProgress(item)" />
<div
class="progress"
:style="{ width: percentPipe(item.percent as number) + '%' }"
></div>
</div>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
:data-datetime="item.datetime"
v-rtime="item.datetime"
/>
</td>
<td class="is-vcentered is-items-center">
<Dropdown icons="fa-solid fa-cogs" :button_classes="'is-small'" label="Actions">
<template v-if="isEmbedable(item.url)">
<NuxtLink
class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string"
>
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item has-text-warning" @click="confirmCancel(item)">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel Download</span>
</NuxtLink>
<template v-if="!item.auto_start && !item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-success" @click="startItem(item)">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start Download</span>
</NuxtLink>
</template>
<template v-if="item.auto_start && !item.status">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-warning" @click="pauseItem(item)">
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause Download</span>
</NuxtLink>
</template>
<hr class="dropdown-divider" />
<NuxtLink
class="dropdown-item"
@click="emitter('getInfo', item.url, item.preset, item.cli)"
>
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
<span>Local Information</span>
</NuxtLink>
</Dropdown>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="columns is-multiline" v-else>
<LateLoader
:unrender="true"
:min-height="showThumbnails ? 475 : 265"
class="column is-6"
v-for="item in filteredItems"
:key="item._id"
>
<div class="card">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
<NuxtLink target="_blank" v-tooltip="item.title" :href="item.url">{{
item.title
}}</NuxtLink>
</div>
<div class="card-header-icon">
<div class="field is-grouped">
<div class="control">
<span class="tag is-info is-unselectable" v-if="item.extras?.duration">
{{ formatTime(item.extras.duration) }}
</span>
</div>
<Popover :showDelay="400" :maxWidth="450" v-if="show_popover">
<template #trigger>
<span class="icon is-pointer"><i class="fa-solid fa-info-circle" /></span>
</template>
<template #title>
<strong>
{{ item.title }}
<span class="tag is-info is-unselectable">{{ item.preset }}</span>
</strong>
</template>
<p v-if="getPath(config.app.download_path, item)">
<b>Path:</b> {{ getPath(config.app.download_path, item) }}
</p>
<hr v-if="item.description" />
<p v-if="item.description">{{ item.description }}</p>
</Popover>
<div class="control">
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
<span class="icon"
><i
class="fa-solid"
:class="{ 'fa-arrow-down': hideThumbnail, 'fa-arrow-up': !hideThumbnail }"
/></span>
</button>
</div>
<div class="control">
<label class="checkbox is-block">
<input
class="completed-checkbox"
type="checkbox"
v-model="selectedElms"
:id="'checkbox-' + item._id"
:value="item._id"
/>
</label>
</div>
</div>
</div>
</header>
<div v-if="showThumbnails" class="card-image">
<figure :class="['image', thumbnail_ratio]">
<span
v-if="isEmbedable(item.url)"
@click="embed_url = getEmbedable(item.url) as string"
class="play-overlay"
>
<div class="play-icon embed-icon"></div>
<img
@load="pImg"
@error="onImgError"
v-if="getImage(config.app.download_path, item)"
:src="getImage(config.app.download_path, item)"
/>
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<img
@load="pImg"
@error="onImgError"
v-if="getImage(config.app.download_path, item)"
:src="getImage(config.app.download_path, item)"
/>
<img v-else src="/images/placeholder.png" />
</template>
</figure>
</div>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<div class="progress-bar is-unselectable">
<div class="progress-percentage" v-html="updateProgress(item)" />
<div
class="progress"
:style="{ width: percentPipe(item.percent as number) + '%' }"
></div>
</div>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span v-tooltip="`Preset: ${item.preset}`" class="has-tooltip">{{
item.preset
}}</span>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')"
:data-datetime="item.datetime"
v-rtime="item.datetime"
/>
</div>
<div
class="column is-half-mobile has-text-centered is-unselectable"
v-if="item.downloaded_bytes"
>
{{ formatBytes(item.downloaded_bytes) }}
</div>
</div>
<div class="columns is-multiline is-mobile">
<div class="column is-half-mobile">
<button class="button is-warning is-fullwidth" @click="confirmCancel(item)">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span>
</button>
</div>
<div class="column is-half-mobile" v-if="!item.auto_start && !item.status">
<button class="button is-success is-fullwidth" @click="startItem(item)">
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
<span>Start</span>
</button>
</div>
<div class="column is-half-mobile" v-if="item.auto_start && !item.status">
<button
class="button is-warning is-background-warning-85 is-fullwidth"
@click="pauseItem(item)"
>
<span class="icon"><i class="fa-solid fa-pause" /></span>
<span>Pause</span>
</button>
</div>
<div class="column">
<Dropdown icons="fa-solid fa-cogs" label="Actions">
<template v-if="isEmbedable(item.url)">
<NuxtLink
class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string"
>
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<NuxtLink
class="dropdown-item"
@click="emitter('getInfo', item.url, item.preset, item.cli)"
>
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="emitter('getItemInfo', item._id)">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
<span>Local Information</span>
</NuxtLink>
</Dropdown>
</div>
</div>
</div>
</div>
</LateLoader>
</div>
<div class="columns is-multiline" v-if="filteredItems.length < 1">
<div class="column is-12">
<Message
class="is-warning"
title="Filter results"
icon="fas fa-search"
:useClose="true"
@close="() => emitter('clear_search')"
v-if="query"
>
<span class="is-block"
>No results found for: <code>{{ query }}</code
>.</span
>
<hr />
<p>
You can search using any value shown in the items
<code
><span class="icon"><i class="fa-solid fa-info-circle" /></span> Local Information</code
>. You can also do a targeted search using <code><u>key</u>:value</code>.
</p>
<h5>Examples:</h5>
<ul>
<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>
</Message>
<Message
v-else
class="is-info"
title="No items"
icon="fas fa-exclamation-triangle"
:useClose="false"
>
<p>Download queue is empty.</p>
</Message>
</div>
</div>
<div
v-if="queuePagination.isLoaded && queuePagination.page < queuePagination.total_pages"
ref="loadMoreTrigger"
class="columns is-centered mt-4"
>
<div class="column is-narrow">
<div v-if="queuePagination.isLoading" class="has-text-centered">
<span class="icon is-large has-text-info">
<i class="fas fa-spinner fa-pulse fa-2x"></i>
</span>
<p class="is-size-7 has-text-grey mt-2">Loading more items...</p>
</div>
</div>
</div>
<div class="modal is-active" v-if="embed_url">
<div class="modal-background" @click="embed_url = ''"></div>
<div class="modal-content is-unbounded-model">
<EmbedPlayer :url="embed_url" @closeModel="embed_url = ''" />
</div>
<button class="modal-close is-large" aria-label="close" @click="embed_url = ''"></button>
</div>
</template>
<script setup lang="ts">
import moment from 'moment';
import { useStorage, useIntersectionObserver } from '@vueuse/core';
import type { StoreItem } from '~/types/store';
import { useConfirm } from '~/composables/useConfirm';
import { deepIncludes } from '~/utils';
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string, cli: string): void;
(e: 'getItemInfo', id: string): void;
(e: 'clear_search'): void;
}>();
const props = defineProps<{
thumbnails?: boolean;
query?: string;
}>();
const config = useConfigStore();
const stateStore = useStateStore();
const socket = useSocketStore();
const box = useConfirm();
const toast = useNotification();
const hideThumbnail = useStorage('hideThumbnailQueue', false);
const display_style = useStorage('display_style', 'grid');
const bg_enable = useStorage('random_bg', true);
const bg_opacity = useStorage('random_bg_opacity', 0.95);
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1');
const show_popover = useStorage<boolean>('show_popover', true);
const selectedElms = ref<string[]>([]);
const masterSelectAll = ref(false);
const embed_url = ref('');
const isRefreshing = ref(false);
const loadMoreTrigger = ref<HTMLElement | null>(null);
const autoRefreshInterval = ref<NodeJS.Timeout | null>(null);
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true);
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000);
const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value);
const queuePagination = computed(() => stateStore.getQueuePagination());
const refreshQueue = async () => {
isRefreshing.value = true;
try {
await stateStore.loadQueue();
} catch {
toast.error('Failed to refresh queue');
} finally {
isRefreshing.value = false;
}
};
const loadMoreQueue = async () => {
try {
await stateStore.loadNextPage('queue', true);
} catch {
toast.error('Failed to load more queue items');
}
};
const startAutoRefresh = () => {
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 = () => {
if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value);
autoRefreshInterval.value = null;
}
};
watch(
() => socket.isConnected,
(connected) => {
if (connected) {
stopAutoRefresh();
} else if (autoRefreshEnabled.value) {
startAutoRefresh();
}
},
);
watch(autoRefreshEnabled, (enabled) => {
if (enabled && !socket.isConnected) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
});
onMounted(async () => {
if (!queuePagination.value.isLoaded) {
try {
await stateStore.loadPaginated('queue', 1, config.app.default_pagination, 'ASC', false);
} catch {
toast.error('Failed to load queue');
}
}
if (!socket.isConnected && autoRefreshEnabled.value) {
startAutoRefresh();
}
});
onBeforeUnmount(() => stopAutoRefresh());
useIntersectionObserver(
loadMoreTrigger,
([entry]) => {
if (
entry?.isIntersecting &&
!queuePagination.value.isLoading &&
queuePagination.value.page < queuePagination.value.total_pages
) {
loadMoreQueue();
}
},
{ threshold: 0.5 },
);
watch(masterSelectAll, (value) => {
if (value) {
selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id);
} else {
selectedElms.value = [];
}
});
const filteredItems = computed<StoreItem[]>(() => {
const items = Object.values(stateStore.queue)
.slice()
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
const q = props.query?.toLowerCase();
if (!q) {
return items;
}
return items.filter((i: StoreItem) => deepIncludes(i, q, new WeakSet()));
});
const hasSelected = computed(() => 0 < selectedElms.value.length);
const hasManualStart = computed(() => {
if (0 > stateStore.count('queue')) {
return false;
}
for (const key in stateStore.queue) {
const item = stateStore.queue[key] as StoreItem;
if (!item.status && false === item.auto_start) {
return true;
}
}
return false;
});
const hasPausable = computed(() => {
if (0 > stateStore.count('queue')) {
return false;
}
for (const key in stateStore.queue) {
const item = stateStore.queue[key] as StoreItem;
if (!item.status && true === item.auto_start) {
return true;
}
}
return false;
});
const setIcon = (item: StoreItem): string => {
if (!item.auto_start) {
return 'fa-hourglass-half';
}
if ('downloading' === item.status && item.is_live) {
return 'fa-globe fa-spin';
}
if ('downloading' === item.status) {
return 'fa-download';
}
if ('postprocessing' === item.status) {
return 'fa-cog fa-spin';
}
if (null === item.status && true === config.paused) {
return 'fa-pause-circle';
}
if (!item.status) {
return 'fa-question';
}
return 'fa-spinner fa-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 as string)) {
return 'has-text-success';
}
if ('postprocessing' === item.status) {
return 'has-text-info';
}
if (!item.auto_start || (null === item.status && true === config.paused)) {
return 'has-text-warning';
}
return '';
};
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 updateProgress = (item: StoreItem): string => {
let 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 `<i class="fa fa-cog fa-spin"></i> 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) {
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live';
}
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..';
if (null != item.status && item.eta) {
string += ' - ' + ETAPipe(item.eta);
}
return string;
};
const confirmCancel = async (item: StoreItem) => {
if (true !== (await box.confirm(`Cancel '${item.title}'?`))) {
return false;
}
cancelItems(item._id);
return true;
};
const cancelSelected = async () => {
if (true !== (await box.confirm(`Cancel '${selectedElms.value.length}' selected items?`))) {
return false;
}
cancelItems(selectedElms.value);
selectedElms.value = [];
masterSelectAll.value = false;
return true;
};
const cancelItems = (item: string | string[]) => {
const items: string[] = [];
if ('object' === typeof item) {
for (const key in item) {
items.push((item as any)[key]);
}
} else {
items.push(item);
}
if (0 > items.length) {
return;
}
stateStore.cancelItems(items);
};
const startItem = async (item: StoreItem) => await stateStore.startItems([item._id]);
const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id]);
const startItems = async () => {
if (1 > selectedElms.value.length) {
return;
}
const filtered: string[] = [];
selectedElms.value.forEach((id) => {
const item = stateStore.get('queue', id) as StoreItem;
if (item && !item.auto_start && !item.status) {
filtered.push(id);
}
});
selectedElms.value = [];
if (1 > filtered.length) {
toast.error('No eligible items to start.');
return;
}
if (true !== (await box.confirm(`Start '${filtered.length}' selected items?`))) {
return false;
}
await stateStore.startItems(filtered);
};
const pauseSelected = async () => {
if (1 > selectedElms.value.length) {
return;
}
const filtered: string[] = [];
selectedElms.value.forEach((id) => {
const item = stateStore.get('queue', id) as StoreItem;
if (item && item.auto_start && !item.status) {
filtered.push(id);
}
});
selectedElms.value = [];
if (1 > filtered.length) {
toast.error('No eligible items to pause.');
return;
}
if (true !== (await box.confirm(`Pause '${filtered.length}' selected items?`))) {
return false;
}
await stateStore.pauseItems(filtered);
};
const pImg = (e: Event) => {
const target = e.target as HTMLImageElement;
if (target.naturalHeight > target.naturalWidth) {
target.classList.add('image-portrait');
}
};
const onImgError = (e: Event) => {
const target = e.target as HTMLImageElement;
if (target.src.endsWith('/images/placeholder.png')) {
return;
}
target.src = '/images/placeholder.png';
};
watch(embed_url, (v) => {
if (!bg_enable.value) {
return;
}
document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`);
});
</script>