merge in the simple mode for testing
This commit is contained in:
parent
4d493433af
commit
4d16618cfb
6 changed files with 774 additions and 147 deletions
|
|
@ -45,6 +45,9 @@ class Config(metaclass=Singleton):
|
|||
temp_path: str = "/tmp"
|
||||
"""The path to the temporary directory."""
|
||||
|
||||
simple_mode: bool = False
|
||||
"""Enable simple mode."""
|
||||
|
||||
temp_keep: bool = False
|
||||
"""Keep temporary files after the download is complete."""
|
||||
|
||||
|
|
@ -244,6 +247,7 @@ class Config(metaclass=Singleton):
|
|||
"prevent_premiere_live",
|
||||
"temp_disabled",
|
||||
"allow_internal_urls",
|
||||
"simple_mode",
|
||||
)
|
||||
"The variables that are booleans."
|
||||
|
||||
|
|
@ -259,6 +263,7 @@ class Config(metaclass=Singleton):
|
|||
"default_preset",
|
||||
"instance_title",
|
||||
"console_enabled",
|
||||
"simple_mode",
|
||||
"browser_control_enabled",
|
||||
"file_logging",
|
||||
"base_path",
|
||||
|
|
|
|||
611
ui/app/components/Simple.vue
Normal file
611
ui/app/components/Simple.vue
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
<template>
|
||||
<div class="basic-wrapper">
|
||||
<div class="form-container" :class="{ 'is-centered': shouldCenterForm }">
|
||||
<section class="download-form box">
|
||||
<form class="download-form__body" autocomplete="off" @submit.prevent="addDownload">
|
||||
<label class="label" for="download-url">What you would like to download?</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input id="download-url" v-model="formUrl" :disabled="!socketStore.isConnected || addInProgress"
|
||||
class="input" placeholder="https://..." type="url" required>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary" :class="{ 'is-loading': addInProgress }"
|
||||
:disabled="!socketStore.isConnected || addInProgress || !formUrl.trim()">
|
||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Transition name="queue-fade">
|
||||
<section v-if="hasAnyItems" key="queue" class="queue-section">
|
||||
<TransitionGroup name="queue-card" tag="div" class="columns is-multiline queue-card-columns">
|
||||
<div v-for="entry in displayItems" :key="entry.item._id" class="column is-12-mobile is-6-tablet">
|
||||
<article class="queue-card card" :class="{ 'is-history': 'history' === entry.source }">
|
||||
<div class="card-content">
|
||||
<div v-if="'queue' === entry.source && shouldShowProgress(entry.item)"
|
||||
class="progress-bar is-unselectable mb-3">
|
||||
<div class="progress-percentage">{{ updateProgress(entry.item) }}</div>
|
||||
<div class="progress" :style="{ width: getProgressWidth(entry.item) }"></div>
|
||||
</div>
|
||||
<article class="media">
|
||||
<figure class="media-left">
|
||||
<figure class="image is-16by9 queue-thumb" :class="{ 'is-clickable': isEmbedable(entry.item.url) }"
|
||||
role="presentation" @click="openPlayer(entry.item)">
|
||||
<img :src="resolveThumbnail(entry)" :alt="entry.item.title || 'Video thumbnail'" loading="lazy">
|
||||
<span
|
||||
v-if="entry.item.filename && entry.item.status === 'finished' || isEmbedable(entry.item.url)"
|
||||
class="queue-thumb__overlay">
|
||||
<span class="icon has-text-white"><i class="fa-solid fa-play" /></span>
|
||||
</span>
|
||||
</figure>
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<p class="title is-6 mb-0 queue-title">
|
||||
<NuxtLink target="_blank" :href="entry.item.url">{{ entry.item.title }}</NuxtLink>
|
||||
</p>
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<span class="tag is-size-7 has-text-weight-semibold is-uppercase"
|
||||
:class="getSourceTagClass(entry)">
|
||||
{{ getSourceLabel(entry) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<span class="tag is-size-7 has-text-weight-semibold" :class="getStatusClass(entry.item)">{{
|
||||
getStatusLabel(entry.item) }}</span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<span class="tag" :date-datetime="entry.item.datetime" v-rtime="entry.item.datetime" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="getDescription(entry.item)" class="content is-size-7 has-text-grey queue-description">
|
||||
{{ getDescription(entry.item) }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="'queue' === entry.source" class="buttons are-small is-right is-flex-wrap-wrap mt-3">
|
||||
<button v-if="canStart(entry.item)" class="button is-success is-light" type="button"
|
||||
@click="startQueueItem(entry.item)">
|
||||
<span class="icon"><i class="fa-solid fa-circle-play" /></span>
|
||||
<span>Start</span>
|
||||
</button>
|
||||
<button v-if="canPause(entry.item)" class="button is-warning is-light" type="button"
|
||||
@click="pauseQueueItem(entry.item)">
|
||||
<span class="icon"><i class="fa-solid fa-pause" /></span>
|
||||
<span>Pause</span>
|
||||
</button>
|
||||
<button class="button is-warning" type="button" @click="cancelDownload(entry.item)">
|
||||
<span class="icon"><i class="fa-solid fa-xmark" /></span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="buttons are-small is-right is-flex-wrap-wrap mt-3">
|
||||
<a v-if="getDownloadLink(entry.item)" class="button is-link" :href="getDownloadLink(entry.item)"
|
||||
:download="getDownloadName(entry.item)">
|
||||
<span class="icon"><i class="fa-solid fa-download" /></span>
|
||||
<span>Download</span>
|
||||
</a>
|
||||
<button v-if="entry.item.status != 'finished' || !entry.item.filename" class="button is-info is-light"
|
||||
type="button" @click="requeueItem(entry.item)">
|
||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
<span>Requeue</span>
|
||||
</button>
|
||||
<button class="button is-danger" type="button" @click="deleteHistoryItem(entry.item)">
|
||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
</Transition>
|
||||
|
||||
<div class="modal is-active" v-if="videoItem">
|
||||
<div class="modal-background" @click="closePlayer"></div>
|
||||
<div class="modal-content is-unbounded-model">
|
||||
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="videoItem"
|
||||
class="is-fullwidth" @closeModel="closePlayer" />
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal is-active" v-if="embedUrl">
|
||||
<div class="modal-background" @click="closePlayer"></div>
|
||||
<div class="modal-content is-unbounded-model">
|
||||
<EmbedPlayer :url="embedUrl" @closeModel="closePlayer" />
|
||||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { item_request } from '~/types/item'
|
||||
import type { ItemStatus, StoreItem } from '~/types/store'
|
||||
import { useNotification } from '~/composables/useNotification'
|
||||
import { useConfigStore } from '~/stores/ConfigStore'
|
||||
import { useStateStore } from '~/stores/StateStore'
|
||||
import { useSocketStore } from '~/stores/SocketStore'
|
||||
import { isEmbedable, getEmbedable } from '~/utils/embedable'
|
||||
import EmbedPlayer from '~/components/EmbedPlayer.vue'
|
||||
import { ag, encodePath, makeDownload, request, stripPath, ucFirst, uri } from '~/utils'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const stateStore = useStateStore()
|
||||
const socketStore = useSocketStore()
|
||||
const toast = useNotification()
|
||||
|
||||
const { app, paused } = storeToRefs(configStore)
|
||||
const { queue, history } = storeToRefs(stateStore)
|
||||
|
||||
const embedUrl = ref<string>('')
|
||||
const videoItem = ref<StoreItem | null>(null)
|
||||
|
||||
const formUrl = ref<string>('')
|
||||
const addInProgress = ref<boolean>(false)
|
||||
|
||||
const sortByNewest = (items: StoreItem[]): StoreItem[] => items.slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
|
||||
|
||||
const queueItems = computed<StoreItem[]>(() => sortByNewest(Object.values(queue.value ?? {})))
|
||||
const historyEntries = computed<StoreItem[]>(() => sortByNewest(Object.values(history.value ?? {})))
|
||||
|
||||
const downloadingStatuses: ReadonlySet<ItemStatus | null> = new Set(['downloading', 'postprocessing', 'preparing'])
|
||||
|
||||
const isDownloading = (status: ItemStatus | null): boolean => downloadingStatuses.has(status)
|
||||
|
||||
const downloadingItems = computed<StoreItem[]>(() => queueItems.value.filter(item => isDownloading(item.status)))
|
||||
const queuedItems = computed<StoreItem[]>(() => queueItems.value.filter(item => !isDownloading(item.status)))
|
||||
|
||||
type DisplayEntry = { item: StoreItem; source: 'queue' | 'history' }
|
||||
|
||||
const displayItems = computed<DisplayEntry[]>(() => [
|
||||
...downloadingItems.value.map(item => ({ item, source: 'queue' as const })),
|
||||
...queuedItems.value.map(item => ({ item, source: 'queue' as const })),
|
||||
...historyEntries.value.map(item => ({ item, source: 'history' as const })),
|
||||
])
|
||||
|
||||
const hasActiveQueue = computed<boolean>(() => queueItems.value.length > 0)
|
||||
const hasAnyItems = computed<boolean>(() => hasActiveQueue.value || historyEntries.value.length > 0)
|
||||
const shouldCenterForm = computed<boolean>(() => 0 === queueItems.value.length && 0 === historyEntries.value.length)
|
||||
|
||||
const addDownload = async (): Promise<void> => {
|
||||
const url = formUrl.value.trim()
|
||||
if (!url) {
|
||||
toast.error('Please enter a valid URL.')
|
||||
return
|
||||
}
|
||||
|
||||
const payload: item_request[] = [{
|
||||
url,
|
||||
preset: app.value.default_preset,
|
||||
auto_start: true,
|
||||
}]
|
||||
|
||||
try {
|
||||
addInProgress.value = true
|
||||
const response = await request('/api/history', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(data?.error ?? 'Failed to add download.')
|
||||
return
|
||||
}
|
||||
|
||||
const failures = Array.isArray(data) ? data.filter((item: Record<string, any>) => false === item?.status) : []
|
||||
if (failures.length > 0) {
|
||||
failures.forEach((item: Record<string, any>) => toast.error(item?.msg ?? 'Failed to add download.'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Added to queue.')
|
||||
formUrl.value = ''
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to add download.'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
addInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveThumbnail = (entry: DisplayEntry): string => {
|
||||
const { item, source } = entry
|
||||
|
||||
const sidecarImage = item.sidecar?.image?.[0]?.file
|
||||
if ('history' === source && sidecarImage) {
|
||||
const relativePath = stripPath(app.value.download_path ?? '', sidecarImage)
|
||||
return uri(`/api/download/${encodeURIComponent(relativePath)}`)
|
||||
}
|
||||
|
||||
const remoteThumb = ag<string | null>(item, 'extras.thumbnail', null)
|
||||
if (remoteThumb) {
|
||||
return uri(`/api/thumbnail?id=${item._id}&url=${encodePath(remoteThumb)}`)
|
||||
}
|
||||
|
||||
return '/images/placeholder.png'
|
||||
}
|
||||
|
||||
const openPlayer = (item: StoreItem): void => {
|
||||
if (item.filename && item.status === 'finished') {
|
||||
videoItem.value = item
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEmbedable(item.url)) {
|
||||
return
|
||||
}
|
||||
const embed = getEmbedable(item.url)
|
||||
if (embed) {
|
||||
embedUrl.value = embed
|
||||
}
|
||||
}
|
||||
|
||||
const closePlayer = (): void => {
|
||||
embedUrl.value = ''
|
||||
videoItem.value = null
|
||||
}
|
||||
|
||||
const getSourceLabel = (entry: DisplayEntry): string => {
|
||||
if ('history' === entry.source) {
|
||||
return 'History'
|
||||
}
|
||||
if (isDownloading(entry.item.status)) {
|
||||
return 'Active'
|
||||
}
|
||||
return 'Queued'
|
||||
}
|
||||
|
||||
const getSourceTagClass = (entry: DisplayEntry): string => {
|
||||
if ('history' === entry.source) {
|
||||
return 'is-light'
|
||||
}
|
||||
if (isDownloading(entry.item.status)) {
|
||||
return 'is-info is-light'
|
||||
}
|
||||
return 'is-warning is-light'
|
||||
}
|
||||
|
||||
const getDescription = (item: StoreItem): string => {
|
||||
const direct = (item.description ?? '').toString().trim()
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
|
||||
const extrasDescription = ag<string | null>(item, 'extras.description', null)?.toString().trim()
|
||||
if (extrasDescription) {
|
||||
return extrasDescription
|
||||
}
|
||||
|
||||
const errorDescription = item.error?.trim()
|
||||
if (errorDescription) {
|
||||
return errorDescription
|
||||
}
|
||||
|
||||
const message = ag<string | null>(item, 'msg', null)?.toString().trim()
|
||||
if (message) {
|
||||
return message
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const statusOverrides: Record<string, string> = {
|
||||
downloading: 'Downloading',
|
||||
postprocessing: 'Post-processing',
|
||||
preparing: 'Preparing',
|
||||
finished: 'Completed',
|
||||
error: 'Error',
|
||||
cancelled: 'Cancelled',
|
||||
not_live: 'Not live yet',
|
||||
skip: 'Skipped',
|
||||
}
|
||||
|
||||
const getStatusLabel = (item: StoreItem): string => {
|
||||
if (null === item.status) {
|
||||
return 'Queued'
|
||||
}
|
||||
return statusOverrides[item.status] ?? ucFirst(item.status.replace(/_/g, ' '))
|
||||
}
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
downloading: 'has-text-info',
|
||||
postprocessing: 'has-text-link',
|
||||
preparing: 'has-text-link',
|
||||
finished: 'has-text-success',
|
||||
error: 'has-text-danger',
|
||||
cancelled: 'has-text-grey',
|
||||
not_live: 'has-text-warning',
|
||||
skip: 'has-text-grey',
|
||||
}
|
||||
|
||||
const getStatusClass = (item: StoreItem): string => {
|
||||
if (null === item.status) {
|
||||
return 'has-text-grey'
|
||||
}
|
||||
return statusColorMap[item.status] ?? 'has-text-info'
|
||||
}
|
||||
|
||||
const shouldShowProgress = (item: StoreItem): boolean => isDownloading(item.status) || null === item.status
|
||||
|
||||
const percentPipe = (value: number | null): string => {
|
||||
if (null === value || Number.isNaN(value)) {
|
||||
return '00.00'
|
||||
}
|
||||
return parseFloat(String(value)).toFixed(2)
|
||||
}
|
||||
|
||||
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 updateProgress = (item: StoreItem): string => {
|
||||
let text = ''
|
||||
if (!item.auto_start) {
|
||||
return 'Manual start'
|
||||
}
|
||||
if (null === item.status && true === paused.value) {
|
||||
return 'Global Pause'
|
||||
}
|
||||
if ('postprocessing' === item.status) {
|
||||
return 'Post-processors are running.'
|
||||
}
|
||||
if ('preparing' === item.status) {
|
||||
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
|
||||
}
|
||||
if (null != item.status) {
|
||||
text += item.percent && !item.is_live ? `${percentPipe(item.percent)}%` : 'Live'
|
||||
}
|
||||
text += item.speed ? ` - ${speedPipe(item.speed)}` : ' - Waiting..'
|
||||
if (null != item.status && item.eta) {
|
||||
text += ` - ${ETAPipe(item.eta)}`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const getProgressWidth = (item: StoreItem): string => {
|
||||
const percent = parseFloat(percentPipe(item.percent ?? 0))
|
||||
const clamped = Number.isNaN(percent) ? 0 : Math.min(100, Math.max(0, percent))
|
||||
return `${clamped}%`
|
||||
}
|
||||
|
||||
const canStart = (item: StoreItem): boolean => !item.status && false === item.auto_start
|
||||
const canPause = (item: StoreItem): boolean => !item.status && true === item.auto_start
|
||||
|
||||
const startQueueItem = (item: StoreItem): void => {
|
||||
socketStore.emit('item_start', item._id)
|
||||
}
|
||||
|
||||
const pauseQueueItem = (item: StoreItem): void => {
|
||||
socketStore.emit('item_pause', item._id)
|
||||
}
|
||||
|
||||
const cancelDownload = (item: StoreItem): void => {
|
||||
socketStore.emit('item_cancel', item._id)
|
||||
}
|
||||
|
||||
const getDownloadLink = (item: StoreItem): string => {
|
||||
if (!item.filename) {
|
||||
return ''
|
||||
}
|
||||
return makeDownload(app.value, item)
|
||||
}
|
||||
|
||||
const getDownloadName = (item: StoreItem): string => {
|
||||
if (!item.filename) {
|
||||
return 'download'
|
||||
}
|
||||
const segments = item.filename.split('/')
|
||||
return segments[segments.length - 1] || 'download'
|
||||
}
|
||||
|
||||
const requeueItem = (item: StoreItem): void => {
|
||||
if (!item.url) {
|
||||
toast.error('Unable to requeue item; missing URL.')
|
||||
return
|
||||
}
|
||||
|
||||
const payload: item_request = {
|
||||
url: item.url,
|
||||
preset: item.preset || app.value.default_preset,
|
||||
folder: item.folder,
|
||||
template: item.template,
|
||||
cookies: item.cookies,
|
||||
cli: item.cli,
|
||||
auto_start: item.auto_start ?? true,
|
||||
}
|
||||
|
||||
if (item.extras && Object.keys(item.extras).length > 0) {
|
||||
payload.extras = JSON.parse(JSON.stringify(item.extras))
|
||||
}
|
||||
|
||||
socketStore.emit('item_delete', { id: item._id, remove_file: false })
|
||||
socketStore.emit('add_url', payload)
|
||||
toast.success('Requeued download.')
|
||||
}
|
||||
|
||||
const deleteHistoryItem = (item: StoreItem): void => {
|
||||
socketStore.emit('item_delete', { id: item._id, remove_file: app.value.remove_files })
|
||||
toast.info('Removed from history queue.')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.basic-wrapper {
|
||||
min-height: calc(100vh - 6rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
padding: 2rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
transition: transform 0.45s cubic-bezier(0.25, 0.8, 0.25, 1),
|
||||
filter 0.35s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.form-container.is-centered {
|
||||
transform: translateY(30vh);
|
||||
filter: drop-shadow(0 18px 32px rgba(10, 10, 10, 0.25));
|
||||
}
|
||||
|
||||
.download-form {
|
||||
width: min(560px, 100%);
|
||||
transition: box-shadow 0.45s ease;
|
||||
}
|
||||
|
||||
.download-form__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
width: min(1100px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.queue-card-columns {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.queue-card {
|
||||
height: 100%;
|
||||
transition: transform 0.3s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.queue-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 14px 30px rgba(10, 10, 10, 0.12);
|
||||
}
|
||||
|
||||
|
||||
.queue-thumb {
|
||||
position: relative;
|
||||
width: 9rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.queue-thumb.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-thumb__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.queue-thumb.is-clickable:hover .queue-thumb__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.queue-title {
|
||||
max-width: 18rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-description {
|
||||
max-height: 4.5rem;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.queue-fade-enter-active,
|
||||
.queue-fade-leave-active {
|
||||
transition: opacity 0.35s ease, transform 0.45s ease;
|
||||
}
|
||||
|
||||
.queue-fade-enter-from,
|
||||
.queue-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
|
||||
.queue-card-enter-active,
|
||||
.queue-card-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
.queue-card-enter-from,
|
||||
.queue-card-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
|
||||
.queue-card-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-container.is-centered {
|
||||
transform: translateY(18vh);
|
||||
}
|
||||
|
||||
.queue-thumb {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,169 +1,174 @@
|
|||
<template>
|
||||
<Shutdown v-if="app_shutdown" />
|
||||
<div id="main_container" class="container" v-else>
|
||||
<NewVersion v-if="newVersionIsAvailable" />
|
||||
<nav class="navbar is-mobile is-dark">
|
||||
<template v-if="config.app.simple_mode">
|
||||
<Simple />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Shutdown v-if="app_shutdown" />
|
||||
<div id="main_container" class="container" v-else>
|
||||
<NewVersion v-if="newVersionIsAvailable" />
|
||||
<nav class="navbar is-mobile is-dark">
|
||||
|
||||
<div class="navbar-brand pl-5">
|
||||
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
|
||||
<span class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-home" /></span>
|
||||
<span class="has-text-bold" :class="`has-text-${socket.isConnected ? 'success' : 'danger'}`">
|
||||
YTPTube
|
||||
</span>
|
||||
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<button class="navbar-burger burger" @click="showMenu = !showMenu">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
|
||||
<div class="navbar-start">
|
||||
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
||||
<span>Files</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/presets" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||
<span>Presets</span>
|
||||
</NuxtLink>
|
||||
<div class="navbar-item has-dropdown">
|
||||
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
|
||||
<span class="icon"><i class="fas fa-tasks" /></span>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown">
|
||||
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||
<span>List</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/task_definitions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
|
||||
<span>Definitions</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||
<span>Notifications</span>
|
||||
<div class="navbar-brand pl-5">
|
||||
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
|
||||
<span class="is-text-overflow">
|
||||
<span class="icon"><i class="fas fa-home" /></span>
|
||||
<span class="has-text-bold" :class="`has-text-${socket.isConnected ? 'success' : 'danger'}`">
|
||||
YTPTube
|
||||
</span>
|
||||
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/conditions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||
<span>Conditions</span>
|
||||
</NuxtLink>
|
||||
|
||||
<button class="navbar-burger burger" @click="showMenu = !showMenu">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown">
|
||||
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
|
||||
<span class="icon"><i class="fas fa-tools" /></span>
|
||||
<span>Other</span>
|
||||
</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-if="config.app.file_logging">
|
||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||
<span>Logs</span>
|
||||
</NuxtLink>
|
||||
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
|
||||
<div class="navbar-start">
|
||||
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
||||
<span>Files</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-if="config.app.console_enabled">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Console</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="navbar-item" to="/presets" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||
<span>Presets</span>
|
||||
</NuxtLink>
|
||||
<div class="navbar-item has-dropdown">
|
||||
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
|
||||
<span class="icon"><i class="fas fa-tasks" /></span>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown">
|
||||
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||
<span>List</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/task_definitions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
|
||||
<span>Definitions</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||
<span>Notifications</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/conditions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||
<span>Conditions</span>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item has-dropdown">
|
||||
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
|
||||
<span class="icon"><i class="fas fa-tools" /></span>
|
||||
<span>Other</span>
|
||||
</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-if="config.app.file_logging">
|
||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||
<span>Logs</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||
v-if="config.app.console_enabled">
|
||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||
<span>Console</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="true === config.app.is_native">
|
||||
<button class="button is-dark" @click="shutdownApp">
|
||||
<span class="icon"><i class="fas fa-power-off" /></span>
|
||||
<span v-if="isMobile">Shutdown</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="navbar-item">
|
||||
<button class="button is-dark" @click="reloadPage">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
<span v-if="isMobile">Reload</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NotifyDropdown />
|
||||
|
||||
<div class="navbar-item" v-if="!isMobile">
|
||||
<button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
|
||||
@click="show_settings = !show_settings">
|
||||
<span class="icon"><i class="fas fa-cog" /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="navbar-item" v-if="isMobile">
|
||||
<button class="button is-dark" @click="show_settings = !show_settings">
|
||||
<span class="icon"><i class="fas fa-cog" /></span>
|
||||
<span>WebUI Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="true === config.app.is_native">
|
||||
<button class="button is-dark" @click="shutdownApp">
|
||||
<span class="icon"><i class="fas fa-power-off" /></span>
|
||||
<span v-if="isMobile">Shutdown</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="navbar-item">
|
||||
<button class="button is-dark" @click="reloadPage">
|
||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||
<span v-if="isMobile">Reload</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NotifyDropdown />
|
||||
|
||||
<div class="navbar-item" v-if="!isMobile">
|
||||
<button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
|
||||
@click="show_settings = !show_settings">
|
||||
<span class="icon"><i class="fas fa-cog" /></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="navbar-item" v-if="isMobile">
|
||||
<button class="button is-dark" @click="show_settings = !show_settings">
|
||||
<span class="icon"><i class="fas fa-cog" /></span>
|
||||
<span>WebUI Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<Settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtPage v-if="!config.app.simple_mode && config.is_loaded" />
|
||||
<Message v-if="!config.is_loaded" class="has-background-info-90 has-text-dark mt-5"
|
||||
title="Loading Configuration" icon="fas fa-spinner fa-spin">
|
||||
<p>Loading application configuration. This usually takes less than a second.</p>
|
||||
<p v-if="!socket.isConnected" class="mt-2">
|
||||
If this is taking too long, please check that the backend server is running and that the WebSocket
|
||||
connection is functional.
|
||||
</p>
|
||||
</Message>
|
||||
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
|
||||
<ClientOnly>
|
||||
<Dialog />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<Settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtPage v-if="config.is_loaded" />
|
||||
<Message v-else class="has-background-info-90 has-text-dark mt-5" title="Loading Configuration"
|
||||
icon="fas fa-spinner fa-spin">
|
||||
<p>Loading application configuration. This usually takes less than a second.</p>
|
||||
<p v-if="!socket.isConnected" class="mt-2">
|
||||
If this is taking too long, please check that the backend server is running and that the WebSocket
|
||||
connection is functional.
|
||||
</p>
|
||||
</Message>
|
||||
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
|
||||
<ClientOnly>
|
||||
<Dialog />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<div class="columns mt-3 is-mobile">
|
||||
<div class="column">
|
||||
<div class="has-text-left" v-if="config.app?.app_version">
|
||||
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
|
||||
<span class="has-tooltip"
|
||||
v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`">
|
||||
({{ config?.app?.app_version || 'unknown' }})</span>
|
||||
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
|
||||
<span> ({{ config?.app?.ytdlp_version || 'unknown' }})</span>
|
||||
- <NuxtLink to="/changelog">CHANGELOG</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/FAQ.md'">FAQ</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/README.md'">README</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/API.md'">API</NuxtLink>
|
||||
<div class="columns mt-3 is-mobile">
|
||||
<div class="column">
|
||||
<div class="has-text-left" v-if="config.app?.app_version">
|
||||
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
|
||||
<span class="has-tooltip"
|
||||
v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`">
|
||||
({{ config?.app?.app_version || 'unknown' }})</span>
|
||||
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
|
||||
<span> ({{ config?.app?.ytdlp_version || 'unknown' }})</span>
|
||||
- <NuxtLink to="/changelog">CHANGELOG</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/FAQ.md'">FAQ</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/README.md'">README</NuxtLink>
|
||||
- <NuxtLink @click="doc.file = '/api/docs/API.md'">API</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="config.app?.started">
|
||||
<div class="has-text-right">
|
||||
<span class="user-hint"
|
||||
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
|
||||
{{ moment.unix(config.app?.started).fromNow() }}
|
||||
</span>
|
||||
<div class="column is-narrow" v-if="config.app?.started">
|
||||
<div class="has-text-right">
|
||||
<span class="user-hint"
|
||||
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
|
||||
{{ moment.unix(config.app?.started).fromNow() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -176,6 +181,7 @@ import moment from 'moment'
|
|||
import type { YTDLPOption } from '~/types/ytdlp'
|
||||
import { useDialog } from '~/composables/useDialog'
|
||||
import Dialog from '~/components/Dialog.vue'
|
||||
import Simple from '~/components/Simple.vue'
|
||||
import Shutdown from '~/components/shutdown.vue'
|
||||
import Markdown from '~/components/Markdown.vue'
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||
app_branch: '',
|
||||
started: 0,
|
||||
app_env: 'production',
|
||||
simple_mode: false,
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
|
|
|
|||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -26,6 +26,8 @@ type AppConfig = {
|
|||
browser_control_enabled: boolean
|
||||
/** Indicates if file logging is enabled */
|
||||
file_logging: boolean
|
||||
/** Indicates if the app is in simple mode */
|
||||
simple_mode: boolean
|
||||
/** Indicates if the app is running in a native environment (e.g., Electron) */
|
||||
is_native: boolean
|
||||
/** App version in format "1.0.0" */
|
||||
|
|
|
|||
2
ui/app/types/store.d.ts
vendored
2
ui/app/types/store.d.ts
vendored
|
|
@ -18,6 +18,8 @@ type StoreItem = {
|
|||
id: string
|
||||
/** Title of the item */
|
||||
title: string
|
||||
/** Description of the item */
|
||||
description: string
|
||||
/** URL of the item */
|
||||
url: string
|
||||
/** Preset used for the item */
|
||||
|
|
|
|||
Loading…
Reference in a new issue