diff --git a/COMPLETED_TASKS.md b/COMPLETED_TASKS.md
index d5b5188..c0d8e52 100644
--- a/COMPLETED_TASKS.md
+++ b/COMPLETED_TASKS.md
@@ -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 не выполнялись.
diff --git a/README.md b/README.md
index d312884..c2763d5 100644
--- a/README.md
+++ b/README.md
@@ -189,8 +189,9 @@ URL, независимо от исходного имени файла. Лок
## Локальный проигрыватель SCREEN-004
Подключите игру в блоке «Игра и сервер», выберите внешний дисплей
-и откройте показ. В основном канале доступны вступление и заключительное видео;
-их выбор останавливает прежнее основное медиа. Музыка воспроизводится отдельным
+и откройте показ. В активном шаге «Введение» или «Закрывашка» доступны управление воспроизведением,
+громкость, перемотка и статус основного канала. Файл определяется режимом шага;
+отдельного блока выбора медиа нет. Смена шага останавливает прежнее основное медиа. Музыка воспроизводится отдельным
каналом без автоматического приглушения. Звук выводится только окном показа.
Начальная громкость каждого канала — 35%; общий mute не меняет сохранённые уровни.
diff --git a/docs/SCREEN-004_ACCEPTANCE.md b/docs/SCREEN-004_ACCEPTANCE.md
index 10edad1..89374db 100644
--- a/docs/SCREEN-004_ACCEPTANCE.md
+++ b/docs/SCREEN-004_ACCEPTANCE.md
@@ -22,11 +22,12 @@ SCREEN-004 остаётся IN_PROGRESS до результатов владел
## Сценарии
-- Выберите вступление, дождитесь готовности, проверьте пуск с начала, паузу,
+- Покажите шаг «Введение» в плане, дождитесь готовности; в активном шаге
+ проверьте «Запуск», паузу,
продолжение, повтор с начала и остановку. Пауза не должна отматывать звук назад.
- Перемотайте на середину в воспроизведении и на паузе; затем на конец.
Проверьте фактическую позицию и сохранение состояния паузы/воспроизведения.
-- Переключитесь на заключительное видео: вступление прекращается. Повторите
+- Покажите шаг «Закрывашка»: вступление прекращается. В активном шаге повторите
команды и перемотку, проверьте изображение и звук на внешнем дисплее.
- Запустите музыку вместе с видео/вступлением: оба канала слышны независимо,
автоматического приглушения нет. Изменение громкости одного канала не должно
@@ -39,8 +40,8 @@ SCREEN-004 остаётся IN_PROGRESS до результатов владел
поздний play не оживляет предыдущий ресурс.
- Закройте окно показа и откройте снова. Проверьте оба канала: звук не начинается
до явной команды, позиции сброшены. Пульт и технические ошибки не видны на проекторе.
-- Проверьте отдельный тестовый режим SCREEN-001. Он не должен подменять выбранный
- игровой пакет автоматически во время нормального воспроизведения.
+- Убедитесь, что отдельного блока «Вступление и видео» и выбора медиа нет:
+ громкость, перемотка и статус находятся в активном медийном шаге плана.
## Измерение задержки
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 2e0d0df..926fa66 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -125,6 +125,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
| «Следующий трек» | Переключает музыку, сохраняя паузу или воспроизведение. |
| «Повторять плейлист» | После последнего трека возвращается к первому. |
+Вступление и видео управляются в активном шаге «Введение» или «Закрывашка»:
+«Запуск», «Пауза», «Продолжить», «Стоп», «Повтор», громкость и перемотка.
+Здесь же видны позиция, длительность, статус и ошибка воспроизведения.
+Файл определяется режимом шага; отдельного выбора медиа нет.
+Кнопки воспроизведения и перемотка доступны при открытом показе и выбранном файле.
Вступление и видео используют один основной канал и не звучат одновременно.
Музыка независима, автоматически не приглушается; перед речью уменьшайте её
громкость сами. Смена сцены останавливает основное медиа, музыка продолжает играть.
diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs
index fe32d33..c656f11 100644
--- a/scripts/smoke.cjs
+++ b/scripts/smoke.cjs
@@ -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("Спасибо за игру!")'))
diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue
index 0398134..8ebce77 100644
--- a/src/components/ControlPage.vue
+++ b/src/components/ControlPage.vue
@@ -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"
- />
+ >
+
+
+
+ {{ statusLabels[state.channels.main.status] }} ·
+ {{ state.channels.main.position.toFixed(1) }} /
+ {{ state.channels.main.duration?.toFixed(1) ?? '—' }} с
+ {{ metrics('main') }}
+ Приём команды → подтверждение проигрывателя. Физический звук и первый кадр
+ проектора здесь не измеряются.
+ Программные замеры запуска
+
{{ sceneLabels[state.scene.status] }}
{{ state.timer.label }} · {{ timerDisplay }} · {{ timerLabels[state.timer.status] }}
@@ -361,51 +404,36 @@ onUnmounted(() => {
aria-label="Выключить весь звук"
@update:value="send({ action: 'mute', enabled: $event })"
/>
-