up
This commit is contained in:
@@ -4,6 +4,7 @@ import { Controller, parseCommand } from './controller.js'
|
|||||||
import { buildScreenPackage } from './screen-package.js'
|
import { buildScreenPackage } from './screen-package.js'
|
||||||
import { packageKey, type LocalPackage } from './package-store.js'
|
import { packageKey, type LocalPackage } from './package-store.js'
|
||||||
import { parseRange } from './range.js'
|
import { parseRange } from './range.js'
|
||||||
|
import { applyRemote, parseRemote } from './remote-state.js'
|
||||||
import type { Channel, Intent, Report } from '../src/types/screen.js'
|
import type { Channel, Intent, Report } from '../src/types/screen.js'
|
||||||
|
|
||||||
function testPackage(): LocalPackage {
|
function testPackage(): LocalPackage {
|
||||||
@@ -155,6 +156,7 @@ test('музыка независима от основного канала, mu
|
|||||||
ack(c, 'music', 'playing', 15)
|
ack(c, 'music', 'playing', 15)
|
||||||
send(c, { action: 'select', channel: 'main', sourceId: 'conclusion' })
|
send(c, { action: 'select', channel: 'main', sourceId: 'conclusion' })
|
||||||
send(c, { action: 'play', channel: 'main' })
|
send(c, { action: 'play', channel: 'main' })
|
||||||
|
send(c, { action: 'resume', channel: 'music' })
|
||||||
ack(c, 'main', 'playing', 20)
|
ack(c, 'main', 'playing', 20)
|
||||||
const operation = c.state.channels.main.operation
|
const operation = c.state.channels.main.operation
|
||||||
send(c, { action: 'volume', channel: 'main', volume: 80 })
|
send(c, { action: 'volume', channel: 'main', volume: 80 })
|
||||||
@@ -257,3 +259,117 @@ test('обновление порядка сохраняет текущий фа
|
|||||||
send(c, { action: 'next', channel: 'music' })
|
send(c, { action: 'next', channel: 'music' })
|
||||||
assert.equal(c.state.channels.music.trackIndex, 0)
|
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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ export class Controller {
|
|||||||
}
|
}
|
||||||
private measured = new Map<Channel, { operation: number; startedAt: number }>()
|
private measured = new Map<Channel, { operation: number; startedAt: number }>()
|
||||||
private timerDeadline: number | null = null
|
private timerDeadline: number | null = null
|
||||||
|
private applyingRemote = false
|
||||||
|
private musicAutoPaused = false
|
||||||
|
private musicOverride = false
|
||||||
constructor(
|
constructor(
|
||||||
private publish: (state: State) => void,
|
private publish: (state: State) => void,
|
||||||
private now: () => number = () => performance.now(),
|
private now: () => number = () => performance.now(),
|
||||||
@@ -184,6 +187,7 @@ export class Controller {
|
|||||||
this.state.channels.main.source = this.state.catalog.media[0]!
|
this.state.channels.main.source = this.state.catalog.media[0]!
|
||||||
}
|
}
|
||||||
private broadcast() {
|
private broadcast() {
|
||||||
|
if (this.applyingRemote) return
|
||||||
this.state.revision++
|
this.state.revision++
|
||||||
this.publish(structuredClone(this.state))
|
this.publish(structuredClone(this.state))
|
||||||
}
|
}
|
||||||
@@ -199,6 +203,8 @@ export class Controller {
|
|||||||
this.measured.delete(channel)
|
this.measured.delete(channel)
|
||||||
}
|
}
|
||||||
private newSession() {
|
private newSession() {
|
||||||
|
this.musicAutoPaused = false
|
||||||
|
this.musicOverride = false
|
||||||
this.updateTimer()
|
this.updateTimer()
|
||||||
this.state.session = randomUUID()
|
this.state.session = randomUUID()
|
||||||
this.state.sequence = 0
|
this.state.sequence = 0
|
||||||
@@ -314,6 +320,8 @@ export class Controller {
|
|||||||
this.state.scene.error = ''
|
this.state.scene.error = ''
|
||||||
}
|
}
|
||||||
private mode(mode: ScreenMode, stepIndex: number | null = null, text = this.state.scene.text) {
|
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 }
|
this.state.scene = { ...this.state.scene, mode, stepIndex, text }
|
||||||
const c = this.state.channels.main
|
const c = this.state.channels.main
|
||||||
const kind = mode === 2 ? 'audio' : mode === 4 ? 'video' : null
|
const kind = mode === 2 ? 'audio' : mode === 4 ? 'video' : null
|
||||||
@@ -327,6 +335,36 @@ export class Controller {
|
|||||||
})
|
})
|
||||||
this.invalidateScene()
|
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) {
|
private configureTimer(label: string, seconds: number) {
|
||||||
this.timerDeadline = null
|
this.timerDeadline = null
|
||||||
this.state.timer = {
|
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.state.sequence = command.sequence
|
||||||
this.broadcast()
|
this.broadcast()
|
||||||
}
|
}
|
||||||
@@ -645,6 +687,7 @@ export class Controller {
|
|||||||
this.measured.delete(report.channel)
|
this.measured.delete(report.channel)
|
||||||
}
|
}
|
||||||
if (report.status === 'error') {
|
if (report.status === 'error') {
|
||||||
|
if (report.channel === 'music') this.overrideMusic()
|
||||||
c.desired = 'paused'
|
c.desired = 'paused'
|
||||||
if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) {
|
if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) {
|
||||||
this.state.scene.status = 'error'
|
this.state.scene.status = 'error'
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ test('DOM: музыка независима, только следующий т
|
|||||||
assert.equal(spare.playCalls, 0)
|
assert.equal(spare.playCalls, 0)
|
||||||
assert.equal(spare.muted, true)
|
assert.equal(spare.muted, true)
|
||||||
send(f.c, { action: 'play', channel: 'main' })
|
send(f.c, { action: 'play', channel: 'main' })
|
||||||
|
send(f.c, { action: 'resume', channel: 'music' })
|
||||||
f.apply()
|
f.apply()
|
||||||
await setImmediate()
|
await setImmediate()
|
||||||
assert.equal(playing.paused, false)
|
assert.equal(playing.paused, false)
|
||||||
@@ -465,3 +466,35 @@ for (const mode of [2, 4]) {
|
|||||||
assert.equal(media.paused, true)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1393,6 +1393,8 @@ test('постоянная связь: подключение без пакет
|
|||||||
test('дозагрузка нового плейлиста сохраняет музыку; смена текста не ждёт файл; новый трек запускается только локально', async (t) => {
|
test('дозагрузка нового плейлиста сохраняет музыку; смена текста не ждёт файл; новый трек запускается только локально', async (t) => {
|
||||||
const f = await fixture(t)
|
const f = await fixture(t)
|
||||||
await f.prepare()
|
await f.prepare()
|
||||||
|
f.model.remote.mode = 1
|
||||||
|
f.model.remote.command = 0
|
||||||
f.model.remote.music = objectMusic(1)
|
f.model.remote.music = objectMusic(1)
|
||||||
f.controller.output(true)
|
f.controller.output(true)
|
||||||
await f.online.poll()
|
await f.online.poll()
|
||||||
@@ -1655,7 +1657,7 @@ test('без истории и медиа текст, таймер и музык
|
|||||||
await f.online.poll()
|
await f.online.poll()
|
||||||
assert.equal(f.controller.state.scene.mode, 2)
|
assert.equal(f.controller.state.scene.mode, 2)
|
||||||
assert.equal(f.controller.state.channels.main.source, null)
|
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().connection, 'connected')
|
||||||
assert.equal(f.online.snapshot().preparation.error, '')
|
assert.equal(f.online.snapshot().preparation.error, '')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -214,6 +214,13 @@ export function send(controller: Controller, intent: Intent) {
|
|||||||
}
|
}
|
||||||
// Сравниваем с предыдущим ответом сервера, а не с локальными действиями ведущего.
|
// Сравниваем с предыдущим ответом сервера, а не с локальными действиями ведущего.
|
||||||
export function applyRemote(controller: Controller, previous: RemoteState, next: RemoteState) {
|
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.sound !== previous.sound) send(controller, { action: 'mute', enabled: !next.sound })
|
||||||
if (next.version > previous.version) {
|
if (next.version > previous.version) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in New Issue
Block a user