549 lines
18 KiB
Vue
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>
|