This commit is contained in:
2026-10-18 04:03:44 +07:00
parent 9c5419b9bf
commit e85d899fd7
7 changed files with 160 additions and 59 deletions
+17
View File
@@ -297,3 +297,20 @@
33 локальные Markdown-ссылки и git diff --check — PASS.
- Границы: изменения только evening_detective_screen. Архивы не пересобирались;
физический проектор не проверялся. Commit/push не выполнялись.
## SCREEN-015 — Управление вступлением и видео через план
- Статус: `DONE`.
- Дата: 2026-10-18.
- Результат: удалён отдельный блок «Вступление и видео» и ручной выбор медиа.
Файл определяется режимом шага. В активном шаге «Введение»/«Закрывашка» доступны
управление воспроизведением, громкость, перемотка, позиция, длительность,
статус, ошибка и прежние программные замеры основного канала.
- Воспроизведение и перемотка требуют открытого показа и выбранного файла.
Музыка и общий mute сохранены отдельно. Серверный API и типы команд не менялись.
- Проверки: type-check, lint, lint:css, сборка, Electron GUI smoke — PASS.
GUI проверяет отсутствие блока, запуск/паузу/стоп в обоих медийных шагах,
перемотку и громкость на паузе, независимость музыки, план и синхронизацию.
Документация обновлена; локальные Markdown-ссылки и git diff --check — PASS.
- Границы: изменения только evening_detective_screen. Архивы не пересобирались;
физический проектор не проверялся. Commit/push не выполнялись.
+3 -2
View File
@@ -189,8 +189,9 @@ URL, независимо от исходного имени файла. Лок
## Локальный проигрыватель SCREEN-004
Подключите игру в блоке «Игра и сервер», выберите внешний дисплей
и откройте показ. В основном канале доступны вступление и заключительное видео;
их выбор останавливает прежнее основное медиа. Музыка воспроизводится отдельным
и откройте показ. В активном шаге «Введение» или «Закрывашка» доступны управление воспроизведением,
громкость, перемотка и статус основного канала. Файл определяется режимом шага;
отдельного блока выбора медиа нет. Смена шага останавливает прежнее основное медиа. Музыка воспроизводится отдельным
каналом без автоматического приглушения. Звук выводится только окном показа.
Начальная громкость каждого канала — 35%; общий mute не меняет сохранённые уровни.
+5 -4
View File
@@ -22,11 +22,12 @@ SCREEN-004 остаётся IN_PROGRESS до результатов владел
## Сценарии
- Выберите вступление, дождитесь готовности, проверьте пуск с начала, паузу,
- Покажите шаг «Введение» в плане, дождитесь готовности; в активном шаге
проверьте «Запуск», паузу,
продолжение, повтор с начала и остановку. Пауза не должна отматывать звук назад.
- Перемотайте на середину в воспроизведении и на паузе; затем на конец.
Проверьте фактическую позицию и сохранение состояния паузы/воспроизведения.
- Переключитесь на заключительное видео: вступление прекращается. Повторите
- Покажите шаг «Закрывашка»: вступление прекращается. В активном шаге повторите
команды и перемотку, проверьте изображение и звук на внешнем дисплее.
- Запустите музыку вместе с видео/вступлением: оба канала слышны независимо,
автоматического приглушения нет. Изменение громкости одного канала не должно
@@ -39,8 +40,8 @@ SCREEN-004 остаётся IN_PROGRESS до результатов владел
поздний play не оживляет предыдущий ресурс.
- Закройте окно показа и откройте снова. Проверьте оба канала: звук не начинается
до явной команды, позиции сброшены. Пульт и технические ошибки не видны на проекторе.
- Проверьте отдельный тестовый режим SCREEN-001. Он не должен подменять выбранный
игровой пакет автоматически во время нормального воспроизведения.
- Убедитесь, что отдельного блока «Вступление и видео» и выбора медиа нет:
громкость, перемотка и статус находятся в активном медийном шаге плана.
## Измерение задержки
+5
View File
@@ -125,6 +125,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
| «Следующий трек» | Переключает музыку, сохраняя паузу или воспроизведение. |
| «Повторять плейлист» | После последнего трека возвращается к первому. |
Вступление и видео управляются в активном шаге «Введение» или «Закрывашка»:
«Запуск», «Пауза», «Продолжить», «Стоп», «Повтор», громкость и перемотка.
Здесь же видны позиция, длительность, статус и ошибка воспроизведения.
Файл определяется режимом шага; отдельного выбора медиа нет.
Кнопки воспроизведения и перемотка доступны при открытом показе и выбранном файле.
Вступление и видео используют один основной канал и не звучат одновременно.
Музыка независима, автоматически не приглушается; перед речью уменьшайте её
громкость сами. Смена сцены останавливает основное медиа, музыка продолжает играть.
+49 -4
View File
@@ -257,10 +257,8 @@ app.whenReady().then(async () => {
}
}
await checkDisplay(output)
await evaluate(
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Пуск с начала')).click()`,
)
await poll(async () => (await state()).channels.main.status === 'playing', 'UI fixture play')
await command({ action: 'play', channel: 'main' })
await poll(async () => (await state()).channels.main.status === 'playing', 'fixture play')
await command({ action: 'pause', channel: 'main' })
await command({ action: 'resume', channel: 'main' })
await command({ action: 'stop', channel: 'main' })
@@ -275,6 +273,7 @@ app.whenReady().then(async () => {
assert.deepEqual(range, { status: 206, size: 100 })
await command({ action: 'pause', channel: 'main' })
assert.equal(await evaluate('document.body.textContent.includes("Материалы показа")'), false)
assert.equal(await evaluate('document.body.textContent.includes("Вступление и видео")'), false)
await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
await poll(async () => {
const s = await state()
@@ -403,6 +402,52 @@ app.whenReady().then(async () => {
if (index === 0)
assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320)
else assert.equal(await inspect('document.querySelector("img.background").naturalWidth'), 320)
if (index === 1 || index === 3) {
const clickMedia = (label) =>
evaluate(`(() => {
const row = document.querySelector('[data-step-index="${index}"]')
const button = Array.from(row.querySelectorAll('button')).find(b => b.textContent.trim() === ${JSON.stringify(label)})
if (!button || button.disabled) throw new Error('Main control unavailable')
button.click()
})()`)
await clickMedia('Запуск')
await poll(
async () => (await state()).channels.main.status === 'playing',
'plan media plays',
)
await clickMedia('Пауза')
await poll(
async () => (await state()).channels.main.status === 'paused',
'plan media paused',
)
await evaluate(`(() => {
const input = document.querySelector('#position-main')
input.focus(); input.value = '1'
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
input.blur()
})()`)
await clickMedia('Перемотать')
await poll(
async () => Math.abs((await state()).channels.main.position - 1) < 0.1,
'plan media seek',
)
await evaluate(`(() => {
const input = document.querySelector('#volume-main')
input.focus(); input.value = '42'
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' }))
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
input.blur()
})()`)
await poll(async () => (await state()).channels.main.volume === 42, 'plan media volume')
assert.equal((await state()).channels.main.desired, 'paused')
assert.equal((await state()).channels.music.desired, 'playing')
await clickMedia('Стоп')
await poll(
async () => (await state()).channels.main.status === 'stopped',
'plan media stopped',
)
}
if (index === 2) assert.ok(await inspect('document.body.textContent.includes("Успехов")'))
if (index === 4)
assert.ok(await inspect('document.body.textContent.includes("Спасибо за игру!")'))
+71 -44
View File
@@ -94,7 +94,7 @@ async function refreshPreview() {
}
}
const channels = ['main', 'music'] as const
const channel = 'music' as const
const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
)
@@ -296,6 +296,7 @@ onUnmounted(() => {
: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"
@@ -311,7 +312,49 @@ onUnmounted(() => {
@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] }}
@@ -361,51 +404,36 @@ onUnmounted(() => {
aria-label="Выключить весь звук"
@update:value="send({ action: 'mute', enabled: $event })"
/></label>
<n-card
v-for="channel in channels"
:key="channel"
:title="channel === 'main' ? 'Вступление и видео' : 'Музыка'"
>
<n-card title="Музыка">
<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="Выберите медиа"
: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: 'select', channel: 'main', sourceId: $event })"
:input-props="{ 'aria-label': 'Плейлист' }"
@update:value="send({ action: 'playlist', channel: 'music', playlistId: $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-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"
@@ -433,7 +461,6 @@ onUnmounted(() => {
>Повторить с начала</n-button
>
<n-button
v-if="channel === 'music'"
:disabled="!state.outputOpen || !state.channels.music.source || isBusy"
@click="send({ action: 'next', channel: 'music' })"
>Следующий трек</n-button
+10 -5
View File
@@ -9,6 +9,7 @@ defineProps<{
draft: ScreenStep[]
isEditing: boolean
isBusy: boolean
canPlayMain: boolean
canEdit: boolean
notice: string
}>()
@@ -150,32 +151,36 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</template>
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)">
<n-button
:disabled="isBusy"
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'play', channel: 'main' })"
>Запуск</n-button
>
<n-button
:disabled="isBusy"
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'pause', channel: 'main' })"
>Пауза</n-button
>
<n-button
:disabled="isBusy"
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'resume', channel: 'main' })"
>Продолжить</n-button
>
<n-button
:disabled="isBusy"
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'stop', channel: 'main' })"
>Стоп</n-button
>
<n-button
:disabled="isBusy"
:disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'replay', channel: 'main' })"
>Повтор</n-button
>
</template>
</n-space>
<slot
v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)"
name="main-controls"
/>
</n-space>
<n-space>
<n-button