generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashOutline } from '@vicons/ionicons5'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
@@ -18,6 +20,7 @@ import type {
|
||||
PlaylistGroup,
|
||||
} from '@/api/generated/crabs/evening_detective_server'
|
||||
import PlaylistGroupEditor from '@/components/PlaylistGroupEditor.vue'
|
||||
import { usePlaylistDurations } from '@/composables/usePlaylistDurations'
|
||||
|
||||
const props = defineProps<{
|
||||
playlists: Playlist[]
|
||||
@@ -108,6 +111,8 @@ const isPlaylistActive = computed(
|
||||
selectedId.value === props.music?.playlist?.ID &&
|
||||
props.music?.playback !== 'MUSIC_PLAYBACK_STOPPED',
|
||||
)
|
||||
const { groupDuration } = usePlaylistDurations(selected)
|
||||
|
||||
function isGroupMissing(groupID: number) {
|
||||
return (
|
||||
isSelectedMissing.value ||
|
||||
@@ -353,7 +358,10 @@ reset()
|
||||
class="music-group"
|
||||
:aria-label="section.name"
|
||||
>
|
||||
<h4>{{ section.name }}</h4>
|
||||
<h4>
|
||||
{{ section.name }}
|
||||
<span class="group-duration">· {{ groupDuration(section.ID) }}</span>
|
||||
</h4>
|
||||
<PlaylistGroupEditor
|
||||
v-if="section.ID"
|
||||
:group="section"
|
||||
@@ -395,54 +403,62 @@ reset()
|
||||
</n-upload>
|
||||
<p v-if="!trackIds(section.ID).length">Треков пока нет.</p>
|
||||
<div v-for="(id, index) in trackIds(section.ID)" :key="id" class="track-row">
|
||||
<span
|
||||
>{{ index + 1 }}.
|
||||
{{
|
||||
selected.tracks?.find((track) => track.ID === id)?.name ?? 'Трек удалён на сервере'
|
||||
}}</span
|
||||
>
|
||||
<n-select
|
||||
:value="section.ID"
|
||||
:options="groupOptions"
|
||||
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
|
||||
:input-props="{
|
||||
'aria-label': `Группа трека ${selected.tracks?.find((track) => track.ID === id)?.name}`,
|
||||
}"
|
||||
@update:value="emit('setTrackGroup', selectedId!, id, $event)"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || hasConflict || isGroupLocked(section.ID) || index === 0"
|
||||
:aria-label="`Поднять трек ${index + 1} в группе ${section.name}`"
|
||||
@click="move(id, section.ID, -1)"
|
||||
>↑</n-button
|
||||
<div class="track-heading">
|
||||
<span class="track-name"
|
||||
>{{ index + 1 }}.
|
||||
{{
|
||||
selected.tracks?.find((track) => track.ID === id)?.name ??
|
||||
'Трек удалён на сервере'
|
||||
}}</span
|
||||
>
|
||||
<n-button
|
||||
<n-select
|
||||
class="track-group"
|
||||
size="small"
|
||||
:disabled="
|
||||
isBusy ||
|
||||
hasConflict ||
|
||||
isGroupLocked(section.ID) ||
|
||||
index === trackIds(section.ID).length - 1
|
||||
"
|
||||
:aria-label="`Опустить трек ${index + 1} в группе ${section.name}`"
|
||||
@click="move(id, section.ID, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('deleteTrack', selectedId!, id)">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
|
||||
>Удалить</n-button
|
||||
>
|
||||
</template>
|
||||
Удалить трек из плейлиста?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
:value="section.ID"
|
||||
:options="groupOptions"
|
||||
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
|
||||
:input-props="{
|
||||
'aria-label': `Группа трека ${selected.tracks?.find((track) => track.ID === id)?.name}`,
|
||||
}"
|
||||
@update:value="emit('setTrackGroup', selectedId!, id, $event)"
|
||||
/>
|
||||
<n-space :wrap="false" class="track-actions">
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || hasConflict || isGroupLocked(section.ID) || index === 0"
|
||||
:aria-label="`Поднять трек ${index + 1} в группе ${section.name}`"
|
||||
@click="move(id, section.ID, -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="
|
||||
isBusy ||
|
||||
hasConflict ||
|
||||
isGroupLocked(section.ID) ||
|
||||
index === trackIds(section.ID).length - 1
|
||||
"
|
||||
:aria-label="`Опустить трек ${index + 1} в группе ${section.name}`"
|
||||
@click="move(id, section.ID, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('deleteTrack', selectedId!, id)">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
|
||||
aria-label="Удалить трек"
|
||||
title="Удалить трек"
|
||||
>
|
||||
<Icon size="18"><TrashOutline /></Icon>
|
||||
</n-button>
|
||||
</template>
|
||||
Удалить трек из плейлиста?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -455,16 +471,22 @@ p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.group-duration {
|
||||
color: var(--brand-text-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.music-groups {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.music-group {
|
||||
background: var(--brand-surface);
|
||||
border-top: 1px solid var(--brand-border);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.music-content {
|
||||
@@ -474,11 +496,30 @@ p {
|
||||
.track-row {
|
||||
align-items: center;
|
||||
border: 1px solid var(--brand-border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.track-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-group {
|
||||
flex: 0 1 180px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashOutline } from '@vicons/ionicons5'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { NAlert, NButton, NInput, NPopconfirm, NSelect, NSpace } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -52,61 +54,98 @@ watch(
|
||||
|
||||
<template>
|
||||
<n-space vertical>
|
||||
<n-input
|
||||
v-model:value="name"
|
||||
:disabled="isBusy"
|
||||
:maxlength="255"
|
||||
:aria-label="`Название группы ${group.name}`"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="endMode"
|
||||
:options="modes"
|
||||
:disabled="isBusy || isLocked || isMissing"
|
||||
:input-props="{ 'aria-label': `Завершение группы ${group.name}` }"
|
||||
/>
|
||||
<div class="group-heading">
|
||||
<n-input
|
||||
class="group-name"
|
||||
size="small"
|
||||
v-model:value="name"
|
||||
:disabled="isBusy"
|
||||
:maxlength="255"
|
||||
:aria-label="`Название группы ${group.name}`"
|
||||
/>
|
||||
<n-select
|
||||
class="group-mode"
|
||||
size="small"
|
||||
v-model:value="endMode"
|
||||
:options="modes"
|
||||
:disabled="isBusy || isLocked || isMissing"
|
||||
:input-props="{ 'aria-label': `Завершение группы ${group.name}` }"
|
||||
/>
|
||||
<n-space justify="end" class="group-actions">
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || !name.trim() || !isDirty || hasConflict || isMissing"
|
||||
@click="emit('save', name.trim(), endMode)"
|
||||
>Сохранить группу</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="isDirty || hasConflict || isMissing"
|
||||
size="small"
|
||||
:disabled="isBusy"
|
||||
@click="reset"
|
||||
>
|
||||
Отменить изменения
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || isLocked || isMissing || !canMoveUp"
|
||||
:aria-label="`Поднять группу ${group.name}`"
|
||||
@click="emit('move', -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || isLocked || isMissing || !canMoveDown"
|
||||
:aria-label="`Опустить группу ${group.name}`"
|
||||
@click="emit('move', 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('remove')">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isLocked || isMissing"
|
||||
aria-label="Удалить группу"
|
||||
title="Удалить группу"
|
||||
>
|
||||
<Icon size="18"><TrashOutline /></Icon>
|
||||
</n-button>
|
||||
</template>
|
||||
Удалить группу «{{ group.name }}»? Треки останутся в плейлисте без группы.
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-alert v-if="isMissing" type="warning"
|
||||
>Группа удалена на сервере. Черновик сохранён до отмены изменений.</n-alert
|
||||
>
|
||||
<n-alert v-if="hasConflict" type="warning">
|
||||
Группа изменена на сервере. Черновик сохранён; загрузите актуальные данные перед сохранением.
|
||||
</n-alert>
|
||||
<n-space>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || !name.trim() || !isDirty || hasConflict || isMissing"
|
||||
@click="emit('save', name.trim(), endMode)"
|
||||
>Сохранить группу</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="isDirty || hasConflict || isMissing"
|
||||
size="small"
|
||||
:disabled="isBusy"
|
||||
@click="reset"
|
||||
>
|
||||
Отменить изменения
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || isLocked || isMissing || !canMoveUp"
|
||||
:aria-label="`Поднять группу ${group.name}`"
|
||||
@click="emit('move', -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || isLocked || isMissing || !canMoveDown"
|
||||
:aria-label="`Опустить группу ${group.name}`"
|
||||
@click="emit('move', 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('remove')">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="error" ghost :disabled="isBusy || isLocked || isMissing">
|
||||
Удалить группу
|
||||
</n-button>
|
||||
</template>
|
||||
Удалить группу «{{ group.name }}»? Треки останутся в плейлисте без группы.
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group-heading {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-mode {
|
||||
flex: 0 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { createServer } from 'vite'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
|
||||
class FakeAudio {
|
||||
static instances: FakeAudio[] = []
|
||||
duration = 0
|
||||
preload = ''
|
||||
src = ''
|
||||
onloadedmetadata: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
constructor() {
|
||||
FakeAudio.instances.push(this)
|
||||
}
|
||||
removeAttribute() {
|
||||
this.src = ''
|
||||
}
|
||||
load() {}
|
||||
complete(duration: number) {
|
||||
this.duration = duration
|
||||
this.onloadedmetadata?.()
|
||||
}
|
||||
}
|
||||
|
||||
test('metadata concurrency, cache, regrouping, errors and cancellation', async () => {
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
root: fileURLToPath(new URL('../../..', import.meta.url)),
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('../..', import.meta.url)) } },
|
||||
server: { middlewareMode: true, hmr: false, ws: false },
|
||||
})
|
||||
const originalAudio = Object.getOwnPropertyDescriptor(globalThis, 'Audio')
|
||||
Object.defineProperty(globalThis, 'Audio', { configurable: true, value: FakeAudio })
|
||||
const scope = effectScope()
|
||||
try {
|
||||
const { usePlaylistDurations } = await server.ssrLoadModule(
|
||||
'/src/composables/usePlaylistDurations.ts',
|
||||
)
|
||||
const playlist = ref({
|
||||
tracks: [1, 2, 3, 4].map((id) => ({ filename: `${id}.mp3`, groupId: 1 })),
|
||||
})
|
||||
const state = scope.run(() => usePlaylistDurations(playlist))!
|
||||
assert.equal(FakeAudio.instances.length, 3)
|
||||
assert.equal(state.groupDuration(1), '…')
|
||||
FakeAudio.instances[0]!.complete(60)
|
||||
await new Promise(setImmediate)
|
||||
assert.equal(FakeAudio.instances.length, 4)
|
||||
FakeAudio.instances[1]!.complete(61)
|
||||
FakeAudio.instances[2]!.complete(62)
|
||||
FakeAudio.instances[3]!.complete(63)
|
||||
await new Promise(setImmediate)
|
||||
assert.equal(state.groupDuration(1), '4:06')
|
||||
playlist.value.tracks[0]!.groupId = 2
|
||||
await nextTick()
|
||||
assert.equal(state.groupDuration(2), '1:00')
|
||||
assert.equal(state.groupDuration(1), '3:06')
|
||||
assert.equal(FakeAudio.instances.length, 4)
|
||||
playlist.value = { tracks: [{ filename: 'bad.mp3', groupId: 1 }] }
|
||||
await nextTick()
|
||||
FakeAudio.instances.at(-1)!.onerror?.()
|
||||
await new Promise(setImmediate)
|
||||
assert.equal(state.groupDuration(1), 'длительность недоступна')
|
||||
playlist.value = { tracks: [{ filename: 'cancel.mp3', groupId: 1 }] }
|
||||
await nextTick()
|
||||
const cancelled = FakeAudio.instances.at(-1)!
|
||||
playlist.value = { tracks: [{ filename: 'unmount.mp3', groupId: 1 }] }
|
||||
await nextTick()
|
||||
assert.equal(cancelled.src, '')
|
||||
assert.equal(cancelled.onloadedmetadata, null)
|
||||
scope.stop()
|
||||
assert.equal(FakeAudio.instances.at(-1)!.src, '')
|
||||
assert.equal(FakeAudio.instances.at(-1)!.onerror, null)
|
||||
} finally {
|
||||
scope.stop()
|
||||
if (originalAudio) Object.defineProperty(globalThis, 'Audio', originalAudio)
|
||||
else Reflect.deleteProperty(globalThis, 'Audio')
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { computed, onScopeDispose, type Ref, shallowReactive, watch } from 'vue'
|
||||
|
||||
import type { Playlist } from '@/api/generated/crabs/evening_detective_server'
|
||||
import { getURL } from '@/api/generated/crabs/evening_detective_server/client'
|
||||
import { playlistDurationLabel } from '@/utils/playlistDuration'
|
||||
|
||||
export function usePlaylistDurations(playlist: Ref<Playlist | undefined>) {
|
||||
const durations = shallowReactive(new Map<string, number | null>())
|
||||
const files = computed(() =>
|
||||
JSON.stringify(
|
||||
[...new Set(playlist.value?.tracks?.map((track) => track.filename ?? '') ?? [])].sort(),
|
||||
),
|
||||
)
|
||||
const cancelLoads = new Set<() => void>()
|
||||
let version = 0
|
||||
|
||||
function cancel() {
|
||||
version++
|
||||
for (const stop of cancelLoads) stop()
|
||||
}
|
||||
|
||||
function load(filename: string): Promise<number | null | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const audio = new Audio()
|
||||
const finish = (duration: number | null | undefined) => {
|
||||
clearTimeout(timeout)
|
||||
audio.onloadedmetadata = null
|
||||
audio.onerror = null
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
cancelLoads.delete(stop)
|
||||
resolve(duration)
|
||||
}
|
||||
const stop = () => finish(undefined)
|
||||
const timeout = setTimeout(() => finish(null), 15000)
|
||||
cancelLoads.add(stop)
|
||||
audio.preload = 'metadata'
|
||||
audio.onloadedmetadata = () =>
|
||||
finish(Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : null)
|
||||
audio.onerror = () => finish(null)
|
||||
audio.src = `${getURL()}/api/files/${encodeURIComponent(filename)}`
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
files,
|
||||
() => {
|
||||
cancel()
|
||||
const currentVersion = version
|
||||
const pending = [
|
||||
...new Set(playlist.value?.tracks?.map((track) => track.filename ?? '') ?? []),
|
||||
].filter((filename) => !durations.has(filename))
|
||||
async function worker() {
|
||||
while (currentVersion === version && pending.length) {
|
||||
const filename = pending.shift()!
|
||||
if (!filename) {
|
||||
durations.set(filename, null)
|
||||
continue
|
||||
}
|
||||
const duration = await load(filename)
|
||||
if (currentVersion !== version) return
|
||||
if (duration !== undefined) durations.set(filename, duration)
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < 3; index++) void worker()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onScopeDispose(cancel)
|
||||
|
||||
function groupDuration(groupID: number) {
|
||||
return playlistDurationLabel(
|
||||
(playlist.value?.tracks ?? [])
|
||||
.filter((track) => (track.groupId ?? 0) === groupID)
|
||||
.map((track) => durations.get(track.filename ?? '')),
|
||||
)
|
||||
}
|
||||
|
||||
return { groupDuration }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { playlistDurationLabel } from '../playlistDuration.ts'
|
||||
|
||||
test('group duration totals and states', () => {
|
||||
assert.equal(playlistDurationLabel([]), '0:00')
|
||||
assert.equal(playlistDurationLabel([60.6, 1.6]), '1:02')
|
||||
assert.equal(playlistDurationLabel([3600, 154]), '1:02:34')
|
||||
assert.equal(playlistDurationLabel([0]), '0:00')
|
||||
assert.equal(playlistDurationLabel([10, undefined]), '…')
|
||||
assert.equal(playlistDurationLabel([10, null]), 'длительность недоступна')
|
||||
assert.equal(playlistDurationLabel([undefined, null]), 'длительность недоступна')
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
export function playlistDurationLabel(durations: (number | null | undefined)[]): string {
|
||||
if (durations.some((duration) => duration === null)) return 'длительность недоступна'
|
||||
if (durations.some((duration) => duration === undefined)) return '…'
|
||||
const total = Math.floor(durations.reduce<number>((sum, duration) => sum + (duration ?? 0), 0))
|
||||
const seconds = String(total % 60).padStart(2, '0')
|
||||
const minutes = Math.floor(total / 60)
|
||||
if (minutes < 60) return `${minutes}:${seconds}`
|
||||
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${seconds}`
|
||||
}
|
||||
Reference in New Issue
Block a user