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>
|
||||
|
||||
Reference in New Issue
Block a user