generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { Edit, Pause, Play, Repeat, Reset, Stop } from '@vicons/carbon'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NInput,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSlider,
|
||||
NSpace,
|
||||
NUpload,
|
||||
type UploadFileInfo,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { MusicState, Playlist } from '@/api/generated/crabs/evening_detective_server'
|
||||
import UiBlock from '@/components/UiBlock.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
playlists: Playlist[]
|
||||
music?: MusicState
|
||||
isBusy: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
setMusic: [
|
||||
change: {
|
||||
playlistID?: number
|
||||
trackIndex?: number
|
||||
positionMs?: number
|
||||
playback?: MusicState['playback']
|
||||
loop?: boolean
|
||||
volume?: number
|
||||
},
|
||||
]
|
||||
createPlaylist: [name: string]
|
||||
renamePlaylist: [id: number, name: string]
|
||||
reorderTracks: [playlistID: number, trackIDs: number[]]
|
||||
deletePlaylist: [id: number]
|
||||
uploadTrack: [playlistID: number, file: File]
|
||||
deleteTrack: [playlistID: number, trackID: number]
|
||||
}>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const selectedId = ref<number | null>(null)
|
||||
const name = ref('')
|
||||
const newName = ref('')
|
||||
const volume = ref(props.music?.volume ?? 100)
|
||||
const originalName = ref('')
|
||||
const order = ref<number[]>([])
|
||||
const originalOrder = ref<number[]>([])
|
||||
const selected = computed(() => props.playlists.find((item) => item.ID === selectedId.value))
|
||||
const options = computed(() =>
|
||||
props.playlists.map((item) => ({ label: item.name, value: item.ID })),
|
||||
)
|
||||
const isOrderDirty = computed(
|
||||
() => JSON.stringify(order.value) !== JSON.stringify(originalOrder.value),
|
||||
)
|
||||
const isNameDirty = computed(() => name.value !== originalName.value)
|
||||
const hasConflict = computed(
|
||||
() =>
|
||||
!!selected.value &&
|
||||
JSON.stringify(selected.value.tracks?.map((track) => track.ID) ?? []) !==
|
||||
JSON.stringify(originalOrder.value),
|
||||
)
|
||||
const canPlay = computed(() => !!props.music?.playlist?.tracks?.length && !props.isBusy)
|
||||
const isPlaying = computed(() => props.music?.playback === 'MUSIC_PLAYBACK_PLAYING')
|
||||
const playbackLabel = computed(() =>
|
||||
isPlaying.value
|
||||
? 'Пауза'
|
||||
: props.music?.playback === 'MUSIC_PLAYBACK_PAUSED'
|
||||
? 'Продолжить'
|
||||
: 'Старт',
|
||||
)
|
||||
const currentTrack = computed(() => props.music?.playlist?.tracks?.[props.music.trackIndex ?? 0])
|
||||
|
||||
function reset() {
|
||||
if (!selected.value) selectedId.value = null
|
||||
name.value = selected.value?.name ?? ''
|
||||
originalName.value = name.value
|
||||
order.value =
|
||||
selected.value?.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
|
||||
originalOrder.value = [...order.value]
|
||||
}
|
||||
function edit() {
|
||||
selectedId.value = props.music?.playlist?.ID ?? props.playlists[0]?.ID ?? null
|
||||
reset()
|
||||
isEditing.value = true
|
||||
}
|
||||
function finish() {
|
||||
if (
|
||||
(isOrderDirty.value || isNameDirty.value) &&
|
||||
!window.confirm('Отменить несохранённые изменения плейлиста?')
|
||||
)
|
||||
return
|
||||
reset()
|
||||
isEditing.value = false
|
||||
}
|
||||
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 uploadTrack(options: { file: UploadFileInfo }) {
|
||||
if (selectedId.value && options.file.file)
|
||||
emit('uploadTrack', selectedId.value, options.file.file)
|
||||
}
|
||||
function selectTrack(index: number) {
|
||||
if (index === (props.music?.trackIndex ?? 0)) return
|
||||
emit('setMusic', { trackIndex: index, positionMs: 0 })
|
||||
}
|
||||
watch(
|
||||
() => props.music?.volume,
|
||||
(value) => {
|
||||
volume.value = value ?? 100
|
||||
},
|
||||
)
|
||||
watch(selectedId, reset)
|
||||
watch(
|
||||
() => props.playlists,
|
||||
(items, previous) => {
|
||||
if (!isEditing.value) return
|
||||
const added = items.find((item) => !previous.some((old) => old.ID === item.ID))
|
||||
if (
|
||||
added &&
|
||||
props.isBusy &&
|
||||
added.name === newName.value.trim() &&
|
||||
!isOrderDirty.value &&
|
||||
!isNameDirty.value
|
||||
) {
|
||||
selectedId.value = added.ID ?? null
|
||||
newName.value = ''
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if (!selected.value) {
|
||||
if (isOrderDirty.value || isNameDirty.value) return
|
||||
selectedId.value = null
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if (!isNameDirty.value || name.value.trim() === selected.value.name) {
|
||||
name.value = selected.value.name ?? ''
|
||||
originalName.value = name.value
|
||||
}
|
||||
const ids =
|
||||
selected.value.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
|
||||
if (!isOrderDirty.value || JSON.stringify(ids) === JSON.stringify(order.value)) {
|
||||
order.value = ids
|
||||
originalOrder.value = [...ids]
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiBlock class="music-panel" data-testid="music-plan">
|
||||
<n-space justify="space-between" align="center">
|
||||
<h3>План музыки</h3>
|
||||
<n-button
|
||||
v-if="!isEditing"
|
||||
size="small"
|
||||
square
|
||||
aria-label="Редактировать музыку"
|
||||
title="Редактировать музыку"
|
||||
:disabled="isBusy"
|
||||
@click="edit"
|
||||
>
|
||||
<Icon :size="18"><Edit /></Icon>
|
||||
</n-button>
|
||||
<n-button v-else size="small" :disabled="isBusy" @click="finish">Готово</n-button>
|
||||
</n-space>
|
||||
<n-space v-if="isEditing" vertical class="music-content" data-testid="music-editor">
|
||||
<n-space :wrap="false">
|
||||
<n-input
|
||||
v-model:value="newName"
|
||||
placeholder="Новый плейлист"
|
||||
aria-label="Название нового плейлиста"
|
||||
:disabled="isBusy"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || !newName.trim()"
|
||||
@click="emit('createPlaylist', newName.trim())"
|
||||
>Создать</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-select
|
||||
v-model:value="selectedId"
|
||||
:options="options"
|
||||
placeholder="Плейлист для редактирования"
|
||||
:disabled="isBusy || isOrderDirty || isNameDirty"
|
||||
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
|
||||
/>
|
||||
<p v-if="!playlists.length">Плейлистов пока нет.</p>
|
||||
<n-alert v-if="selectedId && !selected" type="warning">
|
||||
Плейлист удалён на сервере. Несохранённый черновик сохранён до отмены.
|
||||
<n-button size="small" :disabled="isBusy" @click="reset"
|
||||
>Загрузить актуальный список</n-button
|
||||
>
|
||||
</n-alert>
|
||||
<template v-if="selected && selectedId">
|
||||
<label for="music-playlist-name">Название плейлиста</label>
|
||||
<n-input
|
||||
v-model:value="name"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ id: 'music-playlist-name' }"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || !name.trim() || !isNameDirty"
|
||||
@click="emit('renamePlaylist', selectedId, name.trim())"
|
||||
>Сохранить название</n-button
|
||||
>
|
||||
<n-upload
|
||||
accept=".mp3,audio/mpeg"
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
:disabled="isBusy || isOrderDirty || hasConflict"
|
||||
@change="uploadTrack"
|
||||
>
|
||||
<n-button size="small" :disabled="isBusy || isOrderDirty || hasConflict"
|
||||
>Добавить MP3</n-button
|
||||
>
|
||||
</n-upload>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || isOrderDirty || hasConflict || !order.length"
|
||||
@click="
|
||||
emit('setMusic', {
|
||||
playlistID: selectedId ?? undefined,
|
||||
trackIndex: 0,
|
||||
positionMs: 0,
|
||||
playback: 'MUSIC_PLAYBACK_STOPPED',
|
||||
})
|
||||
"
|
||||
>Использовать в игре</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('deletePlaylist', selectedId)">
|
||||
<template #trigger
|
||||
><n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isOrderDirty || isNameDirty"
|
||||
>Удалить плейлист</n-button
|
||||
></template
|
||||
>
|
||||
Удалить плейлист «{{ selected.name }}»?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<n-alert v-if="hasConflict" type="warning"
|
||||
>Порядок или состав треков изменился на сервере. Загрузите актуальный список перед
|
||||
сохранением.</n-alert
|
||||
>
|
||||
<n-space v-if="isOrderDirty || isNameDirty || hasConflict">
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || !isOrderDirty || hasConflict"
|
||||
@click="emit('reorderTracks', selectedId, [...order])"
|
||||
>Сохранить порядок</n-button
|
||||
>
|
||||
<n-button size="small" :disabled="isBusy" @click="reset">{{
|
||||
hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения'
|
||||
}}</n-button>
|
||||
</n-space>
|
||||
<p v-if="!order.length">Треков пока нет.</p>
|
||||
<div v-for="(id, index) in order" :key="id" class="track-row">
|
||||
<span
|
||||
>{{ index + 1 }}.
|
||||
{{
|
||||
selected.tracks?.find((track) => track.ID === id)?.name ?? 'Трек удалён на сервере'
|
||||
}}</span
|
||||
>
|
||||
<n-space>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || hasConflict || index === 0"
|
||||
:aria-label="`Поднять трек ${index + 1}`"
|
||||
@click="move(index, -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || hasConflict || index === order.length - 1"
|
||||
:aria-label="`Опустить трек ${index + 1}`"
|
||||
@click="move(index, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('deleteTrack', selectedId, id)">
|
||||
<template #trigger
|
||||
><n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isOrderDirty || hasConflict"
|
||||
>Удалить</n-button
|
||||
></template
|
||||
>
|
||||
Удалить трек из плейлиста?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
</n-space>
|
||||
<n-space v-else vertical class="music-content">
|
||||
<div class="playlist-picker">
|
||||
<n-select
|
||||
:value="music?.playlist?.ID"
|
||||
:options="options"
|
||||
clearable
|
||||
placeholder="Выберите плейлист"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': 'Музыка игры' }"
|
||||
@update:value="
|
||||
emit('setMusic', {
|
||||
playlistID: $event ?? 0,
|
||||
trackIndex: 0,
|
||||
positionMs: 0,
|
||||
playback: 'MUSIC_PLAYBACK_STOPPED',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<n-button
|
||||
square
|
||||
:type="music?.loop ? 'primary' : 'default'"
|
||||
:aria-pressed="music?.loop ?? false"
|
||||
aria-label="Повторять плейлист"
|
||||
title="Повторять плейлист"
|
||||
:disabled="isBusy"
|
||||
@click="emit('setMusic', { loop: !music?.loop })"
|
||||
><Icon :size="18"><Repeat /></Icon
|
||||
></n-button>
|
||||
</div>
|
||||
<p v-if="currentTrack" role="status">
|
||||
{{ currentTrack.name }} ·
|
||||
{{
|
||||
isPlaying
|
||||
? 'Играет'
|
||||
: music?.playback === 'MUSIC_PLAYBACK_PAUSED'
|
||||
? 'Пауза'
|
||||
: 'Остановлено'
|
||||
}}
|
||||
</p>
|
||||
<n-space align="center">
|
||||
<n-button
|
||||
square
|
||||
:aria-label="playbackLabel"
|
||||
:title="playbackLabel"
|
||||
:disabled="!canPlay"
|
||||
@click="
|
||||
emit('setMusic', {
|
||||
playback: isPlaying ? 'MUSIC_PLAYBACK_PAUSED' : 'MUSIC_PLAYBACK_PLAYING',
|
||||
})
|
||||
"
|
||||
><Icon :size="18"><Pause v-if="isPlaying" /><Play v-else /></Icon
|
||||
></n-button>
|
||||
<n-button
|
||||
square
|
||||
aria-label="Стоп"
|
||||
title="Стоп"
|
||||
:disabled="!canPlay"
|
||||
@click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_STOPPED', positionMs: 0 })"
|
||||
><Icon :size="18"><Stop /></Icon
|
||||
></n-button>
|
||||
<n-button
|
||||
square
|
||||
aria-label="Сначала"
|
||||
title="Сначала"
|
||||
:disabled="!canPlay"
|
||||
@click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_PLAYING', positionMs: 0 })"
|
||||
><Icon :size="18"><Reset /></Icon
|
||||
></n-button>
|
||||
<div class="music-volume">
|
||||
<span id="music-volume-label">Громкость</span
|
||||
><n-slider
|
||||
:value="volume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="isBusy || !music?.playlist"
|
||||
aria-labelledby="music-volume-label"
|
||||
:format-tooltip="(value) => `${value}%`"
|
||||
@update:value="volume = $event"
|
||||
@dragend="emit('setMusic', { volume })"
|
||||
@keyup="emit('setMusic', { volume })"
|
||||
/>
|
||||
</div>
|
||||
</n-space>
|
||||
<p v-if="!music?.playlist">Выберите плейлист для игры.</p>
|
||||
<p v-else-if="!music.playlist.tracks?.length">В плейлисте пока нет треков.</p>
|
||||
<div
|
||||
v-for="(track, index) in music?.playlist?.tracks ?? []"
|
||||
:key="track.ID"
|
||||
class="track-row"
|
||||
:class="{ 'track-current': index === (music?.trackIndex ?? 0) }"
|
||||
:aria-current="index === (music?.trackIndex ?? 0) ? 'true' : undefined"
|
||||
>
|
||||
<span>{{ index + 1 }}. {{ track.name }}</span>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || index === (music?.trackIndex ?? 0)"
|
||||
@click="selectTrack(index)"
|
||||
>{{ index === (music?.trackIndex ?? 0) ? 'Выбран' : 'Выбрать' }}</n-button
|
||||
>
|
||||
</div>
|
||||
</n-space>
|
||||
</UiBlock>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.music-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.music-content {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.playlist-picker {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.music-volume {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 210px;
|
||||
}
|
||||
|
||||
.music-volume .n-slider {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
align-items: center;
|
||||
border: 1px solid var(--brand-border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.track-current {
|
||||
border-color: var(--brand-accent);
|
||||
}
|
||||
</style>
|
||||
+202
-38
@@ -47,11 +47,13 @@ import type {
|
||||
} from '@/api/generated/crabs/evening_detective_server'
|
||||
import notificationSoundURL from '@/assets/icq-uh-oh.wav'
|
||||
import GameMapPanel from '@/components/GameMapPanel.vue'
|
||||
import GameMusicPlanPanel from '@/components/GameMusicPlanPanel.vue'
|
||||
import GameScreenPlanPanel from '@/components/GameScreenPlanPanel.vue'
|
||||
import GameScreensPanel from '@/components/GameScreensPanel.vue'
|
||||
import GameStatePanel from '@/components/GameStatePanel.vue'
|
||||
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import ScreenPreview from '@/components/ScreenPreview.vue'
|
||||
import TeamReceiptModal from '@/components/TeamReceiptModal.vue'
|
||||
import TicketCheckModal from '@/components/TicketCheckModal.vue'
|
||||
import UiBlock from '@/components/UiBlock.vue'
|
||||
@@ -234,6 +236,12 @@ const activeTeamCount = computed(
|
||||
const screens = ref<ScreenState[]>([])
|
||||
const playlists = ref<Playlist[]>([])
|
||||
const music = ref<MusicState>()
|
||||
const isMusicBusy = ref(false)
|
||||
let musicRequestVersion = 0
|
||||
let musicSyncedAt = Date.now()
|
||||
const isScreenBusy = ref(false)
|
||||
const isScreensLoading = ref(true)
|
||||
const screensError = ref('')
|
||||
const screenPlan = ref<ScreenPlan>()
|
||||
const screenPlanDraft = ref<ScreenPlanStep[]>([])
|
||||
const screenPlanDirty = ref(false)
|
||||
@@ -246,7 +254,15 @@ const mapItems = ref<MapItem[]>([])
|
||||
const mapSaving = ref(false)
|
||||
const mapDragging = ref(false)
|
||||
const screenVolume = ref(100)
|
||||
const screenPosition = ref(0)
|
||||
const previewScreen = computed(
|
||||
() => screens.value.find((screen) => screen.status === 'connected') || screens.value[0],
|
||||
)
|
||||
watch(
|
||||
() => previewScreen.value?.volume,
|
||||
(value) => {
|
||||
screenVolume.value = value ?? 100
|
||||
},
|
||||
)
|
||||
|
||||
function reportSoundError(error: unknown) {
|
||||
if (isDisposed || hasReportedSoundError) return
|
||||
@@ -368,26 +384,38 @@ async function getGame(id: number) {
|
||||
if (canManageGame.value) getGame(Number(gameId))
|
||||
|
||||
async function getGameScreens() {
|
||||
try {
|
||||
const res = await client.GetGameScreens({ gameID: Number(gameId) })
|
||||
screens.value = res.screens || []
|
||||
const previewScreen =
|
||||
screens.value.find((screen) => screen.status === 'connected') || screens.value[0]
|
||||
if (previewScreen?.mode === 'SCREEN_MODE_RESULTS') {
|
||||
screensError.value = ''
|
||||
if (
|
||||
(screenPlanMode.value === 'edit' ? screenPlanDraft.value : screenPlan.value?.steps)?.some(
|
||||
(step) => step.mode === 'SCREEN_MODE_RESULTS',
|
||||
) ||
|
||||
previewScreen.value?.mode === 'SCREEN_MODE_RESULTS'
|
||||
) {
|
||||
const result = await client.GetGameResults({ gameID: Number(gameId) })
|
||||
previewQuestions.value = result.questions || []
|
||||
previewTeams.value = result.teams || []
|
||||
} else {
|
||||
previewQuestions.value = []
|
||||
previewTeams.value = []
|
||||
}
|
||||
} catch (err) {
|
||||
screensError.value =
|
||||
err instanceof Error ? err.message : 'Не удалось получить состояние экранов'
|
||||
} finally {
|
||||
isScreensLoading.value = false
|
||||
}
|
||||
}
|
||||
if (canManageGame.value) getGameScreens()
|
||||
|
||||
async function getGameMusic() {
|
||||
async function getGameMusic(isAfterSave = false) {
|
||||
if (isMusicBusy.value && !isAfterSave) return
|
||||
const requestVersion = ++musicRequestVersion
|
||||
try {
|
||||
const res = await client.GetGameMusic({ gameID: Number(gameId) })
|
||||
if (requestVersion !== musicRequestVersion) return
|
||||
playlists.value = res.playlists || []
|
||||
music.value = res.state
|
||||
musicSyncedAt = Date.now()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры')
|
||||
}
|
||||
@@ -399,20 +427,23 @@ function isScreenPlanEditing() {
|
||||
}
|
||||
|
||||
async function getScreenPlan() {
|
||||
console.log('getScreenPlan')
|
||||
if (isScreenPlanEditing() || screenPlanDirty.value) return
|
||||
console.log('no skip getScreenPlan')
|
||||
if (isScreenPlanEditing() || screenPlanDirty.value || isScreenBusy.value) return
|
||||
const requestVersion = ++screenPlanRequestVersion
|
||||
try {
|
||||
const res = await client.GetScreenPlan({ gameID: Number(gameId) })
|
||||
if (
|
||||
requestVersion !== screenPlanRequestVersion ||
|
||||
isScreenPlanEditing() ||
|
||||
isScreenBusy.value ||
|
||||
screenPlanDirty.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
screenPlan.value = res.plan
|
||||
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось получить план экранов')
|
||||
}
|
||||
}
|
||||
if (canManageGame.value) getScreenPlan()
|
||||
|
||||
@@ -464,6 +495,18 @@ function clearScreenPlan() {
|
||||
|
||||
async function saveScreenPlan() {
|
||||
if (screenPlanSaving.value) return
|
||||
const invalidStep = screenPlanDraft.value.findIndex(
|
||||
(step) =>
|
||||
(step.mode === 'SCREEN_MODE_TEXT' && !step.text?.trim()) ||
|
||||
(step.mode === 'SCREEN_MODE_TIMER' &&
|
||||
(!Number.isInteger(step.timerDurationSeconds) ||
|
||||
(step.timerDurationSeconds ?? 0) < 1 ||
|
||||
(step.timerDurationSeconds ?? 0) > 86400)),
|
||||
)
|
||||
if (invalidStep !== -1) {
|
||||
message.error(`Проверьте текст или длительность таймера в шаге ${invalidStep + 1}`)
|
||||
return
|
||||
}
|
||||
screenPlanRequestVersion += 1
|
||||
screenPlanSaving.value = true
|
||||
try {
|
||||
@@ -489,18 +532,25 @@ async function saveScreenPlan() {
|
||||
}
|
||||
|
||||
async function setScreenPlanStep(index: number) {
|
||||
if (screenPlanDirty.value || !screenPlan.value) return
|
||||
if (screenPlanDirty.value || !screenPlan.value || isScreenBusy.value || screenPlanSaving.value)
|
||||
return
|
||||
isScreenBusy.value = true
|
||||
screenPlanRequestVersion += 1
|
||||
try {
|
||||
const res = await client.SetScreenPlanStep({ gameID: Number(gameId), currentStep: index })
|
||||
screenPlan.value = res.plan
|
||||
await getGameScreens()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось перейти на шаг плана')
|
||||
} finally {
|
||||
isScreenBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: ScreenCommand) {
|
||||
if (
|
||||
isScreenBusy.value ||
|
||||
screenPlanSaving.value ||
|
||||
screenPlanDirty.value ||
|
||||
(mode !== 'SCREEN_MODE_TIMER' &&
|
||||
mode !== 'SCREEN_MODE_INTRO' &&
|
||||
@@ -508,19 +558,21 @@ async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: Scre
|
||||
) {
|
||||
return
|
||||
}
|
||||
isScreenBusy.value = true
|
||||
try {
|
||||
await client.SendScreenCommand({
|
||||
gameID: Number(gameId),
|
||||
command,
|
||||
positionMs: Math.max(0, Math.round(screenPosition.value * 1000)),
|
||||
positionMs: 0,
|
||||
volume: Math.max(0, Math.min(100, Math.round(screenVolume.value))),
|
||||
})
|
||||
await getGameScreens()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось управлять экраном')
|
||||
} finally {
|
||||
isScreenBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bindScannedScreen(screenID: string, accessToken: string) {
|
||||
if (screenBinding.value) return
|
||||
screenBinding.value = true
|
||||
@@ -570,8 +622,16 @@ async function saveMapItemAndReload(item: MapItem, hidden = item.hidden) {
|
||||
}
|
||||
|
||||
async function setSoundScreen(screen: ScreenState) {
|
||||
if (isScreenBusy.value) return
|
||||
isScreenBusy.value = true
|
||||
try {
|
||||
await client.SetScreenSound({ screenID: screen.ID, soundEnabled: true })
|
||||
await getGameScreens()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось назначить звук')
|
||||
} finally {
|
||||
isScreenBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setGameMusic(change: {
|
||||
@@ -582,67 +642,122 @@ async function setGameMusic(change: {
|
||||
loop?: boolean
|
||||
volume?: number
|
||||
}) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
const current = music.value
|
||||
try {
|
||||
const res = await client.SetGameMusic({
|
||||
gameID: Number(gameId),
|
||||
playlistID: change.playlistID ?? current?.playlist?.ID ?? 0,
|
||||
trackIndex: change.trackIndex ?? current?.trackIndex ?? 0,
|
||||
positionMs: change.positionMs ?? current?.positionMs ?? 0,
|
||||
positionMs:
|
||||
change.positionMs ??
|
||||
(current?.positionMs ?? 0) +
|
||||
(current?.playback === 'MUSIC_PLAYBACK_PLAYING'
|
||||
? Math.max(0, Date.now() - musicSyncedAt)
|
||||
: 0),
|
||||
volume: change.volume ?? current?.volume ?? 100,
|
||||
loop: change.loop ?? current?.loop ?? false,
|
||||
playback: change.playback ?? current?.playback ?? 'MUSIC_PLAYBACK_STOPPED',
|
||||
})
|
||||
music.value = res.state
|
||||
musicSyncedAt = Date.now()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось изменить музыку')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlaylist(name: string) {
|
||||
if (isMusicBusy.value || !name.trim()) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.CreatePlaylist({ name })
|
||||
await getGameMusic()
|
||||
await client.CreatePlaylist({ name: name.trim() })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renamePlaylist(id: number, name: string) {
|
||||
if (isMusicBusy.value || !name.trim()) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.UpdatePlaylist({ ID: id, name: name.trim() })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось переименовать плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderTracks(playlistID: number, trackIDs: number[]) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.ReorderPlaylistTracks({ playlistID, trackIDs })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось сохранить порядок треков')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlaylist(id: number) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.DeletePlaylist({ ID: id })
|
||||
await getGameMusic()
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTrack(file: File) {
|
||||
const playlistID = music.value?.playlist?.ID
|
||||
if (!playlistID) {
|
||||
message.warning('Сначала выберите плейлист')
|
||||
async function uploadTrack(playlistID: number, file: File) {
|
||||
if (isMusicBusy.value) return
|
||||
if (!/\.mp3$/i.test(file.name) || file.size === 0 || file.size > 64 * 1024 * 1024) {
|
||||
message.error('Выберите непустой MP3 размером до 64 МиБ')
|
||||
return
|
||||
}
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer()))
|
||||
await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data })
|
||||
await getGameMusic()
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrack(id: number) {
|
||||
const playlistID = music.value?.playlist?.ID
|
||||
if (!playlistID) return
|
||||
async function deleteTrack(playlistID: number, id: number) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.DeletePlaylistTrack({ playlistID, trackID: id })
|
||||
await getGameMusic()
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить трек')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function uint8ToBase64(data: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let i = 0; i < data.length; i += 0x8000) binary += String.fromCharCode(...data.subarray(i, i + 0x8000))
|
||||
@@ -650,8 +765,16 @@ function uint8ToBase64(data: Uint8Array): string {
|
||||
}
|
||||
|
||||
async function unbindScreen(screen: ScreenState) {
|
||||
if (isScreenBusy.value) return
|
||||
isScreenBusy.value = true
|
||||
try {
|
||||
await client.UnbindScreen({ screenID: screen.ID })
|
||||
await getGameScreens()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось отвязать экран')
|
||||
} finally {
|
||||
isScreenBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toTeamStory(id: string, password: string, newTab: boolean = false) {
|
||||
@@ -1387,20 +1510,29 @@ useSimplePolling(() => {
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="canManageGame" name="screens" tab="Экраны">
|
||||
<div class="game-tab-content">
|
||||
<n-alert v-if="screensError" type="error">{{ screensError }}</n-alert>
|
||||
<p v-if="isScreensLoading" role="status">Загрузка экранов…</p>
|
||||
<div class="screen-overview">
|
||||
<UiBlock>
|
||||
<h3 class="screens-title">Превью экрана</h3>
|
||||
<ScreenPreview
|
||||
v-if="previewScreen?.status === 'connected'"
|
||||
:state="previewScreen"
|
||||
:questions="previewQuestions"
|
||||
:teams="previewTeams"
|
||||
/>
|
||||
<div v-else class="screen-preview-placeholder">Экран не подключён</div>
|
||||
</UiBlock>
|
||||
<GameScreensPanel
|
||||
:screens="screens"
|
||||
:screen-binding="screenBinding"
|
||||
:playlists="playlists"
|
||||
:music="music"
|
||||
:is-busy="isScreenBusy"
|
||||
@bind="bindScannedScreen"
|
||||
@set-sound="setSoundScreen"
|
||||
@unbind="unbindScreen"
|
||||
@set-music="setGameMusic"
|
||||
@create-playlist="createPlaylist"
|
||||
@delete-playlist="deletePlaylist"
|
||||
@upload-track="uploadTrack"
|
||||
@delete-track="deleteTrack"
|
||||
/>
|
||||
</div>
|
||||
<div class="presentation-grid">
|
||||
<GameScreenPlanPanel
|
||||
:screens="screens"
|
||||
:preview-questions="previewQuestions"
|
||||
@@ -1410,7 +1542,7 @@ useSimplePolling(() => {
|
||||
:screen-plan-saving="screenPlanSaving"
|
||||
:screen-plan-mode="screenPlanMode"
|
||||
:screen-volume="screenVolume"
|
||||
:screen-position="screenPosition"
|
||||
:is-busy="isScreenBusy"
|
||||
@add-step="addScreenPlanStep"
|
||||
@edit="editScreenPlan"
|
||||
@cancel="cancelScreenPlanEdit"
|
||||
@@ -1421,9 +1553,21 @@ useSimplePolling(() => {
|
||||
@save="saveScreenPlan"
|
||||
@set-step="setScreenPlanStep"
|
||||
@command="sendScreenPlanCommand"
|
||||
@update:screen-volume="screenVolume = $event ?? 0"
|
||||
@update:screen-position="screenPosition = $event ?? 0"
|
||||
@update:screen-volume="screenVolume = $event"
|
||||
/>
|
||||
<GameMusicPlanPanel
|
||||
:playlists="playlists"
|
||||
:music="music"
|
||||
:is-busy="isMusicBusy"
|
||||
@set-music="setGameMusic"
|
||||
@create-playlist="createPlaylist"
|
||||
@rename-playlist="renamePlaylist"
|
||||
@reorder-tracks="reorderTracks"
|
||||
@delete-playlist="deletePlaylist"
|
||||
@upload-track="uploadTrack"
|
||||
@delete-track="deleteTrack"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="canManageGame" name="game" tab="Игра">
|
||||
@@ -1945,6 +2089,26 @@ useSimplePolling(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screen-overview,
|
||||
.presentation-grid {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screen-overview > *,
|
||||
.presentation-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (width >= 1024px) {
|
||||
.screen-overview,
|
||||
.presentation-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-local-mode-modal {
|
||||
max-width: calc(100vw - 32px);
|
||||
width: 560px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex, NInput, NInputNumber, NSelect, NSpace, NText } from 'naive-ui'
|
||||
import { Edit, Pause, Play, Reset, Stop } from '@vicons/carbon'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { NButton, NInput, NInputNumber, NSelect, NSlider, NSpace } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type {
|
||||
@@ -23,9 +25,8 @@ const props = defineProps<{
|
||||
screenPlanSaving: boolean
|
||||
screenPlanMode: 'use' | 'edit'
|
||||
screenVolume: number
|
||||
screenPosition: number
|
||||
isBusy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
addStep: []
|
||||
edit: []
|
||||
@@ -37,14 +38,38 @@ const emit = defineEmits<{
|
||||
save: []
|
||||
setStep: [index: number]
|
||||
command: [mode: ScreenMode | undefined, command: ScreenCommand]
|
||||
'update:screenVolume': [value: number | null]
|
||||
'update:screenPosition': [value: number | null]
|
||||
'update:screenVolume': [value: number]
|
||||
}>()
|
||||
|
||||
const previewScreen = computed(
|
||||
() => props.screens.find((screen) => screen.status === 'connected') || props.screens[0],
|
||||
)
|
||||
const screenPlanModeOptions = [
|
||||
const hasMainSource = computed(() =>
|
||||
previewScreen.value?.mode === 'SCREEN_MODE_INTRO'
|
||||
? !!previewScreen.value.introAudio
|
||||
: !!previewScreen.value?.conclusionVideo,
|
||||
)
|
||||
const hasConnectedScreen = computed(() =>
|
||||
props.screens.some((screen) => screen.status === 'connected'),
|
||||
)
|
||||
const isDisabled = computed(() => props.screenPlanSaving || props.isBusy)
|
||||
const steps = computed(() =>
|
||||
props.screenPlanMode === 'edit' ? props.screenPlanDraft : (props.screenPlan?.steps ?? []),
|
||||
)
|
||||
const timerControl = computed(() => {
|
||||
if (previewScreen.value?.timerState === 'SCREEN_TIMER_STATE_RUNNING')
|
||||
return { label: 'Пауза', command: 'SCREEN_COMMAND_TIMER_PAUSE' as const, icon: Pause }
|
||||
if (previewScreen.value?.timerState === 'SCREEN_TIMER_STATE_PAUSED')
|
||||
return { label: 'Продолжить', command: 'SCREEN_COMMAND_TIMER_RESUME' as const, icon: Play }
|
||||
return { label: 'Старт', command: 'SCREEN_COMMAND_TIMER_START' as const, icon: Play }
|
||||
})
|
||||
const mediaControls = [
|
||||
{ label: 'Запуск', command: 'SCREEN_COMMAND_PLAY', icon: Play },
|
||||
{ label: 'Пауза', command: 'SCREEN_COMMAND_PAUSE', icon: Pause },
|
||||
{ label: 'Продолжить', command: 'SCREEN_COMMAND_RESUME', icon: Play },
|
||||
{ label: 'Стоп', command: 'SCREEN_COMMAND_STOP', icon: Stop },
|
||||
{ label: 'Сначала', command: 'SCREEN_COMMAND_REPLAY', icon: Reset },
|
||||
] satisfies { label: string; command: ScreenCommand; icon: typeof Play }[]
|
||||
const modeOptions = [
|
||||
{ label: 'Обложка', value: 'SCREEN_MODE_COVER' },
|
||||
{ label: 'Введение', value: 'SCREEN_MODE_INTRO' },
|
||||
{ label: 'Закрывашка', value: 'SCREEN_MODE_CONCLUSION' },
|
||||
@@ -52,278 +77,266 @@ const screenPlanModeOptions = [
|
||||
{ label: 'Таймер', value: 'SCREEN_MODE_TIMER' },
|
||||
{ label: 'Текст', value: 'SCREEN_MODE_TEXT' },
|
||||
]
|
||||
function thumbnail(step: ScreenPlanStep): Partial<ScreenState> {
|
||||
return {
|
||||
...previewScreen.value,
|
||||
...step,
|
||||
timerState: 'SCREEN_TIMER_STATE_STOPPED',
|
||||
timerRemainingSeconds: step.timerDurationSeconds,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiBlock class="screen-preview-panel">
|
||||
<p class="screens-title">Превью экрана</p>
|
||||
<ScreenPreview
|
||||
v-if="previewScreen?.status === 'connected'"
|
||||
:state="previewScreen"
|
||||
:questions="previewQuestions"
|
||||
:teams="previewTeams"
|
||||
/>
|
||||
<div v-else class="screen-preview-placeholder">Экран не подключён</div>
|
||||
<div class="screen-plan">
|
||||
<n-flex align="center" justify="space-between" class="screen-header">
|
||||
<p class="screens-title">План экранов</p>
|
||||
<UiBlock class="screen-plan-panel" data-testid="screen-plan">
|
||||
<n-space align="center" justify="space-between">
|
||||
<h3>План экранов</h3>
|
||||
<n-space>
|
||||
<n-button
|
||||
v-if="!screenPlan && screenPlanMode === 'use'"
|
||||
v-if="screenPlanMode === 'use' && !screenPlan"
|
||||
size="small"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('addStep')"
|
||||
>Создать план</n-button
|
||||
>
|
||||
Создать план
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="screenPlan && screenPlanMode === 'use'"
|
||||
v-else-if="screenPlanMode === 'use'"
|
||||
size="small"
|
||||
square
|
||||
aria-label="Редактировать план экранов"
|
||||
title="Редактировать план экранов"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
Редактировать
|
||||
</n-button>
|
||||
<template v-if="screenPlanMode === 'edit'">
|
||||
<n-button size="small" :disabled="screenPlanSaving" @click="emit('cancel')">
|
||||
Отменить
|
||||
</n-button>
|
||||
><Icon :size="18"><Edit /></Icon
|
||||
></n-button>
|
||||
<template v-else>
|
||||
<n-button size="small" :disabled="isDisabled" @click="emit('cancel')">Отменить</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="screenPlanSaving"
|
||||
:disabled="screenPlanSaving"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('save')"
|
||||
>Сохранить план</n-button
|
||||
>
|
||||
Сохранить план
|
||||
</n-button>
|
||||
</template>
|
||||
</n-space>
|
||||
</n-flex>
|
||||
<p v-if="!screenPlan && screenPlanMode === 'use'" class="screen-empty">
|
||||
План экранов ещё не создан.
|
||||
</n-space>
|
||||
<p v-if="!steps.length">
|
||||
{{
|
||||
screenPlanMode === 'edit'
|
||||
? 'Добавьте шаги или сохраните пустой план для удаления.'
|
||||
: 'План экранов ещё не создан.'
|
||||
}}
|
||||
</p>
|
||||
<template v-else-if="screenPlanMode === 'edit'">
|
||||
<n-space
|
||||
v-for="(step, index) in screenPlanDraft"
|
||||
<div class="plan-steps">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="screen-plan-step"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap
|
||||
class="plan-step"
|
||||
:class="{
|
||||
'plan-step-current': screenPlanMode === 'use' && screenPlan?.currentStep === index,
|
||||
}"
|
||||
:aria-current="
|
||||
screenPlanMode === 'use' && screenPlan?.currentStep === index ? 'step' : undefined
|
||||
"
|
||||
>
|
||||
<n-space align="center" wrap>
|
||||
<n-text>{{ index + 1 }}</n-text>
|
||||
<div class="step-preview" aria-hidden="true">
|
||||
<ScreenPreview
|
||||
:state="thumbnail(step)"
|
||||
:questions="previewQuestions"
|
||||
:teams="previewTeams"
|
||||
is-thumbnail
|
||||
/>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<template v-if="screenPlanMode === 'edit'">
|
||||
<n-space align="center">
|
||||
<span>{{ index + 1 }}</span>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isDisabled || index === 0"
|
||||
:aria-label="`Поднять шаг ${index + 1}`"
|
||||
@click="emit('moveStep', index, -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isDisabled || index === steps.length - 1"
|
||||
:aria-label="`Опустить шаг ${index + 1}`"
|
||||
@click="emit('moveStep', index, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isDisabled"
|
||||
@click="emit('removeStep', index)"
|
||||
>Удалить</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-select
|
||||
v-model:value="step.mode"
|
||||
:options="screenPlanModeOptions"
|
||||
:disabled="screenPlanSaving"
|
||||
size="small"
|
||||
style="width: 180px"
|
||||
:options="modeOptions"
|
||||
:disabled="isDisabled"
|
||||
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
|
||||
@update:value="emit('dirty')"
|
||||
/>
|
||||
<n-input
|
||||
v-if="step.mode === 'SCREEN_MODE_TEXT'"
|
||||
v-model:value="step.text"
|
||||
:disabled="screenPlanSaving"
|
||||
size="small"
|
||||
type="textarea"
|
||||
placeholder="Текст экрана"
|
||||
style="min-width: 240px"
|
||||
:disabled="isDisabled"
|
||||
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
|
||||
@update:value="emit('dirty')"
|
||||
/>
|
||||
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||||
<label :for="`timer-label-${index}`">Надпись таймера</label>
|
||||
<n-input
|
||||
v-model:value="step.timerLabel"
|
||||
:disabled="screenPlanSaving"
|
||||
size="small"
|
||||
placeholder="Надпись таймера"
|
||||
:disabled="isDisabled"
|
||||
:input-props="{ id: `timer-label-${index}` }"
|
||||
@update:value="emit('dirty')"
|
||||
/>
|
||||
<label :for="`timer-duration-${index}`">Длительность в секундах</label>
|
||||
<n-input-number
|
||||
v-model:value="step.timerDurationSeconds"
|
||||
:min="1"
|
||||
:max="86400"
|
||||
:disabled="screenPlanSaving"
|
||||
size="small"
|
||||
placeholder="Секунды"
|
||||
:disabled="isDisabled"
|
||||
:input-props="{ id: `timer-duration-${index}` }"
|
||||
@update:value="emit('dirty')"
|
||||
/>
|
||||
</template>
|
||||
</n-space>
|
||||
<n-space class="screen-plan-actions">
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="screenPlanSaving || index === 0"
|
||||
@click="emit('moveStep', index, -1)"
|
||||
>
|
||||
↑
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="screenPlanSaving || index === screenPlanDraft.length - 1"
|
||||
@click="emit('moveStep', index, 1)"
|
||||
>
|
||||
↓
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="screenPlanSaving"
|
||||
@click="emit('removeStep', index)"
|
||||
>
|
||||
Удалить
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<n-button size="small" :disabled="screenPlanSaving" @click="emit('addStep')">
|
||||
Добавить шаг
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="screenPlanDraft.length"
|
||||
size="small"
|
||||
:disabled="screenPlanSaving"
|
||||
@click="emit('clearPlan')"
|
||||
>
|
||||
Удалить план
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
<template v-else-if="screenPlan">
|
||||
<n-space
|
||||
v-for="(step, index) in screenPlan.steps || []"
|
||||
:key="index"
|
||||
class="screen-plan-step"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap
|
||||
<template v-else>
|
||||
<div class="step-heading">
|
||||
<span
|
||||
>{{ index + 1 }}.
|
||||
{{ modeOptions.find((option) => option.value === step.mode)?.label
|
||||
}}<template v-if="step.mode === 'SCREEN_MODE_TEXT'">: {{ step.text }}</template
|
||||
><template v-if="step.mode === 'SCREEN_MODE_TIMER'"
|
||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||
></span
|
||||
>
|
||||
<n-space align="center">
|
||||
<n-text>{{ index + 1 }}</n-text>
|
||||
<n-text>
|
||||
{{ screenPlanModeOptions.find((option) => option.value === step.mode)?.label }}
|
||||
<template v-if="step.mode === 'SCREEN_MODE_TEXT'">: {{ step.text }}</template>
|
||||
<template v-else-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||||
: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)
|
||||
</template>
|
||||
</n-text>
|
||||
</n-space>
|
||||
<n-space class="screen-plan-actions">
|
||||
<n-button
|
||||
size="small"
|
||||
:type="screenPlan.currentStep === index ? 'primary' : 'default'"
|
||||
:disabled="screenPlan.currentStep === index"
|
||||
:disabled="isDisabled || screenPlan?.currentStep === index || !hasConnectedScreen"
|
||||
@click="emit('setStep', index)"
|
||||
>{{ screenPlan?.currentStep === index ? 'Показан' : 'Показать' }}</n-button
|
||||
>
|
||||
Показать
|
||||
</n-button>
|
||||
<template v-if="screenPlan.currentStep === index">
|
||||
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||||
</div>
|
||||
<template v-if="screenPlan?.currentStep === index">
|
||||
<n-space v-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||||
<n-button
|
||||
size="small"
|
||||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_START')"
|
||||
>
|
||||
Старт
|
||||
</n-button>
|
||||
square
|
||||
:aria-label="timerControl.label"
|
||||
:title="timerControl.label"
|
||||
:disabled="isDisabled || !hasConnectedScreen"
|
||||
@click="emit('command', step.mode, timerControl.command)"
|
||||
><Icon :size="18"><component :is="timerControl.icon" /></Icon
|
||||
></n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_PAUSE')"
|
||||
>
|
||||
Пауза
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESUME')"
|
||||
>
|
||||
Продолжить
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
square
|
||||
aria-label="Сбросить таймер"
|
||||
title="Сбросить таймер"
|
||||
:disabled="isDisabled || !hasConnectedScreen"
|
||||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESET')"
|
||||
>
|
||||
Сбросить
|
||||
</n-button>
|
||||
</template>
|
||||
><Icon :size="18"><Reset /></Icon
|
||||
></n-button>
|
||||
</n-space>
|
||||
<template
|
||||
v-else-if="
|
||||
step.mode === 'SCREEN_MODE_INTRO' || step.mode === 'SCREEN_MODE_CONCLUSION'
|
||||
"
|
||||
>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_PLAY')">
|
||||
Запуск
|
||||
</n-button>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_PAUSE')">
|
||||
Пауза
|
||||
</n-button>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_RESUME')">
|
||||
Продолжить
|
||||
</n-button>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_STOP')">
|
||||
Стоп
|
||||
</n-button>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_REPLAY')">
|
||||
Повтор
|
||||
</n-button>
|
||||
<n-input-number
|
||||
<n-space>
|
||||
<n-button
|
||||
v-for="control in mediaControls"
|
||||
:key="control.command"
|
||||
square
|
||||
:aria-label="control.label"
|
||||
:title="control.label"
|
||||
:disabled="isDisabled || !hasConnectedScreen || !hasMainSource"
|
||||
@click="emit('command', step.mode, control.command)"
|
||||
><Icon :size="18"><component :is="control.icon" /></Icon
|
||||
></n-button>
|
||||
</n-space>
|
||||
<label :id="`main-volume-${index}`">Громкость</label>
|
||||
<n-slider
|
||||
:value="screenVolume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="5"
|
||||
size="small"
|
||||
:disabled="isDisabled || !hasConnectedScreen"
|
||||
:aria-labelledby="`main-volume-${index}`"
|
||||
:format-tooltip="(value) => `${value}%`"
|
||||
@update:value="emit('update:screenVolume', $event)"
|
||||
@dragend="emit('command', step.mode, 'SCREEN_COMMAND_VOLUME')"
|
||||
@keyup="emit('command', step.mode, 'SCREEN_COMMAND_VOLUME')"
|
||||
/>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_VOLUME')">
|
||||
Громкость
|
||||
</n-button>
|
||||
<n-input-number
|
||||
:value="screenPosition"
|
||||
:min="0"
|
||||
:step="5"
|
||||
size="small"
|
||||
@update:value="emit('update:screenPosition', $event)"
|
||||
/>
|
||||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_SEEK')">
|
||||
Перемотать
|
||||
</n-button>
|
||||
</template>
|
||||
</template>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-space v-if="screenPlanMode === 'edit'">
|
||||
<n-button size="small" :disabled="isDisabled" @click="emit('addStep')">Добавить шаг</n-button>
|
||||
<n-button size="small" :disabled="isDisabled || !steps.length" @click="emit('clearPlan')"
|
||||
>Удалить план</n-button
|
||||
>
|
||||
</n-space>
|
||||
</UiBlock>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screens-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.screen-header {
|
||||
margin-bottom: 8px;
|
||||
.screen-plan-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screen-preview-placeholder {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-color: var(--brand-background);
|
||||
color: var(--brand-text-muted);
|
||||
.plan-steps {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.plan-step {
|
||||
align-items: start;
|
||||
border: 1px solid var(--brand-border);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.screen-preview-panel > .screens-title {
|
||||
margin-bottom: 8px;
|
||||
.plan-step-current {
|
||||
border-color: var(--brand-accent);
|
||||
}
|
||||
|
||||
.screen-plan {
|
||||
margin-top: 16px;
|
||||
.step-preview {
|
||||
flex: 0 0 160px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.screen-plan-step {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 8px 0;
|
||||
.step-content {
|
||||
display: flex;
|
||||
flex: 1 1 240px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.step-heading {
|
||||
align-items: start;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NFlex, NInput, NInputNumber, NSelect, NSwitch, NUpload, type UploadFileInfo } from 'naive-ui'
|
||||
import { NAlert, NButton, NFlex, NTag } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { MusicState, Playlist, ScreenState } from '@/api/generated/crabs/evening_detective_server'
|
||||
import type { ScreenState } from '@/api/generated/crabs/evening_detective_server'
|
||||
import ScreenBindingModal from '@/components/ScreenBindingModal.vue'
|
||||
import UiBlock from '@/components/UiBlock.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
screens: ScreenState[]
|
||||
screenBinding: boolean
|
||||
playlists: Playlist[]
|
||||
music?: MusicState
|
||||
isBusy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
bind: [screenID: string, accessToken: string]
|
||||
setSound: [screen: ScreenState]
|
||||
unbind: [screen: ScreenState]
|
||||
setMusic: [change: { playlistID?: number; trackIndex?: number; positionMs?: number; playback?: MusicState['playback']; loop?: boolean; volume?: number }]
|
||||
createPlaylist: [name: string]
|
||||
deletePlaylist: [id: number]
|
||||
uploadTrack: [file: File]
|
||||
deleteTrack: [id: number]
|
||||
}>()
|
||||
|
||||
const showBindingModal = ref(false)
|
||||
const playlistName = ref('')
|
||||
|
||||
function uploadTrack(options: { file: UploadFileInfo }) {
|
||||
if (options.file.file) emit('uploadTrack', options.file.file)
|
||||
}
|
||||
|
||||
function selectTrack(index: number) {
|
||||
if (index === props.music?.trackIndex) return
|
||||
emit('setMusic', { trackIndex: index, positionMs: 0 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiBlock>
|
||||
<n-flex align="center" justify="space-between" class="screen-header">
|
||||
<p class="screens-title">Экраны</p>
|
||||
<n-button size="small" @click="showBindingModal = true"> Привязать экран </n-button>
|
||||
<n-button size="small" :disabled="isBusy || screenBinding" @click="showBindingModal = true">
|
||||
Привязать экран
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-alert
|
||||
v-if="screens.some((screen) => screen.status === 'disconnected')"
|
||||
@@ -62,8 +48,16 @@ function selectTrack(index: number) {
|
||||
</div>
|
||||
<n-flex justify="end">
|
||||
<n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag>
|
||||
<n-button v-else size="small" @click="emit('setSound', screen)">Назначить звук</n-button>
|
||||
<n-button size="small" type="error" ghost @click="emit('unbind', screen)">
|
||||
<n-button v-else size="small" :disabled="isBusy" @click="emit('setSound', screen)"
|
||||
>Назначить звук</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy"
|
||||
@click="emit('unbind', screen)"
|
||||
>
|
||||
Отвязать
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -75,104 +69,6 @@ function selectTrack(index: number) {
|
||||
:binding="props.screenBinding"
|
||||
@scanned="(screenID, accessToken) => emit('bind', screenID, accessToken)"
|
||||
/>
|
||||
<UiBlock class="music-block">
|
||||
<p class="screens-title">Музыка</p>
|
||||
<n-flex align="center" wrap class="playlist-actions">
|
||||
<n-input v-model:value="playlistName" placeholder="Новый плейлист" style="max-width: 220px" />
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="!playlistName.trim()"
|
||||
@click="emit('createPlaylist', playlistName.trim()); playlistName = ''"
|
||||
>
|
||||
Создать
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="props.music?.playlist?.ID"
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
@click="emit('deletePlaylist', props.music.playlist.ID)"
|
||||
>
|
||||
Удалить плейлист
|
||||
</n-button>
|
||||
<n-upload accept=".mp3,audio/mpeg" :default-upload="false" :show-file-list="false" @change="uploadTrack">
|
||||
<n-button size="small">Добавить MP3</n-button>
|
||||
</n-upload>
|
||||
</n-flex>
|
||||
<n-flex align="center" wrap>
|
||||
<n-select
|
||||
:value="props.music?.playlist?.ID"
|
||||
:options="props.playlists.map((playlist) => ({ label: playlist.name, value: playlist.ID }))"
|
||||
placeholder="Плейлист"
|
||||
clearable
|
||||
style="min-width: 220px"
|
||||
@update:value="(value) => emit('setMusic', { playlistID: value || 0, trackIndex: 0, playback: 'MUSIC_PLAYBACK_STOPPED' })"
|
||||
/>
|
||||
<div v-if="props.music?.playlist" class="track-list">
|
||||
<p v-if="!props.music.playlist.tracks?.length" class="track-empty">В плейлисте пока нет треков.</p>
|
||||
<div
|
||||
v-for="(track, index) in props.music.playlist.tracks"
|
||||
:key="track.ID"
|
||||
class="track-row"
|
||||
:class="{ 'track-row-active': index === (props.music.trackIndex ?? 0) }"
|
||||
>
|
||||
<div class="track-main">
|
||||
<span class="track-number">{{ index + 1 }}</span>
|
||||
<span class="track-details">
|
||||
<span class="track-name">{{ track.name || `Трек ${index + 1}` }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<n-flex align="center" class="track-actions">
|
||||
<n-button
|
||||
size="small"
|
||||
:type="index === (props.music.trackIndex ?? 0) ? 'success' : 'primary'"
|
||||
ghost
|
||||
:class="{ 'track-switch-active': index === (props.music.trackIndex ?? 0) }"
|
||||
@click="selectTrack(index)"
|
||||
>
|
||||
{{
|
||||
index === (props.music.trackIndex ?? 0)
|
||||
? props.music.playback === 'MUSIC_PLAYBACK_PLAYING'
|
||||
? 'Играет'
|
||||
: 'Выбран'
|
||||
: 'Включить'
|
||||
}}
|
||||
</n-button>
|
||||
<n-button size="small" type="error" ghost @click="emit('deleteTrack', track.ID!)">
|
||||
Удалить
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_PLAYING' })">Старт</n-button>
|
||||
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_PAUSED' })">Пауза</n-button>
|
||||
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_STOPPED', positionMs: 0 })">Стоп</n-button>
|
||||
<div class="volume-control">
|
||||
<span class="volume-label">Громкость</span>
|
||||
<n-input-number
|
||||
:value="props.music?.volume ?? 100"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="5"
|
||||
size="small"
|
||||
:show-button="true"
|
||||
aria-label="Громкость музыки"
|
||||
class="volume-input"
|
||||
@update:value="(value) => value != null && emit('setMusic', { volume: value })"
|
||||
>
|
||||
<template #suffix>%</template>
|
||||
</n-input-number>
|
||||
</div>
|
||||
<n-switch
|
||||
:value="props.music?.loop"
|
||||
:round="false"
|
||||
@update:value="(value) => emit('setMusic', { loop: value })"
|
||||
>
|
||||
<template #checked>Loop</template>
|
||||
<template #unchecked>Loop</template>
|
||||
</n-switch>
|
||||
</n-flex>
|
||||
</UiBlock>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -186,146 +82,28 @@ function selectTrack(index: number) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.music-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.volume-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.volume-input {
|
||||
width: 108px;
|
||||
}
|
||||
|
||||
.screen-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||||
border-top: 1px solid var(--brand-border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.screen-status {
|
||||
color: #aaa;
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.screen-empty {
|
||||
color: #aaa;
|
||||
color: var(--brand-text-muted);
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.screen-error {
|
||||
color: #f2a2a2;
|
||||
color: var(--brand-error);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
flex-basis: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.track-empty {
|
||||
color: #aaa;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-height: 56px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.track-row-active {
|
||||
background: rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
.track-main {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-number {
|
||||
color: #aaa;
|
||||
flex: 0 0 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.track-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.track-switch-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (width < 1024px) {
|
||||
.track-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.track-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.track-actions .n-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.volume-input {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -82,6 +82,8 @@ const musicSyncState = computed(() => [
|
||||
state.value?.music?.playlist?.ID,
|
||||
state.value?.music?.trackIndex,
|
||||
state.value?.music?.playback,
|
||||
state.value?.music?.stateVersion,
|
||||
state.value?.music?.positionMs,
|
||||
] as const)
|
||||
const screenBackground = computed(() => {
|
||||
const image = state.value?.backgroundImage || state.value?.coverImage
|
||||
@@ -185,7 +187,12 @@ watch(state, async (value) => {
|
||||
await nextTick()
|
||||
const media = screenMode.value === 'SCREEN_MODE_CONCLUSION' ? conclusionVideoElement.value : introAudioElement.value
|
||||
if (!media) return
|
||||
if (
|
||||
['SCREEN_COMMAND_PLAY', 'SCREEN_COMMAND_REPLAY', 'SCREEN_COMMAND_STOP', 'SCREEN_COMMAND_SEEK']
|
||||
.includes(value.command)
|
||||
) {
|
||||
media.currentTime = (value.positionMs || 0) / 1000
|
||||
}
|
||||
media.volume = Math.max(0, Math.min(1, (value.volume ?? 100) / 100))
|
||||
if (value.command === 'SCREEN_COMMAND_SEEK') {
|
||||
return
|
||||
@@ -226,7 +233,8 @@ watch(
|
||||
if (!media) return
|
||||
const trackChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1]
|
||||
const playbackChanged = !previous || value[2] !== previous[2]
|
||||
if (!trackChanged && !playbackChanged) return
|
||||
const isRestart = !!previous && value[3] !== previous[3] && (value[4] ?? 0) < (previous[4] ?? 0)
|
||||
if (!trackChanged && !playbackChanged && !isRestart) return
|
||||
await waitForMusicMetadata(media)
|
||||
media.currentTime = trackChanged ? 0 : (music.positionMs || 0) / 1000
|
||||
if (music.playback === 'MUSIC_PLAYBACK_PLAYING') {
|
||||
|
||||
@@ -9,9 +9,10 @@ import type {
|
||||
import { applicationFileURL } from '@/utils/application'
|
||||
|
||||
const props = defineProps<{
|
||||
state: ScreenState
|
||||
state: Partial<ScreenState>
|
||||
questions: ResultsQuestion[]
|
||||
teams: TeamResult[]
|
||||
isThumbnail?: boolean
|
||||
}>()
|
||||
|
||||
const timerNow = ref(Date.now())
|
||||
@@ -42,8 +43,14 @@ watch(
|
||||
() => [props.state.timerRemainingSeconds, props.state.timerState, props.state.stateVersion],
|
||||
() => {
|
||||
const remaining = Math.max(0, Number(props.state.timerRemainingSeconds || 0))
|
||||
if (props.state.timerState === 'SCREEN_TIMER_STATE_RUNNING' && timerStateAtSync.value === props.state.timerState) {
|
||||
const localRemaining = Math.max(0, timerRemainingAtSync.value - (Date.now() - timerSyncedAt.value) / 1000)
|
||||
if (
|
||||
props.state.timerState === 'SCREEN_TIMER_STATE_RUNNING' &&
|
||||
timerStateAtSync.value === props.state.timerState
|
||||
) {
|
||||
const localRemaining = Math.max(
|
||||
0,
|
||||
timerRemainingAtSync.value - (Date.now() - timerSyncedAt.value) / 1000,
|
||||
)
|
||||
if (Math.abs(remaining - localRemaining) <= 0.5) return
|
||||
}
|
||||
timerRemainingAtSync.value = remaining
|
||||
@@ -54,6 +61,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isThumbnail) return
|
||||
timerTick = window.setInterval(() => {
|
||||
timerNow.value = Date.now()
|
||||
}, 250)
|
||||
@@ -65,25 +73,34 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="screen-preview" :style="screenBackground">
|
||||
<div
|
||||
class="screen-preview"
|
||||
:class="{ 'screen-thumbnail': isThumbnail }"
|
||||
:style="screenBackground"
|
||||
>
|
||||
<div v-if="state.mode === 'SCREEN_MODE_TIMER'" class="timer-preview">
|
||||
<h2>{{ state.timerLabel || 'Перерыв' }}</h2>
|
||||
<strong>{{ timerDisplay }}</strong>
|
||||
</div>
|
||||
<audio v-else-if="state.mode === 'SCREEN_MODE_INTRO' && introAudio" :src="introAudio" controls />
|
||||
<div v-else-if="state.mode === 'SCREEN_MODE_INTRO'" class="preview-message">
|
||||
{{ introAudio ? 'Аудиозапись введения' : 'Введение не задано' }}
|
||||
</div>
|
||||
<video
|
||||
v-else-if="state.mode === 'SCREEN_MODE_CONCLUSION' && conclusionVideo"
|
||||
:src="conclusionVideo"
|
||||
autoplay
|
||||
:autoplay="!isThumbnail"
|
||||
preload="metadata"
|
||||
class="preview-video"
|
||||
loop
|
||||
:loop="!isThumbnail"
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<div v-else-if="state.mode === 'SCREEN_MODE_CONCLUSION'" class="preview-message">
|
||||
Видео закрывашки не задано
|
||||
</div>
|
||||
<div v-else-if="state.mode === 'SCREEN_MODE_TEXT'" class="preview-message">{{ state.text }}</div>
|
||||
<div v-else-if="state.mode === 'SCREEN_MODE_TEXT'" class="preview-message">
|
||||
{{ state.text }}
|
||||
</div>
|
||||
<div v-else-if="state.mode === 'SCREEN_MODE_RESULTS'" class="preview-results">
|
||||
<h2>Результаты</h2>
|
||||
<table>
|
||||
@@ -101,7 +118,9 @@ onUnmounted(() => {
|
||||
<td>{{ result.place }}</td>
|
||||
<td>{{ result.team?.name }}</td>
|
||||
<td v-for="question in questions" :key="question.code">
|
||||
{{ result.answers?.find((answer) => answer.questionCode === question.code)?.score ?? 0 }}
|
||||
{{
|
||||
result.answers?.find((answer) => answer.questionCode === question.code)?.score ?? 0
|
||||
}}
|
||||
</td>
|
||||
<td>{{ result.totalScore }}</td>
|
||||
<td>{{ result.actionsCount }}</td>
|
||||
@@ -109,7 +128,13 @@ onUnmounted(() => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<img v-else-if="state.coverImage" :src="state.coverImage" alt="Обложка дела" class="preview-cover" />
|
||||
<img
|
||||
v-else-if="state.coverImage"
|
||||
:src="state.coverImage"
|
||||
alt="Обложка дела"
|
||||
class="preview-cover"
|
||||
/>
|
||||
<div v-else class="preview-message">{{ state.gameName || 'Обложка не задана' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -117,10 +142,11 @@ onUnmounted(() => {
|
||||
.screen-preview {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-color: #111;
|
||||
background-color: var(--brand-background);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
color: #eee;
|
||||
color: var(--brand-text);
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
@@ -136,13 +162,13 @@ onUnmounted(() => {
|
||||
.timer-preview h2,
|
||||
.preview-message,
|
||||
.preview-results h2 {
|
||||
font-size: clamp(16px, 3vw, 32px);
|
||||
font-size: clamp(10px, 4cqw, 32px);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.timer-preview strong {
|
||||
display: block;
|
||||
font-size: clamp(48px, 10vw, 120px);
|
||||
font-size: clamp(20px, 16cqw, 120px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 0.9;
|
||||
}
|
||||
@@ -174,14 +200,26 @@ onUnmounted(() => {
|
||||
|
||||
.preview-results table {
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
font-size: clamp(3px, 1.4cqw, 10px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-results th,
|
||||
.preview-results td {
|
||||
border-bottom: 1px solid rgb(255 255 255 / 18%);
|
||||
border-bottom: 1px solid var(--brand-border);
|
||||
padding: 3px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.screen-thumbnail {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-message {
|
||||
max-height: 90%;
|
||||
max-width: 95%;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// Run: node tests/screens-control.mjs
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import { compileScript, parse } from 'vue/compiler-sfc'
|
||||
|
||||
function source(name) {
|
||||
return parse(readFileSync(new URL(`../src/components/${name}.vue`, import.meta.url), 'utf8'))
|
||||
.descriptor
|
||||
}
|
||||
function setup(name, props) {
|
||||
const script = compileScript(source(name), { id: name })
|
||||
const context = { exports: {}, require: (name) => (name === 'vue' ? vue : {}) }
|
||||
runInNewContext(ts.transpile(script.content, { module: ts.ModuleKind.CommonJS }), context)
|
||||
const events = []
|
||||
const scope = vue.effectScope()
|
||||
const state = scope.run(() =>
|
||||
context.exports.default.setup(props, {
|
||||
expose() {},
|
||||
emit: (...event) => events.push(structuredClone(event)),
|
||||
}),
|
||||
)
|
||||
return { state, events, stop: () => scope.stop() }
|
||||
}
|
||||
const playlist = (id, ids) => ({
|
||||
ID: id,
|
||||
name: `Плейлист ${id}`,
|
||||
tracks: ids.map((ID) => ({ ID, name: `Трек ${ID}` })),
|
||||
})
|
||||
const props = vue.reactive({
|
||||
playlists: [playlist(1, [11, 12]), playlist(2, [21, 22])],
|
||||
music: { playlist: playlist(1, [11, 12]), playback: 'MUSIC_PLAYBACK_PLAYING', trackIndex: 0 },
|
||||
isBusy: false,
|
||||
})
|
||||
const panel = setup('GameMusicPlanPanel', props)
|
||||
panel.state.edit()
|
||||
await vue.nextTick()
|
||||
panel.state.selectedId.value = 2
|
||||
await vue.nextTick()
|
||||
assert.equal(panel.events.length, 0, 'Selecting an editor playlist must not change game music')
|
||||
panel.state.uploadTrack({ file: { file: { name: 'test.mp3' } } })
|
||||
assert.deepEqual(panel.events.at(-1), ['uploadTrack', 2, { name: 'test.mp3' }])
|
||||
panel.state.move(0, 1)
|
||||
assert.deepEqual(Array.from(panel.state.order.value), [22, 21])
|
||||
props.playlists = [playlist(1, [11, 12]), playlist(2, [21, 22])]
|
||||
await vue.nextTick()
|
||||
assert.deepEqual(Array.from(panel.state.order.value), [22, 21], 'Polling must preserve draft order')
|
||||
props.playlists = [playlist(1, [11, 12]), playlist(2, [21, 22, 23])]
|
||||
await vue.nextTick()
|
||||
assert.equal(panel.state.hasConflict.value, true)
|
||||
assert.deepEqual(Array.from(panel.state.order.value), [22, 21])
|
||||
panel.state.reset()
|
||||
assert.deepEqual(Array.from(panel.state.order.value), [21, 22, 23])
|
||||
panel.state.move(0, 1)
|
||||
props.playlists = [playlist(1, [11, 12]), playlist(2, [22, 21, 23])]
|
||||
await vue.nextTick()
|
||||
assert.equal(
|
||||
panel.state.isOrderDirty.value,
|
||||
false,
|
||||
'Successful save must accept the returned order',
|
||||
)
|
||||
panel.state.name.value = 'Несохранённое название'
|
||||
props.playlists = [playlist(1, [11, 12])]
|
||||
await vue.nextTick()
|
||||
assert.equal(
|
||||
panel.state.name.value,
|
||||
'Несохранённое название',
|
||||
'Remote deletion must preserve the draft',
|
||||
)
|
||||
assert.equal(panel.state.selectedId.value, 2)
|
||||
panel.state.reset()
|
||||
assert.equal(panel.state.selectedId.value, null)
|
||||
assert.equal(panel.state.playbackLabel.value, 'Пауза')
|
||||
props.music.playback = 'MUSIC_PLAYBACK_PAUSED'
|
||||
assert.equal(panel.state.playbackLabel.value, 'Продолжить')
|
||||
props.music.playback = 'MUSIC_PLAYBACK_STOPPED'
|
||||
assert.equal(panel.state.playbackLabel.value, 'Старт')
|
||||
panel.state.newName.value = 'Черновик нового плейлиста'
|
||||
props.playlists = [playlist(1, [11, 12]), playlist(3, [31, 32])]
|
||||
await vue.nextTick()
|
||||
assert.equal(panel.state.newName.value, 'Черновик нового плейлиста', 'Background library updates must preserve a new playlist name')
|
||||
panel.stop()
|
||||
|
||||
const screenProps = vue.reactive({
|
||||
screens: [{ status: 'connected', timerState: 'SCREEN_TIMER_STATE_RUNNING' }],
|
||||
previewQuestions: [],
|
||||
previewTeams: [],
|
||||
screenPlanDraft: [],
|
||||
screenPlanSaving: false,
|
||||
screenPlanMode: 'use',
|
||||
screenVolume: 100,
|
||||
isBusy: false,
|
||||
})
|
||||
const screens = setup('GameScreenPlanPanel', screenProps)
|
||||
for (const [timerState, command] of [
|
||||
['RUNNING', 'PAUSE'],
|
||||
['PAUSED', 'RESUME'],
|
||||
['STOPPED', 'START'],
|
||||
['FINISHED', 'START'],
|
||||
]) {
|
||||
screenProps.screens[0].timerState = `SCREEN_TIMER_STATE_${timerState}`
|
||||
assert.equal(screens.state.timerControl.value.command, `SCREEN_COMMAND_TIMER_${command}`)
|
||||
}
|
||||
screenProps.screens = []
|
||||
assert.equal(screens.state.hasConnectedScreen.value, false)
|
||||
assert.equal(
|
||||
screens.state.thumbnail({ mode: 'SCREEN_MODE_TEXT', text: 'Offline draft' }).text,
|
||||
'Offline draft',
|
||||
)
|
||||
screens.stop()
|
||||
|
||||
function functions(name, names) {
|
||||
const text = source(name).scriptSetup.content
|
||||
const ast = ts.createSourceFile(`${name}.ts`, text, ts.ScriptTarget.Latest, true)
|
||||
return ast.statements
|
||||
.filter((item) => ts.isFunctionDeclaration(item) && names.includes(item.name.text))
|
||||
.map((item) => item.getText(ast))
|
||||
.join('\n')
|
||||
}
|
||||
const errors = []
|
||||
const calls = []
|
||||
const state = {
|
||||
Error,
|
||||
music: vue.ref({
|
||||
playlist: playlist(1, [11, 12]),
|
||||
positionMs: 5000,
|
||||
playback: 'MUSIC_PLAYBACK_PLAYING',
|
||||
}),
|
||||
playlists: vue.ref([]),
|
||||
isMusicBusy: vue.ref(false),
|
||||
musicRequestVersion: 0,
|
||||
musicSyncedAt: Date.now(),
|
||||
gameId: '7',
|
||||
message: { error: (error) => errors.push(error), success() {} },
|
||||
client: {
|
||||
GetGameMusic: async () => ({
|
||||
playlists: [playlist(2, [21, 22])],
|
||||
state: { playlist: playlist(1, [11, 12]) },
|
||||
}),
|
||||
SetGameMusic: async (request) => {
|
||||
calls.push(request)
|
||||
return { state: { ...state.music.value, ...request } }
|
||||
},
|
||||
DeletePlaylistTrack: async (request) => {
|
||||
calls.push(request)
|
||||
},
|
||||
UploadPlaylistTrack: async (request) => {
|
||||
calls.push(request)
|
||||
},
|
||||
ReorderPlaylistTracks: async () => {
|
||||
throw new Error('Save failed')
|
||||
},
|
||||
},
|
||||
btoa: (value) => Buffer.from(value, 'binary').toString('base64'),
|
||||
}
|
||||
runInNewContext(
|
||||
ts.transpile(
|
||||
functions('GamePage', [
|
||||
'getGameMusic',
|
||||
'setGameMusic',
|
||||
'deleteTrack',
|
||||
'uploadTrack',
|
||||
'uint8ToBase64',
|
||||
'reorderTracks',
|
||||
]),
|
||||
{ target: ts.ScriptTarget.ES2022 },
|
||||
),
|
||||
state,
|
||||
)
|
||||
await state.deleteTrack(2, 21)
|
||||
assert.deepEqual({ ...calls.at(-1) }, { playlistID: 2, trackID: 21 })
|
||||
await state.uploadTrack(2, {
|
||||
name: 'test.mp3',
|
||||
size: 3,
|
||||
arrayBuffer: async () => Uint8Array.from([1, 2, 3]).buffer,
|
||||
})
|
||||
assert.equal(calls.at(-1).playlistID, 2)
|
||||
for (const file of [
|
||||
{ name: 'empty.mp3', size: 0 },
|
||||
{ name: 'large.mp3', size: 64 * 1024 * 1024 + 1 },
|
||||
]) {
|
||||
const before = calls.length
|
||||
await state.uploadTrack(2, file)
|
||||
assert.equal(calls.length, before)
|
||||
}
|
||||
const count = calls.length
|
||||
await state.uploadTrack(2, { name: 'test.png', size: 3 })
|
||||
assert.equal(calls.length, count)
|
||||
await state.reorderTracks(2, [22, 21])
|
||||
assert.equal(errors.at(-1), 'Save failed')
|
||||
assert.equal(state.isMusicBusy.value, false)
|
||||
state.music.value = {
|
||||
playlist: playlist(1, [11, 12]),
|
||||
positionMs: 5000,
|
||||
playback: 'MUSIC_PLAYBACK_PLAYING',
|
||||
}
|
||||
state.musicSyncedAt = Date.now() - 1500
|
||||
await state.setGameMusic({ playback: 'MUSIC_PLAYBACK_PAUSED' })
|
||||
assert.ok(calls.at(-1).positionMs >= 6500, 'Pausing must include time elapsed since the last poll')
|
||||
let resolvePoll
|
||||
state.client.GetGameMusic = () =>
|
||||
new Promise((resolve) => {
|
||||
resolvePoll = resolve
|
||||
})
|
||||
const poll = state.getGameMusic()
|
||||
await state.setGameMusic({ volume: 40 })
|
||||
resolvePoll({ state: { volume: 1 }, playlists: [] })
|
||||
await poll
|
||||
assert.equal(state.music.value.volume, 40, 'An old poll must not overwrite a successful command')
|
||||
|
||||
const planState = {
|
||||
Error,
|
||||
screenPlanSaving: vue.ref(false),
|
||||
screenPlanRequestVersion: 0,
|
||||
screenPlanDraft: vue.ref([{ mode: 'SCREEN_MODE_TIMER', timerDurationSeconds: 0 }]),
|
||||
screenPlan: vue.ref(),
|
||||
screenPlanDirty: vue.ref(true),
|
||||
screenPlanMode: vue.ref('edit'),
|
||||
gameId: '7',
|
||||
message: state.message,
|
||||
client: {
|
||||
SaveScreenPlan: async () => {
|
||||
throw new Error('Plan save failed')
|
||||
},
|
||||
},
|
||||
}
|
||||
runInNewContext(
|
||||
ts.transpile(functions('GamePage', ['saveScreenPlan']), { target: ts.ScriptTarget.ES2022 }),
|
||||
planState,
|
||||
)
|
||||
await planState.saveScreenPlan()
|
||||
assert.match(errors.at(-1), /шаге 1/)
|
||||
planState.screenPlanDraft.value[0].timerDurationSeconds = 60
|
||||
await planState.saveScreenPlan()
|
||||
assert.equal(errors.at(-1), 'Plan save failed')
|
||||
assert.equal(planState.screenPlanMode.value, 'edit')
|
||||
assert.equal(planState.screenPlanDirty.value, true)
|
||||
assert.equal(planState.screenPlanDraft.value[0].timerDurationSeconds, 60)
|
||||
assert.equal(planState.screenPlanSaving.value, false)
|
||||
|
||||
function watcher(name, observed) {
|
||||
const script = source(name).scriptSetup.content
|
||||
const ast = ts.createSourceFile(`${name}.ts`, script, ts.ScriptTarget.Latest, true)
|
||||
const statement = ast.statements.find(
|
||||
(item) =>
|
||||
ts.isExpressionStatement(item) &&
|
||||
ts.isCallExpression(item.expression) &&
|
||||
item.expression.expression.getText(ast) === 'watch' &&
|
||||
item.expression.arguments[0].getText(ast) === observed,
|
||||
)
|
||||
return `const handler = ${statement.expression.arguments[1].getText(ast)}`
|
||||
}
|
||||
const media = {
|
||||
currentTime: 23,
|
||||
volume: 1,
|
||||
readyState: 1,
|
||||
play() {
|
||||
this.played = true
|
||||
return Promise.resolve()
|
||||
},
|
||||
pause() {
|
||||
this.paused = true
|
||||
},
|
||||
}
|
||||
const player = {
|
||||
appliedCommandVersion: 0,
|
||||
nextTick: vue.nextTick,
|
||||
screenMode: vue.ref('SCREEN_MODE_INTRO'),
|
||||
introAudioElement: vue.ref(media),
|
||||
conclusionVideoElement: vue.ref(null),
|
||||
mediaError: vue.ref(''),
|
||||
}
|
||||
runInNewContext(
|
||||
ts.transpile(watcher('ScreenPage', 'state') + '\nthis.commandHandler = handler', {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
}),
|
||||
player,
|
||||
)
|
||||
for (const [index, command] of ['VOLUME', 'PAUSE', 'RESUME'].entries()) {
|
||||
await player.commandHandler({
|
||||
command: `SCREEN_COMMAND_${command}`,
|
||||
stateVersion: index + 1,
|
||||
positionMs: 0,
|
||||
volume: 30,
|
||||
})
|
||||
assert.equal(media.currentTime, 23, `${command} must preserve playback position`)
|
||||
}
|
||||
await player.commandHandler({ command: 'SCREEN_COMMAND_REPLAY', stateVersion: 4, positionMs: 0 })
|
||||
assert.equal(media.currentTime, 0)
|
||||
const musicPlayer = {
|
||||
state: vue.ref({
|
||||
music: { playlist: playlist(1, [11]), playback: 'MUSIC_PLAYBACK_PLAYING', positionMs: 0 },
|
||||
}),
|
||||
nextTick: vue.nextTick,
|
||||
musicAudioElement: vue.ref(media),
|
||||
waitForMusicMetadata: async () => {},
|
||||
mediaError: vue.ref(''),
|
||||
}
|
||||
runInNewContext(
|
||||
ts.transpile(watcher('ScreenPage', 'musicSyncState') + '\nthis.musicHandler = handler', {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
}),
|
||||
musicPlayer,
|
||||
)
|
||||
media.currentTime = 23
|
||||
await musicPlayer.musicHandler(
|
||||
[1, 0, 'MUSIC_PLAYBACK_PLAYING', 2, 0],
|
||||
[1, 0, 'MUSIC_PLAYBACK_PLAYING', 1, 20000],
|
||||
)
|
||||
assert.equal(media.currentTime, 0, 'Restarting the current track must reset the player')
|
||||
media.currentTime = 23
|
||||
await musicPlayer.musicHandler(
|
||||
[1, 0, 'MUSIC_PLAYBACK_PLAYING', 2, 21000],
|
||||
[1, 0, 'MUSIC_PLAYBACK_PLAYING', 2, 20000],
|
||||
)
|
||||
assert.equal(media.currentTime, 23, 'Polling must not seek the player')
|
||||
console.log(
|
||||
'Screen controls: drafts, conflicts, explicit playlist IDs, timer/music controls, errors, stale polls and playback positions passed',
|
||||
)
|
||||
Reference in New Issue
Block a user