Files
evening_detective_screen/src/components/ControlPage.vue
T
2026-10-18 23:18:25 +07:00

549 lines
18 KiB
Vue

<script setup lang="ts">
import {
NAlert,
NButton,
NCard,
NSlider,
NProgress,
NSelect,
NSpace,
NTag,
useMessage,
useDialog,
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type {
HostInfo,
Intent,
Playback,
PreviewFrame,
State,
ScreenStep,
PlanStatus,
} from '../types/screen'
import ConnectionPanel from './ConnectionPanel.vue'
import MusicPlanPanel from './MusicPlanPanel.vue'
import { useOnlineConnection } from '../composables/useOnlineConnection'
import ScreenPlanPanel from './ScreenPlanPanel.vue'
import { formatTimer } from '../utils/presentation'
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 mainProgress = computed(() => {
const channel = state.value?.channels.main
if (!channel?.duration || channel.duration <= 0) return 0
return Math.min(100, Math.max(0, (channel.position / channel.duration) * 100))
})
const host = ref<HostInfo>({
displays: [],
notice: '',
sleepBlocked: false,
preferredDisplayId: null,
})
const display = ref<number | null>(null)
const isBusy = ref(false)
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,
)
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 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 resetGame() {
if (!bridge || isBusy.value || isOnlineBusy.value || onlineState.value.busy) return
await run(async () => {
acceptState(await bridge.selectPackage(null))
onlineState.value = await bridge.onlineState()
})
}
async function selectLocalGame(key: string) {
if (!bridge || isBusy.value || isOnlineBusy.value || onlineState.value.busy) return
await run(async () => {
acceptState(await bridge.selectPackage(key))
onlineState.value = await bridge.onlineState()
await refreshHost()
})
}
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
}
async function selectAudioOutput(id: string) {
await run(async () => {
await bridge!.selectAudioOutput(id)
await refreshHost()
})
}
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">
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
<connection-panel
:state="onlineState"
:is-ready="isOnlineReady"
:is-busy="isOnlineBusy || isBusy"
@action="runOnline"
@cancel="cancelOnline"
@reset-game="resetGame"
@select-local="selectLocalGame"
:output-open="state?.outputOpen ?? false"
:selected-key="state?.catalog.key ?? null"
:selected-name="state?.catalog.name ?? null"
:can-connect-current="
!!state?.catalog.key && planStatus.key === state.catalog.key && planStatus.editable
"
/>
<n-card v-if="bridge" title="Экран">
<template #header-extra>
<n-tag
role="status"
data-testid="display-connection-status"
:type="host.displays.length ? 'success' : 'error'"
>
{{ host.displays.length ? 'Подключён' : 'Не подключён' }}
</n-tag>
</template>
<div class="screen-grid">
<n-space vertical>
<n-alert v-if="host.notice" type="warning">{{ host.notice }}</n-alert>
<n-select
v-model:value="display"
:options="host.displays.map((d) => ({ label: d.label, value: d.id }))"
placeholder="Выберите внешний дисплей"
:input-props="{ 'aria-label': 'Дисплей показа' }"
/>
<label for="audio-output-input">Вывод звука</label>
<n-select
id="audio-output"
:value="host.audioOutputId ?? ''"
:options="[
{ label: 'По умолчанию в системе', value: '' },
...(host.audioOutputs ?? []).map((device) => ({ label: device.label, value: device.id })),
]"
:disabled="isBusy"
:input-props="{ id: 'audio-output-input', 'aria-label': 'Вывод звука' }"
@update:value="selectAudioOutput"
/>
<n-alert v-if="host.audioOutputNotice" type="warning" role="status">{{ host.audioOutputNotice }}</n-alert>
<n-space align="center">
<n-button
v-if="!state?.outputOpen"
:disabled="display === null || isBusy"
@click="run(() => bridge!.show(display!))"
>Открыть показ</n-button
>
<n-button v-else :disabled="isBusy" @click="run(() => bridge!.closeOutput())"
>Закрыть показ</n-button
>
<n-button
v-if="state"
square
:type="state.muted ? 'error' : 'default'"
:ghost="state.muted"
:disabled="isBusy"
:aria-label="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
:title="state.muted ? 'Включить весь звук' : 'Выключить весь звук'"
:aria-pressed="state.muted"
@click="send({ action: 'mute', enabled: !state.muted })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4z" />
<path v-if="state.muted" d="m16 9 6 6m0-6-6 6" />
<path v-else d="M15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
</svg>
</n-button>
</n-space>
</n-space>
<div v-if="state" class="screen-preview">
<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>
</div>
</div>
</n-card>
<template v-if="state">
<section class="presentation-grid">
<n-card title="План экранов">
<template #header-extra>
<n-button
v-if="!isPlanEditing"
:disabled="isBusy || isPlanSaving || planStatus.key !== state.catalog.key || !planStatus.editable"
@click="editPlan"
>
{{ state.catalog.steps.length ? 'Редактировать' : 'Создать план' }}
</n-button>
<n-space v-else>
<n-button
:disabled="isBusy || isPlanSaving"
@click="discardPlan(() => { isPlanEditing = false })"
>Отменить</n-button>
<n-button
type="primary"
:disabled="isBusy || isPlanSaving || planStatus.key !== state.catalog.key || !planStatus.editable"
:loading="isBusy || isPlanSaving"
@click="savePlan"
>Сохранить план</n-button>
</n-space>
</template>
<n-space vertical>
<ScreenPlanPanel
:game-name="state.catalog.name"
:cover="state.catalog.cover"
:background="state.catalog.background"
: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"
:main-desired="state.channels.main.desired"
:main-status="state.channels.main.status"
:timer-status="state.timer.status"
:can-edit="planStatus.key === state.catalog.key && planStatus.editable"
:notice="planStatus.key === state.catalog.key ? planStatus.notice : ''"
@add="addPlanStep"
@remove="planDraft.splice($event, 1)"
@move="movePlanStep"
@clear="planDraft = []"
@update-step="(index, patch) => Object.assign(planDraft[index]!, patch)"
@command="send"
>
<template #main-volume="{ index, active }">
<div class="plan-volume-control">
<label :for="`volume-main-${index}`" title="Громкость, %">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4zM15 8a6 6 0 0 1 0 8M18 5a10 10 0 0 1 0 14" />
</svg>
</label>
<n-slider
class="plan-volume"
:value="state.channels.main.volume"
:id="`volume-main-${index}`"
role="group"
aria-label="Громкость, %"
:step="1"
:format-tooltip="(value) => `${value}%`"
:min="0"
:max="100"
:disabled="!active || isBusy || isPlanSaving"
@update:value="send({ action: 'volume', channel: 'main', volume: $event ?? 0 })"
/>
</div>
</template>
<template #main-controls="{ active }">
<div class="playback-progress-row">
<n-progress
class="playback-progress-bar"
type="line"
:percentage="active ? mainProgress : 0"
:show-indicator="false"
aria-label="Прогресс воспроизведения"
/>
<p v-if="active" role="status" class="playback-progress">
{{ statusLabels[state.channels.main.status] }} ·
{{ formatTimer(Math.floor(state.channels.main.position) * 1000) }} /
{{ state.channels.main.duration === null ? '—' : formatTimer(state.channels.main.duration * 1000) }}
</p>
</div>
<n-alert v-if="active && state.channels.main.error" type="error">{{
state.channels.main.error
}}</n-alert>
</template>
</ScreenPlanPanel>
<p v-if="state.scene.status === 'pending'" role="status">Подготовка показа…</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>
<MusicPlanPanel
:playlists="onlineState.playlists"
:local-playlists="state.catalog.playlists"
:channel="state.channels.music"
:output-open="state.outputOpen"
:can-edit="onlineState.authenticated && onlineState.connection !== 'offline'"
:can-use="planStatus.editable"
:is-busy="isBusy || isOnlineBusy || onlineState.busy"
@action="runOnline"
@command="send"
/>
</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));
}
.screen-grid {
align-items: start;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
}
.screen-grid > * {
min-width: 0;
}
.preview {
background: var(--brand-background);
display: block;
max-height: 25vh;
object-fit: contain;
width: 100%;
}
.preview-empty {
align-items: center;
aspect-ratio: 16 / 9;
background: var(--brand-background);
display: flex;
justify-content: center;
}
.plan-volume-control {
align-items: center;
display: flex;
gap: 8px;
}
.plan-volume {
width: 110px;
}
.playback-progress-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.playback-progress-bar {
flex: 1 1 160px;
min-width: 0;
}
.playback-progress {
margin: 0;
white-space: nowrap;
}
.playback-progress {
font-variant-numeric: tabular-nums;
}
p {
overflow-wrap: anywhere;
}
</style>