diff --git a/electron/controller.test.ts b/electron/controller.test.ts index c95d00e..e024152 100644 --- a/electron/controller.test.ts +++ b/electron/controller.test.ts @@ -4,6 +4,7 @@ import { Controller, parseCommand } from './controller.js' import { buildScreenPackage } from './screen-package.js' import { packageKey, type LocalPackage } from './package-store.js' import { parseRange } from './range.js' +import { applyRemote, parseRemote } from './remote-state.js' import type { Channel, Intent, Report } from '../src/types/screen.js' function testPackage(): LocalPackage { @@ -155,6 +156,7 @@ test('музыка независима от основного канала, mu ack(c, 'music', 'playing', 15) send(c, { action: 'select', channel: 'main', sourceId: 'conclusion' }) send(c, { action: 'play', channel: 'main' }) + send(c, { action: 'resume', channel: 'music' }) ack(c, 'main', 'playing', 20) const operation = c.state.channels.main.operation send(c, { action: 'volume', channel: 'main', volume: 80 }) @@ -257,3 +259,117 @@ test('обновление порядка сохраняет текущий фа send(c, { action: 'next', channel: 'music' }) assert.equal(c.state.channels.music.trackIndex, 0) }) + +for (const mode of [2, 4] as const) { + test(`локальная автопауза: режим ${mode}, шаг и запуск медиа сохраняют позицию музыки`, () => { + const entries: Intent[] = [ + { action: 'mode', mode }, + { action: 'step', index: 0 }, + { action: 'select', channel: 'main', sourceId: mode === 2 ? 'intro' : 'conclusion' }, + { action: 'play', channel: 'main' }, + ] + for (const entry of entries) { + const c = controller() + c.selectPackage(testPackage()) + c.setPlan([{ mode, text: '', timerLabel: '', timerDurationSeconds: 30 }]) + if (entry.action === 'play' && mode === 4) { + send(c, { action: 'select', channel: 'main', sourceId: 'conclusion' }) + send(c, { action: 'mode', mode: 1 }) + } + send(c, { action: 'play', channel: 'music' }) + ack(c, 'music', 'playing', 17) + const seek = c.state.channels.music.seekOperation + send(c, entry) + assert.equal(c.state.channels.music.desired, 'paused') + assert.equal(c.state.channels.music.position, 17) + assert.equal(c.state.channels.music.seekOperation, seek) + ack(c, 'music', 'paused', 18) + send(c, { action: 'mode', mode: mode === 2 ? 4 : 2 }) + assert.equal(c.state.channels.music.desired, 'paused') + send(c, { action: 'text', text: 'Другой экран' }) + assert.equal(c.state.channels.music.desired, 'playing') + assert.equal(c.state.channels.music.position, 18) + assert.equal(c.state.channels.music.seekOperation, seek) + } + }) +} + +test('ручные команды музыки отменяют автопродолжение до следующего входа', () => { + const commands: Intent[] = [ + { action: 'pause', channel: 'music' }, { action: 'stop', channel: 'music' }, + { action: 'volume', channel: 'music', volume: 40 }, + { action: 'loop', channel: 'music', enabled: true }, + { action: 'seek', channel: 'music', position: 20 }, + { action: 'playlist', channel: 'music', playlistId: 1 }, + { action: 'track', channel: 'music', trackIndex: 1 }, + { action: 'next', channel: 'music' }, { action: 'resume', channel: 'music' }, + { action: 'play', channel: 'music' }, { action: 'replay', channel: 'music' }, + ] + for (const command of commands) { + const c = controller() + c.selectPackage(testPackage()) + send(c, { action: 'play', channel: 'music' }) + send(c, { action: 'mode', mode: 2 }) + send(c, command) + const music = structuredClone(c.state.channels.music) + send(c, { action: 'mode', mode: 4 }) + send(c, { action: 'mode', mode: 1 }) + assert.deepEqual(c.state.channels.music, music, command.action) + send(c, { action: 'resume', channel: 'music' }) + send(c, { action: 'mode', mode: 2 }) + assert.equal(c.state.channels.music.desired, 'paused') + } +}) + +test('автопауза не запускает остановленную музыку и сбрасывается с сессией, игрой и ошибкой', () => { + for (const initial of ['stop', 'pause'] as const) { + const c = controller() + c.selectPackage(testPackage()) + send(c, { action: initial, channel: 'music' }) + send(c, { action: 'mode', mode: 2 }) + send(c, { action: 'mode', mode: 1 }) + assert.equal(c.state.channels.music.desired, initial === 'stop' ? 'stopped' : 'paused') + } + for (const reset of ['session', 'game', 'error']) { + const c = controller() + c.selectPackage(testPackage()) + send(c, { action: 'play', channel: 'music' }) + send(c, { action: 'mode', mode: 2 }) + if (reset === 'session') { c.output(false); c.output(true) } + else if (reset === 'game') c.selectPackage(testPackage()) + else ack(c, 'music', 'error') + send(c, { action: 'mode', mode: 1 }) + assert.notEqual(c.state.channels.music.desired, 'playing') + } +}) + +test('серверная синхронизация не отменяет локальную паузу; новая музыкальная команда имеет приоритет', () => { + const music = { stateVersion: '1', playlist: { ID: 1, tracks: [{ ID: 1 }, { ID: 2 }] }, trackIndex: 0, playback: 2, positionMs: 10000, volume: 80, loop: true } + for (const initialMode of [1, 2, 4]) { + const snapshots: { mode: number; desired: string }[] = [] + const c = new Controller((state) => snapshots.push({ mode: state.scene.mode, desired: state.channels.music.desired })) + c.selectPackage(testPackage()) + c.output(true) + const start = parseRemote({ mode: initialMode, stateVersion: '1', music }) + applyRemote(c, parseRemote({}), start) + const intro = parseRemote({ mode: 2, stateVersion: '2', music }) + applyRemote(c, start, intro) + assert.equal(c.state.channels.music.desired, 'paused') + ack(c, 'music', 'paused', 11) + const operation = c.state.channels.music.operation + applyRemote(c, intro, intro) + assert.equal(c.state.channels.music.operation, operation) + assert.equal(snapshots.some(s => [2, 4].includes(s.mode) && s.desired === 'playing'), false) + const end = parseRemote({ mode: 1, stateVersion: '3', music }) + applyRemote(c, intro, end) + assert.equal(c.state.channels.music.desired, 'playing') + assert.equal(c.state.channels.music.position, 11) + const again = parseRemote({ mode: 4, stateVersion: '4', music }) + applyRemote(c, end, again) + const manualPause = parseRemote({ mode: 4, stateVersion: '4', music: { ...music, stateVersion: '2', playback: 3 } }) + applyRemote(c, again, manualPause) + const after = parseRemote({ mode: 1, stateVersion: '5', music: { ...music, stateVersion: '2', playback: 3 } }) + applyRemote(c, manualPause, after) + assert.equal(c.state.channels.music.desired, 'paused') + } +}) diff --git a/electron/controller.ts b/electron/controller.ts index c1dd1c1..b02345f 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -177,6 +177,9 @@ export class Controller { } private measured = new Map() private timerDeadline: number | null = null + private applyingRemote = false + private musicAutoPaused = false + private musicOverride = false constructor( private publish: (state: State) => void, private now: () => number = () => performance.now(), @@ -184,6 +187,7 @@ export class Controller { this.state.channels.main.source = this.state.catalog.media[0]! } private broadcast() { + if (this.applyingRemote) return this.state.revision++ this.publish(structuredClone(this.state)) } @@ -199,6 +203,8 @@ export class Controller { this.measured.delete(channel) } private newSession() { + this.musicAutoPaused = false + this.musicOverride = false this.updateTimer() this.state.session = randomUUID() this.state.sequence = 0 @@ -314,6 +320,8 @@ export class Controller { this.state.scene.error = '' } private mode(mode: ScreenMode, stepIndex: number | null = null, text = this.state.scene.text) { + if ([2, 4].includes(mode) && ![2, 4].includes(this.state.scene.mode)) + this.musicOverride = false this.state.scene = { ...this.state.scene, mode, stepIndex, text } const c = this.state.channels.main const kind = mode === 2 ? 'audio' : mode === 4 ? 'video' : null @@ -327,6 +335,36 @@ export class Controller { }) this.invalidateScene() } + private overrideMusic() { + this.musicAutoPaused = false + this.musicOverride = [2, 4].includes(this.state.scene.mode) + } + private reconcileSceneMusic() { + const music = this.state.channels.music + if ([2, 4].includes(this.state.scene.mode)) { + if (!this.musicOverride && music.source && music.desired === 'playing') { + this.musicAutoPaused = true + this.change('music', { desired: 'paused', status: 'pending' }) + } + } else { + if (this.musicAutoPaused && music.source && music.desired === 'paused') + this.change('music', { desired: 'playing', status: 'pending' }) + this.musicAutoPaused = false + this.musicOverride = false + } + } + synchronizeRemote(apply: () => void, musicChanged: boolean) { + // Применить снимок целиком, прежде чем публиковать локальную автопаузу. + this.applyingRemote = true + try { + apply() + } finally { + this.applyingRemote = false + if (musicChanged) this.overrideMusic() + this.reconcileSceneMusic() + this.broadcast() + } + } private configureTimer(label: string, seconds: number) { this.timerDeadline = null this.state.timer = { @@ -598,6 +636,10 @@ export class Controller { } } } + if (!this.applyingRemote) { + if ('channel' in command && command.channel === 'music') this.overrideMusic() + this.reconcileSceneMusic() + } this.state.sequence = command.sequence this.broadcast() } @@ -645,6 +687,7 @@ export class Controller { this.measured.delete(report.channel) } if (report.status === 'error') { + if (report.channel === 'music') this.overrideMusic() c.desired = 'paused' if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) { this.state.scene.status = 'error' diff --git a/electron/media-player.test.ts b/electron/media-player.test.ts index 28bd066..23c6e77 100644 --- a/electron/media-player.test.ts +++ b/electron/media-player.test.ts @@ -196,6 +196,7 @@ test('DOM: музыка независима, только следующий т assert.equal(spare.playCalls, 0) assert.equal(spare.muted, true) send(f.c, { action: 'play', channel: 'main' }) + send(f.c, { action: 'resume', channel: 'music' }) f.apply() await setImmediate() assert.equal(playing.paused, false) @@ -465,3 +466,35 @@ for (const mode of [2, 4]) { assert.equal(media.paused, true) }) } + +for (const mode of [2, 4] as const) { + test(`DOM: локальная автопауза на ${mode} затихает и продолжается с фактической позиции`, async (t) => { + const f = await fixture(t) + send(f.c, { action: 'volume', channel: 'music', volume: 80 }) + send(f.c, { action: 'play', channel: 'music' }) + f.apply() + await setImmediate() + const media = [f.a, f.b].find(m => !m.paused)! + media.currentTime = 12 + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 10000 }) + send(f.c, { action: 'mode', mode }) + send(f.c, { action: 'play', channel: 'main' }) + f.apply() + await setImmediate() + t.mock.timers.tick(500) + assert.equal(media.paused, false) + assert.ok(Math.abs(media.volume - 0.4) < 0.01) + assert.equal((mode === 2 ? f.audio : f.video).paused, false) + media.currentTime = 13 + t.mock.timers.tick(500) + await setImmediate() + assert.equal(media.paused, true) + f.c.report(f.reports.filter(report => report.channel === 'music' && report.status === 'paused').at(-1)!) + send(f.c, { action: 'mode', mode: 1 }) + f.apply() + await setImmediate() + assert.equal(media.paused, false) + assert.equal(media.currentTime, 13) + assert.equal(media.volume, 0.8) + }) +} diff --git a/electron/online.test.ts b/electron/online.test.ts index fbf5996..438da8d 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -1393,6 +1393,8 @@ test('постоянная связь: подключение без пакет test('дозагрузка нового плейлиста сохраняет музыку; смена текста не ждёт файл; новый трек запускается только локально', async (t) => { const f = await fixture(t) await f.prepare() + f.model.remote.mode = 1 + f.model.remote.command = 0 f.model.remote.music = objectMusic(1) f.controller.output(true) await f.online.poll() @@ -1655,7 +1657,7 @@ test('без истории и медиа текст, таймер и музык await f.online.poll() assert.equal(f.controller.state.scene.mode, 2) assert.equal(f.controller.state.channels.main.source, null) - assert.equal(f.controller.state.channels.music.desired, 'playing') + assert.equal(f.controller.state.channels.music.desired, 'paused') assert.equal(f.online.snapshot().connection, 'connected') assert.equal(f.online.snapshot().preparation.error, '') }) diff --git a/electron/remote-state.ts b/electron/remote-state.ts index 88578ee..b429617 100644 --- a/electron/remote-state.ts +++ b/electron/remote-state.ts @@ -214,6 +214,13 @@ export function send(controller: Controller, intent: Intent) { } // Сравниваем с предыдущим ответом сервера, а не с локальными действиями ведущего. export function applyRemote(controller: Controller, previous: RemoteState, next: RemoteState) { + const old = previous.music + const music = next.music + // Новая версия музыки означает отдельную серверную команду, даже при тех же значениях. + const musicChanged = !!old && !!music && music.version > old.version + controller.synchronizeRemote(() => applyRemoteState(controller, previous, next), musicChanged) +} +function applyRemoteState(controller: Controller, previous: RemoteState, next: RemoteState) { if (next.sound !== previous.sound) send(controller, { action: 'mute', enabled: !next.sound }) if (next.version > previous.version) { if (