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. 33 локальные Markdown-ссылки и git diff --check — PASS.
- Границы: изменения только evening_detective_screen. Архивы не пересобирались; - Границы: изменения только evening_detective_screen. Архивы не пересобирались;
физический проектор не проверялся. Commit/push не выполнялись. физический проектор не проверялся. 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 ## Локальный проигрыватель SCREEN-004
Подключите игру в блоке «Игра и сервер», выберите внешний дисплей Подключите игру в блоке «Игра и сервер», выберите внешний дисплей
и откройте показ. В основном канале доступны вступление и заключительное видео; и откройте показ. В активном шаге «Введение» или «Закрывашка» доступны управление воспроизведением,
их выбор останавливает прежнее основное медиа. Музыка воспроизводится отдельным громкость, перемотка и статус основного канала. Файл определяется режимом шага;
отдельного блока выбора медиа нет. Смена шага останавливает прежнее основное медиа. Музыка воспроизводится отдельным
каналом без автоматического приглушения. Звук выводится только окном показа. каналом без автоматического приглушения. Звук выводится только окном показа.
Начальная громкость каждого канала — 35%; общий mute не меняет сохранённые уровни. Начальная громкость каждого канала — 35%; общий mute не меняет сохранённые уровни.
+5 -4
View File
@@ -22,11 +22,12 @@ SCREEN-004 остаётся IN_PROGRESS до результатов владел
## Сценарии ## Сценарии
- Выберите вступление, дождитесь готовности, проверьте пуск с начала, паузу, - Покажите шаг «Введение» в плане, дождитесь готовности; в активном шаге
проверьте «Запуск», паузу,
продолжение, повтор с начала и остановку. Пауза не должна отматывать звук назад. продолжение, повтор с начала и остановку. Пауза не должна отматывать звук назад.
- Перемотайте на середину в воспроизведении и на паузе; затем на конец. - Перемотайте на середину в воспроизведении и на паузе; затем на конец.
Проверьте фактическую позицию и сохранение состояния паузы/воспроизведения. Проверьте фактическую позицию и сохранение состояния паузы/воспроизведения.
- Переключитесь на заключительное видео: вступление прекращается. Повторите - Покажите шаг «Закрывашка»: вступление прекращается. В активном шаге повторите
команды и перемотку, проверьте изображение и звук на внешнем дисплее. команды и перемотку, проверьте изображение и звук на внешнем дисплее.
- Запустите музыку вместе с видео/вступлением: оба канала слышны независимо, - Запустите музыку вместе с видео/вступлением: оба канала слышны независимо,
автоматического приглушения нет. Изменение громкости одного канала не должно автоматического приглушения нет. Изменение громкости одного канала не должно
@@ -39,8 +40,8 @@ SCREEN-004 остаётся IN_PROGRESS до результатов владел
поздний play не оживляет предыдущий ресурс. поздний 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 checkDisplay(output)
await evaluate( await command({ action: 'play', channel: 'main' })
`Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Пуск с начала')).click()`, await poll(async () => (await state()).channels.main.status === 'playing', 'fixture play')
)
await poll(async () => (await state()).channels.main.status === 'playing', 'UI fixture play')
await command({ action: 'pause', channel: 'main' }) await command({ action: 'pause', channel: 'main' })
await command({ action: 'resume', channel: 'main' }) await command({ action: 'resume', channel: 'main' })
await command({ action: 'stop', channel: 'main' }) await command({ action: 'stop', channel: 'main' })
@@ -275,6 +273,7 @@ app.whenReady().then(async () => {
assert.deepEqual(range, { status: 206, size: 100 }) assert.deepEqual(range, { status: 206, size: 100 })
await command({ action: 'pause', channel: 'main' }) await command({ action: 'pause', channel: 'main' })
assert.equal(await evaluate('document.body.textContent.includes("Материалы показа")'), false) 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 evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`)
await poll(async () => { await poll(async () => {
const s = await state() const s = await state()
@@ -403,6 +402,52 @@ app.whenReady().then(async () => {
if (index === 0) if (index === 0)
assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320) assert.equal(await inspect('document.querySelector("img.cover").naturalWidth'), 320)
else assert.equal(await inspect('document.querySelector("img.background").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 === 2) assert.ok(await inspect('document.body.textContent.includes("Успехов")'))
if (index === 4) if (index === 4)
assert.ok(await inspect('document.body.textContent.includes("Спасибо за игру!")')) assert.ok(await inspect('document.body.textContent.includes("Спасибо за игру!")'))
+45 -18
View File
@@ -94,7 +94,7 @@ async function refreshPreview() {
} }
} }
const channels = ['main', 'music'] as const const channel = 'music' as const
const playlist = computed(() => const playlist = computed(() =>
state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId), state.value?.catalog.playlists.find((p) => p.id === state.value?.channels.music.playlistId),
) )
@@ -296,6 +296,7 @@ onUnmounted(() => {
:draft="planDraft" :draft="planDraft"
:is-editing="isPlanEditing" :is-editing="isPlanEditing"
:is-busy="isBusy || isPlanSaving" :is-busy="isBusy || isPlanSaving"
:can-play-main="state.outputOpen && !!state.channels.main.source"
:can-edit="planStatus.key === state.catalog.key && planStatus.editable" :can-edit="planStatus.key === state.catalog.key && planStatus.editable"
:notice="planStatus.key === state.catalog.key ? planStatus.notice : ''" :notice="planStatus.key === state.catalog.key ? planStatus.notice : ''"
@edit="editPlan" @edit="editPlan"
@@ -311,7 +312,49 @@ onUnmounted(() => {
@clear="planDraft = []" @clear="planDraft = []"
@update-step="(index, patch) => Object.assign(planDraft[index]!, patch)" @update-step="(index, patch) => Object.assign(planDraft[index]!, patch)"
@command="send" @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 role="status">{{ sceneLabels[state.scene.status] }}</p>
<p v-if="state.timer.durationMs > 0" role="status" class="timer-status"> <p v-if="state.timer.durationMs > 0" role="status" class="timer-status">
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }} {{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
@@ -361,22 +404,8 @@ onUnmounted(() => {
aria-label="Выключить весь звук" aria-label="Выключить весь звук"
@update:value="send({ action: 'mute', enabled: $event })" @update:value="send({ action: 'mute', enabled: $event })"
/></label> /></label>
<n-card <n-card title="Музыка">
v-for="channel in channels"
:key="channel"
:title="channel === 'main' ? 'Вступление и видео' : 'Музыка'"
>
<n-space vertical> <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="Выберите медиа"
:disabled="isBusy"
:input-props="{ 'aria-label': 'Медиа показа' }"
@update:value="send({ action: 'select', channel: 'main', sourceId: $event })"
/>
<template v-else>
<n-select <n-select
:value="state.channels.music.playlistId" :value="state.channels.music.playlistId"
:options="state.catalog.playlists.map((p) => ({ label: p.name, value: p.id }))" :options="state.catalog.playlists.map((p) => ({ label: p.name, value: p.id }))"
@@ -405,7 +434,6 @@ onUnmounted(() => {
aria-label="Повторять плейлист" aria-label="Повторять плейлист"
@update:value="send({ action: 'loop', channel: 'music', enabled: $event })" @update:value="send({ action: 'loop', channel: 'music', enabled: $event })"
/></label> /></label>
</template>
<n-space> <n-space>
<n-button <n-button
:disabled="!state.outputOpen || !state.channels[channel].source || isBusy" :disabled="!state.outputOpen || !state.channels[channel].source || isBusy"
@@ -433,7 +461,6 @@ onUnmounted(() => {
>Повторить с начала</n-button >Повторить с начала</n-button
> >
<n-button <n-button
v-if="channel === 'music'"
:disabled="!state.outputOpen || !state.channels.music.source || isBusy" :disabled="!state.outputOpen || !state.channels.music.source || isBusy"
@click="send({ action: 'next', channel: 'music' })" @click="send({ action: 'next', channel: 'music' })"
>Следующий трек</n-button >Следующий трек</n-button
+10 -5
View File
@@ -9,6 +9,7 @@ defineProps<{
draft: ScreenStep[] draft: ScreenStep[]
isEditing: boolean isEditing: boolean
isBusy: boolean isBusy: boolean
canPlayMain: boolean
canEdit: boolean canEdit: boolean
notice: string notice: string
}>() }>()
@@ -150,32 +151,36 @@ const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
</template> </template>
<template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)"> <template v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)">
<n-button <n-button
:disabled="isBusy" :disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'play', channel: 'main' })" @click="emit('command', { action: 'play', channel: 'main' })"
>Запуск</n-button >Запуск</n-button
> >
<n-button <n-button
:disabled="isBusy" :disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'pause', channel: 'main' })" @click="emit('command', { action: 'pause', channel: 'main' })"
>Пауза</n-button >Пауза</n-button
> >
<n-button <n-button
:disabled="isBusy" :disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'resume', channel: 'main' })" @click="emit('command', { action: 'resume', channel: 'main' })"
>Продолжить</n-button >Продолжить</n-button
> >
<n-button <n-button
:disabled="isBusy" :disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'stop', channel: 'main' })" @click="emit('command', { action: 'stop', channel: 'main' })"
>Стоп</n-button >Стоп</n-button
> >
<n-button <n-button
:disabled="isBusy" :disabled="isBusy || !canPlayMain"
@click="emit('command', { action: 'replay', channel: 'main' })" @click="emit('command', { action: 'replay', channel: 'main' })"
>Повтор</n-button >Повтор</n-button
> >
</template> </template>
</n-space> </n-space>
<slot
v-if="currentIndex === index && (step.mode === 2 || step.mode === 4)"
name="main-controls"
/>
</n-space> </n-space>
<n-space> <n-space>
<n-button <n-button