This commit is contained in:
2026-10-18 12:42:04 +07:00
parent 30999630f0
commit b05d55b3fa
15 changed files with 261 additions and 208 deletions
+22 -50
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NAlert, NButton, NCard, NInput, NSelect, NSpace } from 'naive-ui'
import { NAlert, NButton, NCard, NInput, NSelect } from 'naive-ui'
import { ref, watch } from 'vue'
import { DEFAULT_SERVER, type OnlineIntent, type OnlineState } from '../types/online'
@@ -9,6 +9,7 @@ const props = defineProps<{
isBusy: boolean
selectedKey: string | null
selectedName: string | null
canConnectCurrent: boolean
}>()
const emit = defineEmits<{ action: [intent: OnlineIntent]; cancel: [] }>()
const bridge = window.screenBridge
@@ -20,6 +21,7 @@ watch(
() => props.state.server,
(value) => {
server.value = value
gameId.value = null
},
{ immediate: true },
)
@@ -27,6 +29,7 @@ watch(
() => props.state.authenticated,
() => {
password.value = ''
if (!props.state.authenticated) gameId.value = null
},
)
function run(intent: OnlineIntent) {
@@ -87,8 +90,7 @@ const connectionLabels = {
<template v-else>
<p>{{ state.server }}</p>
<n-alert v-if="!state.remembered" type="warning"
>Хранилище секретов ОС недоступно. Вход действует до закрытия приложения; материалы
сохраняются.</n-alert
>Вход не сохранён: после перезапуска потребуется войти снова.</n-alert
>
<n-button :disabled="isBusy || state.busy" @click="run({ action: 'logout' })"
>Выйти</n-button
@@ -113,6 +115,7 @@ const connectionLabels = {
>
<n-select
v-model:value="gameId"
clearable
:options="
state.games.map((g) => ({
label: `${g.name} · ${gameStatusLabels.get(g.status) ?? 'Неизвестный статус'}`,
@@ -120,56 +123,28 @@ const connectionLabels = {
}))
"
:disabled="isBusy || state.busy"
placeholder="Выберите игру для подготовки"
placeholder="Выберите игру"
:input-props="{ 'aria-label': 'Игра на сервере' }"
/>
<p v-if="!state.games.length">Список игр пока пуст. Обновите его при доступном сервере.</p>
<p v-if="!state.games.length">Нет игр.</p>
<n-button
:disabled="(gameId === null && !canConnectCurrent) || isBusy || state.busy"
@click="run(gameId === null ? { action: 'connect' } : { action: 'connect', gameId })"
>Подключить</n-button
>
<n-button
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'connect', gameId: gameId! })"
>Подключить выбранную игру</n-button
>
<n-space>
<n-button
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'check', gameId: gameId! })"
>Проверить изменения</n-button
>
<n-button
:disabled="gameId === null || isBusy || state.busy"
@click="run({ action: 'download', gameId: gameId! })"
>Загрузить материалы</n-button
>
</n-space>
<p>
Новые материалы загружаются автоматически. Для принудительного обновления файлов по
прежним адресам закройте показ и загрузите материалы повторно.
</p>
<p>
Игру можно подключить без предварительной загрузки: недостающие файлы сохранятся
автоматически.
</p>
<p v-if="selectedKey" data-testid="control-game">Игра для управления: {{ selectedName }}</p>
<n-button
v-if="selectedKey"
:disabled="isBusy || state.busy"
@click="run({ action: 'connect' })"
>Подключить управление этой игрой</n-button
@click="run({ action: 'download', gameId: gameId! })"
>Загрузить материалы</n-button
>
</template>
<n-button
v-if="state.screenId"
:disabled="isBusy || state.busy"
@click="run({ action: 'results' })"
>Обновить снимок результатов</n-button
>
<p v-if="selectedKey" data-testid="control-game">Текущая игра: {{ selectedName }}</p>
<p v-if="state.preparation.active" role="status" data-testid="materials-progress">
Подготовка материалов: {{ state.preparation.completed }} /
{{ state.preparation.total }} файлов; текущая загрузка
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ.
Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} ·
{{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ
</p>
<n-alert v-if="state.preparation.error" type="warning" class="connection-error">
{{ state.preparation.error }}. Прежний показ сохраняется до готовности замены.
{{ state.preparation.error }}
<n-button
:disabled="isBusy || state.busy || state.preparation.active"
@click="run({ action: 'retry-materials' })"
@@ -177,12 +152,9 @@ const connectionLabels = {
>
</n-alert>
<template v-if="state.busy">
<p role="status">
{{
state.progress
? `Файлов: ${state.progress.completedFiles}/${state.progress.totalFiles}, загружено ${(state.progress.downloadedBytes / 1048576).toFixed(1)} МиБ`
: 'Выполняется запрос…'
}}
<p v-if="state.progress" role="status">
Файлов: {{ state.progress.completedFiles }}/{{ state.progress.totalFiles }} ·
{{ (state.progress.downloadedBytes / 1048576).toFixed(1) }} МиБ
</p>
<n-button v-if="state.canCancel" @click="cancel">Отменить загрузку</n-button>
</template>
+28 -79
View File
@@ -28,6 +28,7 @@ import MusicPlanPanel from './MusicPlanPanel.vue'
import { useOnlineConnection } from '../composables/useOnlineConnection'
import ScreenPlanPanel from './ScreenPlanPanel.vue'
import { useScreenTimer } from '../composables/useScreenTimer'
import { playbackControl } from '../utils/playbackControl'
const {
state: onlineState,
@@ -63,12 +64,6 @@ const previewURL = computed(() =>
? preview.value?.dataURL
: null,
)
const sceneLabels = {
idle: 'Окно показа закрыто',
pending: 'Подготовка показа',
ready: 'Показ готов',
error: 'Не удалось подготовить показ',
}
const timerLabels = {
stopped: 'Не запущен',
running: 'Идёт',
@@ -105,15 +100,18 @@ async function refreshPreview() {
}
const channel = 'music' as const
const musicControl = computed(() =>
playbackControl(state.value?.channels.music ?? { desired: 'stopped', status: 'idle' }),
)
const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
)
const statusLabels: Record<Playback, string> = {
idle: 'Ресурс не выбран',
pending: 'Подготовка или выполнение команды',
playing: 'Воспроизведение подтверждено',
idle: 'Файл не выбран',
pending: 'Загрузка…',
playing: 'Играет',
paused: 'Пауза',
stopped: 'Готово, остановлено',
stopped: 'Остановлено',
ended: 'Завершено',
error: 'Ошибка',
}
@@ -212,18 +210,6 @@ async function refreshHost() {
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')
@@ -254,7 +240,7 @@ onUnmounted(() => {
<template>
<main class="control">
<h1>Вечерний детектив · Локальный проигрыватель</h1>
<h1>Вечерний детектив</h1>
<n-alert v-if="!bridge" type="warning">Откройте пульт в приложении Electron.</n-alert>
<connection-panel
:state="onlineState"
@@ -264,22 +250,13 @@ onUnmounted(() => {
@cancel="cancelOnline"
: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="Экран">
<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 }))"
@@ -295,12 +272,7 @@ onUnmounted(() => {
<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"
/>
<ScreenConnectionPanel :state="onlineState" :is-busy="isOnlineBusy" @action="runOnline" />
<template v-if="state">
<label class="mute"
>Выключить весь звук
@@ -325,14 +297,10 @@ onUnmounted(() => {
: 'Предпросмотр недоступен'
}}
</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>
@@ -343,6 +311,9 @@ onUnmounted(() => {
: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 : ''"
@edit="editPlan"
@@ -391,23 +362,19 @@ onUnmounted(() => {
<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">
<p v-if="state.scene.status === 'pending'" role="status">Подготовка показа…</p>
<p
v-if="
state.timer.durationMs > 0 &&
(state.scene.mode === 7 || state.timer.status === 'running')
"
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'"
@@ -464,18 +431,8 @@ onUnmounted(() => {
<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
@click="send({ action: musicControl.action, channel })"
>{{ musicControl.label }}</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@@ -485,7 +442,7 @@ onUnmounted(() => {
<n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@click="send({ action: 'replay', channel })"
>Повторить с начала</n-button
>Сначала</n-button
>
<n-button
:disabled="!state.outputOpen || !state.channels.music.source || isBusy"
@@ -521,14 +478,6 @@ onUnmounted(() => {
<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>
-2
View File
@@ -94,7 +94,6 @@ function remove(trackId?: number) {
<n-space vertical data-testid="music-plan-editor">
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
<template v-else>
<n-button :disabled="isDisabled" @click="refresh">Обновить плейлисты</n-button>
<label for="new-playlist-name">Новый плейлист</label>
<n-input
v-model:value="newName"
@@ -138,7 +137,6 @@ function remove(trackId?: number) {
>Использовать в игре</n-button
>
</n-space>
<p>Выбор в редакторе не меняет музыку игры. MP3 — до 64 МиБ.</p>
<p v-if="!order.length">Треков пока нет.</p>
<ol class="track-list">
<li v-for="(id, index) in order" :key="id">
+4 -21
View File
@@ -1,27 +1,14 @@
<script setup lang="ts">
import { NButton, NSpace } from 'naive-ui'
import type { OnlineIntent, OnlineState } from '../types/online'
defineProps<{ state: OnlineState; isBusy: boolean; outputOpen: boolean }>()
defineProps<{ state: OnlineState; isBusy: boolean }>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
function run(intent: OnlineIntent) {
emit('action', intent)
}
const connectionLabels = {
disabled: 'Управление не подключено',
waiting: 'Ожидание ответа сервера',
connected: 'Связь с сервером есть',
offline: 'Нет связи; локальный показ продолжается',
expired: 'Сессия экрана недействительна; подключите управление заново',
}
</script>
<template>
<p v-if="state.screenId" role="status">
{{ connectionLabels[state.connection]
}}{{ state.screenId ? ` · Экран №${state.screenId}` : '' }}
</p>
<p v-if="state.screenId && !outputOpen">
Связь и загрузка материалов продолжаются; показ откройте вручную.
</p>
<p v-if="state.screenId">Экран №{{ state.screenId }}</p>
<template v-if="state.screenId">
<p role="status" data-testid="sound-assignment">
{{
@@ -32,13 +19,13 @@ const connectionLabels = {
: 'Экран не назначен для звука'
}}
<template v-if="state.soundEnabled !== null && state.connection !== 'connected'">
· Последнее подтверждённое состояние; сейчас назначение не проверяется.
· Последнее состояние
</template>
</p>
<p>Переключатель «Выключить весь звук» управляет локальным звуком отдельно.</p>
</template>
<n-space v-if="state.screenId">
<n-button
v-if="state.soundEnabled !== true"
:disabled="isBusy || state.busy || !state.authenticated"
@click="run({ action: 'sound' })"
>Назначить звук этому экрану</n-button
@@ -47,8 +34,4 @@ const connectionLabels = {
>Отключить управление</n-button
>
</n-space>
<p v-if="state.screenId">
«Отключить управление» прекращает управление приложением; привязка экрана на сервере
сохраняется.
</p>
</template>
+35 -25
View File
@@ -1,15 +1,20 @@
<script setup lang="ts">
import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui'
import type { Intent, ScreenMode, ScreenStep } from '../types/screen'
import { computed } from 'vue'
import { playbackControl } from '../utils/playbackControl'
import type { ChannelState, Intent, ScreenMode, ScreenStep, TimerState } from '../types/screen'
import { modeLabels } from '../utils/presentation'
defineProps<{
const props = defineProps<{
steps: ScreenStep[]
currentIndex: number | null
draft: ScreenStep[]
isEditing: boolean
isBusy: boolean
canPlayMain: boolean
mainDesired: ChannelState['desired']
mainStatus: ChannelState['status']
timerStatus: TimerState['status']
canEdit: boolean
notice: string
}>()
@@ -24,6 +29,9 @@ const emit = defineEmits<{
updateStep: [index: number, patch: Partial<ScreenStep>]
command: [intent: Intent]
}>()
const mainControl = computed(() =>
playbackControl({ desired: props.mainDesired, status: props.mainStatus }),
)
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
value: Number(mode) as ScreenMode,
label,
@@ -49,7 +57,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</template>
</n-space>
<n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert>
<p v-if="!canEdit">Для редактирования войдите на сервер выбранной подготовленной игры.</p>
<p v-if="!canEdit">Для редактирования войдите на сервер игры.</p>
<template v-if="isEditing">
<n-space v-for="(step, index) in draft" :key="index" vertical class="plan-step">
<span>Шаг {{ index + 1 }}</span>
@@ -111,7 +119,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</n-space>
</template>
<template v-else>
<p v-if="!steps.length">План экранов ещё не создан. Создайте план, чтобы настроить экраны.</p>
<p v-if="!steps.length">План экранов пуст.</p>
<n-space
v-for="(step, index) in steps"
:key="index"
@@ -136,15 +144,27 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
>
<template v-if="currentIndex === index && step.mode === 7">
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-start' })"
>Старт</n-button
>
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-pause' })"
>Пауза</n-button
>
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-resume' })"
>Продолжить</n-button
<n-button
:disabled="isBusy"
@click="
emit('command', {
action:
timerStatus === 'running'
? 'timer-pause'
: timerStatus === 'paused'
? 'timer-resume'
: 'timer-start',
})
"
>
{{
timerStatus === 'running'
? 'Пауза'
: timerStatus === 'paused'
? 'Продолжить'
: 'Старт'
}}
</n-button>
<n-button :disabled="isBusy" @click="emit('command', { action: 'timer-reset' })"
>Сбросить</n-button
>
@@ -152,18 +172,8 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)">
<n-button
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'play', channel: 'main' })"
>Запуск</n-button
>
<n-button
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'pause', channel: 'main' })"
>Пауза</n-button
>
<n-button
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'resume', channel: 'main' })"
>Продолжить</n-button
@click="emit('command', { action: mainControl.action, channel: 'main' })"
>{{ mainControl.label }}</n-button
>
<n-button
:disabled="isBusy || !canPlayMain"
@@ -173,7 +183,7 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
<n-button
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'replay', channel: 'main' })"
>Повтор</n-button
>Сначала</n-button
>
</template>
</n-space>