up
This commit is contained in:
+27
-12
@@ -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
@@ -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')
|
||||||
|
|||||||
+39
-110
@@ -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="План музыки">
|
<MusicPlanPanel
|
||||||
<n-space vertical>
|
:playlists="onlineState.playlists"
|
||||||
<MusicPlanPanel
|
:local-playlists="state.catalog.playlists"
|
||||||
:playlists="onlineState.playlists"
|
:channel="state.channels.music"
|
||||||
:can-edit="onlineState.authenticated && onlineState.connection !== 'offline'"
|
:output-open="state.outputOpen"
|
||||||
:can-use="planStatus.editable"
|
:can-edit="onlineState.authenticated && onlineState.connection !== 'offline'"
|
||||||
:is-busy="isOnlineBusy || onlineState.busy"
|
:can-use="planStatus.editable"
|
||||||
@action="runOnline"
|
:is-busy="isBusy || isOnlineBusy || onlineState.busy"
|
||||||
/>
|
@action="runOnline"
|
||||||
<n-select
|
@command="send"
|
||||||
: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>
|
||||||
|
|||||||
@@ -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,85 +134,15 @@ function remove(trackId?: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-space vertical data-testid="music-plan-editor">
|
<n-card title="План музыки" data-testid="music-plan">
|
||||||
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
|
<template #header-extra>
|
||||||
<template v-else>
|
<n-button v-if="!isEditing" :disabled="isDisabled" @click="edit">Редактировать</n-button>
|
||||||
<label for="new-playlist-name">Новый плейлист</label>
|
<n-space v-else>
|
||||||
<n-input
|
<template v-if="isDirty || hasConflict">
|
||||||
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-button
|
<n-button
|
||||||
:disabled="isDisabled || !name.trim() || name === selected.name"
|
:disabled="isDisabled || !selected || hasConflict"
|
||||||
@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"
|
|
||||||
@click="
|
@click="
|
||||||
|
selected &&
|
||||||
emit('action', {
|
emit('action', {
|
||||||
action: 'playlist-reorder',
|
action: 'playlist-reorder',
|
||||||
playlistId: selected.id,
|
playlistId: selected.id,
|
||||||
@@ -178,23 +151,321 @@ function remove(trackId?: number) {
|
|||||||
"
|
"
|
||||||
>Сохранить порядок</n-button
|
>Сохранить порядок</n-button
|
||||||
>
|
>
|
||||||
<n-button :disabled="props.isBusy" @click="reset">Отменить</n-button>
|
<n-button :disabled="props.isBusy" @click="reset">Отменить порядок</n-button>
|
||||||
</n-space>
|
</template>
|
||||||
</template>
|
<n-button :disabled="isBusy || isDirty || !!hasConflict" @click="isEditing = false"
|
||||||
|
>Готово</n-button
|
||||||
|
>
|
||||||
|
</n-space>
|
||||||
</template>
|
</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>
|
</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>
|
||||||
|
|||||||
@@ -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,13 +137,22 @@ function timerLabel(index: number) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="plan-step-content">
|
<div class="plan-step-content">
|
||||||
<span
|
<div class="plan-step-title">
|
||||||
>{{ modeLabels[step.mode]
|
<span
|
||||||
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
>{{ modeLabels[step.mode]
|
||||||
><template v-if="step.mode === 7"
|
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
||||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
><template v-if="step.mode === 7"
|
||||||
></span
|
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||||
>
|
></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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user