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>
+202 -38
View File
@@ -47,11 +47,13 @@ import type {
} from '@/api/generated/crabs/evening_detective_server' } from '@/api/generated/crabs/evening_detective_server'
import notificationSoundURL from '@/assets/icq-uh-oh.wav' import notificationSoundURL from '@/assets/icq-uh-oh.wav'
import GameMapPanel from '@/components/GameMapPanel.vue' import GameMapPanel from '@/components/GameMapPanel.vue'
import GameMusicPlanPanel from '@/components/GameMusicPlanPanel.vue'
import GameScreenPlanPanel from '@/components/GameScreenPlanPanel.vue' import GameScreenPlanPanel from '@/components/GameScreenPlanPanel.vue'
import GameScreensPanel from '@/components/GameScreensPanel.vue' import GameScreensPanel from '@/components/GameScreensPanel.vue'
import GameStatePanel from '@/components/GameStatePanel.vue' import GameStatePanel from '@/components/GameStatePanel.vue'
import GameTeamsPanel from '@/components/GameTeamsPanel.vue' import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
import HeaderMenu from '@/components/HeaderMenu.vue' import HeaderMenu from '@/components/HeaderMenu.vue'
import ScreenPreview from '@/components/ScreenPreview.vue'
import TeamReceiptModal from '@/components/TeamReceiptModal.vue' import TeamReceiptModal from '@/components/TeamReceiptModal.vue'
import TicketCheckModal from '@/components/TicketCheckModal.vue' import TicketCheckModal from '@/components/TicketCheckModal.vue'
import UiBlock from '@/components/UiBlock.vue' import UiBlock from '@/components/UiBlock.vue'
@@ -234,6 +236,12 @@ const activeTeamCount = computed(
const screens = ref<ScreenState[]>([]) const screens = ref<ScreenState[]>([])
const playlists = ref<Playlist[]>([]) const playlists = ref<Playlist[]>([])
const music = ref<MusicState>() 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 screenPlan = ref<ScreenPlan>()
const screenPlanDraft = ref<ScreenPlanStep[]>([]) const screenPlanDraft = ref<ScreenPlanStep[]>([])
const screenPlanDirty = ref(false) const screenPlanDirty = ref(false)
@@ -246,7 +254,15 @@ const mapItems = ref<MapItem[]>([])
const mapSaving = ref(false) const mapSaving = ref(false)
const mapDragging = ref(false) const mapDragging = ref(false)
const screenVolume = ref(100) 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) { function reportSoundError(error: unknown) {
if (isDisposed || hasReportedSoundError) return if (isDisposed || hasReportedSoundError) return
@@ -368,26 +384,38 @@ async function getGame(id: number) {
if (canManageGame.value) getGame(Number(gameId)) if (canManageGame.value) getGame(Number(gameId))
async function getGameScreens() { async function getGameScreens() {
try {
const res = await client.GetGameScreens({ gameID: Number(gameId) }) const res = await client.GetGameScreens({ gameID: Number(gameId) })
screens.value = res.screens || [] screens.value = res.screens || []
const previewScreen = screensError.value = ''
screens.value.find((screen) => screen.status === 'connected') || screens.value[0] if (
if (previewScreen?.mode === 'SCREEN_MODE_RESULTS') { (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) }) const result = await client.GetGameResults({ gameID: Number(gameId) })
previewQuestions.value = result.questions || [] previewQuestions.value = result.questions || []
previewTeams.value = result.teams || [] previewTeams.value = result.teams || []
} else { }
previewQuestions.value = [] } catch (err) {
previewTeams.value = [] screensError.value =
err instanceof Error ? err.message : 'Не удалось получить состояние экранов'
} finally {
isScreensLoading.value = false
} }
} }
if (canManageGame.value) getGameScreens() if (canManageGame.value) getGameScreens()
async function getGameMusic() { async function getGameMusic(isAfterSave = false) {
if (isMusicBusy.value && !isAfterSave) return
const requestVersion = ++musicRequestVersion
try { try {
const res = await client.GetGameMusic({ gameID: Number(gameId) }) const res = await client.GetGameMusic({ gameID: Number(gameId) })
if (requestVersion !== musicRequestVersion) return
playlists.value = res.playlists || [] playlists.value = res.playlists || []
music.value = res.state music.value = res.state
musicSyncedAt = Date.now()
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры') message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры')
} }
@@ -399,20 +427,23 @@ function isScreenPlanEditing() {
} }
async function getScreenPlan() { async function getScreenPlan() {
console.log('getScreenPlan') if (isScreenPlanEditing() || screenPlanDirty.value || isScreenBusy.value) return
if (isScreenPlanEditing() || screenPlanDirty.value) return
console.log('no skip getScreenPlan')
const requestVersion = ++screenPlanRequestVersion const requestVersion = ++screenPlanRequestVersion
try {
const res = await client.GetScreenPlan({ gameID: Number(gameId) }) const res = await client.GetScreenPlan({ gameID: Number(gameId) })
if ( if (
requestVersion !== screenPlanRequestVersion || requestVersion !== screenPlanRequestVersion ||
isScreenPlanEditing() || isScreenPlanEditing() ||
isScreenBusy.value ||
screenPlanDirty.value screenPlanDirty.value
) { ) {
return return
} }
screenPlan.value = res.plan screenPlan.value = res.plan
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || [] screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось получить план экранов')
}
} }
if (canManageGame.value) getScreenPlan() if (canManageGame.value) getScreenPlan()
@@ -464,6 +495,18 @@ function clearScreenPlan() {
async function saveScreenPlan() { async function saveScreenPlan() {
if (screenPlanSaving.value) return 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 screenPlanRequestVersion += 1
screenPlanSaving.value = true screenPlanSaving.value = true
try { try {
@@ -489,18 +532,25 @@ async function saveScreenPlan() {
} }
async function setScreenPlanStep(index: number) { 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 { try {
const res = await client.SetScreenPlanStep({ gameID: Number(gameId), currentStep: index }) const res = await client.SetScreenPlanStep({ gameID: Number(gameId), currentStep: index })
screenPlan.value = res.plan screenPlan.value = res.plan
await getGameScreens() await getGameScreens()
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось перейти на шаг плана') message.error(err instanceof Error ? err.message : 'Не удалось перейти на шаг плана')
} finally {
isScreenBusy.value = false
} }
} }
async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: ScreenCommand) { async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: ScreenCommand) {
if ( if (
isScreenBusy.value ||
screenPlanSaving.value ||
screenPlanDirty.value || screenPlanDirty.value ||
(mode !== 'SCREEN_MODE_TIMER' && (mode !== 'SCREEN_MODE_TIMER' &&
mode !== 'SCREEN_MODE_INTRO' && mode !== 'SCREEN_MODE_INTRO' &&
@@ -508,19 +558,21 @@ async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: Scre
) { ) {
return return
} }
isScreenBusy.value = true
try { try {
await client.SendScreenCommand({ await client.SendScreenCommand({
gameID: Number(gameId), gameID: Number(gameId),
command, command,
positionMs: Math.max(0, Math.round(screenPosition.value * 1000)), positionMs: 0,
volume: Math.max(0, Math.min(100, Math.round(screenVolume.value))), volume: Math.max(0, Math.min(100, Math.round(screenVolume.value))),
}) })
await getGameScreens() await getGameScreens()
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось управлять экраном') message.error(err instanceof Error ? err.message : 'Не удалось управлять экраном')
} finally {
isScreenBusy.value = false
} }
} }
async function bindScannedScreen(screenID: string, accessToken: string) { async function bindScannedScreen(screenID: string, accessToken: string) {
if (screenBinding.value) return if (screenBinding.value) return
screenBinding.value = true screenBinding.value = true
@@ -570,8 +622,16 @@ async function saveMapItemAndReload(item: MapItem, hidden = item.hidden) {
} }
async function setSoundScreen(screen: ScreenState) { async function setSoundScreen(screen: ScreenState) {
if (isScreenBusy.value) return
isScreenBusy.value = true
try {
await client.SetScreenSound({ screenID: screen.ID, soundEnabled: true }) await client.SetScreenSound({ screenID: screen.ID, soundEnabled: true })
await getGameScreens() await getGameScreens()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось назначить звук')
} finally {
isScreenBusy.value = false
}
} }
async function setGameMusic(change: { async function setGameMusic(change: {
@@ -582,67 +642,122 @@ async function setGameMusic(change: {
loop?: boolean loop?: boolean
volume?: number volume?: number
}) { }) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
const current = music.value const current = music.value
try { try {
const res = await client.SetGameMusic({ const res = await client.SetGameMusic({
gameID: Number(gameId), gameID: Number(gameId),
playlistID: change.playlistID ?? current?.playlist?.ID ?? 0, playlistID: change.playlistID ?? current?.playlist?.ID ?? 0,
trackIndex: change.trackIndex ?? current?.trackIndex ?? 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, volume: change.volume ?? current?.volume ?? 100,
loop: change.loop ?? current?.loop ?? false, loop: change.loop ?? current?.loop ?? false,
playback: change.playback ?? current?.playback ?? 'MUSIC_PLAYBACK_STOPPED', playback: change.playback ?? current?.playback ?? 'MUSIC_PLAYBACK_STOPPED',
}) })
music.value = res.state music.value = res.state
musicSyncedAt = Date.now()
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить музыку') message.error(err instanceof Error ? err.message : 'Не удалось изменить музыку')
} finally {
isMusicBusy.value = false
} }
} }
async function createPlaylist(name: string) { async function createPlaylist(name: string) {
if (isMusicBusy.value || !name.trim()) return
isMusicBusy.value = true
musicRequestVersion += 1
try { try {
await client.CreatePlaylist({ name }) await client.CreatePlaylist({ name: name.trim() })
await getGameMusic() await getGameMusic(true)
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист') 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) { async function deletePlaylist(id: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
try { try {
await client.DeletePlaylist({ ID: id }) await client.DeletePlaylist({ ID: id })
await getGameMusic() await getGameMusic(true)
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист') message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист')
} finally {
isMusicBusy.value = false
} }
} }
async function uploadTrack(file: File) { async function uploadTrack(playlistID: number, file: File) {
const playlistID = music.value?.playlist?.ID if (isMusicBusy.value) return
if (!playlistID) { if (!/\.mp3$/i.test(file.name) || file.size === 0 || file.size > 64 * 1024 * 1024) {
message.warning('Сначала выберите плейлист') message.error('Выберите непустой MP3 размером до 64 МиБ')
return return
} }
isMusicBusy.value = true
musicRequestVersion += 1
try { try {
const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer())) const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer()))
await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data }) await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data })
await getGameMusic() await getGameMusic(true)
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3') message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3')
} finally {
isMusicBusy.value = false
} }
} }
async function deleteTrack(id: number) { async function deleteTrack(playlistID: number, id: number) {
const playlistID = music.value?.playlist?.ID if (isMusicBusy.value) return
if (!playlistID) return isMusicBusy.value = true
musicRequestVersion += 1
try { try {
await client.DeletePlaylistTrack({ playlistID, trackID: id }) await client.DeletePlaylistTrack({ playlistID, trackID: id })
await getGameMusic() await getGameMusic(true)
} catch (err) { } catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить трек') message.error(err instanceof Error ? err.message : 'Не удалось удалить трек')
} finally {
isMusicBusy.value = false
} }
} }
function uint8ToBase64(data: Uint8Array): string { function uint8ToBase64(data: Uint8Array): string {
let binary = '' let binary = ''
for (let i = 0; i < data.length; i += 0x8000) binary += String.fromCharCode(...data.subarray(i, i + 0x8000)) 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) { async function unbindScreen(screen: ScreenState) {
if (isScreenBusy.value) return
isScreenBusy.value = true
try {
await client.UnbindScreen({ screenID: screen.ID }) await client.UnbindScreen({ screenID: screen.ID })
await getGameScreens() 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) { async function toTeamStory(id: string, password: string, newTab: boolean = false) {
@@ -1387,20 +1510,29 @@ useSimplePolling(() => {
</n-tab-pane> </n-tab-pane>
<n-tab-pane v-if="canManageGame" name="screens" tab="Экраны"> <n-tab-pane v-if="canManageGame" name="screens" tab="Экраны">
<div class="game-tab-content"> <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 <GameScreensPanel
:screens="screens" :screens="screens"
:screen-binding="screenBinding" :screen-binding="screenBinding"
:playlists="playlists" :is-busy="isScreenBusy"
:music="music"
@bind="bindScannedScreen" @bind="bindScannedScreen"
@set-sound="setSoundScreen" @set-sound="setSoundScreen"
@unbind="unbindScreen" @unbind="unbindScreen"
@set-music="setGameMusic"
@create-playlist="createPlaylist"
@delete-playlist="deletePlaylist"
@upload-track="uploadTrack"
@delete-track="deleteTrack"
/> />
</div>
<div class="presentation-grid">
<GameScreenPlanPanel <GameScreenPlanPanel
:screens="screens" :screens="screens"
:preview-questions="previewQuestions" :preview-questions="previewQuestions"
@@ -1410,7 +1542,7 @@ useSimplePolling(() => {
:screen-plan-saving="screenPlanSaving" :screen-plan-saving="screenPlanSaving"
:screen-plan-mode="screenPlanMode" :screen-plan-mode="screenPlanMode"
:screen-volume="screenVolume" :screen-volume="screenVolume"
:screen-position="screenPosition" :is-busy="isScreenBusy"
@add-step="addScreenPlanStep" @add-step="addScreenPlanStep"
@edit="editScreenPlan" @edit="editScreenPlan"
@cancel="cancelScreenPlanEdit" @cancel="cancelScreenPlanEdit"
@@ -1421,9 +1553,21 @@ useSimplePolling(() => {
@save="saveScreenPlan" @save="saveScreenPlan"
@set-step="setScreenPlanStep" @set-step="setScreenPlanStep"
@command="sendScreenPlanCommand" @command="sendScreenPlanCommand"
@update:screen-volume="screenVolume = $event ?? 0" @update:screen-volume="screenVolume = $event"
@update:screen-position="screenPosition = $event ?? 0"
/> />
<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> </div>
</n-tab-pane> </n-tab-pane>
<n-tab-pane v-if="canManageGame" name="game" tab="Игра"> <n-tab-pane v-if="canManageGame" name="game" tab="Игра">
@@ -1945,6 +2089,26 @@ useSimplePolling(() => {
</template> </template>
<style scoped> <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 { .reset-local-mode-modal {
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
width: 560px; width: 560px;
+210 -197
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <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 { computed } from 'vue'
import type { import type {
@@ -23,9 +25,8 @@ const props = defineProps<{
screenPlanSaving: boolean screenPlanSaving: boolean
screenPlanMode: 'use' | 'edit' screenPlanMode: 'use' | 'edit'
screenVolume: number screenVolume: number
screenPosition: number isBusy: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
addStep: [] addStep: []
edit: [] edit: []
@@ -37,14 +38,38 @@ const emit = defineEmits<{
save: [] save: []
setStep: [index: number] setStep: [index: number]
command: [mode: ScreenMode | undefined, command: ScreenCommand] command: [mode: ScreenMode | undefined, command: ScreenCommand]
'update:screenVolume': [value: number | null] 'update:screenVolume': [value: number]
'update:screenPosition': [value: number | null]
}>() }>()
const previewScreen = computed( const previewScreen = computed(
() => props.screens.find((screen) => screen.status === 'connected') || props.screens[0], () => 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_COVER' },
{ label: 'Введение', value: 'SCREEN_MODE_INTRO' }, { label: 'Введение', value: 'SCREEN_MODE_INTRO' },
{ label: 'Закрывашка', value: 'SCREEN_MODE_CONCLUSION' }, { label: 'Закрывашка', value: 'SCREEN_MODE_CONCLUSION' },
@@ -52,278 +77,266 @@ const screenPlanModeOptions = [
{ label: 'Таймер', value: 'SCREEN_MODE_TIMER' }, { label: 'Таймер', value: 'SCREEN_MODE_TIMER' },
{ label: 'Текст', value: 'SCREEN_MODE_TEXT' }, { label: 'Текст', value: 'SCREEN_MODE_TEXT' },
] ]
function thumbnail(step: ScreenPlanStep): Partial<ScreenState> {
return {
...previewScreen.value,
...step,
timerState: 'SCREEN_TIMER_STATE_STOPPED',
timerRemainingSeconds: step.timerDurationSeconds,
}
}
</script> </script>
<template> <template>
<UiBlock class="screen-preview-panel"> <UiBlock class="screen-plan-panel" data-testid="screen-plan">
<p class="screens-title">Превью экрана</p> <n-space align="center" justify="space-between">
<ScreenPreview <h3>План экранов</h3>
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-space>
<n-button <n-button
v-if="!screenPlan && screenPlanMode === 'use'" v-if="screenPlanMode === 'use' && !screenPlan"
size="small" size="small"
:disabled="isDisabled"
@click="emit('addStep')" @click="emit('addStep')"
>Создать план</n-button
> >
Создать план
</n-button>
<n-button <n-button
v-if="screenPlan && screenPlanMode === 'use'" v-else-if="screenPlanMode === 'use'"
size="small" size="small"
square
aria-label="Редактировать план экранов"
title="Редактировать план экранов"
:disabled="isDisabled"
@click="emit('edit')" @click="emit('edit')"
> ><Icon :size="18"><Edit /></Icon
Редактировать ></n-button>
</n-button> <template v-else>
<template v-if="screenPlanMode === 'edit'"> <n-button size="small" :disabled="isDisabled" @click="emit('cancel')">Отменить</n-button>
<n-button size="small" :disabled="screenPlanSaving" @click="emit('cancel')">
Отменить
</n-button>
<n-button <n-button
size="small" size="small"
type="primary" type="primary"
:loading="screenPlanSaving" :loading="screenPlanSaving"
:disabled="screenPlanSaving" :disabled="isDisabled"
@click="emit('save')" @click="emit('save')"
>Сохранить план</n-button
> >
Сохранить план
</n-button>
</template> </template>
</n-space> </n-space>
</n-flex> </n-space>
<p v-if="!screenPlan && screenPlanMode === 'use'" class="screen-empty"> <p v-if="!steps.length">
План экранов ещё не создан. {{
screenPlanMode === 'edit'
? 'Добавьте шаги или сохраните пустой план для удаления.'
: 'План экранов ещё не создан.'
}}
</p> </p>
<template v-else-if="screenPlanMode === 'edit'"> <div class="plan-steps">
<n-space <div
v-for="(step, index) in screenPlanDraft" v-for="(step, index) in steps"
:key="index" :key="index"
class="screen-plan-step" class="plan-step"
align="center" :class="{
justify="space-between" 'plan-step-current': screenPlanMode === 'use' && screenPlan?.currentStep === index,
wrap }"
:aria-current="
screenPlanMode === 'use' && screenPlan?.currentStep === index ? 'step' : undefined
"
> >
<n-space align="center" wrap> <div class="step-preview" aria-hidden="true">
<n-text>{{ index + 1 }}</n-text> <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 <n-select
v-model:value="step.mode" v-model:value="step.mode"
:options="screenPlanModeOptions" :options="modeOptions"
:disabled="screenPlanSaving" :disabled="isDisabled"
size="small" :input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
style="width: 180px"
@update:value="emit('dirty')" @update:value="emit('dirty')"
/> />
<n-input <n-input
v-if="step.mode === 'SCREEN_MODE_TEXT'" v-if="step.mode === 'SCREEN_MODE_TEXT'"
v-model:value="step.text" v-model:value="step.text"
:disabled="screenPlanSaving" type="textarea"
size="small"
placeholder="Текст экрана" placeholder="Текст экрана"
style="min-width: 240px" :disabled="isDisabled"
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
@update:value="emit('dirty')" @update:value="emit('dirty')"
/> />
<template v-if="step.mode === 'SCREEN_MODE_TIMER'"> <template v-if="step.mode === 'SCREEN_MODE_TIMER'">
<label :for="`timer-label-${index}`">Надпись таймера</label>
<n-input <n-input
v-model:value="step.timerLabel" v-model:value="step.timerLabel"
:disabled="screenPlanSaving" :disabled="isDisabled"
size="small" :input-props="{ id: `timer-label-${index}` }"
placeholder="Надпись таймера"
@update:value="emit('dirty')" @update:value="emit('dirty')"
/> />
<label :for="`timer-duration-${index}`">Длительность в секундах</label>
<n-input-number <n-input-number
v-model:value="step.timerDurationSeconds" v-model:value="step.timerDurationSeconds"
:min="1" :min="1"
:max="86400" :max="86400"
:disabled="screenPlanSaving" :disabled="isDisabled"
size="small" :input-props="{ id: `timer-duration-${index}` }"
placeholder="Секунды"
@update:value="emit('dirty')" @update:value="emit('dirty')"
/> />
</template> </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>
<template v-else-if="screenPlan"> <template v-else>
<n-space <div class="step-heading">
v-for="(step, index) in screenPlan.steps || []" <span
:key="index" >{{ index + 1 }}.
class="screen-plan-step" {{ modeOptions.find((option) => option.value === step.mode)?.label
align="center" }}<template v-if="step.mode === 'SCREEN_MODE_TEXT'">: {{ step.text }}</template
justify="space-between" ><template v-if="step.mode === 'SCREEN_MODE_TIMER'"
wrap >: {{ 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 <n-button
size="small" size="small"
:type="screenPlan.currentStep === index ? 'primary' : 'default'" :disabled="isDisabled || screenPlan?.currentStep === index || !hasConnectedScreen"
:disabled="screenPlan.currentStep === index"
@click="emit('setStep', index)" @click="emit('setStep', index)"
>{{ screenPlan?.currentStep === index ? 'Показан' : 'Показать' }}</n-button
> >
Показать </div>
</n-button> <template v-if="screenPlan?.currentStep === index">
<template v-if="screenPlan.currentStep === index"> <n-space v-if="step.mode === 'SCREEN_MODE_TIMER'">
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
<n-button <n-button
size="small" square
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_START')" :aria-label="timerControl.label"
> :title="timerControl.label"
Старт :disabled="isDisabled || !hasConnectedScreen"
</n-button> @click="emit('command', step.mode, timerControl.command)"
><Icon :size="18"><component :is="timerControl.icon" /></Icon
></n-button>
<n-button <n-button
size="small" square
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_PAUSE')" aria-label="Сбросить таймер"
> title="Сбросить таймер"
Пауза :disabled="isDisabled || !hasConnectedScreen"
</n-button>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESUME')"
>
Продолжить
</n-button>
<n-button
size="small"
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESET')" @click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESET')"
> ><Icon :size="18"><Reset /></Icon
Сбросить ></n-button>
</n-button> </n-space>
</template>
<template <template
v-else-if=" v-else-if="
step.mode === 'SCREEN_MODE_INTRO' || step.mode === 'SCREEN_MODE_CONCLUSION' step.mode === 'SCREEN_MODE_INTRO' || step.mode === 'SCREEN_MODE_CONCLUSION'
" "
> >
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_PLAY')"> <n-space>
Запуск <n-button
</n-button> v-for="control in mediaControls"
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_PAUSE')"> :key="control.command"
Пауза square
</n-button> :aria-label="control.label"
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_RESUME')"> :title="control.label"
Продолжить :disabled="isDisabled || !hasConnectedScreen || !hasMainSource"
</n-button> @click="emit('command', step.mode, control.command)"
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_STOP')"> ><Icon :size="18"><component :is="control.icon" /></Icon
Стоп ></n-button>
</n-button> </n-space>
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_REPLAY')"> <label :id="`main-volume-${index}`">Громкость</label>
Повтор <n-slider
</n-button>
<n-input-number
:value="screenVolume" :value="screenVolume"
:min="0" :min="0"
:max="100" :max="100"
:step="5" :disabled="isDisabled || !hasConnectedScreen"
size="small" :aria-labelledby="`main-volume-${index}`"
:format-tooltip="(value) => `${value}%`"
@update:value="emit('update:screenVolume', $event)" @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>
</template> </template>
</n-space>
</n-space>
</template> </template>
</div> </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> </UiBlock>
</template> </template>
<style scoped> <style scoped>
.screens-title { h3 {
font-size: 18px;
font-weight: 600;
margin: 0; margin: 0;
} }
.screen-header { .screen-plan-panel {
margin-bottom: 8px; min-width: 0;
} }
.screen-preview-placeholder { .plan-steps {
align-items: center; display: grid;
aspect-ratio: 16 / 9; gap: 12px;
background-color: var(--brand-background); margin: 12px 0;
color: var(--brand-text-muted); }
.plan-step {
align-items: start;
border: 1px solid var(--brand-border);
display: flex; display: flex;
justify-content: center; flex-wrap: wrap;
text-align: center; gap: 12px;
padding: 12px;
} }
.screen-preview-panel > .screens-title { .plan-step-current {
margin-bottom: 8px; border-color: var(--brand-accent);
} }
.screen-plan { .step-preview {
margin-top: 16px; flex: 0 0 160px;
max-width: 100%;
} }
.screen-plan-step { .step-content {
border-top: 1px solid var(--color-border); display: flex;
padding: 8px 0; 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> </style>
+21 -243
View File
@@ -1,47 +1,33 @@
<script setup lang="ts"> <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 { 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 ScreenBindingModal from '@/components/ScreenBindingModal.vue'
import UiBlock from '@/components/UiBlock.vue' import UiBlock from '@/components/UiBlock.vue'
const props = defineProps<{ const props = defineProps<{
screens: ScreenState[] screens: ScreenState[]
screenBinding: boolean screenBinding: boolean
playlists: Playlist[] isBusy: boolean
music?: MusicState
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
bind: [screenID: string, accessToken: string] bind: [screenID: string, accessToken: string]
setSound: [screen: ScreenState] setSound: [screen: ScreenState]
unbind: [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 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> </script>
<template> <template>
<UiBlock> <UiBlock>
<n-flex align="center" justify="space-between" class="screen-header"> <n-flex align="center" justify="space-between" class="screen-header">
<p class="screens-title">Экраны</p> <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-flex>
<n-alert <n-alert
v-if="screens.some((screen) => screen.status === 'disconnected')" v-if="screens.some((screen) => screen.status === 'disconnected')"
@@ -62,8 +48,16 @@ function selectTrack(index: number) {
</div> </div>
<n-flex justify="end"> <n-flex justify="end">
<n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag> <n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag>
<n-button v-else size="small" @click="emit('setSound', screen)">Назначить звук</n-button> <n-button v-else size="small" :disabled="isBusy" @click="emit('setSound', screen)"
<n-button size="small" type="error" ghost @click="emit('unbind', screen)"> >Назначить звук</n-button
>
<n-button
size="small"
type="error"
ghost
:disabled="isBusy"
@click="emit('unbind', screen)"
>
Отвязать Отвязать
</n-button> </n-button>
</n-flex> </n-flex>
@@ -75,104 +69,6 @@ function selectTrack(index: number) {
:binding="props.screenBinding" :binding="props.screenBinding"
@scanned="(screenID, accessToken) => emit('bind', screenID, accessToken)" @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> </template>
<style scoped> <style scoped>
@@ -186,146 +82,28 @@ function selectTrack(index: number) {
margin-bottom: 8px; 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 { .screen-row {
align-items: center; align-items: center;
border-top: 1px solid rgb(255 255 255 / 10%); border-top: 1px solid var(--brand-border);
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
justify-content: space-between; justify-content: space-between;
padding: 8px 0; padding: 8px 0;
} }
.screen-status { .screen-status {
color: #aaa; color: var(--brand-text-muted);
font-size: 13px; font-size: 13px;
} }
.screen-empty { .screen-empty {
color: #aaa; color: var(--brand-text-muted);
margin: 16px 0 0; margin: 16px 0 0;
} }
.screen-error { .screen-error {
color: #f2a2a2; color: var(--brand-error);
font-size: 13px; 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> </style>
+9 -1
View File
@@ -82,6 +82,8 @@ const musicSyncState = computed(() => [
state.value?.music?.playlist?.ID, state.value?.music?.playlist?.ID,
state.value?.music?.trackIndex, state.value?.music?.trackIndex,
state.value?.music?.playback, state.value?.music?.playback,
state.value?.music?.stateVersion,
state.value?.music?.positionMs,
] as const) ] as const)
const screenBackground = computed(() => { const screenBackground = computed(() => {
const image = state.value?.backgroundImage || state.value?.coverImage const image = state.value?.backgroundImage || state.value?.coverImage
@@ -185,7 +187,12 @@ watch(state, async (value) => {
await nextTick() await nextTick()
const media = screenMode.value === 'SCREEN_MODE_CONCLUSION' ? conclusionVideoElement.value : introAudioElement.value const media = screenMode.value === 'SCREEN_MODE_CONCLUSION' ? conclusionVideoElement.value : introAudioElement.value
if (!media) return 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.currentTime = (value.positionMs || 0) / 1000
}
media.volume = Math.max(0, Math.min(1, (value.volume ?? 100) / 100)) media.volume = Math.max(0, Math.min(1, (value.volume ?? 100) / 100))
if (value.command === 'SCREEN_COMMAND_SEEK') { if (value.command === 'SCREEN_COMMAND_SEEK') {
return return
@@ -226,7 +233,8 @@ watch(
if (!media) return if (!media) return
const trackChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1] const trackChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1]
const playbackChanged = !previous || value[2] !== previous[2] 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) await waitForMusicMetadata(media)
media.currentTime = trackChanged ? 0 : (music.positionMs || 0) / 1000 media.currentTime = trackChanged ? 0 : (music.positionMs || 0) / 1000
if (music.playback === 'MUSIC_PLAYBACK_PLAYING') { if (music.playback === 'MUSIC_PLAYBACK_PLAYING') {
+54 -16
View File
@@ -9,9 +9,10 @@ import type {
import { applicationFileURL } from '@/utils/application' import { applicationFileURL } from '@/utils/application'
const props = defineProps<{ const props = defineProps<{
state: ScreenState state: Partial<ScreenState>
questions: ResultsQuestion[] questions: ResultsQuestion[]
teams: TeamResult[] teams: TeamResult[]
isThumbnail?: boolean
}>() }>()
const timerNow = ref(Date.now()) const timerNow = ref(Date.now())
@@ -42,8 +43,14 @@ watch(
() => [props.state.timerRemainingSeconds, props.state.timerState, props.state.stateVersion], () => [props.state.timerRemainingSeconds, props.state.timerState, props.state.stateVersion],
() => { () => {
const remaining = Math.max(0, Number(props.state.timerRemainingSeconds || 0)) const remaining = Math.max(0, Number(props.state.timerRemainingSeconds || 0))
if (props.state.timerState === 'SCREEN_TIMER_STATE_RUNNING' && timerStateAtSync.value === props.state.timerState) { if (
const localRemaining = Math.max(0, timerRemainingAtSync.value - (Date.now() - timerSyncedAt.value) / 1000) 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 if (Math.abs(remaining - localRemaining) <= 0.5) return
} }
timerRemainingAtSync.value = remaining timerRemainingAtSync.value = remaining
@@ -54,6 +61,7 @@ watch(
) )
onMounted(() => { onMounted(() => {
if (props.isThumbnail) return
timerTick = window.setInterval(() => { timerTick = window.setInterval(() => {
timerNow.value = Date.now() timerNow.value = Date.now()
}, 250) }, 250)
@@ -65,25 +73,34 @@ onUnmounted(() => {
</script> </script>
<template> <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"> <div v-if="state.mode === 'SCREEN_MODE_TIMER'" class="timer-preview">
<h2>{{ state.timerLabel || 'Перерыв' }}</h2> <h2>{{ state.timerLabel || 'Перерыв' }}</h2>
<strong>{{ timerDisplay }}</strong> <strong>{{ timerDisplay }}</strong>
</div> </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 <video
v-else-if="state.mode === 'SCREEN_MODE_CONCLUSION' && conclusionVideo" v-else-if="state.mode === 'SCREEN_MODE_CONCLUSION' && conclusionVideo"
:src="conclusionVideo" :src="conclusionVideo"
autoplay :autoplay="!isThumbnail"
preload="metadata"
class="preview-video" class="preview-video"
loop :loop="!isThumbnail"
muted muted
playsinline playsinline
/> />
<div v-else-if="state.mode === 'SCREEN_MODE_CONCLUSION'" class="preview-message"> <div v-else-if="state.mode === 'SCREEN_MODE_CONCLUSION'" class="preview-message">
Видео закрывашки не задано Видео закрывашки не задано
</div> </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"> <div v-else-if="state.mode === 'SCREEN_MODE_RESULTS'" class="preview-results">
<h2>Результаты</h2> <h2>Результаты</h2>
<table> <table>
@@ -101,7 +118,9 @@ onUnmounted(() => {
<td>{{ result.place }}</td> <td>{{ result.place }}</td>
<td>{{ result.team?.name }}</td> <td>{{ result.team?.name }}</td>
<td v-for="question in questions" :key="question.code"> <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>
<td>{{ result.totalScore }}</td> <td>{{ result.totalScore }}</td>
<td>{{ result.actionsCount }}</td> <td>{{ result.actionsCount }}</td>
@@ -109,7 +128,13 @@ onUnmounted(() => {
</tbody> </tbody>
</table> </table>
</div> </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> </div>
</template> </template>
@@ -117,10 +142,11 @@ onUnmounted(() => {
.screen-preview { .screen-preview {
align-items: center; align-items: center;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
background-color: #111; background-color: var(--brand-background);
background-position: center; background-position: center;
background-size: cover; background-size: cover;
color: #eee; color: var(--brand-text);
container-type: inline-size;
display: flex; display: flex;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
@@ -136,13 +162,13 @@ onUnmounted(() => {
.timer-preview h2, .timer-preview h2,
.preview-message, .preview-message,
.preview-results h2 { .preview-results h2 {
font-size: clamp(16px, 3vw, 32px); font-size: clamp(10px, 4cqw, 32px);
margin: 0 0 10px; margin: 0 0 10px;
} }
.timer-preview strong { .timer-preview strong {
display: block; display: block;
font-size: clamp(48px, 10vw, 120px); font-size: clamp(20px, 16cqw, 120px);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 0.9; line-height: 0.9;
} }
@@ -174,14 +200,26 @@ onUnmounted(() => {
.preview-results table { .preview-results table {
border-collapse: collapse; border-collapse: collapse;
font-size: 10px; font-size: clamp(3px, 1.4cqw, 10px);
width: 100%; width: 100%;
} }
.preview-results th, .preview-results th,
.preview-results td { .preview-results td {
border-bottom: 1px solid rgb(255 255 255 / 18%); border-bottom: 1px solid var(--brand-border);
padding: 3px; padding: 3px;
text-align: left; 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> </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',
)