This commit is contained in:
2026-10-21 02:55:40 +07:00
parent f45ad73224
commit 4e4e5c66df
7 changed files with 1346 additions and 566 deletions
+457
View File
@@ -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>
+247 -83
View File
@@ -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() {
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') {
const result = await client.GetGameResults({ gameID: Number(gameId) })
previewQuestions.value = result.questions || []
previewTeams.value = result.teams || []
} else {
previewQuestions.value = []
previewTeams.value = []
try {
const res = await client.GetGameScreens({ gameID: Number(gameId) })
screens.value = res.screens || []
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 || []
}
} 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
const res = await client.GetScreenPlan({ gameID: Number(gameId) })
if (
requestVersion !== screenPlanRequestVersion ||
isScreenPlanEditing() ||
screenPlanDirty.value
) {
return
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 : 'Не удалось получить план экранов')
}
screenPlan.value = res.plan
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
}
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) {
await client.SetScreenSound({ screenID: screen.ID, soundEnabled: true })
await getGameScreens()
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) {
await client.UnbindScreen({ screenID: screen.ID })
await getGameScreens()
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,43 +1510,64 @@ useSimplePolling(() => {
</n-tab-pane>
<n-tab-pane v-if="canManageGame" name="screens" tab="Экраны">
<div class="game-tab-content">
<GameScreensPanel
:screens="screens"
:screen-binding="screenBinding"
:playlists="playlists"
:music="music"
@bind="bindScannedScreen"
@set-sound="setSoundScreen"
@unbind="unbindScreen"
@set-music="setGameMusic"
@create-playlist="createPlaylist"
@delete-playlist="deletePlaylist"
@upload-track="uploadTrack"
@delete-track="deleteTrack"
/>
<GameScreenPlanPanel
:screens="screens"
:preview-questions="previewQuestions"
:preview-teams="previewTeams"
:screen-plan="screenPlan"
:screen-plan-draft="screenPlanDraft"
:screen-plan-saving="screenPlanSaving"
:screen-plan-mode="screenPlanMode"
:screen-volume="screenVolume"
:screen-position="screenPosition"
@add-step="addScreenPlanStep"
@edit="editScreenPlan"
@cancel="cancelScreenPlanEdit"
@remove-step="removeScreenPlanStep"
@move-step="moveScreenPlanStep"
@dirty="markScreenPlanDirty"
@clear-plan="clearScreenPlan"
@save="saveScreenPlan"
@set-step="setScreenPlanStep"
@command="sendScreenPlanCommand"
@update:screen-volume="screenVolume = $event ?? 0"
@update:screen-position="screenPosition = $event ?? 0"
/>
<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"
:is-busy="isScreenBusy"
@bind="bindScannedScreen"
@set-sound="setSoundScreen"
@unbind="unbindScreen"
/>
</div>
<div class="presentation-grid">
<GameScreenPlanPanel
:screens="screens"
:preview-questions="previewQuestions"
:preview-teams="previewTeams"
:screen-plan="screenPlan"
:screen-plan-draft="screenPlanDraft"
:screen-plan-saving="screenPlanSaving"
:screen-plan-mode="screenPlanMode"
:screen-volume="screenVolume"
:is-busy="isScreenBusy"
@add-step="addScreenPlanStep"
@edit="editScreenPlan"
@cancel="cancelScreenPlanEdit"
@remove-step="removeScreenPlanStep"
@move-step="moveScreenPlanStep"
@dirty="markScreenPlanDirty"
@clear-plan="clearScreenPlan"
@save="saveScreenPlan"
@set-step="setScreenPlanStep"
@command="sendScreenPlanCommand"
@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;
+235 -222
View File
@@ -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>
<n-space>
<n-button
v-if="!screenPlan && screenPlanMode === 'use'"
size="small"
@click="emit('addStep')"
>
Создать план
</n-button>
<n-button
v-if="screenPlan && screenPlanMode === 'use'"
size="small"
@click="emit('edit')"
>
Редактировать
</n-button>
<template v-if="screenPlanMode === 'edit'">
<n-button size="small" :disabled="screenPlanSaving" @click="emit('cancel')">
Отменить
</n-button>
<n-button
size="small"
type="primary"
:loading="screenPlanSaving"
:disabled="screenPlanSaving"
@click="emit('save')"
>
Сохранить план
</n-button>
</template>
</n-space>
</n-flex>
<p v-if="!screenPlan && screenPlanMode === 'use'" class="screen-empty">
План экранов ещё не создан.
</p>
<template v-else-if="screenPlanMode === 'edit'">
<n-space
v-for="(step, index) in screenPlanDraft"
:key="index"
class="screen-plan-step"
align="center"
justify="space-between"
wrap
<UiBlock class="screen-plan-panel" data-testid="screen-plan">
<n-space align="center" justify="space-between">
<h3>План экранов</h3>
<n-space>
<n-button
v-if="screenPlanMode === 'use' && !screenPlan"
size="small"
:disabled="isDisabled"
@click="emit('addStep')"
>Создать план</n-button
>
<n-space align="center" wrap>
<n-text>{{ index + 1 }}</n-text>
<n-button
v-else-if="screenPlanMode === 'use'"
size="small"
square
aria-label="Редактировать план экранов"
title="Редактировать план экранов"
:disabled="isDisabled"
@click="emit('edit')"
><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="isDisabled"
@click="emit('save')"
>Сохранить план</n-button
>
</template>
</n-space>
</n-space>
<p v-if="!steps.length">
{{
screenPlanMode === 'edit'
? 'Добавьте шаги или сохраните пустой план для удаления.'
: 'План экранов ещё не создан.'
}}
</p>
<div class="plan-steps">
<div
v-for="(step, index) in steps"
:key="index"
class="plan-step"
:class="{
'plan-step-current': screenPlanMode === 'use' && screenPlan?.currentStep === index,
}"
:aria-current="
screenPlanMode === 'use' && screenPlan?.currentStep === index ? 'step' : undefined
"
>
<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
>
<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"
@click="emit('setStep', index)"
>
Показать
</n-button>
<template v-if="screenPlan.currentStep === index">
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
</template>
<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-button
size="small"
:disabled="isDisabled || screenPlan?.currentStep === index || !hasConnectedScreen"
@click="emit('setStep', index)"
>{{ screenPlan?.currentStep === index ? 'Показан' : 'Показать' }}</n-button
>
</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>
</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>
+21 -243
View File
@@ -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>
+10 -2
View File
@@ -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
media.currentTime = (value.positionMs || 0) / 1000
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') {
+54 -16
View File
@@ -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>
+322
View File
@@ -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',
)