Files
evening_detective_frontend/src/components/ScreenPage.vue
T
2026-10-07 01:56:54 +07:00

590 lines
19 KiB
Vue

<script setup lang="ts">
import { NButton, NCard, NQrCode, NSelect, useMessage } from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import type { Game, ScreenState, TeamResult } from '@/api/generated/crabs/evening_detective_server'
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
import HeaderMenu from '@/components/HeaderMenu.vue'
import { useSimplePolling } from '@/composables/useSimplePolling'
import { applicationFileURL } from '@/utils/application'
import { formatGameDateTime } from '@/utils/datetime'
const sessionKey = 'evening-detective-screen-session'
const screenClient = buildClient()
const authClient = getAuthClient()
const message = useMessage()
const route = useRoute()
const screenId = ref('')
const accessToken = ref('')
const state = ref<ScreenState>()
const resultTeams = ref<TeamResult[]>([])
const resultQuestions = ref<{ code: string | undefined; text: string | undefined }[]>([])
const resultsContainer = ref<HTMLElement>()
let resultsScrollTimer: number | undefined
let resultsScrollStarted = false
const games = ref<Game[]>([])
const selectedGameId = ref<number | null>(null)
const prepared = ref(false)
const mediaError = ref('')
const loading = ref(true)
const binding = ref(false)
const bindSuccess = ref(false)
const timerNow = ref(Date.now())
const timerRemainingAtSync = ref(0)
const timerSyncedAt = ref(Date.now())
const timerStateAtSync = ref<string>()
const pollingReady = ref(false)
const introAudioElement = ref<HTMLAudioElement>()
const musicAudioElement = ref<HTMLAudioElement>()
const conclusionVideoElement = ref<HTMLVideoElement>()
let appliedCommandVersion = 0
let timerTick: number | null = null
const bindingFlow = computed(() => Boolean(route.query.screenID && route.query.accessToken))
const qrValue = computed(() => {
if (!screenId.value || !accessToken.value) return ''
const url = new URL('/screen', window.location.origin)
url.searchParams.set('screenId', screenId.value)
url.searchParams.set('accessToken', accessToken.value)
return url.toString()
})
const gameOptions = computed(() =>
games.value.map((game) => ({
label: `${formatGameDateTime(game.startAt).split(',')[0] || 'дата не указана'} — ${game.name || `Игра ${game.ID}`}`,
value: game.ID!,
})),
)
const screenMode = computed(() => state.value?.mode || 'SCREEN_MODE_COVER')
const timerRemainingSeconds = computed(() => {
if (state.value?.timerState !== 'SCREEN_TIMER_STATE_RUNNING') return timerRemainingAtSync.value
return Math.max(0, timerRemainingAtSync.value - (timerNow.value - timerSyncedAt.value) / 1000)
})
const timerDisplay = computed(() => {
const minutes = Math.floor(timerRemainingSeconds.value / 60)
const seconds = Math.floor(timerRemainingSeconds.value % 60)
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
})
const introAudio = computed(() => applicationFileURL(state.value?.introAudio))
const conclusionVideo = computed(() => applicationFileURL(state.value?.conclusionVideo))
const musicTrack = computed(() => {
const tracks = state.value?.music?.playlist?.tracks || []
const index = state.value?.music?.trackIndex || 0
return applicationFileURL(tracks[index]?.filename)
})
const musicSyncState = computed(() => [
state.value?.music?.playlist?.ID,
state.value?.music?.trackIndex,
state.value?.music?.playback,
] as const)
const screenBackground = computed(() => {
const image = state.value?.backgroundImage || state.value?.coverImage
return image ? { backgroundImage: `linear-gradient(rgb(0 0 0 / 55%), rgb(0 0 0 / 55%)), url("${image}")` } : {}
})
function saveSession() {
sessionStorage.setItem(sessionKey, JSON.stringify({ screenID: screenId.value, accessToken: accessToken.value }))
}
function syncTimer(value: ScreenState | undefined) {
const remaining = Math.max(0, Number(value?.timerRemainingSeconds || 0))
if (value?.timerState === 'SCREEN_TIMER_STATE_RUNNING' && timerStateAtSync.value === value.timerState) {
const localRemaining = Math.max(0, timerRemainingAtSync.value - (Date.now() - timerSyncedAt.value) / 1000)
if (Math.abs(remaining - localRemaining) <= 0.5) return
}
timerRemainingAtSync.value = remaining
timerSyncedAt.value = Date.now()
timerStateAtSync.value = value?.timerState
}
function readSession() {
try {
const saved = JSON.parse(sessionStorage.getItem(sessionKey) || '{}') as {
screenId?: string
accessToken?: string
}
screenId.value = saved.screenId || ''
accessToken.value = saved.accessToken || ''
} catch {
sessionStorage.removeItem(sessionKey)
}
}
async function createSession() {
const res = await screenClient.CreateScreenSession({})
if (!res.screenID || !res.accessToken) throw new Error('Не удалось создать сессию экрана')
screenId.value = res.screenID
accessToken.value = res.accessToken
saveSession()
}
async function loadState(recreateOnUnauthorized = true) {
if (!screenId.value || !accessToken.value) return
try {
const res = await screenClient.GetScreenState({ screenID: screenId.value, accessToken: accessToken.value })
state.value = res.state
syncTimer(state.value)
if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_RESULTS') await loadResults()
} catch (err) {
if (recreateOnUnauthorized && err instanceof Error && err.message === 'unauthorized') {
sessionStorage.removeItem(sessionKey)
screenId.value = ''
accessToken.value = ''
await createSession()
await loadState(false)
return
}
message.error(err instanceof Error ? err.message : 'Не удалось получить состояние экрана')
}
}
async function loadResults() {
if (!screenId.value || !accessToken.value) return
const res = await screenClient.GetScreenResults({ screenID: screenId.value, accessToken: accessToken.value })
resultQuestions.value = res.questions || []
resultTeams.value = res.teams || []
if (!resultsScrollStarted) {
resultsScrollStarted = true
await nextTick()
const container = resultsContainer.value
if (container) {
container.scrollTop = container.scrollHeight
resultsScrollTimer = window.setInterval(() => {
container.scrollTop = Math.max(0, container.scrollTop - 32)
if (container.scrollTop === 0 && resultsScrollTimer != null) {
window.clearInterval(resultsScrollTimer)
resultsScrollTimer = undefined
}
}, 1000)
}
}
}
async function loadGames() {
try {
const res = await authClient.GetGames({})
games.value = res.games || []
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось загрузить игры')
}
}
useSimplePolling(() => {
if (pollingReady.value) void loadState()
}, 1000)
watch(state, async (value) => {
if (!value || !value.command || value.stateVersion === appliedCommandVersion) return
appliedCommandVersion = value.stateVersion || 0
await nextTick()
const media = screenMode.value === 'SCREEN_MODE_CONCLUSION' ? conclusionVideoElement.value : introAudioElement.value
if (!media) return
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
} else if (value.command === 'SCREEN_COMMAND_VOLUME') {
return
} else if (value.command === 'SCREEN_COMMAND_PLAY' || value.command === 'SCREEN_COMMAND_RESUME' || value.command === 'SCREEN_COMMAND_REPLAY') {
if (value.command === 'SCREEN_COMMAND_REPLAY') media.currentTime = 0
void media.play().catch(() => {
mediaError.value = 'Браузер заблокировал воспроизведение. Нажмите «Подготовить экран» на этом устройстве.'
})
} else if (value.command === 'SCREEN_COMMAND_PAUSE') {
media.pause()
} else if (value.command === 'SCREEN_COMMAND_STOP') {
media.pause()
}
})
function waitForMusicMetadata(media: HTMLAudioElement): Promise<void> {
if (media.readyState >= HTMLMediaElement.HAVE_METADATA) return Promise.resolve()
return new Promise((resolve) => {
const finish = () => {
media.removeEventListener('loadedmetadata', finish)
media.removeEventListener('error', finish)
resolve()
}
media.addEventListener('loadedmetadata', finish, { once: true })
media.addEventListener('error', finish, { once: true })
})
}
watch(
musicSyncState,
async (value, previous) => {
const music = state.value?.music
if (!music || !music.playlist?.tracks?.length) return
await nextTick()
const media = musicAudioElement.value
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
await waitForMusicMetadata(media)
media.currentTime = trackChanged ? 0 : (music.positionMs || 0) / 1000
if (music.playback === 'MUSIC_PLAYBACK_PLAYING') {
void media.play().catch(() => {
mediaError.value = 'Браузер заблокировал воспроизведение музыки. Нажмите «Подготовить экран».'
})
} else {
media.pause()
}
},
{ immediate: true },
)
watch(
() => [state.value?.music?.volume, state.value?.soundEnabled] as const,
([volume, soundEnabled]) => {
const media = musicAudioElement.value
if (!media) return
media.volume = Math.max(0, Math.min(1, (volume ?? 100) / 100))
media.muted = !soundEnabled
},
{ immediate: true },
)
async function advanceMusic() {
if (!screenId.value || !accessToken.value) return
try {
await screenClient.AdvanceScreenMusic({ screenID: screenId.value, accessToken: accessToken.value })
} catch {
mediaError.value = 'Не удалось синхронизировать музыку. Повторная попытка выполняется автоматически.'
}
}
async function bind() {
if (!selectedGameId.value || !route.query.screenID || !route.query.accessToken) return
binding.value = true
try {
await authClient.BindScreen({
screenID: String(route.query.screenID),
accessToken: String(route.query.accessToken),
gameID: selectedGameId.value,
})
bindSuccess.value = true
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось привязать экран')
} finally {
binding.value = false
}
}
async function prepareScreen() {
try {
await document.documentElement.requestFullscreen?.()
} catch {
message.warning('Полноэкранный режим недоступен в этом браузере')
}
prepared.value = true
await nextTick()
for (const media of [introAudioElement.value, conclusionVideoElement.value, musicAudioElement.value]) {
if (!media) continue
media.muted = true
try {
await media.play()
media.pause()
media.currentTime = 0
media.muted = !state.value?.soundEnabled
} catch {
mediaError.value = 'Браузер не разрешил подготовить воспроизведение на этом устройстве.'
}
}
}
onMounted(async () => {
if (bindingFlow.value) {
await loadGames()
loading.value = false
return
}
readSession()
try {
if (!screenId.value || !accessToken.value) await createSession()
await loadState()
pollingReady.value = true
timerTick = window.setInterval(() => {
timerNow.value = Date.now()
}, 250)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось подготовить экран')
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (timerTick != null) window.clearInterval(timerTick)
if (resultsScrollTimer != null) window.clearInterval(resultsScrollTimer)
})
</script>
<template>
<div v-if="bindingFlow" class="screen-center">
<n-card class="binding-card" title="Привязка экрана">
<template v-if="bindSuccess">
<p>Экран привязан к игре.</p>
<p>Можно закрыть эту страницу.</p>
</template>
<template v-else>
<p>Выберите игру, к которой привязать экран.</p>
<n-select v-model:value="selectedGameId" :options="gameOptions" placeholder="Игра" />
<n-button type="primary" :loading="binding" :disabled="selectedGameId == null" @click="bind">
Привязать
</n-button>
</template>
</n-card>
</div>
<div v-else class="screen-page">
<div v-if="loading" class="screen-center">Подготовка экрана…</div>
<template v-else>
<div v-if="!state?.gameID" class="screen-center">
<n-card class="qr-card" title="Подключение экрана">
<div v-if="qrValue" class="screen-qr">
<n-qr-code :value="qrValue" :size="260" :padding="0" />
</div>
<p>Отсканируйте QR телефоном организатора и выберите игру.</p>
<n-button v-if="!prepared" type="primary" @click="prepareScreen">Подготовить экран</n-button>
</n-card>
</div>
<div v-else class="screen-content" :style="screenBackground">
<p v-if="mediaError" class="media-error">{{ mediaError }}</p>
<div v-if="state.introAudio" v-show="screenMode === 'SCREEN_MODE_INTRO'" class="screen-media">
<audio ref="introAudioElement" :src="introAudio" :muted="!state.soundEnabled" controls />
</div>
<audio
v-if="state.music?.playlist?.tracks?.length"
ref="musicAudioElement"
:src="musicTrack"
:muted="!state.soundEnabled"
@ended="advanceMusic"
/>
<div v-if="state.conclusionVideo" v-show="screenMode === 'SCREEN_MODE_CONCLUSION'" class="conclusion-screen">
<video ref="conclusionVideoElement" :src="conclusionVideo" :muted="!state.soundEnabled" playsinline />
</div>
<div v-if="screenMode === 'SCREEN_MODE_CONCLUSION' && !state.conclusionVideo" class="screen-message">Видео закрывашки не задано</div>
<div v-if="screenMode === 'SCREEN_MODE_INTRO_DONE'" class="screen-message">Успехов</div>
<div v-if="screenMode === 'SCREEN_MODE_CONCLUSION_DONE'" class="screen-message">Спасибо за игру!</div>
<div v-if="screenMode === 'SCREEN_MODE_TEXT'" class="screen-message">{{ state.text }}</div>
<div v-if="screenMode === 'SCREEN_MODE_TIMER'" class="timer-screen">
<h1 class="timer-label">{{ state.timerLabel || 'Перерыв' }}</h1>
<div class="timer-value">{{ timerDisplay }}</div>
</div>
<div v-if="screenMode === 'SCREEN_MODE_RESULTS'" ref="resultsContainer" class="results-screen">
<h1>Результаты</h1>
<table>
<thead><tr><th>Место</th><th>Команда</th><th v-for="question in resultQuestions" :key="question.code">{{ question.code }}</th><th>Сумма</th><th>Ходы</th></tr></thead>
<tbody>
<tr v-for="result in resultTeams" :key="result.team?.ID">
<td>{{ result.place }}</td>
<td>{{ result.team?.name }}</td>
<td v-for="question in resultQuestions" :key="question.code">
{{ result.answers?.find(answer => answer.questionCode === question.code)?.score ?? 0 }}
</td>
<td>{{ result.totalScore }}</td>
<td>{{ result.actionsCount }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="screenMode === 'SCREEN_MODE_COVER'" class="screen-cover">
<img v-if="state.coverImage" :src="state.coverImage" class="cover-image" alt="Обложка дела" />
<div v-if="!prepared" class="cover-controls">
<n-button type="primary" @click="prepareScreen">Подготовить экран</n-button>
</div>
</div>
</div>
</template>
</div>
<HeaderMenu v-if="bindingFlow" active="games" />
</template>
<style scoped>
.binding-card,
.qr-card,
.ready-card {
max-width: min(94vw, 520px);
text-align: center;
width: 100%;
}
.binding-card p,
.qr-card p,
.ready-card p {
margin: 0 0 16px;
}
.binding-card .n-select {
margin-bottom: 8px;
}
.binding-card .n-button,
.qr-card .n-button,
.ready-card .n-button {
margin-top: 8px;
}
.ready-title {
font-size: 28px;
}
.screen-qr {
background: #fff;
line-height: 0;
padding: 12px;
}
.screen-center {
align-items: center;
display: flex;
justify-content: center;
min-height: 100dvh;
padding: 20px;
}
.screen-page {
background: #111;
color: #eee;
min-height: 100dvh;
}
.screen-content {
align-items: center;
background-position: center;
background-size: cover;
display: flex;
justify-content: center;
min-height: 100dvh;
text-align: center;
}
.media-error {
background: #6b2d2d;
border-radius: 8px;
left: 20px;
margin: 0;
padding: 10px 14px;
position: fixed;
right: 20px;
top: 20px;
z-index: 2;
}
.screen-cover {
height: 100dvh;
position: relative;
width: 100vw;
}
.cover-label {
font-size: clamp(28px, 6vw, 72px);
margin: 0 0 20px;
}
.cover-image {
display: block;
height: 100%;
object-fit: contain;
width: 100%;
}
.cover-controls {
left: 0;
position: absolute;
right: 0;
text-align: center;
top: 50%;
transform: translateY(-50%);
}
.cover-controls .n-button {
font-weight: 700;
margin-bottom: 16px;
}
.screen-message {
font-size: clamp(42px, 10vw, 140px);
font-weight: 700;
}
.timer-screen {
align-items: center;
display: flex;
flex-direction: column;
gap: 24px;
justify-content: center;
max-width: 90vw;
}
.timer-label {
font-size: clamp(32px, 6vw, 88px);
margin: 0;
}
.timer-value {
font-size: clamp(96px, 24vw, 360px);
font-variant-numeric: tabular-nums;
font-weight: 700;
line-height: 0.9;
}
.screen-media {
max-width: 90vw;
width: 900px;
}
.screen-media audio,
.screen-media video {
max-height: 75dvh;
width: 100%;
}
.conclusion-screen {
background: #000;
height: 100dvh;
width: 100vw;
}
.conclusion-screen video {
display: block;
height: 100%;
object-fit: contain;
width: 100%;
}
.results-screen {
max-height: 90dvh;
max-width: 96vw;
overflow: hidden;
width: 1400px;
}
.results-screen h1 {
font-size: clamp(28px, 5vw, 64px);
}
.results-screen table {
border-collapse: collapse;
font-size: clamp(14px, 2vw, 28px);
width: 100%;
}
.results-screen th,
.results-screen td {
border-bottom: 1px solid rgb(255 255 255 / 18%);
padding: 8px 12px;
text-align: left;
}
.results-screen tbody tr:nth-child(odd) {
background: rgb(255 255 255 / 8%);
}
</style>