This commit is contained in:
2026-10-27 22:38:29 +07:00
parent a5f8b5757b
commit f1b2c954ee
6 changed files with 366 additions and 101 deletions
+92 -51
View File
@@ -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>
+89 -50
View File
@@ -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()
}
})
+80
View File
@@ -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]), 'длительность недоступна')
})
+9
View File
@@ -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}`
}