Files
evening_detective_screen/src/components/ControlPage.vue
T
2026-10-18 03:32:50 +07:00

529 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
NAlert,
NButton,
NCard,
NInputNumber,
NSelect,
NSpace,
NSwitch,
useMessage,
useDialog,
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type {
Channel,
HostInfo,
Intent,
Playback,
PreviewFrame,
State,
ScreenStep,
PlanStatus,
} from '../types/screen'
import ConnectionPanel from './ConnectionPanel.vue'
import ScreenPlanPanel from './ScreenPlanPanel.vue'
import { useScreenTimer } from '../composables/useScreenTimer'
const message = useMessage()
const dialog = useDialog()
const planStatus = ref<PlanStatus>({ key: null, editable: false, notice: '' })
const isPlanEditing = ref(false)
const isPlanSaving = ref(false)
const planDraft = ref<ScreenStep[]>([])
const originalPlan = ref('')
const isPlanDirty = computed(() => JSON.stringify(planDraft.value) !== originalPlan.value)
const bridge = window.screenBridge
const state = ref<State | null>(null)
const host = ref<HostInfo>({
displays: [],
notice: '',
sleepBlocked: false,
preferredDisplayId: null,
})
const display = ref<number | null>(null)
const positions = ref<Record<Channel, number | null>>({ main: 10, music: 10 })
const isBusy = ref(false)
const timerDisplay = useScreenTimer(() => state.value)
const preview = ref<PreviewFrame | null>(null)
const previewURL = computed(() =>
preview.value?.session === state.value?.session &&
preview.value?.revision === state.value?.scene.revision
? preview.value?.dataURL
: null,
)
const sceneLabels = {
idle: 'Окно показа закрыто',
pending: 'Подготовка показа',
ready: 'Показ готов',
error: 'Не удалось подготовить показ',
}
const timerLabels = {
stopped: 'Не запущен',
running: 'Идёт',
paused: 'Пауза',
finished: 'Завершён',
}
let previewTimer: ReturnType<typeof setInterval> | undefined
let previewBusy = false
async function refreshPreview() {
if (
!bridge ||
previewBusy ||
!state.value?.outputOpen ||
document.visibilityState !== 'visible' ||
state.value.scene.status !== 'ready'
)
return
previewBusy = true
try {
const frame = await bridge.preview()
if (
frame &&
state.value.outputOpen &&
frame.session === state.value.session &&
frame.revision === state.value.scene.revision &&
document.visibilityState === 'visible'
)
preview.value = frame
} catch {
/* Показ продолжает работать и при недоступности кадра предпросмотра. */
} finally {
previewBusy = false
}
}
const channels = ['main', 'music'] as const
const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
)
const statusLabels: Record<Playback, string> = {
idle: 'Ресурс не выбран',
pending: 'Подготовка или выполнение команды',
playing: 'Воспроизведение подтверждено',
paused: 'Пауза',
stopped: 'Готово, остановлено',
ended: 'Завершено',
error: 'Ошибка',
}
let timer: ReturnType<typeof setInterval> | undefined
let unsubscribe: (() => void) | undefined
let session = ''
let sequence = 0
function editPlan() {
planDraft.value = state.value?.catalog.steps.map((step) => ({ ...step })) ?? []
originalPlan.value = JSON.stringify(planDraft.value)
isPlanEditing.value = true
if (!planDraft.value.length) addPlanStep()
}
function addPlanStep() {
planDraft.value.push({
mode: 8,
text: 'Новый экран',
timerLabel: 'Перерыв',
timerDurationSeconds: 600,
})
}
function movePlanStep(index: number, offset: number) {
const target = index + offset
if (target < 0 || target >= planDraft.value.length) return
const [step] = planDraft.value.splice(index, 1)
if (step) planDraft.value.splice(target, 0, step)
}
function discardPlan(action: () => void) {
if (!isPlanEditing.value || !isPlanDirty.value) return action()
dialog.warning({
title: 'Несохранённые изменения',
content: 'Отменить несохранённые изменения плана?',
positiveText: 'Отменить изменения',
negativeText: 'Продолжить редактирование',
onPositiveClick: action,
})
}
async function savePlan() {
if (!bridge || !state.value?.catalog.key || isPlanSaving.value) return
const key = state.value.catalog.key
isPlanSaving.value = true
try {
await bridge.onlineAction({
action: 'plan-save',
key,
steps: planDraft.value.map((step) => ({ ...step })),
})
if (state.value.catalog.key === key) isPlanEditing.value = false
message.success('План экранов сохранён')
} catch (error) {
message.error(error instanceof Error ? error.message : 'Не удалось сохранить план')
} finally {
isPlanSaving.value = false
}
}
function acceptState(value: State) {
if (state.value && value.revision < state.value.revision) return
if (value.catalog.key !== state.value?.catalog.key) {
isPlanEditing.value = false
planDraft.value = []
}
if (
!value.outputOpen ||
value.session !== state.value?.session ||
value.scene.revision !== state.value?.scene.revision
)
preview.value = null
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 {
await action()
} catch (error) {
message.error(error instanceof Error ? error.message : 'Не удалось выполнить действие')
} finally {
isBusy.value = false
}
}
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 refreshHost() {
if (!bridge) return
host.value = await bridge.host()
planStatus.value = await bridge.planStatus()
if (display.value === null) display.value = host.value.preferredDisplayId
if (!host.value.displays.some((d) => d.id === display.value)) display.value = null
}
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())
await refreshHost()
})
previewTimer = setInterval(() => {
void refreshPreview()
}, 500)
let refreshing = false
timer = setInterval(() => {
if (refreshing) return
refreshing = true
void refreshHost()
.catch(() => {})
.finally(() => {
refreshing = false
})
}, 1000)
})
onUnmounted(() => {
clearInterval(timer)
clearInterval(previewTimer)
unsubscribe?.()
})
</script>
<template>
<main class="control">
<h1>Вечерний детектив · Локальный проигрыватель</h1>
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
<connection-panel
:output-open="state?.outputOpen ?? false"
:selected-key="state?.catalog.key ?? null"
:selected-name="state?.catalog.name ?? null"
/>
<n-card v-if="bridge" title="Дисплей показа">
<n-space vertical>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
<p role="status">
{{
host.sleepBlocked
? 'Автоматический сон заблокирован на время показа'
: 'Блокировка сна выключена'
}}
</p>
<p>
Перед игрой выберите аудиовыход в настройках звука ОС, проверьте громкость и выполните
пробный пуск. После подключения HDMI проверьте выход повторно: система может переключить
звук на проектор.
</p>
<n-select
v-model:value="display"
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
placeholder="Выберите внешний дисплей"
:input-props="{ 'aria-label': 'Дисплей показа' }"
/>
<n-alert v-if="!host.displays.length" type="warning"
>Подключите внешний дисплей и включите расширенный рабочий стол.</n-alert
>
<n-button :disabled="display === null || isBusy" @click="run(() => bridge!.show(display!))"
>Открыть показ</n-button
>
<n-button v-if="state?.outputOpen" @click="run(() => bridge!.closeOutput())"
>Закрыть показ</n-button
>
</n-space>
</n-card>
<template v-if="state">
<n-alert v-if="!state.outputOpen" type="info"
>Откройте показ, чтобы подготовить медиа и включить воспроизведение.</n-alert
>
<section class="presentation-grid">
<n-card title="План экранов">
<n-space vertical>
<ScreenPlanPanel
:steps="state.catalog.steps"
:current-index="state.scene.stepIndex"
:draft="planDraft"
:is-editing="isPlanEditing"
:is-busy="isBusy || isPlanSaving"
:can-edit="planStatus.key === state.catalog.key && planStatus.editable"
:notice="planStatus.key === state.catalog.key ? planStatus.notice : ''"
@edit="editPlan"
@cancel="
discardPlan(() => {
isPlanEditing = false
})
"
@save="savePlan"
@add="addPlanStep"
@remove="planDraft.splice($event, 1)"
@move="movePlanStep"
@clear="planDraft = []"
@update-step="(index, patch) => Object.assign(planDraft[index]!, patch)"
@command="send"
/>
<p role="status">{{ sceneLabels[state.scene.status] }}</p>
<p v-if="state.timer.durationMs > 0" role="status" class="timer-status">
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
</p>
<p v-if="state.scene.mode !== 7 && state.timer.status === 'running'">
Таймер продолжает отсчёт в фоне.
</p>
<n-alert v-if="state.scene.error" type="error">{{ state.scene.error }}</n-alert>
<n-button
v-if="state.scene.status === 'error'"
@click="send({ action: 'mode', mode: state.scene.mode })"
>Повторить подготовку</n-button
>
<p v-if="state.scene.mode === 6">
{{
state.results
? `Снимок результатов: ${new Date(state.results.capturedAt).toLocaleString('ru-RU')}`
: 'Результаты пока не загружены'
}}
</p>
</n-space>
</n-card>
<n-card title="Текущий показ">
<img
v-if="previewURL"
class="preview"
data-testid="preview"
:src="previewURL"
alt="Предпросмотр текущего показа"
/>
<div v-else class="preview-empty">
{{
!state.outputOpen
? 'Окно показа закрыто'
: state.scene.status === 'pending'
? 'Подготовка показа…'
: 'Предпросмотр недоступен'
}}
</div>
<p>Кадр реального окна показа, без второго звука.</p>
</n-card>
</section>
<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>
</n-card>
</template>
</main>
</template>
<style scoped>
.control {
display: grid;
gap: 16px;
margin: auto;
max-width: 1280px;
padding: 16px;
}
.presentation-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
}
.preview {
background: var(--brand-background);
display: block;
max-height: 50vh;
object-fit: contain;
width: 100%;
}
.preview-empty {
align-items: center;
aspect-ratio: 16 / 9;
background: var(--brand-background);
display: flex;
justify-content: center;
}
.timer-status {
font-variant-numeric: tabular-nums;
}
p {
overflow-wrap: anywhere;
}
.mute {
align-items: center;
display: flex;
gap: 12px;
}
</style>