This commit is contained in:
2026-10-17 19:29:49 +07:00
parent dc0d82d88a
commit a386f3d9e5
14 changed files with 1852 additions and 421 deletions
+217 -77
View File
@@ -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>
+23 -97
View File
@@ -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
View File
@@ -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>
}
+259
View File
@@ -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'
}
}