This commit is contained in:
2026-10-18 18:32:06 +07:00
parent a3317dbf7a
commit a35c09d0dc
5 changed files with 457 additions and 255 deletions
+362 -91
View File
@@ -1,19 +1,65 @@
<script setup lang="ts">
import { NAlert, NButton, NInput, NSelect, NSpace, useDialog } from 'naive-ui'
import {
NAlert,
NButton,
NCard,
NInput,
NSlider,
NProgress,
NSelect,
NSpace,
NSwitch,
useDialog,
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { MusicPlaylist, OnlineIntent } from '../types/online'
import type { ChannelState, Intent, Playback, Playlist } from '../types/screen'
import { playbackControl } from '../utils/playbackControl'
import { formatTimer } from '../utils/presentation'
const props = defineProps<{
playlists: MusicPlaylist[]
localPlaylists: Playlist[]
channel: ChannelState
outputOpen: boolean
canEdit: boolean
canUse: boolean
isBusy: boolean
}>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
const emit = defineEmits<{ action: [intent: OnlineIntent]; command: [intent: Intent] }>()
const dialog = useDialog()
const isEditing = ref(false)
const activePlaylist = computed(() =>
props.localPlaylists.find((p) => p.id === props.channel.playlistId),
)
const control = computed(() => playbackControl(props.channel))
const progress = computed(() => {
if (!props.channel.duration || props.channel.duration <= 0) return 0
return Math.min(100, Math.max(0, (props.channel.position / props.channel.duration) * 100))
})
const statusLabels: Record<Playback, string> = {
idle: 'Файл не выбран',
pending: 'Загрузка…',
playing: 'Играет',
paused: 'Пауза',
stopped: 'Остановлено',
ended: 'Завершено',
error: 'Ошибка',
}
function isCurrent(index: number) {
return !!props.channel.source && props.channel.trackIndex === index
}
function canPlay(index: number) {
return isCurrent(index) && props.outputOpen && !props.isBusy
}
function edit() {
if (props.isBusy || !props.canEdit) return
selectedId.value = props.channel.playlistId
reset()
isEditing.value = true
}
const selectedId = ref<number | null>(null)
const name = ref('')
const newName = ref('')
const order = ref<number[]>([])
const original = ref<number[]>([])
const selected = computed(() => props.playlists.find((p) => p.id === selectedId.value))
@@ -68,9 +114,6 @@ function move(index: number, offset: number) {
const [id] = order.value.splice(index, 1)
if (id !== undefined) order.value.splice(target, 0, id)
}
function create() {
emit('action', { action: 'playlist-create', name: newName.value })
}
function remove(trackId?: number) {
if (!selected.value) return
const playlistId = selected.value.id
@@ -91,85 +134,15 @@ function remove(trackId?: number) {
</script>
<template>
<n-space vertical data-testid="music-plan-editor">
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
<template v-else>
<label for="new-playlist-name">Новый плейлист</label>
<n-input
v-model:value="newName"
:input-props="{ id: 'new-playlist-name' }"
:disabled="isDisabled"
placeholder="Название плейлиста"
/>
<n-button :disabled="isDisabled || isDirty || !newName.trim()" @click="create"
>Создать плейлист</n-button
>
<n-select
v-model:value="selectedId"
:options="playlists.map((p) => ({ label: p.name, value: p.id }))"
:disabled="isDisabled || isDirty"
placeholder="Плейлист для редактирования"
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
/>
<p v-if="!playlists.length">Плейлистов пока нет. Создайте первый.</p>
<template v-if="selected">
<label for="playlist-name">Название плейлиста</label>
<n-input
v-model:value="name"
:input-props="{ id: 'playlist-name' }"
:disabled="isDisabled"
/>
<n-space>
<n-card title="План музыки" data-testid="music-plan">
<template #header-extra>
<n-button v-if="!isEditing" :disabled="isDisabled" @click="edit">Редактировать</n-button>
<n-space v-else>
<template v-if="isDirty || hasConflict">
<n-button
:disabled="isDisabled || !name.trim() || name === selected.name"
@click="emit('action', { action: 'playlist-rename', playlistId: selected.id, name })"
>Сохранить название</n-button
>
<n-button :disabled="isDisabled || isDirty" @click="remove()">Удалить плейлист</n-button>
<n-button
:disabled="isDisabled || isDirty"
@click="emit('action', { action: 'playlist-upload', playlistId: selected.id })"
>Добавить MP3</n-button
>
<n-button
:disabled="isDisabled || isDirty || !canUse || !selected.tracks.length"
@click="emit('action', { action: 'playlist-use', playlistId: selected.id })"
>Использовать в игре</n-button
>
</n-space>
<p v-if="!order.length">Треков пока нет.</p>
<ol class="track-list">
<li v-for="(id, index) in order" :key="id">
<span>{{
selected.tracks.find((t) => t.id === id)?.name ?? `Трек №${id} удалён на сервере`
}}</span>
<n-space>
<n-button
:disabled="isDisabled || index === 0"
:aria-label="`Поднять трек ${index + 1}`"
@click="move(index, -1)"
>↑</n-button
>
<n-button
:disabled="isDisabled || index === order.length - 1"
:aria-label="`Опустить трек ${index + 1}`"
@click="move(index, 1)"
>↓</n-button
>
<n-button :disabled="isDisabled || isDirty" @click="remove(id)"
>Удалить трек</n-button
>
</n-space>
</li>
</ol>
<n-alert v-if="hasConflict" type="warning"
>Состав треков изменился на сервере. Отмените черновик, чтобы принять актуальный
список.</n-alert
>
<n-space v-if="isDirty || hasConflict">
<n-button
:disabled="isDisabled || hasConflict"
:disabled="isDisabled || !selected || hasConflict"
@click="
selected &&
emit('action', {
action: 'playlist-reorder',
playlistId: selected.id,
@@ -178,23 +151,321 @@ function remove(trackId?: number) {
"
>Сохранить порядок</n-button
>
<n-button :disabled="props.isBusy" @click="reset">Отменить</n-button>
</n-space>
</template>
<n-button :disabled="props.isBusy" @click="reset">Отменить порядок</n-button>
</template>
<n-button :disabled="isBusy || isDirty || !!hasConflict" @click="isEditing = false"
>Готово</n-button
>
</n-space>
</template>
</n-space>
<n-space v-if="isEditing" vertical data-testid="music-plan-editor">
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
<template v-else>
<n-select
v-model:value="selectedId"
:options="playlists.map((p) => ({ label: p.name, value: p.id }))"
:disabled="isDisabled || isDirty"
placeholder="Плейлист для редактирования"
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
/>
<p v-if="!playlists.length">Плейлистов пока нет.</p>
<template v-if="selected">
<label for="playlist-name">Название плейлиста</label>
<n-input
v-model:value="name"
:input-props="{ id: 'playlist-name' }"
:disabled="isDisabled"
/>
<n-space>
<n-button
:disabled="isDisabled || !name.trim() || name === selected.name"
@click="emit('action', { action: 'playlist-rename', playlistId: selected.id, name })"
>Сохранить название</n-button
>
<n-button :disabled="isDisabled || isDirty" @click="remove()"
>Удалить плейлист</n-button
>
<n-button
:disabled="isDisabled || isDirty"
@click="emit('action', { action: 'playlist-upload', playlistId: selected.id })"
>Добавить MP3</n-button
>
<n-button
:disabled="isDisabled || isDirty || !canUse || !selected.tracks.length"
@click="emit('action', { action: 'playlist-use', playlistId: selected.id })"
>Использовать в игре</n-button
>
</n-space>
<p v-if="!order.length">Треков пока нет.</p>
<ul class="track-list">
<li v-for="(id, index) in order" :key="id" class="track-row">
<div class="track-content track-edit-content">
<span>{{
selected.tracks.find((t) => t.id === id)?.name ?? `Трек №${id} удалён на сервере`
}}</span>
<n-space>
<n-button
:disabled="isDisabled || index === 0"
:aria-label="`Поднять трек ${index + 1}`"
@click="move(index, -1)"
>↑</n-button
>
<n-button
:disabled="isDisabled || index === order.length - 1"
:aria-label="`Опустить трек ${index + 1}`"
@click="move(index, 1)"
>↓</n-button
>
<n-button :disabled="isDisabled || isDirty" @click="remove(id)"
>Удалить трек</n-button
>
</n-space>
</div>
</li>
</ul>
<n-alert v-if="hasConflict" type="warning"
>Состав треков изменился на сервере. Отмените черновик, чтобы принять актуальный
список.</n-alert
>
</template>
</template>
</n-space>
<n-space v-else vertical data-testid="music-plan-view">
<n-select
:value="channel.playlistId"
:options="localPlaylists.map((p) => ({ label: p.name, value: p.id }))"
:disabled="isBusy"
placeholder="Выберите плейлист"
:input-props="{ 'aria-label': 'Плейлист' }"
@update:value="
emit('command', { action: 'playlist', channel: 'music', playlistId: $event })
"
/>
<label class="music-loop"
>Повторять плейлист
<n-switch
:value="channel.loop"
:disabled="isBusy"
aria-label="Повторять плейлист"
@update:value="emit('command', { action: 'loop', channel: 'music', enabled: $event })"
/>
</label>
<p v-if="!localPlaylists.length">Плейлистов в игре пока нет.</p>
<p v-else-if="!activePlaylist">Выберите плейлист.</p>
<p v-else-if="!activePlaylist.tracks.length">Треков пока нет.</p>
<ul v-if="activePlaylist" class="track-list">
<li
v-for="(track, index) in activePlaylist.tracks"
:key="track.id"
class="track-row"
:class="{ 'track-current': isCurrent(index) }"
:data-track-index="index"
:aria-current="isCurrent(index) ? 'true' : undefined"
>
<div class="track-content">
<div class="track-title">
<span>{{ track.name || `Трек ${index + 1}` }}</span>
<n-button
data-testid="select-track"
:type="isCurrent(index) ? 'primary' : 'default'"
:disabled="isBusy || isCurrent(index)"
@click="emit('command', { action: 'track', channel: 'music', trackIndex: index })"
>{{ isCurrent(index) ? 'Выбран' : 'Выбрать' }}</n-button
>
</div>
<div class="track-actions">
<n-space align="center">
<n-button
square
:disabled="!canPlay(index)"
:aria-label="isCurrent(index) ? control.label : 'Воспроизвести'"
:title="isCurrent(index) ? control.label : 'Воспроизвести'"
@click="emit('command', { action: control.action, channel: 'music' })"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path
v-if="isCurrent(index) && control.action === 'pause'"
d="M6 4h4v16H6zM14 4h4v16h-4z"
/>
<path v-else d="M7 4v16l13-8z" />
</svg>
</n-button>
<n-button
square
:disabled="!canPlay(index)"
aria-label="Стоп"
title="Стоп"
@click="emit('command', { action: 'stop', channel: 'music' })"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M5 5h14v14H5z" />
</svg>
</n-button>
<n-button
square
:disabled="!canPlay(index)"
aria-label="Сначала"
title="Сначала"
@click="emit('command', { action: 'replay', channel: 'music' })"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M20 11a8 8 0 1 0-2 6M20 4v7h-7" />
</svg>
</n-button>
<div class="track-volume">
<label :for="`volume-music-${track.id}`" title="Громкость, %">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M11 5 6 9H3v6h3l5 4zM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
</svg>
</label>
<n-slider
class="track-volume-input"
:value="channel.volume"
:id="`volume-music-${track.id}`"
role="group"
aria-label="Громкость, %"
:step="1"
:format-tooltip="(value) => `${value}%`"
:min="0"
:max="100"
:disabled="!isCurrent(index) || isBusy"
@update:value="
emit('command', { action: 'volume', channel: 'music', volume: $event ?? 0 })
"
/>
</div>
</n-space>
</div>
<div class="track-progress">
<n-progress
class="track-progress-bar"
type="line"
:percentage="isCurrent(index) ? progress : 0"
:show-indicator="false"
aria-label="Прогресс воспроизведения"
/>
<span v-if="isCurrent(index)" class="track-time" role="status">
{{ statusLabels[channel.status] }} ·
{{ formatTimer(Math.floor(channel.position) * 1000) }} /
{{ channel.duration === null ? '—' : formatTimer(channel.duration * 1000) }}
</span>
</div>
<n-alert v-if="isCurrent(index) && channel.error" type="error">{{
channel.error
}}</n-alert>
</div>
</li>
</ul>
</n-space>
</n-card>
</template>
<style scoped>
.track-list {
display: grid;
gap: 12px;
padding-left: 24px;
list-style: none;
margin: 0;
padding: 0;
}
.track-list li > span {
display: block;
margin-bottom: 8px;
.track-row {
align-items: stretch;
border: 1px solid var(--brand-background);
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 12px;
}
.track-current {
border-color: var(--brand-text);
}
.track-content {
display: flex;
flex: 1 1 380px;
flex-direction: column;
gap: 8px;
justify-content: space-between;
min-width: 0;
overflow-wrap: anywhere;
}
.track-edit-content {
align-items: center;
flex-flow: row wrap;
}
.track-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.track-title {
align-items: start;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
}
.music-loop,
.track-volume {
align-items: center;
display: flex;
gap: 8px;
}
.track-volume-input {
width: 110px;
}
.track-progress {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.track-progress-bar {
flex: 1 1 160px;
min-width: 0;
}
.track-time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
</style>