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
+50 -9
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { TrashOutline } from '@vicons/ionicons5'
import { Icon } from '@vicons/utils'
import { import {
NAlert, NAlert,
NButton, NButton,
@@ -18,6 +20,7 @@ import type {
PlaylistGroup, PlaylistGroup,
} from '@/api/generated/crabs/evening_detective_server' } from '@/api/generated/crabs/evening_detective_server'
import PlaylistGroupEditor from '@/components/PlaylistGroupEditor.vue' import PlaylistGroupEditor from '@/components/PlaylistGroupEditor.vue'
import { usePlaylistDurations } from '@/composables/usePlaylistDurations'
const props = defineProps<{ const props = defineProps<{
playlists: Playlist[] playlists: Playlist[]
@@ -108,6 +111,8 @@ const isPlaylistActive = computed(
selectedId.value === props.music?.playlist?.ID && selectedId.value === props.music?.playlist?.ID &&
props.music?.playback !== 'MUSIC_PLAYBACK_STOPPED', props.music?.playback !== 'MUSIC_PLAYBACK_STOPPED',
) )
const { groupDuration } = usePlaylistDurations(selected)
function isGroupMissing(groupID: number) { function isGroupMissing(groupID: number) {
return ( return (
isSelectedMissing.value || isSelectedMissing.value ||
@@ -353,7 +358,10 @@ reset()
class="music-group" class="music-group"
:aria-label="section.name" :aria-label="section.name"
> >
<h4>{{ section.name }}</h4> <h4>
{{ section.name }}
<span class="group-duration">· {{ groupDuration(section.ID) }}</span>
</h4>
<PlaylistGroupEditor <PlaylistGroupEditor
v-if="section.ID" v-if="section.ID"
:group="section" :group="section"
@@ -395,13 +403,17 @@ reset()
</n-upload> </n-upload>
<p v-if="!trackIds(section.ID).length">Треков пока нет.</p> <p v-if="!trackIds(section.ID).length">Треков пока нет.</p>
<div v-for="(id, index) in trackIds(section.ID)" :key="id" class="track-row"> <div v-for="(id, index) in trackIds(section.ID)" :key="id" class="track-row">
<span <div class="track-heading">
<span class="track-name"
>{{ index + 1 }}. >{{ index + 1 }}.
{{ {{
selected.tracks?.find((track) => track.ID === id)?.name ?? 'Трек удалён на сервере' selected.tracks?.find((track) => track.ID === id)?.name ??
'Трек удалён на сервере'
}}</span }}</span
> >
<n-select <n-select
class="track-group"
size="small"
:value="section.ID" :value="section.ID"
:options="groupOptions" :options="groupOptions"
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)" :disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
@@ -410,7 +422,7 @@ reset()
}" }"
@update:value="emit('setTrackGroup', selectedId!, id, $event)" @update:value="emit('setTrackGroup', selectedId!, id, $event)"
/> />
<n-space> <n-space :wrap="false" class="track-actions">
<n-button <n-button
size="small" size="small"
:disabled="isBusy || hasConflict || isGroupLocked(section.ID) || index === 0" :disabled="isBusy || hasConflict || isGroupLocked(section.ID) || index === 0"
@@ -437,13 +449,17 @@ reset()
type="error" type="error"
ghost ghost
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)" :disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
>Удалить</n-button aria-label="Удалить трек"
title="Удалить трек"
> >
<Icon size="18"><TrashOutline /></Icon>
</n-button>
</template> </template>
Удалить трек из плейлиста? Удалить трек из плейлиста?
</n-popconfirm> </n-popconfirm>
</n-space> </n-space>
</div> </div>
</div>
</section> </section>
</div> </div>
</template> </template>
@@ -455,16 +471,22 @@ p {
margin: 0; margin: 0;
} }
.group-duration {
color: var(--brand-text-muted);
font-weight: normal;
}
.music-groups { .music-groups {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.music-group { .music-group {
background: var(--brand-surface);
border-top: 1px solid var(--brand-border); border-top: 1px solid var(--brand-border);
display: grid; display: grid;
gap: 8px; gap: 8px;
padding-top: 12px; padding: 12px;
} }
.music-content { .music-content {
@@ -474,11 +496,30 @@ p {
.track-row { .track-row {
align-items: center; align-items: center;
border: 1px solid var(--brand-border); border: 1px solid var(--brand-border);
display: flex; display: grid;
flex-wrap: wrap;
gap: 12px; gap: 12px;
justify-content: space-between;
overflow-wrap: anywhere; overflow-wrap: anywhere;
padding: 12px; 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> </style>
+48 -9
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <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 { NAlert, NButton, NInput, NPopconfirm, NSelect, NSpace } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@@ -52,25 +54,24 @@ watch(
<template> <template>
<n-space vertical> <n-space vertical>
<div class="group-heading">
<n-input <n-input
class="group-name"
size="small"
v-model:value="name" v-model:value="name"
:disabled="isBusy" :disabled="isBusy"
:maxlength="255" :maxlength="255"
:aria-label="`Название группы ${group.name}`" :aria-label="`Название группы ${group.name}`"
/> />
<n-select <n-select
class="group-mode"
size="small"
v-model:value="endMode" v-model:value="endMode"
:options="modes" :options="modes"
:disabled="isBusy || isLocked || isMissing" :disabled="isBusy || isLocked || isMissing"
:input-props="{ 'aria-label': `Завершение группы ${group.name}` }" :input-props="{ 'aria-label': `Завершение группы ${group.name}` }"
/> />
<n-alert v-if="isMissing" type="warning" <n-space justify="end" class="group-actions">
>Группа удалена на сервере. Черновик сохранён до отмены изменений.</n-alert
>
<n-alert v-if="hasConflict" type="warning">
Группа изменена на сервере. Черновик сохранён; загрузите актуальные данные перед сохранением.
</n-alert>
<n-space>
<n-button <n-button
size="small" size="small"
:disabled="isBusy || !name.trim() || !isDirty || hasConflict || isMissing" :disabled="isBusy || !name.trim() || !isDirty || hasConflict || isMissing"
@@ -101,12 +102,50 @@ watch(
> >
<n-popconfirm @positive-click="emit('remove')"> <n-popconfirm @positive-click="emit('remove')">
<template #trigger> <template #trigger>
<n-button size="small" type="error" ghost :disabled="isBusy || isLocked || isMissing"> <n-button
Удалить группу size="small"
type="error"
ghost
:disabled="isBusy || isLocked || isMissing"
aria-label="Удалить группу"
title="Удалить группу"
>
<Icon size="18"><TrashOutline /></Icon>
</n-button> </n-button>
</template> </template>
Удалить группу «{{ group.name }}»? Треки останутся в плейлисте без группы. Удалить группу «{{ group.name }}»? Треки останутся в плейлисте без группы.
</n-popconfirm> </n-popconfirm>
</n-space> </n-space>
</div>
<n-alert v-if="isMissing" type="warning"
>Группа удалена на сервере. Черновик сохранён до отмены изменений.</n-alert
>
<n-alert v-if="hasConflict" type="warning">
Группа изменена на сервере. Черновик сохранён; загрузите актуальные данные перед сохранением.
</n-alert>
</n-space> </n-space>
</template> </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}`
}