generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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