583 lines
21 KiB
Vue
583 lines
21 KiB
Vue
<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 ScreenConnectionPanel from './ScreenConnectionPanel.vue'
|
||
import MusicPlanPanel from './MusicPlanPanel.vue'
|
||
import { useOnlineConnection } from '../composables/useOnlineConnection'
|
||
import ScreenPlanPanel from './ScreenPlanPanel.vue'
|
||
import { useScreenTimer } from '../composables/useScreenTimer'
|
||
|
||
const {
|
||
state: onlineState,
|
||
isReady: isOnlineReady,
|
||
isBusy: isOnlineBusy,
|
||
run: runOnline,
|
||
cancel: cancelOnline,
|
||
} = useOnlineConnection()
|
||
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 channel = '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
|
||
:state="onlineState"
|
||
:is-ready="isOnlineReady"
|
||
:is-busy="isOnlineBusy"
|
||
@action="runOnline"
|
||
@cancel="cancelOnline"
|
||
: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
|
||
>
|
||
<ScreenConnectionPanel
|
||
:state="onlineState"
|
||
:is-busy="isOnlineBusy"
|
||
:output-open="state?.outputOpen ?? false"
|
||
@action="runOnline"
|
||
/>
|
||
<template v-if="state">
|
||
<label class="mute"
|
||
>Выключить весь звук
|
||
<n-switch
|
||
:value="state.muted"
|
||
aria-label="Выключить весь звук"
|
||
@update:value="send({ action: 'mute', enabled: $event })"
|
||
/></label>
|
||
<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>
|
||
</template>
|
||
</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-play-main="state.outputOpen && !!state.channels.main.source"
|
||
: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"
|
||
>
|
||
<template #main-controls>
|
||
<label for="position-main">Позиция, секунды</label>
|
||
<n-input-number
|
||
v-model:value="positions.main"
|
||
:input-props="{ id: 'position-main' }"
|
||
:min="0"
|
||
:max="state.channels.main.duration ?? undefined"
|
||
/>
|
||
<n-button
|
||
:disabled="
|
||
!state.outputOpen || !state.channels.main.source || isBusy || isPlanSaving
|
||
"
|
||
@click="send({ action: 'seek', channel: 'main', position: positions.main ?? 0 })"
|
||
>Перемотать</n-button
|
||
>
|
||
<label for="volume-main">Громкость, %</label>
|
||
<n-input-number
|
||
:value="state.channels.main.volume"
|
||
:input-props="{ id: 'volume-main' }"
|
||
:min="0"
|
||
:max="100"
|
||
:disabled="isBusy || isPlanSaving"
|
||
@update:value="send({ action: 'volume', channel: 'main', volume: $event ?? 0 })"
|
||
/>
|
||
<p role="status">
|
||
{{ statusLabels[state.channels.main.status] }} ·
|
||
{{ state.channels.main.position.toFixed(1) }} /
|
||
{{ state.channels.main.duration?.toFixed(1) ?? '—' }} с
|
||
</p>
|
||
<n-alert v-if="state.channels.main.error" type="error">{{
|
||
state.channels.main.error
|
||
}}</n-alert>
|
||
<details>
|
||
<summary>Программные замеры запуска</summary>
|
||
<p>{{ metrics('main') }}</p>
|
||
<p>
|
||
Приём команды → подтверждение проигрывателя. Физический звук и первый кадр
|
||
проектора здесь не измеряются.
|
||
</p>
|
||
</details>
|
||
</template>
|
||
</ScreenPlanPanel>
|
||
<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>
|
||
</section>
|
||
<n-card title="План музыки">
|
||
<n-space vertical>
|
||
<MusicPlanPanel
|
||
:playlists="onlineState.playlists"
|
||
:can-edit="onlineState.authenticated && onlineState.connection !== 'offline'"
|
||
:can-use="planStatus.editable"
|
||
:is-busy="isOnlineBusy || onlineState.busy"
|
||
@action="runOnline"
|
||
/>
|
||
<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>
|
||
<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
|
||
: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>
|