483 lines
16 KiB
Vue
483 lines
16 KiB
Vue
<script setup lang="ts">
|
|
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]; 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
|
|
}
|
|
const canPlay = computed(() => !!props.channel.source && 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 order = ref<number[]>([])
|
|
const original = ref<number[]>([])
|
|
const selected = computed(() => props.playlists.find((p) => p.id === selectedId.value))
|
|
const isDirty = computed(() => JSON.stringify(order.value) !== JSON.stringify(original.value))
|
|
const hasConflict = computed(
|
|
() =>
|
|
selected.value &&
|
|
JSON.stringify(selected.value.tracks.map((t) => t.id).sort((a, b) => a - b)) !==
|
|
JSON.stringify([...original.value].sort((a, b) => a - b)),
|
|
)
|
|
const isDisabled = computed(() => !props.canEdit || props.isBusy)
|
|
let timer: ReturnType<typeof setInterval> | undefined
|
|
function reset() {
|
|
original.value = selected.value?.tracks.map((t) => t.id) ?? []
|
|
order.value = [...original.value]
|
|
name.value = selected.value?.name ?? ''
|
|
}
|
|
watch(selectedId, reset)
|
|
watch(
|
|
() => props.playlists,
|
|
(items, old) => {
|
|
const added = items.find((p) => !old.some((o) => o.id === p.id))
|
|
if (!isDirty.value && added) selectedId.value = added.id
|
|
if (selectedId.value && !selected.value) selectedId.value = null
|
|
const oldName = old.find((p) => p.id === selectedId.value)?.name
|
|
if (name.value === oldName) name.value = selected.value?.name ?? ''
|
|
const ids = selected.value?.tracks.map((t) => t.id) ?? []
|
|
if (!isDirty.value || JSON.stringify(order.value) === JSON.stringify(ids)) {
|
|
original.value = ids
|
|
order.value = [...ids]
|
|
}
|
|
},
|
|
)
|
|
|
|
function refresh() {
|
|
if (props.canEdit && !props.isBusy) emit('action', { action: 'playlists' })
|
|
}
|
|
watch(
|
|
() => props.canEdit,
|
|
(value) => {
|
|
if (value) refresh()
|
|
},
|
|
)
|
|
onMounted(() => {
|
|
refresh()
|
|
timer = setInterval(refresh, 10000)
|
|
})
|
|
onUnmounted(() => clearInterval(timer))
|
|
function move(index: number, offset: number) {
|
|
const target = index + offset
|
|
if (target < 0 || target >= order.value.length) return
|
|
const [id] = order.value.splice(index, 1)
|
|
if (id !== undefined) order.value.splice(target, 0, id)
|
|
}
|
|
function remove(trackId?: number) {
|
|
if (!selected.value) return
|
|
const playlistId = selected.value.id
|
|
dialog.warning({
|
|
title: trackId === undefined ? 'Удалить плейлист?' : 'Удалить трек?',
|
|
content: 'Изменение сохранится на сервере. Используемые плейлисты и треки удалить нельзя.',
|
|
positiveText: 'Удалить',
|
|
negativeText: 'Отмена',
|
|
onPositiveClick: () =>
|
|
emit(
|
|
'action',
|
|
trackId === undefined
|
|
? { action: 'playlist-delete', playlistId }
|
|
: { action: 'playlist-track-delete', playlistId, trackId },
|
|
),
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<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 || !selected || hasConflict"
|
|
@click="
|
|
selected &&
|
|
emit('action', {
|
|
action: 'playlist-reorder',
|
|
playlistId: selected.id,
|
|
trackIds: [...order],
|
|
})
|
|
"
|
|
>Сохранить порядок</n-button
|
|
>
|
|
<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 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>
|
|
<div v-else class="music-plan-view" 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="channel.source" class="current-track" data-testid="current-music-track">{{ channel.source.name }}</p>
|
|
<div class="track-actions">
|
|
<n-space align="center">
|
|
<n-button
|
|
square
|
|
:disabled="!canPlay"
|
|
:aria-label="control.label"
|
|
:title="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="control.action === 'pause'"
|
|
d="M6 4h4v16H6zM14 4h4v16h-4z"
|
|
/>
|
|
<path v-else d="M7 4v16l13-8z" />
|
|
</svg>
|
|
</n-button>
|
|
<n-button
|
|
square
|
|
:disabled="!canPlay"
|
|
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"
|
|
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" 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"
|
|
role="group"
|
|
aria-label="Громкость, %"
|
|
:step="1"
|
|
:format-tooltip="(value) => `${value}%`"
|
|
:min="0"
|
|
:max="100"
|
|
:disabled="!channel.source || 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="progress"
|
|
:show-indicator="false"
|
|
aria-label="Прогресс воспроизведения"
|
|
/>
|
|
<span 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="channel.error" type="error">{{
|
|
channel.error
|
|
}}</n-alert>
|
|
|
|
<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>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</n-card>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.music-plan-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.track-list {
|
|
display: grid;
|
|
gap: 12px;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.current-track {
|
|
margin: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.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>
|