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
+27 -12
View File
@@ -42,8 +42,9 @@ module.exports = async function onlineSmoke({
{ ID: 92, name: 'Два', filename: 'music-b.wav', position: 1 }, { ID: 92, name: 'Два', filename: 'music-b.wav', position: 1 },
], ],
}, },
{ ID: 10, name: 'Тестовый плейлист', tracks: [] },
] ]
let nextPlaylistId = 10 let nextPlaylistId = 11
const requests = [] const requests = []
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
const chunks = [] const chunks = []
@@ -627,7 +628,7 @@ module.exports = async function onlineSmoke({
await poll( await poll(
() => () =>
evaluate(`(() => { evaluate(`(() => {
const button = Array.from(document.querySelectorAll('[data-testid="screen-plan"] button')) const button = Array.from(document.querySelector('[data-testid="screen-plan"]').closest('.n-card').querySelectorAll('button'))
.find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)}) .find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)})
if (!button || button.disabled) return false if (!button || button.disabled) return false
button.click(); return true button.click(); return true
@@ -730,7 +731,7 @@ module.exports = async function onlineSmoke({
poll( poll(
() => () =>
evaluate(`(() => { evaluate(`(() => {
const button = Array.from(document.querySelectorAll('[data-testid="music-plan-editor"] button')).find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)}) const button = Array.from(document.querySelector('[data-testid="music-plan"]').querySelectorAll('button')).find(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === ${JSON.stringify(label)})
if (!button || button.disabled) return false if (!button || button.disabled) return false
button.click(); return true button.click(); return true
})()`), })()`),
@@ -774,13 +775,15 @@ module.exports = async function onlineSmoke({
async () => (await evaluate('window.screenBridge.onlineState()')).playlists.length > 0, async () => (await evaluate('window.screenBridge.onlineState()')).playlists.length > 0,
'library loaded automatically', 'library loaded automatically',
) )
await setInput('#new-playlist-name', 'Создан из приложения') await clickMusic('Редактировать')
await clickMusic('Создать плейлист') assert.equal(
await poll(() => library.some((p) => p.name === 'Создан из приложения'), 'playlist created') await evaluate(`!!document.querySelector('#new-playlist-name') ||
await poll( Array.from(document.querySelectorAll('[data-testid="music-plan"] button'))
() => evaluate(`document.querySelector('#playlist-name')?.value === 'Создан из приложения'`), .some(b => b.textContent.trim() === 'Создать плейлист')`),
'new playlist selected', false,
'playlist creation form is absent',
) )
await choosePlaylist('Тестовый плейлист')
await setInput('#playlist-name', 'Переименован') await setInput('#playlist-name', 'Переименован')
// Повторный snapshot не затирает ввод названия. // Повторный snapshot не затирает ввод названия.
await new Promise((resolve) => setTimeout(resolve, 650)) await new Promise((resolve) => setTimeout(resolve, 650))
@@ -797,7 +800,7 @@ module.exports = async function onlineSmoke({
await poll( await poll(
() => () =>
evaluate( evaluate(
`document.querySelector('[data-testid="music-plan-editor"] ol')?.textContent.includes('native.mp3')`, `document.querySelector('[data-testid="music-plan-editor"] .track-list')?.textContent.includes('native.mp3')`,
), ),
'native MP3 uploaded', 'native MP3 uploaded',
) )
@@ -810,7 +813,7 @@ module.exports = async function onlineSmoke({
await poll( await poll(
() => () =>
evaluate( evaluate(
`!document.querySelector('[data-testid="music-plan-editor"] ol')?.textContent.includes('native.mp3')`, `!document.querySelector('[data-testid="music-plan-editor"] .track-list')?.textContent.includes('native.mp3')`,
), ),
'track removed', 'track removed',
) )
@@ -835,7 +838,7 @@ module.exports = async function onlineSmoke({
[91, 92], [91, 92],
'draft not published', 'draft not published',
) )
await clickMusic('Отменить') await clickMusic('Отменить порядок')
await evaluate(`document.querySelector('[aria-label="Опустить трек 1"]').click()`) await evaluate(`document.querySelector('[aria-label="Опустить трек 1"]').click()`)
await clickMusic('Сохранить порядок') await clickMusic('Сохранить порядок')
await poll( await poll(
@@ -848,6 +851,18 @@ module.exports = async function onlineSmoke({
assert.equal(afterOrder.seekOperation, beforeOrder.seekOperation) assert.equal(afterOrder.seekOperation, beforeOrder.seekOperation)
assert.equal(afterOrder.desired, 'playing') assert.equal(afterOrder.desired, 'playing')
assert.ok(afterOrder.position >= beforeOrder.position) assert.ok(afterOrder.position >= beforeOrder.position)
await clickMusic('Готово')
await poll(
() => evaluate(`(() => {
const view = document.querySelector('[data-testid="music-plan-view"]')
const rows = Array.from(view?.querySelectorAll('[data-track-index]') ?? [])
return rows.length === 2 && !document.querySelector('[data-testid="music-plan-editor"]')
&& rows.every(row => !row.querySelector('.track-preview') && row.querySelector('.n-progress'))
&& rows.filter(row => !row.classList.contains('track-current')).every(row =>
Array.from(row.querySelectorAll('button[aria-label]')).every(button => button.disabled))
})()`),
'music view shows tracks, progress and inactive controls',
)
console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS') console.log('SCREEN_MUSIC_PLAN_SMOKE_PASS')
await evaluate(`Array.from(document.querySelectorAll('button')) await evaluate(`Array.from(document.querySelectorAll('button'))
.find(b => b.textContent.trim() === 'Отключиться').click()`) .find(b => b.textContent.trim() === 'Отключиться').click()`)
+8 -7
View File
@@ -426,13 +426,14 @@ app.whenReady().then(async () => {
async () => (await state()).channels.main.status === 'paused', async () => (await state()).channels.main.status === 'paused',
'plan media paused', 'plan media paused',
) )
await evaluate(`(() => { await evaluate(`document.querySelector('.plan-step-current [id^="volume-main-"] [role="slider"]').focus()`)
const input = document.querySelector('.plan-step-current input[id^="volume-main-"]') for (let attempt = 0; attempt < 100; attempt++) {
input.focus(); input.value = '42' const volume = (await state()).channels.main.volume
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) if (volume === 42) break
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) await evaluate(`document.querySelector('.plan-step-current [id^="volume-main-"] [role="slider"]')
input.blur() .dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(volume > 42 ? 'ArrowLeft' : 'ArrowRight')}, bubbles: true }))`)
})()`) await poll(async () => (await state()).channels.main.volume !== volume, 'volume slider keyboard update')
}
await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume') await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume')
assert.equal((await state()).channels.main.desired, 'paused') assert.equal((await state()).channels.main.desired, 'paused')
assert.equal((await state()).channels.music.desired, 'playing') assert.equal((await state()).channels.music.desired, 'playing')
+33 -104
View File
@@ -3,18 +3,16 @@ import {
NAlert, NAlert,
NButton, NButton,
NCard, NCard,
NInputNumber, NSlider,
NProgress, NProgress,
NSelect, NSelect,
NSpace, NSpace,
NSwitch,
NTag, NTag,
useMessage, useMessage,
useDialog, useDialog,
} from 'naive-ui' } from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { import type {
Channel,
HostInfo, HostInfo,
Intent, Intent,
Playback, Playback,
@@ -28,7 +26,6 @@ import ConnectionPanel from './ConnectionPanel.vue'
import MusicPlanPanel from './MusicPlanPanel.vue' import MusicPlanPanel from './MusicPlanPanel.vue'
import { useOnlineConnection } from '../composables/useOnlineConnection' import { useOnlineConnection } from '../composables/useOnlineConnection'
import ScreenPlanPanel from './ScreenPlanPanel.vue' import ScreenPlanPanel from './ScreenPlanPanel.vue'
import { playbackControl } from '../utils/playbackControl'
import { formatTimer } from '../utils/presentation' import { formatTimer } from '../utils/presentation'
const { const {
@@ -60,7 +57,6 @@ const host = ref<HostInfo>({
preferredDisplayId: null, preferredDisplayId: null,
}) })
const display = ref<number | null>(null) const display = ref<number | null>(null)
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
const isBusy = ref(false) const isBusy = ref(false)
const preview = ref<PreviewFrame | null>(null) const preview = ref<PreviewFrame | null>(null)
const previewURL = computed(() => const previewURL = computed(() =>
@@ -98,13 +94,6 @@ async function refreshPreview() {
} }
} }
const channel = 'music' as const
const musicControl = computed(() =>
playbackControl(state.value?.channels.music ?? { desired: 'stopped', status: 'idle' }),
)
const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
)
const statusLabels: Record<Playback, string> = { const statusLabels: Record<Playback, string> = {
idle: 'Файл не выбран', idle: 'Файл не выбран',
pending: 'Загрузка…', pending: 'Загрузка…',
@@ -331,6 +320,27 @@ onUnmounted(() => {
<template v-if="state"> <template v-if="state">
<section class="presentation-grid"> <section class="presentation-grid">
<n-card title="План экранов"> <n-card title="План экранов">
<template #header-extra>
<n-button
v-if="!isPlanEditing"
:disabled="isBusy || isPlanSaving || planStatus.key !== state.catalog.key || !planStatus.editable"
@click="editPlan"
>
{{ state.catalog.steps.length ? 'Редактировать' : 'Создать план' }}
</n-button>
<n-space v-else>
<n-button
:disabled="isBusy || isPlanSaving"
@click="discardPlan(() => { isPlanEditing = false })"
>Отменить</n-button>
<n-button
type="primary"
:disabled="isBusy || isPlanSaving || planStatus.key !== state.catalog.key || !planStatus.editable"
:loading="isBusy || isPlanSaving"
@click="savePlan"
>Сохранить план</n-button>
</n-space>
</template>
<n-space vertical> <n-space vertical>
<ScreenPlanPanel <ScreenPlanPanel
:game-name="state.catalog.name" :game-name="state.catalog.name"
@@ -347,13 +357,6 @@ onUnmounted(() => {
:timer-status="state.timer.status" :timer-status="state.timer.status"
:can-edit="planStatus.key === state.catalog.key && planStatus.editable" :can-edit="planStatus.key === state.catalog.key && planStatus.editable"
:notice="planStatus.key === state.catalog.key ? planStatus.notice : ''" :notice="planStatus.key === state.catalog.key ? planStatus.notice : ''"
@edit="editPlan"
@cancel="
discardPlan(() => {
isPlanEditing = false
})
"
@save="savePlan"
@add="addPlanStep" @add="addPlanStep"
@remove="planDraft.splice($event, 1)" @remove="planDraft.splice($event, 1)"
@move="movePlanStep" @move="movePlanStep"
@@ -368,10 +371,14 @@ onUnmounted(() => {
<path d="M11 5 6 9H3v6h3l5 4zM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" /> <path d="M11 5 6 9H3v6h3l5 4zM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
</svg> </svg>
</label> </label>
<n-input-number <n-slider
class="plan-volume" class="plan-volume"
:value="state.channels.main.volume" :value="state.channels.main.volume"
:input-props="{ id: `volume-main-${index}`, 'aria-label': 'Громкость, %' }" :id="`volume-main-${index}`"
role="group"
aria-label="Громкость, %"
:step="1"
:format-tooltip="(value) => `${value}%`"
:min="0" :min="0"
:max="100" :max="100"
:disabled="!active || isBusy || isPlanSaving" :disabled="!active || isBusy || isPlanSaving"
@@ -416,95 +423,17 @@ onUnmounted(() => {
</n-space> </n-space>
</n-card> </n-card>
</section> </section>
<n-card title="План музыки">
<n-space vertical>
<MusicPlanPanel <MusicPlanPanel
:playlists="onlineState.playlists" :playlists="onlineState.playlists"
:local-playlists="state.catalog.playlists"
:channel="state.channels.music"
:output-open="state.outputOpen"
:can-edit="onlineState.authenticated && onlineState.connection !== 'offline'" :can-edit="onlineState.authenticated && onlineState.connection !== 'offline'"
:can-use="planStatus.editable" :can-use="planStatus.editable"
:is-busy="isOnlineBusy || onlineState.busy" :is-busy="isBusy || isOnlineBusy || onlineState.busy"
@action="runOnline" @action="runOnline"
@command="send"
/> />
<n-select
:value="state.channels.music.playlistId"
:options="state.catalog.playlists.map((p) => ({ label: p.name, value: p.id }))"
placeholder="Выберите плейлист"
:disabled="isBusy"
:input-props="{ 'aria-label': 'Плейлист' }"
@update:value="send({ action: 'playlist', channel: 'music', playlistId: $event })"
/>
<n-select
:value="state.channels.music.source ? state.channels.music.trackIndex : null"
:options="
(playlist?.tracks ?? []).map((t, index) => ({
label: t.name || `Трек ${index + 1}`,
value: index,
}))
"
placeholder="Выберите трек"
:disabled="isBusy"
:input-props="{ 'aria-label': 'Трек' }"
@update:value="send({ action: 'track', channel: 'music', trackIndex: $event })"
/>
<label
>Повторять плейлист
<n-switch
:value="state.channels.music.loop"
aria-label="Повторять плейлист"
@update:value="send({ action: 'loop', channel: 'music', enabled: $event })"
/></label>
<n-space>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: musicControl.action, channel })"
>{{ musicControl.label }}</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'stop', channel })"
>Стоп</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'replay', channel })"
>Сначала</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels.music.source || isBusy"
@click="send({ action: 'next', channel: 'music' })"
>Следующий трек</n-button
>
</n-space>
<label :for="`position-${channel}`">Позиция, секунды</label>
<n-input-number
v-model:value="positions[channel]"
:input-props="{ id: `position-${channel}` }"
:min="0"
:max="state.channels[channel].duration ?? undefined"
/>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'seek', channel, position: positions[channel] ?? 0 })"
>Перемотать</n-button
>
<label :for="`volume-${channel}`">Громкость, %</label>
<n-input-number
:value="state.channels[channel].volume"
:input-props="{ id: `volume-${channel}` }"
:min="0"
:max="100"
@update:value="send({ action: 'volume', channel, volume: $event ?? 0 })"
/>
<p role="status">
{{ statusLabels[state.channels[channel].status] }} ·
{{ state.channels[channel].position.toFixed(1) }} /
{{ state.channels[channel].duration?.toFixed(1) ?? '—' }} с
</p>
<n-alert v-if="state.channels[channel].error" type="error">{{
state.channels[channel].error
}}</n-alert>
</n-space>
</n-card>
</template> </template>
</main> </main>
</template> </template>
+310 -39
View File
@@ -1,19 +1,65 @@
<script setup lang="ts"> <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 { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { MusicPlaylist, OnlineIntent } from '../types/online' 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<{ const props = defineProps<{
playlists: MusicPlaylist[] playlists: MusicPlaylist[]
localPlaylists: Playlist[]
channel: ChannelState
outputOpen: boolean
canEdit: boolean canEdit: boolean
canUse: boolean canUse: boolean
isBusy: boolean isBusy: boolean
}>() }>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>() const emit = defineEmits<{ action: [intent: OnlineIntent]; command: [intent: Intent] }>()
const dialog = useDialog() 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 selectedId = ref<number | null>(null)
const name = ref('') const name = ref('')
const newName = ref('')
const order = ref<number[]>([]) const order = ref<number[]>([])
const original = ref<number[]>([]) const original = ref<number[]>([])
const selected = computed(() => props.playlists.find((p) => p.id === selectedId.value)) 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) const [id] = order.value.splice(index, 1)
if (id !== undefined) order.value.splice(target, 0, id) if (id !== undefined) order.value.splice(target, 0, id)
} }
function create() {
emit('action', { action: 'playlist-create', name: newName.value })
}
function remove(trackId?: number) { function remove(trackId?: number) {
if (!selected.value) return if (!selected.value) return
const playlistId = selected.value.id const playlistId = selected.value.id
@@ -91,19 +134,33 @@ function remove(trackId?: number) {
</script> </script>
<template> <template>
<n-space vertical data-testid="music-plan-editor"> <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> <p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
<template v-else> <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 <n-select
v-model:value="selectedId" v-model:value="selectedId"
:options="playlists.map((p) => ({ label: p.name, value: p.id }))" :options="playlists.map((p) => ({ label: p.name, value: p.id }))"
@@ -111,7 +168,7 @@ function remove(trackId?: number) {
placeholder="Плейлист для редактирования" placeholder="Плейлист для редактирования"
:input-props="{ 'aria-label': 'Плейлист для редактирования' }" :input-props="{ 'aria-label': 'Плейлист для редактирования' }"
/> />
<p v-if="!playlists.length">Плейлистов пока нет. Создайте первый.</p> <p v-if="!playlists.length">Плейлистов пока нет.</p>
<template v-if="selected"> <template v-if="selected">
<label for="playlist-name">Название плейлиста</label> <label for="playlist-name">Название плейлиста</label>
<n-input <n-input
@@ -125,7 +182,9 @@ function remove(trackId?: number) {
@click="emit('action', { action: 'playlist-rename', playlistId: selected.id, name })" @click="emit('action', { action: 'playlist-rename', playlistId: selected.id, name })"
>Сохранить название</n-button >Сохранить название</n-button
> >
<n-button :disabled="isDisabled || isDirty" @click="remove()">Удалить плейлист</n-button> <n-button :disabled="isDisabled || isDirty" @click="remove()"
>Удалить плейлист</n-button
>
<n-button <n-button
:disabled="isDisabled || isDirty" :disabled="isDisabled || isDirty"
@click="emit('action', { action: 'playlist-upload', playlistId: selected.id })" @click="emit('action', { action: 'playlist-upload', playlistId: selected.id })"
@@ -138,8 +197,9 @@ function remove(trackId?: number) {
> >
</n-space> </n-space>
<p v-if="!order.length">Треков пока нет.</p> <p v-if="!order.length">Треков пока нет.</p>
<ol class="track-list"> <ul class="track-list">
<li v-for="(id, index) in order" :key="id"> <li v-for="(id, index) in order" :key="id" class="track-row">
<div class="track-content track-edit-content">
<span>{{ <span>{{
selected.tracks.find((t) => t.id === id)?.name ?? `Трек №${id} удалён на сервере` selected.tracks.find((t) => t.id === id)?.name ?? `Трек №${id} удалён на сервере`
}}</span> }}</span>
@@ -160,41 +220,252 @@ function remove(trackId?: number) {
>Удалить трек</n-button >Удалить трек</n-button
> >
</n-space> </n-space>
</div>
</li> </li>
</ol> </ul>
<n-alert v-if="hasConflict" type="warning" <n-alert v-if="hasConflict" type="warning"
>Состав треков изменился на сервере. Отмените черновик, чтобы принять актуальный >Состав треков изменился на сервере. Отмените черновик, чтобы принять актуальный
список.</n-alert список.</n-alert
> >
<n-space v-if="isDirty || hasConflict"> </template>
<n-button </template>
:disabled="isDisabled || hasConflict" </n-space>
@click=" <n-space v-else vertical data-testid="music-plan-view">
emit('action', { <n-select
action: 'playlist-reorder', :value="channel.playlistId"
playlistId: selected.id, :options="localPlaylists.map((p) => ({ label: p.name, value: p.id }))"
trackIds: [...order], :disabled="isBusy"
}) placeholder="Выберите плейлист"
:input-props="{ 'aria-label': 'Плейлист' }"
@update:value="
emit('command', { action: 'playlist', channel: 'music', playlistId: $event })
" "
>Сохранить порядок</n-button />
<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"
> >
<n-button :disabled="props.isBusy" @click="reset">Отменить</n-button> <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> </n-space>
</template> </div>
</template> <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-space>
</n-card>
</template> </template>
<style scoped> <style scoped>
.track-list { .track-list {
display: grid; display: grid;
gap: 12px; gap: 12px;
padding-left: 24px; list-style: none;
margin: 0;
padding: 0;
} }
.track-list li > span { .track-row {
display: block; align-items: stretch;
margin-bottom: 8px; 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; 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> </style>
+14 -28
View File
@@ -23,9 +23,6 @@ const props = defineProps<{
notice: string notice: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
edit: []
cancel: []
save: []
add: [] add: []
remove: [index: number] remove: [index: number]
move: [index: number, offset: number] move: [index: number, offset: number]
@@ -49,22 +46,6 @@ function timerLabel(index: number) {
<template> <template>
<n-space vertical data-testid="screen-plan"> <n-space vertical data-testid="screen-plan">
<n-space>
<n-button v-if="!isEditing" :disabled="isBusy || !canEdit" @click="emit('edit')">
{{ steps.length ? 'Редактировать' : 'Создать план' }}
</n-button>
<template v-else>
<n-button :disabled="isBusy" @click="emit('cancel')">Отменить</n-button>
<n-button
type="primary"
:disabled="isBusy || !canEdit"
:loading="isBusy"
@click="emit('save')"
>
Сохранить план
</n-button>
</template>
</n-space>
<n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert> <n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert>
<p v-if="!canEdit">Для редактирования войдите на сервер игры.</p> <p v-if="!canEdit">Для редактирования войдите на сервер игры.</p>
<template v-if="isEditing"> <template v-if="isEditing">
@@ -156,6 +137,7 @@ function timerLabel(index: number) {
/> />
</div> </div>
<div class="plan-step-content"> <div class="plan-step-content">
<div class="plan-step-title">
<span <span
>{{ modeLabels[step.mode] >{{ modeLabels[step.mode]
}}<template v-if="step.mode === 8">: {{ step.text }}</template }}<template v-if="step.mode === 8">: {{ step.text }}</template
@@ -163,6 +145,14 @@ function timerLabel(index: number) {
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template >: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
></span ></span
> >
<n-button
data-testid="show-step"
:type="currentIndex === index ? 'primary' : 'default'"
:disabled="isBusy || currentIndex === index"
@click="emit('command', { action: 'step', index })"
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
>
</div>
<div class="plan-step-actions"> <div class="plan-step-actions">
<n-space v-if="[2, 4, 7].includes(step.mode)" align="center" :wrap="false"> <n-space v-if="[2, 4, 7].includes(step.mode)" align="center" :wrap="false">
<template v-if="step.mode === 7"> <template v-if="step.mode === 7">
@@ -236,13 +226,6 @@ function timerLabel(index: number) {
<slot name="main-volume" :index="index" :active="currentIndex === index" /> <slot name="main-volume" :index="index" :active="currentIndex === index" />
</template> </template>
</n-space> </n-space>
<n-button
data-testid="show-step"
:type="currentIndex === index ? 'primary' : 'default'"
:disabled="isBusy || currentIndex === index"
@click="emit('command', { action: 'step', index })"
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
>
</div> </div>
<slot <slot
v-if="step.mode === 2 || step.mode === 4" v-if="step.mode === 2 || step.mode === 4"
@@ -296,8 +279,11 @@ function timerLabel(index: number) {
} }
.plan-step-actions > [data-testid="show-step"] { .plan-step-title {
margin-left: auto; align-items: start;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
} }
.plan-step-preview { .plan-step-preview {