up
This commit is contained in:
+217
-77
@@ -1,38 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NCard, NInputNumber, NSelect, NSpace, useMessage } from 'naive-ui'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NInputNumber,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Action, HostInfo, MediaKind, State } from '../types/screen'
|
||||
import type { Channel, HostInfo, Intent, PackageSummary, Playback, State } from '../types/screen'
|
||||
|
||||
const message = useMessage()
|
||||
const bridge = window.screenBridge
|
||||
const state = ref<State | null>(null)
|
||||
const packages = ref<PackageSummary[]>([])
|
||||
const host = ref<HostInfo>({ displays: [] })
|
||||
const display = ref<number | null>(null)
|
||||
const media = ref<MediaKind>('audio')
|
||||
const position = ref<number | null>(10)
|
||||
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
|
||||
const isBusy = ref(false)
|
||||
const metrics = computed(() => {
|
||||
const values = (state.value?.samples ?? [])
|
||||
.filter((sample) => sample.media === media.value)
|
||||
.map((sample) => sample.milliseconds)
|
||||
.sort((a, b) => a - b)
|
||||
if (!values.length) return 'Замеров пока нет'
|
||||
const middle = Math.floor(values.length / 2)
|
||||
const median = values.length % 2 ? values[middle]! : (values[middle - 1]! + values[middle]!) / 2
|
||||
return `n=${values.length}, медиана ${median.toFixed(1)} мс, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
|
||||
})
|
||||
const statusLabels: Record<State['status'], string> = {
|
||||
idle: 'Ожидание',
|
||||
pending: 'Команда отправлена',
|
||||
const channels = ['main', 'music'] as const
|
||||
const playlist = computed(() =>
|
||||
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
|
||||
)
|
||||
const packageOptions = computed(() => [
|
||||
{ label: 'Тестовый режим SCREEN-001', value: 'fixtures' },
|
||||
...packages.value.map((p) => ({ label: p.name, value: p.key })),
|
||||
])
|
||||
const statusLabels: Record<Playback, string> = {
|
||||
idle: 'Ресурс не выбран',
|
||||
pending: 'Подготовка или выполнение команды',
|
||||
playing: 'Воспроизведение подтверждено',
|
||||
paused: 'Пауза',
|
||||
stopped: 'Остановлено',
|
||||
stopped: 'Готово, остановлено',
|
||||
ended: 'Завершено',
|
||||
error: 'Ошибка',
|
||||
}
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
let session = ''
|
||||
let sequence = 0
|
||||
function acceptState(value: State) {
|
||||
if (state.value && value.revision < state.value.revision) return
|
||||
if (session !== value.session) {
|
||||
session = value.session
|
||||
sequence = value.sequence
|
||||
} else sequence = Math.max(sequence, value.sequence)
|
||||
state.value = value
|
||||
}
|
||||
async function run(action: () => Promise<unknown>) {
|
||||
isBusy.value = true
|
||||
try {
|
||||
@@ -43,40 +59,55 @@ async function run(action: () => Promise<unknown>) {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
function acceptState(value: State) {
|
||||
if (!state.value || value.revision >= state.value.revision) state.value = value
|
||||
async function send(intent: Intent) {
|
||||
try {
|
||||
if (!bridge || !state.value) throw new Error('Откройте пульт в приложении Electron')
|
||||
await bridge.command({ session, sequence: ++sequence, ...intent })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'Команда не выполнена')
|
||||
}
|
||||
}
|
||||
async function selectPackage(key: string) {
|
||||
await run(async () => {
|
||||
if (!bridge) return
|
||||
try {
|
||||
acceptState(await bridge.selectPackage(key === 'fixtures' ? null : key))
|
||||
} finally {
|
||||
packages.value = await bridge.packages()
|
||||
}
|
||||
})
|
||||
}
|
||||
async function refreshHost() {
|
||||
if (bridge) host.value = await bridge.host()
|
||||
}
|
||||
async function send(action: Action) {
|
||||
await run(async () => {
|
||||
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
|
||||
await bridge.command({
|
||||
id: crypto.randomUUID(),
|
||||
action,
|
||||
media: media.value,
|
||||
position: position.value ?? 0,
|
||||
})
|
||||
})
|
||||
function metrics(channel: Channel) {
|
||||
const values = (state.value?.samples ?? [])
|
||||
.filter(
|
||||
(sample) =>
|
||||
sample.channel === channel && sample.media === state.value?.channels[channel].source?.kind,
|
||||
)
|
||||
.map((sample) => sample.milliseconds)
|
||||
.sort((a, b) => a - b)
|
||||
return values.length
|
||||
? `n=${values.length}, p95 ${values[Math.ceil(values.length * 0.95) - 1]!.toFixed(1)} мс`
|
||||
: 'Замеров пока нет'
|
||||
}
|
||||
onMounted(async () => {
|
||||
await run(async () => {
|
||||
if (!bridge) throw new Error('Откройте пульт в приложении Electron')
|
||||
unsubscribe = bridge.subscribe(acceptState)
|
||||
acceptState(await bridge.state())
|
||||
packages.value = await bridge.packages()
|
||||
await refreshHost()
|
||||
})
|
||||
let isTicking = false
|
||||
let refreshing = false
|
||||
timer = setInterval(() => {
|
||||
if (isTicking) return
|
||||
isTicking = true
|
||||
if (refreshing) return
|
||||
refreshing = true
|
||||
void refreshHost()
|
||||
.catch((error: unknown) => {
|
||||
message.error(error instanceof Error ? error.message : 'Ошибка связи')
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
isTicking = false
|
||||
refreshing = false
|
||||
})
|
||||
}, 1000)
|
||||
})
|
||||
@@ -88,10 +119,21 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<main class="control">
|
||||
<h1>Вечерний детектив · SCREEN-001</h1>
|
||||
<n-alert type="info"
|
||||
>Технический стенд: синтетические медиа, два окна и локальное воспроизведение.</n-alert
|
||||
>
|
||||
<h1>Вечерний детектив · Локальный проигрыватель</h1>
|
||||
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
||||
<n-card title="Материалы показа">
|
||||
<n-select
|
||||
:value="state?.catalog.key ?? 'fixtures'"
|
||||
:options="packageOptions"
|
||||
:disabled="!bridge || isBusy"
|
||||
:input-props="{ 'aria-label': 'Подготовленный пакет' }"
|
||||
@update:value="selectPackage"
|
||||
/>
|
||||
<n-alert v-if="state && !state.catalog.key" type="info"
|
||||
>Тестовый режим: синтетические аудио и видео.</n-alert
|
||||
>
|
||||
<p v-if="!packages.length">Готовых игровых пакетов на диске пока нет.</p>
|
||||
</n-card>
|
||||
<n-card v-if="bridge" title="Дисплей показа">
|
||||
<n-space vertical>
|
||||
<n-select
|
||||
@@ -108,42 +150,134 @@ onUnmounted(() => {
|
||||
>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<n-card title="Локальное воспроизведение">
|
||||
<n-space vertical>
|
||||
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
|
||||
<n-select
|
||||
v-model:value="media"
|
||||
:options="[
|
||||
{ label: 'Аудио · WAV PCM 48 кГц', value: 'audio' },
|
||||
{ label: 'Видео · H.264 + AAC, 720p30', value: 'video' },
|
||||
]"
|
||||
:input-props="{ 'aria-label': 'Тестовое медиа' }"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('play')">Пуск с начала</n-button>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('pause')">Пауза</n-button>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('stop')">Стоп</n-button>
|
||||
<template v-if="state">
|
||||
<n-alert v-if="!state.outputOpen" type="info"
|
||||
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
|
||||
>
|
||||
<label class="mute"
|
||||
>Выключить весь звук
|
||||
<n-switch
|
||||
:value="state.muted"
|
||||
aria-label="Выключить весь звук"
|
||||
@update:value="send({ action: 'mute', enabled: $event })"
|
||||
/></label>
|
||||
<n-card
|
||||
v-for="channel in channels"
|
||||
:key="channel"
|
||||
:title="channel === 'main' ? 'Вступление и видео' : 'Музыка'"
|
||||
>
|
||||
<n-space vertical>
|
||||
<n-select
|
||||
v-if="channel === 'main'"
|
||||
:value="state.channels.main.source?.id ?? null"
|
||||
:options="state.catalog.media.map((s) => ({ label: s.name, value: s.id }))"
|
||||
placeholder="Выберите медиа"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': 'Медиа показа' }"
|
||||
@update:value="send({ action: 'select', channel: 'main', sourceId: $event })"
|
||||
/>
|
||||
<template v-else>
|
||||
<n-select
|
||||
:value="state.channels.music.playlistId"
|
||||
:options="state.catalog.playlists.map((p) => ({ label: p.name, value: p.id }))"
|
||||
placeholder="Выберите плейлист"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': 'Плейлист' }"
|
||||
@update:value="send({ action: 'playlist', channel: 'music', playlistId: $event })"
|
||||
/>
|
||||
<n-select
|
||||
:value="state.channels.music.source ? state.channels.music.trackIndex : null"
|
||||
:options="
|
||||
(playlist?.tracks ?? []).map((t, index) => ({
|
||||
label: t.name || `Трек ${index + 1}`,
|
||||
value: index,
|
||||
}))
|
||||
"
|
||||
placeholder="Выберите трек"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ 'aria-label': 'Трек' }"
|
||||
@update:value="send({ action: 'track', channel: 'music', trackIndex: $event })"
|
||||
/>
|
||||
<label
|
||||
>Повторять плейлист
|
||||
<n-switch
|
||||
:value="state.channels.music.loop"
|
||||
aria-label="Повторять плейлист"
|
||||
@update:value="send({ action: 'loop', channel: 'music', enabled: $event })"
|
||||
/></label>
|
||||
</template>
|
||||
<n-space>
|
||||
<n-button
|
||||
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
|
||||
@click="send({ action: 'play', channel })"
|
||||
>Пуск с начала</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
|
||||
@click="send({ action: 'pause', channel })"
|
||||
>Пауза</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
|
||||
@click="send({ action: 'resume', channel })"
|
||||
>Продолжить</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
|
||||
@click="send({ action: 'stop', channel })"
|
||||
>Стоп</n-button
|
||||
>
|
||||
<n-button
|
||||
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
|
||||
@click="send({ action: 'replay', channel })"
|
||||
>Повторить с начала</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="channel === 'music'"
|
||||
:disabled="!state.outputOpen || !state.channels.music.source || isBusy"
|
||||
@click="send({ action: 'next', channel: 'music' })"
|
||||
>Следующий трек</n-button
|
||||
>
|
||||
</n-space>
|
||||
<label :for="`position-${channel}`">Позиция, секунды</label>
|
||||
<n-input-number
|
||||
v-model:value="positions[channel]"
|
||||
:input-props="{ id: `position-${channel}` }"
|
||||
:min="0"
|
||||
:max="state.channels[channel].duration ?? undefined"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
|
||||
@click="send({ action: 'seek', channel, position: positions[channel] ?? 0 })"
|
||||
>Перемотать</n-button
|
||||
>
|
||||
<label :for="`volume-${channel}`">Громкость, %</label>
|
||||
<n-input-number
|
||||
:value="state.channels[channel].volume"
|
||||
:input-props="{ id: `volume-${channel}` }"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@update:value="send({ action: 'volume', channel, volume: $event ?? 0 })"
|
||||
/>
|
||||
<p role="status">
|
||||
{{ statusLabels[state.channels[channel].status] }} ·
|
||||
{{ state.channels[channel].position.toFixed(1) }} /
|
||||
{{ state.channels[channel].duration?.toFixed(1) ?? '—' }} с
|
||||
</p>
|
||||
<n-alert v-if="state.channels[channel].error" type="error">{{
|
||||
state.channels[channel].error
|
||||
}}</n-alert>
|
||||
<details>
|
||||
<summary>Программные замеры запуска</summary>
|
||||
<p>{{ metrics(channel) }}</p>
|
||||
<p>
|
||||
Приём команды → подтверждение проигрывателя. Физический звук и первый кадр проектора
|
||||
здесь не измеряются.
|
||||
</p>
|
||||
</details>
|
||||
</n-space>
|
||||
<label for="position">Позиция, секунды</label>
|
||||
<n-input-number
|
||||
v-model:value="position"
|
||||
:input-props="{ id: 'position' }"
|
||||
:min="0"
|
||||
:max="60"
|
||||
/>
|
||||
<n-button :disabled="!bridge || isBusy" @click="send('seek')">Перемотать</n-button>
|
||||
<p role="status">
|
||||
{{ state ? statusLabels[state.status] : 'Нет состояния' }} · ревизия
|
||||
{{ state?.revision ?? 0 }}
|
||||
</p>
|
||||
<n-alert v-if="state?.error" type="error">{{ state.error }}</n-alert>
|
||||
<p>{{ metrics }}</p>
|
||||
<p>
|
||||
Замеры: приём команды контроллером → подтверждение проигрывателя. Они не измеряют
|
||||
физический звук или первый кадр проектора.
|
||||
</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-card>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -159,4 +293,10 @@ onUnmounted(() => {
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mute {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,117 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { Command, MediaKind, State } from '../types/screen'
|
||||
import type { State } from '../types/screen'
|
||||
import { MediaPlayer } from '../utils/media-player'
|
||||
|
||||
const audio = ref<HTMLAudioElement | null>(null)
|
||||
const video = ref<HTMLVideoElement | null>(null)
|
||||
const kind = ref<MediaKind>('audio')
|
||||
const musicA = ref<HTMLAudioElement | null>(null)
|
||||
const musicB = ref<HTMLAudioElement | null>(null)
|
||||
const showVideo = ref(false)
|
||||
const bridge = window.screenBridge!
|
||||
let player: MediaPlayer | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let generation = 0
|
||||
let lastRevision = -1
|
||||
let commandId = ''
|
||||
let lastCommand: Command | null = null
|
||||
|
||||
async function metadata(media: HTMLMediaElement) {
|
||||
if (media.readyState >= 1) return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const finish = (error?: Error) => {
|
||||
clearTimeout(timeout)
|
||||
media.removeEventListener('loadedmetadata', ready)
|
||||
media.removeEventListener('error', failed)
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const ready = () => finish()
|
||||
const failed = () => finish(new Error('Не удалось прочитать тестовое медиа'))
|
||||
const timeout = setTimeout(() => finish(new Error('Медиа не подготовлено за 10 секунд')), 10000)
|
||||
media.addEventListener('loadedmetadata', ready, { once: true })
|
||||
media.addEventListener('error', failed, { once: true })
|
||||
if (media.error) failed()
|
||||
})
|
||||
}
|
||||
async function apply(state: State) {
|
||||
if (state.revision <= lastRevision) return
|
||||
lastRevision = state.revision
|
||||
const command = state.command
|
||||
if (state.status !== 'pending' || !command || command.id === commandId) return
|
||||
commandId = command.id
|
||||
lastCommand = command
|
||||
const current = ++generation
|
||||
const media = command.media === 'audio' ? audio.value : video.value
|
||||
const other = command.media === 'audio' ? video.value : audio.value
|
||||
other?.pause()
|
||||
kind.value = command.media
|
||||
if (!media) return
|
||||
try {
|
||||
if (command.action === 'pause' || command.action === 'stop') media.pause()
|
||||
await metadata(media)
|
||||
if (current !== generation) return
|
||||
if (command.action === 'play' || command.action === 'stop') media.currentTime = 0
|
||||
if (command.action === 'seek') media.currentTime = Math.min(command.position, media.duration)
|
||||
if (command.action === 'play') {
|
||||
await media.play()
|
||||
if (current !== generation) return
|
||||
}
|
||||
const status = command.action === 'stop' ? 'stopped' : media.paused ? 'paused' : 'playing'
|
||||
bridge.report({ id: command.id, status })
|
||||
} catch (error) {
|
||||
if (current === generation)
|
||||
bridge.report({
|
||||
id: command.id,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Ошибка воспроизведения',
|
||||
})
|
||||
}
|
||||
}
|
||||
function activeMedia() {
|
||||
return lastCommand?.media === 'audio' ? audio.value : video.value
|
||||
}
|
||||
function ended(event: Event) {
|
||||
const media = activeMedia()
|
||||
if (lastCommand && event.target === media && media?.ended)
|
||||
bridge.report({ id: lastCommand.id, status: 'ended' })
|
||||
}
|
||||
function failed(event: Event) {
|
||||
if (lastCommand && event.target === activeMedia())
|
||||
bridge.report({
|
||||
id: lastCommand.id,
|
||||
status: 'error',
|
||||
error: 'Ошибка локального медиа; проверьте наличие fixtures',
|
||||
})
|
||||
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)
|
||||
}
|
||||
onMounted(async () => {
|
||||
for (const media of [audio.value, video.value]) if (media) media.volume = 0.35
|
||||
unsubscribe = bridge.subscribe((state) => {
|
||||
void apply(state)
|
||||
})
|
||||
await apply(await bridge.state())
|
||||
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(() => {
|
||||
generation++
|
||||
unsubscribe?.()
|
||||
audio.value?.pause()
|
||||
video.value?.pause()
|
||||
player?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="output">
|
||||
<audio
|
||||
ref="audio"
|
||||
:src="'screen-app://app/media/audio'"
|
||||
preload="auto"
|
||||
@ended="ended"
|
||||
@error="failed"
|
||||
/>
|
||||
<video
|
||||
v-show="kind === 'video'"
|
||||
ref="video"
|
||||
:src="'screen-app://app/media/video'"
|
||||
preload="auto"
|
||||
@ended="ended"
|
||||
@error="failed"
|
||||
/>
|
||||
<audio ref="audio" data-channel="main-audio" preload="auto" />
|
||||
<video v-show="showVideo" 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>
|
||||
|
||||
|
||||
+65
-9
@@ -1,17 +1,66 @@
|
||||
export type MediaKind = 'audio' | 'video'
|
||||
export type Action = 'play' | 'pause' | 'stop' | 'seek'
|
||||
export interface Command {
|
||||
export type Channel = 'main' | 'music'
|
||||
export type Playback = 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
|
||||
export type Intent =
|
||||
| { action: 'play' | 'pause' | 'resume' | 'stop' | 'replay'; channel: Channel }
|
||||
| { action: 'seek'; channel: Channel; position: number }
|
||||
| { action: 'volume'; channel: Channel; volume: number }
|
||||
| { action: 'mute'; enabled: boolean }
|
||||
| { action: 'select'; channel: 'main'; sourceId: string }
|
||||
| { action: 'playlist'; channel: 'music'; playlistId: number }
|
||||
| { action: 'track'; channel: 'music'; trackIndex: number }
|
||||
| { action: 'next'; channel: 'music' }
|
||||
| { action: 'loop'; channel: 'music'; enabled: boolean }
|
||||
export type Command = Intent & { session: string; sequence: number }
|
||||
export interface MediaSource {
|
||||
id: string
|
||||
action: Action
|
||||
media: MediaKind
|
||||
name: string
|
||||
kind: MediaKind
|
||||
url: string
|
||||
}
|
||||
export interface Playlist {
|
||||
id: number
|
||||
name: string
|
||||
tracks: { id: number; name: string; source: MediaSource }[]
|
||||
}
|
||||
export interface Catalog {
|
||||
key: string | null
|
||||
name: string
|
||||
media: MediaSource[]
|
||||
playlists: Playlist[]
|
||||
}
|
||||
export interface ChannelState {
|
||||
operation: number
|
||||
seekOperation: number
|
||||
source: MediaSource | null
|
||||
desired: 'playing' | 'paused' | 'stopped'
|
||||
status: Playback
|
||||
position: number
|
||||
duration: number | null
|
||||
volume: number
|
||||
error: string
|
||||
playlistId: number | null
|
||||
trackIndex: number
|
||||
loop: boolean
|
||||
}
|
||||
export interface State {
|
||||
session: string
|
||||
sequence: number
|
||||
revision: number
|
||||
command: Command | null
|
||||
status: 'idle' | 'pending' | 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
|
||||
error: string
|
||||
samples: { media: MediaKind; milliseconds: number }[]
|
||||
outputOpen: boolean
|
||||
catalog: Catalog
|
||||
muted: boolean
|
||||
channels: Record<Channel, ChannelState>
|
||||
samples: { channel: Channel; media: MediaKind; milliseconds: number }[]
|
||||
}
|
||||
export interface Report {
|
||||
session: string
|
||||
channel: Channel
|
||||
operation: number
|
||||
status: 'playing' | 'paused' | 'stopped' | 'ended' | 'error'
|
||||
position: number
|
||||
duration: number | null
|
||||
error?: string
|
||||
}
|
||||
export interface DisplayInfo {
|
||||
id: number
|
||||
@@ -20,11 +69,18 @@ export interface DisplayInfo {
|
||||
export interface HostInfo {
|
||||
displays: DisplayInfo[]
|
||||
}
|
||||
export interface PackageSummary {
|
||||
key: string
|
||||
version: string
|
||||
name: string
|
||||
}
|
||||
export interface ScreenBridge {
|
||||
command: (command: Command) => Promise<void>
|
||||
report: (report: { id: string; status: State['status']; error?: string }) => void
|
||||
report: (report: Report) => void
|
||||
state: () => Promise<State>
|
||||
subscribe: (listener: (state: State) => void) => () => void
|
||||
host: () => Promise<HostInfo>
|
||||
show: (displayId: number) => Promise<void>
|
||||
packages: () => Promise<PackageSummary[]>
|
||||
selectPackage: (key: string | null) => Promise<State>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { Channel, ChannelState, Report, State } from '../types/screen.js'
|
||||
|
||||
function waitUntil(
|
||||
media: HTMLMediaElement,
|
||||
event: string,
|
||||
ready: () => boolean,
|
||||
signal: AbortSignal,
|
||||
start?: () => void,
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const finish = (error?: Error) => {
|
||||
clearTimeout(timer)
|
||||
media.removeEventListener(event, check)
|
||||
media.removeEventListener('error', failed)
|
||||
signal.removeEventListener('abort', aborted)
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const check = () => {
|
||||
if (ready()) finish()
|
||||
}
|
||||
const failed = () => finish(new Error('Не удалось прочитать локальное медиа'))
|
||||
const aborted = () => finish(new Error('Операция отменена'))
|
||||
const timer = setTimeout(() => finish(new Error('Медиа не подготовлено за 10 секунд')), 10000)
|
||||
media.addEventListener(event, check)
|
||||
media.addEventListener('error', failed)
|
||||
signal.addEventListener('abort', aborted, { once: true })
|
||||
try {
|
||||
start?.()
|
||||
if (signal.aborted) aborted()
|
||||
else if (media.error) failed()
|
||||
else check()
|
||||
} catch {
|
||||
failed()
|
||||
}
|
||||
})
|
||||
}
|
||||
interface Runtime {
|
||||
operation: number
|
||||
seekOperation: number
|
||||
abort: AbortController | null
|
||||
media: HTMLMediaElement | null
|
||||
sourceId: string | null
|
||||
}
|
||||
const runtime = (): Runtime => ({
|
||||
operation: -1,
|
||||
seekOperation: -1,
|
||||
abort: null,
|
||||
media: null,
|
||||
sourceId: null,
|
||||
})
|
||||
|
||||
// DOM и асинхронные операции остаются здесь; решения о плейлисте принимает main.
|
||||
export class MediaPlayer {
|
||||
private state: State | null = null
|
||||
private revision = -1
|
||||
private channels: Record<Channel, Runtime> = { main: runtime(), music: runtime() }
|
||||
constructor(
|
||||
private elements: {
|
||||
audio: HTMLAudioElement
|
||||
video: HTMLVideoElement
|
||||
music: [HTMLAudioElement, HTMLAudioElement]
|
||||
},
|
||||
private report: (report: Report) => void,
|
||||
) {}
|
||||
private all() {
|
||||
return [this.elements.audio, this.elements.video, ...this.elements.music]
|
||||
}
|
||||
private clear(media: HTMLMediaElement) {
|
||||
media.pause()
|
||||
if (media.getAttribute('src')) {
|
||||
media.removeAttribute('src')
|
||||
media.load()
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
for (const r of Object.values(this.channels)) r.abort?.abort()
|
||||
for (const media of this.all()) this.clear(media)
|
||||
this.channels = { main: runtime(), music: runtime() }
|
||||
this.state = null
|
||||
}
|
||||
apply(state: State) {
|
||||
if (state.revision <= this.revision) return
|
||||
if (this.state && this.state.session !== state.session) this.destroy()
|
||||
this.revision = state.revision
|
||||
this.state = state
|
||||
for (const channel of ['main', 'music'] as const) {
|
||||
const r = this.channels[channel]
|
||||
if (r.media) {
|
||||
r.media.volume = state.channels[channel].volume / 100
|
||||
r.media.muted = state.muted
|
||||
if (state.channels[channel].desired !== 'playing') r.media.pause()
|
||||
}
|
||||
if (r.operation !== state.channels[channel].operation) void this.applyChannel(channel, state)
|
||||
}
|
||||
this.warmNext()
|
||||
}
|
||||
private source(media: HTMLMediaElement, url: string) {
|
||||
if (media.getAttribute('src') === url && !media.error) return
|
||||
media.pause()
|
||||
media.preload = 'auto'
|
||||
media.src = url
|
||||
media.load()
|
||||
}
|
||||
private warmNext() {
|
||||
const state = this.state
|
||||
const active = this.channels.music.media
|
||||
if (!state || !active) return
|
||||
const c = state.channels.music
|
||||
const tracks = state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks ?? []
|
||||
const next = tracks[c.trackIndex + 1] ?? (c.loop ? tracks[0] : undefined)
|
||||
const spare = this.elements.music.find((media) => media !== active)!
|
||||
spare.pause()
|
||||
spare.muted = true
|
||||
if (next) this.source(spare, next.source.url)
|
||||
else this.clear(spare)
|
||||
}
|
||||
private async applyChannel(channel: Channel, snapshot: State) {
|
||||
const c = snapshot.channels[channel]
|
||||
const r = this.channels[channel]
|
||||
r.abort?.abort()
|
||||
const abort = new AbortController()
|
||||
r.abort = abort
|
||||
r.operation = c.operation
|
||||
const session = snapshot.session
|
||||
const previousId = r.sourceId
|
||||
r.sourceId = c.source?.id ?? null
|
||||
if (!c.source) {
|
||||
if (channel === 'main') {
|
||||
this.clear(this.elements.audio)
|
||||
this.clear(this.elements.video)
|
||||
} else for (const media of this.elements.music) this.clear(media)
|
||||
r.media = null
|
||||
return
|
||||
}
|
||||
let media: HTMLMediaElement
|
||||
if (channel === 'main') {
|
||||
media = c.source.kind === 'audio' ? this.elements.audio : this.elements.video
|
||||
this.clear(c.source.kind === 'audio' ? this.elements.video : this.elements.audio)
|
||||
} else {
|
||||
media =
|
||||
r.media && previousId === c.source.id
|
||||
? r.media
|
||||
: this.elements.music.find((element) => element !== r.media)!
|
||||
if (r.media && r.media !== media) r.media.pause()
|
||||
}
|
||||
r.media = media
|
||||
media.pause()
|
||||
media.volume = c.volume / 100
|
||||
media.muted = snapshot.muted
|
||||
let failed = false
|
||||
const current = () =>
|
||||
this.state?.session === session && r.abort === abort && !abort.signal.aborted
|
||||
const emit = (status: Report['status'], error?: string) => {
|
||||
if (!current()) return
|
||||
this.report({
|
||||
session,
|
||||
channel,
|
||||
operation: c.operation,
|
||||
status,
|
||||
position: Number.isFinite(media.currentTime) ? Math.max(0, media.currentTime) : 0,
|
||||
duration: Number.isFinite(media.duration) ? Math.max(0, media.duration) : null,
|
||||
...(error ? { error } : {}),
|
||||
})
|
||||
}
|
||||
const fail = (message: string) => {
|
||||
if (!current() || failed) return
|
||||
failed = true
|
||||
media.pause()
|
||||
emit('error', message)
|
||||
}
|
||||
media.addEventListener(
|
||||
'error',
|
||||
() => fail('Ошибка локального медиа; проверьте пакет и поддержку формата'),
|
||||
{ signal: abort.signal },
|
||||
)
|
||||
media.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
if (current() && media.ended && this.state?.channels[channel].desired === 'playing')
|
||||
emit('ended')
|
||||
},
|
||||
{ signal: abort.signal },
|
||||
)
|
||||
try {
|
||||
this.source(media, c.source.url)
|
||||
this.warmNext()
|
||||
await waitUntil(media, 'canplay', () => media.readyState >= 3, abort.signal)
|
||||
if (!current() || failed) return
|
||||
if (!Number.isFinite(media.duration) || media.duration <= 0)
|
||||
throw new Error('Не удалось определить длительность файла')
|
||||
const position = Math.min(c.position, media.duration)
|
||||
if (
|
||||
(c.seekOperation > r.seekOperation || previousId !== c.source.id) &&
|
||||
Math.abs(media.currentTime - position) > 0.01
|
||||
) {
|
||||
await waitUntil(
|
||||
media,
|
||||
'seeked',
|
||||
() => !media.seeking && Math.abs(media.currentTime - position) < 0.25,
|
||||
abort.signal,
|
||||
() => {
|
||||
media.currentTime = position
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!current() || failed) return
|
||||
r.seekOperation = c.seekOperation
|
||||
if (c.desired === 'playing') {
|
||||
const playing = media.play()
|
||||
// Даже позднее завершение play не должно оживлять уже отменённый канал.
|
||||
void playing.then(
|
||||
() => {
|
||||
const latest = this.state?.channels[channel]
|
||||
if (this.channels[channel].media !== media || !latest || latest.desired !== 'playing')
|
||||
media.pause()
|
||||
},
|
||||
() => {},
|
||||
)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const finish = (error?: unknown) => {
|
||||
clearTimeout(timer)
|
||||
abort.signal.removeEventListener('abort', cancelled)
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const cancelled = () => finish(new Error('Операция отменена'))
|
||||
const timer = setTimeout(
|
||||
() => finish(new Error('Воспроизведение не началось за 10 секунд')),
|
||||
10000,
|
||||
)
|
||||
abort.signal.addEventListener('abort', cancelled, { once: true })
|
||||
playing.then(() => finish(), finish)
|
||||
if (abort.signal.aborted) cancelled()
|
||||
})
|
||||
}
|
||||
if (!current() || failed) return
|
||||
const actual = () => this.actualStatus(media, this.state!.channels[channel])
|
||||
emit(actual())
|
||||
let lastTime = 0
|
||||
media.addEventListener(
|
||||
'timeupdate',
|
||||
() => {
|
||||
if (!current() || failed || performance.now() - lastTime < 250) return
|
||||
lastTime = performance.now()
|
||||
emit(actual())
|
||||
},
|
||||
{ signal: abort.signal },
|
||||
)
|
||||
} catch (error) {
|
||||
if (current()) fail(error instanceof Error ? error.message : 'Ошибка воспроизведения')
|
||||
}
|
||||
}
|
||||
private actualStatus(media: HTMLMediaElement, c: ChannelState): Report['status'] {
|
||||
if (media.ended || c.status === 'ended') return 'ended'
|
||||
if (!media.paused) return 'playing'
|
||||
return c.desired === 'stopped' ? 'stopped' : 'paused'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user