348 lines
11 KiB
Vue
348 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
|
import type { State } from '../types/screen'
|
|
import { MediaPlayer, waitUntil } from '../utils/media-player'
|
|
import { useScreenTimer } from '../composables/useScreenTimer'
|
|
import ResultsTable from './ResultsTable.vue'
|
|
import { formatTimer } from '../utils/presentation'
|
|
|
|
const audio = ref<HTMLAudioElement | null>(null)
|
|
const video = ref<HTMLVideoElement | null>(null)
|
|
const musicA = ref<HTMLAudioElement | null>(null)
|
|
const musicB = ref<HTMLAudioElement | null>(null)
|
|
const surface = ref<HTMLElement | null>(null)
|
|
const state = shallowRef<State | null>(null)
|
|
const imageError = ref(false)
|
|
const timerDisplay = useScreenTimer(() => state.value)
|
|
const introPosition = ref(0)
|
|
const introDuration = ref<number | null>(null)
|
|
const introProgress = computed(() => introDuration.value ? introPosition.value / introDuration.value : 0)
|
|
const introTime = computed(() => `${formatTimer(Math.floor(introPosition.value) * 1000)} / ${introDuration.value === null ? '—' : formatTimer(Math.floor(introDuration.value) * 1000)}`)
|
|
const introBars = [24, 38, 18, 46, 58, 32, 68, 44, 26, 54, 72, 40, 60, 30, 48, 64, 36, 76, 52, 28, 44, 62, 34, 56, 70, 42, 22, 50, 66, 38, 58, 74, 46, 30, 54, 40, 68, 48, 26, 60, 36, 72, 52, 32, 46, 62, 28, 18]
|
|
function resetIntroProgress() {
|
|
introPosition.value = 0
|
|
introDuration.value = null
|
|
}
|
|
function updateIntroProgress() {
|
|
const media = audio.value
|
|
if (!media || state.value?.scene.mode !== 2 || media.getAttribute('src') !== state.value.channels.main.source?.url) {
|
|
resetIntroProgress()
|
|
return
|
|
}
|
|
introDuration.value = Number.isFinite(media.duration) && media.duration > 0 ? media.duration : null
|
|
introPosition.value = introDuration.value && Number.isFinite(media.currentTime)
|
|
? Math.max(0, Math.min(media.currentTime, introDuration.value)) : 0
|
|
}
|
|
const bridge = window.screenBridge!
|
|
let player: MediaPlayer | undefined
|
|
let disposed = false
|
|
let unsubscribe: (() => void) | undefined
|
|
let revision = -1
|
|
let sceneToken = ''
|
|
let sceneAbort: AbortController | null = null
|
|
// Новый адрес исключает повторное использование уже декодированной картинки Chromium.
|
|
function imageURL(url: string) {
|
|
const result = new URL(url)
|
|
result.searchParams.set('scene', `${state.value?.session}:${state.value?.scene.revision}`)
|
|
return result.href
|
|
}
|
|
function waitImage(image: HTMLImageElement, signal: AbortSignal) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const finish = (error?: string) => {
|
|
clearTimeout(timeout)
|
|
image.removeEventListener('load', loaded)
|
|
image.removeEventListener('error', failed)
|
|
signal.removeEventListener('abort', aborted)
|
|
if (error) reject(new Error(error))
|
|
else resolve()
|
|
}
|
|
const loaded = () =>
|
|
finish(image.naturalWidth ? undefined : 'Не удалось подготовить изображение')
|
|
const failed = () => finish('Не удалось подготовить изображение')
|
|
const aborted = () => finish('Подготовка отменена')
|
|
const timeout = setTimeout(() => finish('Изображение не подготовлено за 10 секунд'), 10000)
|
|
image.addEventListener('load', loaded)
|
|
image.addEventListener('error', failed)
|
|
signal.addEventListener('abort', aborted, { once: true })
|
|
if (signal.aborted) aborted()
|
|
else if (image.complete) loaded()
|
|
})
|
|
}
|
|
async function prepareScene(snapshot: State) {
|
|
sceneAbort?.abort()
|
|
const abort = new AbortController()
|
|
sceneAbort = abort
|
|
const current = () =>
|
|
!abort.signal.aborted &&
|
|
state.value?.session === snapshot.session &&
|
|
state.value.scene.revision === snapshot.scene.revision
|
|
imageError.value = false
|
|
try {
|
|
await nextTick()
|
|
if (!current()) return
|
|
await Promise.all(
|
|
Array.from(surface.value?.querySelectorAll('img') ?? []).map((image) =>
|
|
waitImage(image, abort.signal),
|
|
),
|
|
)
|
|
const media =
|
|
snapshot.scene.mode === 2 ? audio.value : snapshot.scene.mode === 4 ? video.value : null
|
|
if (media && snapshot.channels.main.source)
|
|
await waitUntil(media, 'canplay', () => media.readyState >= 3, abort.signal)
|
|
if (current())
|
|
bridge.reportScene({
|
|
session: snapshot.session,
|
|
revision: snapshot.scene.revision,
|
|
status: 'ready',
|
|
})
|
|
} catch (error) {
|
|
if (!current()) return
|
|
imageError.value = true
|
|
bridge.reportScene({
|
|
session: snapshot.session,
|
|
revision: snapshot.scene.revision,
|
|
status: 'error',
|
|
error: error instanceof Error ? error.message : 'Не удалось подготовить сцену',
|
|
})
|
|
}
|
|
}
|
|
function apply(value: State) {
|
|
if (disposed) return
|
|
if (value.revision <= revision) return
|
|
revision = value.revision
|
|
if (value.session !== state.value?.session || value.channels.main.source?.url !== state.value?.channels.main.source?.url || value.scene.mode !== 2)
|
|
resetIntroProgress()
|
|
state.value = value
|
|
player?.apply(value)
|
|
updateIntroProgress()
|
|
const token = `${value.session}:${value.scene.revision}`
|
|
if (token !== sceneToken) {
|
|
sceneToken = token
|
|
void prepareScene(value)
|
|
}
|
|
}
|
|
onMounted(async () => {
|
|
try {
|
|
await bridge.prepareAudioOutput()
|
|
} catch {
|
|
return // Ошибка устройства отображается в пульте; не запускать звук на другом выходе.
|
|
}
|
|
if (disposed) return
|
|
player = new MediaPlayer(
|
|
{ audio: audio.value!, video: video.value!, music: [musicA.value!, musicB.value!] },
|
|
(report) => bridge.report(report),
|
|
)
|
|
unsubscribe = bridge.subscribe(apply)
|
|
apply(await bridge.state())
|
|
})
|
|
onUnmounted(() => {
|
|
disposed = true
|
|
sceneAbort?.abort()
|
|
unsubscribe?.()
|
|
player?.destroy()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<main ref="surface" class="output" :data-mode="state?.scene.mode">
|
|
<img
|
|
v-if="state?.catalog.background"
|
|
:key="`${state.session}:${state.scene.revision}:background`"
|
|
class="background"
|
|
:class="{ failed: imageError }"
|
|
:src="imageURL(state.catalog.background)"
|
|
alt=""
|
|
/>
|
|
<div v-if="state" class="scene">
|
|
<template v-if="state.scene.mode === 1">
|
|
<img
|
|
v-if="state.catalog.cover"
|
|
:key="`${state.session}:${state.scene.revision}:cover`"
|
|
class="cover"
|
|
:class="{ failed: imageError }"
|
|
:src="imageURL(state.catalog.cover)"
|
|
alt="Обложка дела"
|
|
/>
|
|
<h1 v-else class="message">{{ state.catalog.name }}</h1>
|
|
</template>
|
|
<p v-else-if="state.scene.mode === 2 && !state.channels.main.source" class="message">
|
|
Вступление не задано
|
|
</p>
|
|
<section v-else-if="state.scene.mode === 2" class="intro-recording" aria-label="Аудиозапись введения">
|
|
<h1>Введение</h1>
|
|
<div
|
|
class="intro-waveform"
|
|
role="progressbar"
|
|
aria-label="Прогресс введения"
|
|
aria-valuemin="0"
|
|
aria-valuemax="100"
|
|
:aria-valuenow="introDuration === null ? undefined : Math.round(introProgress * 100)"
|
|
:aria-valuetext="introTime"
|
|
>
|
|
<svg viewBox="0 0 480 80" preserveAspectRatio="none" aria-hidden="true">
|
|
<defs>
|
|
<mask id="intro-waveform-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="480" height="80">
|
|
<rect v-for="(height, index) in introBars" :key="index" :x="index * 10 + 2" :y="(80 - height) / 2" width="6" :height="height" rx="3" fill="white" />
|
|
</mask>
|
|
</defs>
|
|
<g mask="url(#intro-waveform-mask)" fill="currentColor">
|
|
<rect width="480" height="80" opacity="0.25" />
|
|
<rect :width="480 * introProgress" height="80" />
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
<p class="intro-time">{{ introTime }}</p>
|
|
</section>
|
|
<p v-else-if="state.scene.mode === 3" class="message">Успехов</p>
|
|
<p v-else-if="state.scene.mode === 4 && !state.channels.main.source" class="message">
|
|
Видео закрывашки не задано
|
|
</p>
|
|
<p v-else-if="state.scene.mode === 5" class="message">Спасибо за игру!</p>
|
|
<ResultsTable
|
|
v-else-if="state.scene.mode === 6"
|
|
:snapshot="state.results"
|
|
:revision="state.scene.revision"
|
|
/>
|
|
<section v-else-if="state.scene.mode === 7" class="timer">
|
|
<h1>{{ state.timer.label }}</h1>
|
|
<strong>{{ timerDisplay }}</strong>
|
|
</section>
|
|
<p v-else-if="state.scene.mode === 8" class="message text">{{ state.scene.text }}</p>
|
|
</div>
|
|
<audio
|
|
ref="audio"
|
|
data-channel="main-audio"
|
|
preload="auto"
|
|
@timeupdate="updateIntroProgress"
|
|
@loadedmetadata="updateIntroProgress"
|
|
@durationchange="updateIntroProgress"
|
|
@seeked="updateIntroProgress"
|
|
@pause="updateIntroProgress"
|
|
@ended="updateIntroProgress"
|
|
@emptied="resetIntroProgress"
|
|
/>
|
|
<video
|
|
v-show="state?.scene.mode === 4 && state.channels.main.source?.kind === 'video'"
|
|
ref="video"
|
|
data-channel="main-video"
|
|
preload="auto"
|
|
playsinline
|
|
/>
|
|
<audio ref="musicA" data-channel="music-a" preload="auto" />
|
|
<audio ref="musicB" data-channel="music-b" preload="auto" />
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.output {
|
|
background: var(--brand-background);
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
position: relative;
|
|
width: 100vw;
|
|
}
|
|
|
|
.scene {
|
|
align-items: center;
|
|
display: flex;
|
|
height: 100%;
|
|
justify-content: center;
|
|
position: relative;
|
|
text-align: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.background,
|
|
.cover,
|
|
video {
|
|
height: 100%;
|
|
inset: 0;
|
|
position: absolute;
|
|
width: 100%;
|
|
}
|
|
|
|
.background {
|
|
filter: brightness(45%);
|
|
object-fit: cover;
|
|
}
|
|
|
|
.cover,
|
|
video {
|
|
object-fit: contain;
|
|
}
|
|
|
|
.failed {
|
|
visibility: hidden;
|
|
}
|
|
|
|
.message,
|
|
.timer,
|
|
.intro-recording {
|
|
text-align: center;
|
|
}
|
|
|
|
.message {
|
|
font-size: clamp(32px, 7vw, 140px);
|
|
margin: 0;
|
|
max-height: 90vh;
|
|
max-width: 92vw;
|
|
overflow: hidden;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.text {
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.intro-recording {
|
|
background: rgb(17 17 17 / 78%);
|
|
border-radius: clamp(20px, 3vw, 48px);
|
|
box-sizing: border-box;
|
|
max-width: 1100px;
|
|
padding: clamp(20px, 4vw, 64px);
|
|
width: 82vw;
|
|
}
|
|
|
|
.intro-recording h1 {
|
|
font-size: clamp(24px, min(4vw, 8vh), 64px);
|
|
font-weight: 500;
|
|
margin: 0 0 clamp(16px, 4vh, 48px);
|
|
}
|
|
|
|
.intro-waveform {
|
|
height: clamp(48px, 14vh, 160px);
|
|
}
|
|
|
|
.intro-waveform svg {
|
|
display: block;
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
.intro-time {
|
|
font-size: clamp(18px, min(2.5vw, 5vh), 36px);
|
|
font-variant-numeric: tabular-nums;
|
|
margin: clamp(16px, 3vh, 32px) 0 0;
|
|
}
|
|
|
|
.timer {
|
|
align-items: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: clamp(16px, 3vh, 32px);
|
|
max-width: 92vw;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.timer h1 {
|
|
font-size: clamp(28px, 5vw, 80px);
|
|
margin: 0;
|
|
}
|
|
|
|
.timer strong {
|
|
font-size: clamp(80px, 20vw, 300px);
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 1;
|
|
}
|
|
</style>
|