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>