This commit is contained in:
2026-10-17 19:52:24 +07:00
parent a386f3d9e5
commit f5dc9c1e5b
17 changed files with 1652 additions and 59 deletions
+187 -12
View File
@@ -1,22 +1,100 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import type { State } from '../types/screen'
import { MediaPlayer } from '../utils/media-player'
import { MediaPlayer, waitUntil } from '../utils/media-player'
import { useScreenTimer } from '../composables/useScreenTimer'
import ResultsTable from './ResultsTable.vue'
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 showVideo = ref(false)
const surface = ref<HTMLElement | null>(null)
const state = shallowRef<State | null>(null)
const imageError = ref(false)
const timerDisplay = useScreenTimer(() => state.value)
const bridge = window.screenBridge!
let player: MediaPlayer | undefined
let unsubscribe: (() => void) | undefined
let revision = -1
function apply(state: State) {
if (state.revision <= revision) return
revision = state.revision
showVideo.value = state.channels.main.source?.kind === 'video'
player?.apply(state)
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 (value.revision <= revision) return
revision = value.revision
state.value = value
player?.apply(value)
const token = `${value.session}:${value.scene.revision}`
if (token !== sceneToken) {
sceneToken = token
void prepareScene(value)
}
}
onMounted(async () => {
player = new MediaPlayer(
@@ -27,15 +105,61 @@ onMounted(async () => {
apply(await bridge.state())
})
onUnmounted(() => {
sceneAbort?.abort()
unsubscribe?.()
player?.destroy()
})
</script>
<template>
<main class="output">
<main ref="surface" class="output" :data-mode="state?.scene.mode">
<img
v-if="state?.catalog.background && state.scene.mode !== 1"
: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>
<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" />
<video v-show="showVideo" ref="video" data-channel="main-video" preload="auto" playsinline />
<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>
@@ -46,12 +170,63 @@ onUnmounted(() => {
background: var(--brand-background);
height: 100vh;
overflow: hidden;
position: relative;
width: 100vw;
}
video {
.scene {
align-items: center;
display: flex;
height: 100%;
object-fit: contain;
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 {
font-size: clamp(32px, 7vw, 140px);
margin: 0;
max-height: 90vh;
max-width: 92vw;
overflow: hidden;
overflow-wrap: anywhere;
}
.text {
white-space: pre-wrap;
}
.timer h1 {
font-size: clamp(28px, 5vw, 80px);
}
.timer strong {
font-size: clamp(80px, 20vw, 300px);
font-variant-numeric: tabular-nums;
line-height: 1;
}
</style>