up
This commit is contained in:
+187
-12
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user