generated from VLADIMIR/template_frontend
672 lines
22 KiB
Vue
672 lines
22 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 ScreenPreview from '@/components/ScreenPreview.vue'
|
|
import { useDeviceContext } from '@/composables/useDeviceContext'
|
|
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 screenTeamNames = ref<string[]>()
|
|
let lastTeamsLoaded = -Infinity
|
|
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 isAdvancingMusic = false
|
|
let appliedCommandVersion = 0
|
|
let timerTick: number | null = null
|
|
|
|
const bindingFlow = computed(() => Boolean(route.query.screenID && route.query.accessToken))
|
|
useDeviceContext(() => (screenId.value && accessToken.value && !bindingFlow.value
|
|
? { screenID: screenId.value, accessToken: accessToken.value }
|
|
: {}))
|
|
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?.playlist?.tracks?.[state.value?.music?.trackIndex ?? 0]?.ID,
|
|
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
|
|
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 })
|
|
if (state.value?.gameID !== res.state?.gameID) {
|
|
screenTeamNames.value = undefined
|
|
lastTeamsLoaded = -Infinity
|
|
}
|
|
if (
|
|
state.value?.gameID === res.state?.gameID &&
|
|
state.value?.music &&
|
|
res.state?.music &&
|
|
BigInt(res.state.music.stateVersion ?? 0) < BigInt(state.value.music.stateVersion ?? 0)
|
|
)
|
|
res.state.music = state.value.music
|
|
state.value = res.state
|
|
syncTimer(state.value)
|
|
if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_RESULTS') await loadResults()
|
|
if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_TEAMS' && Date.now() - lastTeamsLoaded >= 5000) await loadScreenTeams()
|
|
} 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 loadScreenTeams() {
|
|
const gameID = state.value?.gameID
|
|
const sessionID = screenId.value
|
|
lastTeamsLoaded = Date.now()
|
|
const res = await screenClient.GetScreenTeams({
|
|
screenID: sessionID,
|
|
accessToken: accessToken.value,
|
|
})
|
|
if (state.value?.gameID !== gameID || screenId.value !== sessionID) return
|
|
screenTeamNames.value = (res.teams ?? [])
|
|
.map((team) => team.name ?? '')
|
|
.sort((a, b) => a.localeCompare(b, 'ru'))
|
|
}
|
|
|
|
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(async () => {
|
|
if (!pollingReady.value) return
|
|
await loadState()
|
|
await nextTick()
|
|
if (musicAudioElement.value?.ended && state.value?.music?.playback === 'MUSIC_PLAYBACK_PLAYING')
|
|
await advanceMusic()
|
|
}, 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
|
|
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
|
|
} 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]
|
|
const isRestart =
|
|
!!previous &&
|
|
value[3] !== previous[3] &&
|
|
Number(value[4] ?? 0) < Number(previous[4] ?? 0)
|
|
const isCompletedRestart =
|
|
media.ended && !!previous && BigInt(value[3] ?? 0) > BigInt(previous[3] ?? 0)
|
|
if (!trackChanged && !playbackChanged && !isRestart && !isCompletedRestart) 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 (isAdvancingMusic || !screenId.value || !accessToken.value || !state.value?.soundEnabled) return
|
|
isAdvancingMusic = true
|
|
const gameID = state.value.gameID
|
|
const sessionID = screenId.value
|
|
try {
|
|
const result = await screenClient.AdvanceScreenMusic({
|
|
screenID: screenId.value,
|
|
accessToken: accessToken.value,
|
|
expectedStateVersion: String(state.value?.music?.stateVersion ?? 0),
|
|
})
|
|
if (
|
|
state.value?.gameID === gameID &&
|
|
screenId.value === sessionID &&
|
|
state.value &&
|
|
result.state &&
|
|
BigInt(result.state.stateVersion ?? 0) >= BigInt(state.value.music?.stateVersion ?? 0)
|
|
) {
|
|
mediaError.value = ''
|
|
state.value.music = result.state
|
|
}
|
|
} catch {
|
|
mediaError.value = 'Не удалось синхронизировать музыку. Повторная попытка выполняется автоматически.'
|
|
} finally {
|
|
isAdvancingMusic = false
|
|
}
|
|
}
|
|
|
|
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,
|
|
initializeAudio: false,
|
|
})
|
|
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>
|
|
<ScreenPreview
|
|
v-if="screenMode === 'SCREEN_MODE_QR' || screenMode === 'SCREEN_MODE_TEAMS'"
|
|
class="static-screen"
|
|
:state="state"
|
|
:questions="[]"
|
|
:teams="[]"
|
|
:team-names="screenTeamNames"
|
|
/>
|
|
<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%);
|
|
}
|
|
|
|
.static-screen {
|
|
height: 100dvh;
|
|
}
|
|
</style>
|