up
This commit is contained in:
@@ -335,8 +335,10 @@ test('автопауза не запускает остановленную му
|
||||
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())
|
||||
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')
|
||||
@@ -373,3 +375,33 @@ test('серверная синхронизация не отменяет лок
|
||||
assert.equal(c.state.channels.music.desired, 'paused')
|
||||
}
|
||||
})
|
||||
|
||||
for (const endMode of ['repeat', 'stop', 'next'] as const) {
|
||||
test(`локальная группа: ${endMode}, ручной выбор и остановка`, () => {
|
||||
const pkg = testPackage()
|
||||
pkg.package.playlists[0]!.groups = [
|
||||
{ id: 1, name: 'First', position: 0, endMode },
|
||||
{ id: 3, name: 'Empty', position: 1, endMode: 'repeat' },
|
||||
{ id: 2, name: 'Final', position: 2, endMode: 'stop' },
|
||||
]
|
||||
pkg.package.playlists[0]!.tracks[0]!.groupId = 1
|
||||
pkg.package.playlists[0]!.tracks[1]!.groupId = 2
|
||||
const c = new Controller(() => {})
|
||||
c.selectPackage(pkg)
|
||||
c.output(true)
|
||||
send(c, { action: 'group', channel: 'music', groupId: 1 })
|
||||
assert.equal(c.state.channels.music.desired, 'playing')
|
||||
assert.equal(c.state.channels.music.groupId, 1)
|
||||
assert.throws(() => send(c, { action: 'group', channel: 'music', groupId: 3 }), /пуста/)
|
||||
ack(c, 'music', 'ended', 4, 4)
|
||||
assert.equal(c.state.channels.music.groupId, endMode === 'next' ? 2 : 1)
|
||||
assert.equal(c.state.channels.music.trackIndex, endMode === 'next' ? 1 : 0)
|
||||
assert.equal(c.state.channels.music.desired, endMode === 'stop' ? 'stopped' : 'playing')
|
||||
send(c, { action: 'track', channel: 'music', trackIndex: 1 })
|
||||
assert.equal(c.state.channels.music.groupId, 2)
|
||||
send(c, { action: 'group', channel: 'music', groupId: 0 })
|
||||
assert.equal(c.state.channels.music.groupId, 0)
|
||||
assert.equal(c.state.channels.music.trackIndex, 0)
|
||||
assert.equal(c.state.channels.music.desired, 'playing')
|
||||
})
|
||||
}
|
||||
|
||||
+80
-7
@@ -1,3 +1,4 @@
|
||||
import { nextMusicTrack } from '../src/utils/musicGroups.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
@@ -104,6 +105,11 @@ export function parseCommand(value: unknown): Command {
|
||||
throw new Error('Некорректный плейлист')
|
||||
intent = { action: 'playlist', channel, playlistId: c.playlistId }
|
||||
break
|
||||
case 'group':
|
||||
if (channel !== 'music' || !number(c.groupId, 0) || !Number.isSafeInteger(c.groupId))
|
||||
throw new Error('Некорректная группа')
|
||||
intent = { action: 'group', channel, groupId: c.groupId }
|
||||
break
|
||||
case 'track':
|
||||
if (channel !== 'music' || !number(c.trackIndex, 0) || !Number.isSafeInteger(c.trackIndex))
|
||||
throw new Error('Некорректный трек')
|
||||
@@ -159,6 +165,7 @@ function initialChannel(): ChannelState {
|
||||
error: '',
|
||||
playlistId: null,
|
||||
trackIndex: 0,
|
||||
groupId: 0,
|
||||
loop: false,
|
||||
}
|
||||
}
|
||||
@@ -276,9 +283,11 @@ export class Controller {
|
||||
playlists: pkg.package.playlists.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
...(p.groups ? { groups: p.groups } : {}),
|
||||
tracks: p.tracks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
...(t.groupId ? { groupId: t.groupId } : {}),
|
||||
source: source(`track-${p.id}-${t.id}`, t.assetId, t.name, 'audio'),
|
||||
})),
|
||||
})),
|
||||
@@ -291,6 +300,22 @@ export class Controller {
|
||||
const tracks = catalog.playlists.find((p) => p.id === music.playlistId)?.tracks
|
||||
const index = tracks?.findIndex((t) => t.source.url === music.source?.url) ?? -1
|
||||
if (index >= 0) music.trackIndex = index
|
||||
const playlist = catalog.playlists.find((p) => p.id === music.playlistId)
|
||||
if (
|
||||
music.groupId &&
|
||||
(!playlist?.groups?.some((g) => g.id === music.groupId) ||
|
||||
playlist.tracks[index]?.groupId !== music.groupId)
|
||||
) {
|
||||
this.change('music', {
|
||||
groupId: 0,
|
||||
desired: 'stopped',
|
||||
status: 'stopped',
|
||||
position: 0,
|
||||
trackIndex: Math.max(index, 0),
|
||||
source: tracks?.[index]?.source ?? null,
|
||||
duration: null,
|
||||
})
|
||||
}
|
||||
this.state.catalog = catalog
|
||||
this.broadcast()
|
||||
return
|
||||
@@ -576,17 +601,34 @@ export class Controller {
|
||||
}
|
||||
private advance() {
|
||||
const c = this.state.channels.music
|
||||
const tracks = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks ?? []
|
||||
if (!tracks.length) throw new Error('Плейлист пуст')
|
||||
const next = c.trackIndex + 1
|
||||
if (next < tracks.length || c.loop) this.track(next % tracks.length)
|
||||
else
|
||||
const playlist = this.state.catalog.playlists.find((p) => p.id === c.playlistId)
|
||||
if (!playlist?.tracks.length) throw new Error('Плейлист пуст')
|
||||
const next = nextMusicTrack(playlist, c.trackIndex, c.groupId, c.loop)
|
||||
if (next) {
|
||||
c.groupId = next.groupId
|
||||
this.track(next.index)
|
||||
} else if (c.groupId) {
|
||||
this.change('music', { desired: 'stopped', status: 'stopped', position: 0 })
|
||||
} else {
|
||||
this.change('music', {
|
||||
desired: 'paused',
|
||||
status: 'ended',
|
||||
position: c.duration ?? c.position,
|
||||
})
|
||||
}
|
||||
}
|
||||
synchronizeMusicGroup(groupId: number) {
|
||||
const c = this.state.channels.music
|
||||
const playlist = this.state.catalog.playlists.find((p) => p.id === c.playlistId)
|
||||
if (
|
||||
groupId &&
|
||||
(!playlist?.groups?.some((group) => group.id === groupId) ||
|
||||
playlist.tracks[c.trackIndex]?.groupId !== groupId)
|
||||
)
|
||||
throw new Error('Группа ожидает подготовки локальных материалов')
|
||||
c.groupId = groupId
|
||||
}
|
||||
|
||||
command(input: unknown) {
|
||||
const command = parseCommand(input)
|
||||
if (command.session !== this.state.session || command.sequence <= this.state.sequence) return
|
||||
@@ -615,6 +657,7 @@ export class Controller {
|
||||
if (!playlist) throw new Error('Плейлист не найден в выбранном пакете')
|
||||
this.change(channel, {
|
||||
playlistId: playlist.id,
|
||||
groupId: 0,
|
||||
trackIndex: 0,
|
||||
source: playlist.tracks[0]?.source ?? null,
|
||||
position: 0,
|
||||
@@ -624,9 +667,38 @@ export class Controller {
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'track':
|
||||
case 'group': {
|
||||
const playlist = this.state.catalog.playlists.find((p) => p.id === c.playlistId)
|
||||
if (
|
||||
!playlist ||
|
||||
(command.groupId && !playlist.groups?.some((group) => group.id === command.groupId))
|
||||
)
|
||||
throw new Error('Группа не найдена')
|
||||
const index = command.groupId
|
||||
? playlist.tracks.findIndex((track) => track.groupId === command.groupId)
|
||||
: 0
|
||||
const track = playlist.tracks[index]
|
||||
if (!track) throw new Error('Группа пуста')
|
||||
this.change('music', {
|
||||
groupId: command.groupId,
|
||||
trackIndex: index,
|
||||
source: track.source,
|
||||
desired: 'playing',
|
||||
status: 'pending',
|
||||
position: 0,
|
||||
duration: null,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'track': {
|
||||
const track = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[
|
||||
command.trackIndex
|
||||
]
|
||||
if (!track) throw new Error('Трек не найден')
|
||||
c.groupId = track.groupId ?? 0
|
||||
this.track(command.trackIndex)
|
||||
break
|
||||
}
|
||||
case 'next':
|
||||
this.advance()
|
||||
break
|
||||
@@ -724,7 +796,8 @@ export class Controller {
|
||||
}
|
||||
if (report.status === 'error') {
|
||||
if (report.channel === 'music') this.overrideMusic()
|
||||
c.desired = 'paused'
|
||||
c.desired = report.channel === 'music' && c.groupId ? 'stopped' : 'paused'
|
||||
if (report.channel === 'music' && c.groupId) c.position = 0
|
||||
if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) {
|
||||
this.state.scene.status = 'error'
|
||||
this.state.scene.error = c.error
|
||||
|
||||
+1
-1
@@ -683,7 +683,7 @@ app
|
||||
const parsed = parseCommand(command)
|
||||
if (
|
||||
!output &&
|
||||
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next'].includes(parsed.action)
|
||||
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next', 'group'].includes(parsed.action)
|
||||
)
|
||||
throw new Error('Сначала откройте окно показа')
|
||||
if (parsed.session !== controller.state.session) return
|
||||
|
||||
@@ -498,3 +498,56 @@ for (const mode of [2, 4] as const) {
|
||||
assert.equal(media.volume, 0.8)
|
||||
})
|
||||
}
|
||||
|
||||
for (const endMode of ['repeat', 'stop', 'next'] as const) {
|
||||
test(`DOM: предзагрузка и окончание группы ${endMode}`, async (t) => {
|
||||
const f = await fixture(t)
|
||||
const playlist = f.c.state.catalog.playlists[0]!
|
||||
playlist.groups = [
|
||||
{ id: 1, name: 'First', position: 0, endMode },
|
||||
{ id: 2, name: 'Final', position: 1, endMode: 'stop' },
|
||||
]
|
||||
playlist.tracks[0]!.groupId = 1
|
||||
playlist.tracks[1]!.groupId = 2
|
||||
send(f.c, { action: 'group', channel: 'music', groupId: 1 })
|
||||
f.apply()
|
||||
await setImmediate()
|
||||
const active = [f.a, f.b].find((media) => !media.paused)!
|
||||
const spare = [f.a, f.b].find((media) => media !== active)!
|
||||
assert.ok(active)
|
||||
assert.equal(
|
||||
spare.src,
|
||||
endMode === 'stop' ? '' : playlist.tracks[endMode === 'next' ? 1 : 0]!.source.url,
|
||||
)
|
||||
active.finish()
|
||||
f.c.report(f.reports.at(-1)!)
|
||||
f.apply()
|
||||
await setImmediate()
|
||||
assert.equal(f.c.state.channels.music.groupId, endMode === 'next' ? 2 : 1)
|
||||
assert.equal(f.c.state.channels.music.desired, endMode === 'stop' ? 'stopped' : 'playing')
|
||||
assert.equal([f.a, f.b].filter((media) => !media.paused).length, endMode === 'stop' ? 0 : 1)
|
||||
})
|
||||
}
|
||||
|
||||
test('DOM: отсутствующий следующий файл останавливает выбранную группу с ошибкой', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const playlist = f.c.state.catalog.playlists[0]!
|
||||
playlist.groups = [{ id: 1, name: 'First', position: 0, endMode: 'repeat' }]
|
||||
playlist.tracks[0]!.groupId = 1
|
||||
playlist.tracks[1]!.groupId = 1
|
||||
playlist.tracks[1]!.source.ready = false
|
||||
send(f.c, { action: 'group', channel: 'music', groupId: 1 })
|
||||
f.apply()
|
||||
await setImmediate()
|
||||
const active = [f.a, f.b].find((media) => !media.paused)!
|
||||
active.finish()
|
||||
f.c.report(f.reports.at(-1)!)
|
||||
f.apply()
|
||||
await setImmediate()
|
||||
const failed = [f.a, f.b].find((media) => media.src === playlist.tracks[1]!.source.url)!
|
||||
failed.dispatchEvent(new Event('error'))
|
||||
assert.equal(f.reports.at(-1)!.status, 'error')
|
||||
f.c.report(f.reports.at(-1)!)
|
||||
assert.equal(f.c.state.channels.music.groupId, 1)
|
||||
assert.equal(f.c.state.channels.music.desired, 'stopped')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { nextMusicTrack } from '../src/utils/musicGroups.js'
|
||||
import { parsePlaylistGroups } from './playlist-groups.js'
|
||||
import type { MusicGroup } from '../src/types/screen.js'
|
||||
|
||||
const groups: MusicGroup[] = [
|
||||
{ id: 1, name: 'First', position: 0, endMode: 'repeat' },
|
||||
{ id: 2, name: 'Empty', position: 1, endMode: 'repeat' },
|
||||
{ id: 3, name: 'Final', position: 2, endMode: 'stop' },
|
||||
]
|
||||
for (const tc of [
|
||||
{ name: 'within group', index: 0, groupId: 1, mode: 'stop', want: { index: 2, groupId: 1 } },
|
||||
{ name: 'repeat', index: 2, groupId: 1, mode: 'repeat', want: { index: 0, groupId: 1 } },
|
||||
{ name: 'stop overrides whole loop', index: 2, groupId: 1, mode: 'stop', want: null },
|
||||
{ name: 'skip empty', index: 2, groupId: 1, mode: 'next', want: { index: 1, groupId: 3 } },
|
||||
{ name: 'last stops', index: 1, groupId: 3, mode: 'repeat', want: null },
|
||||
{ name: 'whole playlist', index: 0, groupId: 0, mode: 'stop', want: { index: 1, groupId: 0 } },
|
||||
{
|
||||
name: 'whole playlist loops',
|
||||
index: 2,
|
||||
groupId: 0,
|
||||
mode: 'stop',
|
||||
want: { index: 0, groupId: 0 },
|
||||
},
|
||||
] as const) {
|
||||
test(`next music: ${tc.name}`, () => {
|
||||
const playlist = {
|
||||
tracks: [{ groupId: 1 }, { groupId: 3 }, { groupId: 1 }],
|
||||
groups: groups.map((group) => (group.id === 1 ? { ...group, endMode: tc.mode } : group)),
|
||||
}
|
||||
assert.deepEqual(nextMusicTrack(playlist, tc.index, tc.groupId, true), tc.want)
|
||||
})
|
||||
}
|
||||
test('group parser normalizes omitted repeat and sorts by position', () => {
|
||||
assert.deepEqual(
|
||||
parsePlaylistGroups([
|
||||
{ ID: 2, name: 'B', position: 5, endMode: 2 },
|
||||
{ ID: 1, name: 'A' },
|
||||
]).map((group) => [group.id, group.endMode]),
|
||||
[
|
||||
[1, 'repeat'],
|
||||
[2, 'next'],
|
||||
],
|
||||
)
|
||||
for (const value of [
|
||||
[{ ID: 1, name: 'A', endMode: 'unknown' }],
|
||||
[
|
||||
{ ID: 1, name: 'A' },
|
||||
{ ID: 1, name: 'B' },
|
||||
],
|
||||
[{ ID: 1, name: 'A', position: -1 }],
|
||||
])
|
||||
assert.throws(() => parsePlaylistGroups(value))
|
||||
})
|
||||
+295
-21
@@ -43,7 +43,12 @@ async function fixture(t: TestContext, secure = true) {
|
||||
{ ID: 2, name: 'Два' },
|
||||
],
|
||||
},
|
||||
],
|
||||
] as {
|
||||
ID: number
|
||||
name: string
|
||||
groups?: { ID: number; name: string; position: number; endMode?: string }[]
|
||||
tracks: { ID: number; name: string; groupId?: number }[]
|
||||
}[],
|
||||
playlistError: 0,
|
||||
upload: null as { filename: string; data: Buffer } | null,
|
||||
bindings: [] as Record<string, unknown>[],
|
||||
@@ -67,6 +72,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
refreshCalls: 0,
|
||||
refreshResponse: null as (() => Promise<Response>) | null,
|
||||
advanceCalls: 0,
|
||||
advanceHandler: null as (() => Promise<Response>) | null,
|
||||
advanceRequest: null as Record<string, unknown> | null,
|
||||
advanceResponse: null as Record<string, unknown> | null,
|
||||
advanceError: 0,
|
||||
@@ -144,6 +150,7 @@ async function fixture(t: TestContext, secure = true) {
|
||||
if (path === '/api/screens/music/advance') {
|
||||
model.advanceCalls++
|
||||
model.advanceRequest = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
if (model.advanceHandler) return model.advanceHandler()
|
||||
if (model.advanceError) return json({}, model.advanceError)
|
||||
return json({ state: model.advanceResponse ?? objectMusic(3, 1) })
|
||||
}
|
||||
@@ -260,6 +267,8 @@ async function fixture(t: TestContext, secure = true) {
|
||||
const state = objectMusic(20, Number(body.trackIndex ?? 0))
|
||||
model.remote.music = {
|
||||
...state,
|
||||
playlist:
|
||||
(model.remote.music as { playlist?: unknown } | undefined)?.playlist ?? state.playlist,
|
||||
playback: body.playback,
|
||||
volume: body.volume,
|
||||
loop: body.loop,
|
||||
@@ -275,11 +284,13 @@ async function fixture(t: TestContext, secure = true) {
|
||||
{
|
||||
ID: 1,
|
||||
name: 'Музыка',
|
||||
groups: model.library[0]?.groups,
|
||||
tracks: [0, 1].map((i) => ({
|
||||
ID: i + 1,
|
||||
name: `Трек ${i}`,
|
||||
filename: `https://media.test/${i}.wav`,
|
||||
position: i,
|
||||
groupId: model.library[0]?.tracks.find((track) => track.ID === i + 1)?.groupId,
|
||||
})),
|
||||
},
|
||||
],
|
||||
@@ -311,6 +322,35 @@ async function fixture(t: TestContext, secure = true) {
|
||||
}
|
||||
if (path === '/api/playlists/1' && init?.method === 'PUT') model.library[0]!.name = data.name
|
||||
if (path === '/api/playlists/2' && init?.method === 'DELETE') model.library.pop()
|
||||
const playlist = model.library[0]!
|
||||
if (path === '/api/playlists/1/groups' && init?.method === 'POST') {
|
||||
playlist.groups ??= []
|
||||
const current = playlist.groups.find((group) => group.ID === data.groupID)
|
||||
if (current) Object.assign(current, { name: data.name, endMode: data.endMode })
|
||||
else
|
||||
playlist.groups.push({
|
||||
ID: Math.max(0, ...playlist.groups.map((group) => group.ID)) + 1,
|
||||
name: data.name,
|
||||
position: playlist.groups.length,
|
||||
endMode: data.endMode,
|
||||
})
|
||||
}
|
||||
if (path === '/api/playlists/1/groups/order') {
|
||||
playlist.groups = data.groupIDs.map((id: number, position: number) => ({
|
||||
...playlist.groups!.find((group) => group.ID === id)!,
|
||||
position,
|
||||
}))
|
||||
}
|
||||
if (/^\/api\/playlists\/1\/groups\/\d+$/.test(path) && init?.method === 'DELETE') {
|
||||
const groupId = Number(path.split('/').at(-1))
|
||||
playlist.groups = playlist.groups?.filter((group) => group.ID !== groupId)
|
||||
playlist.tracks.forEach((track) => {
|
||||
if (track.groupId === groupId) delete track.groupId
|
||||
})
|
||||
}
|
||||
if (/^\/api\/playlists\/1\/tracks\/\d+\/group$/.test(path))
|
||||
playlist.tracks.find((track) => track.ID === Number(path.split('/')[5]))!.groupId =
|
||||
data.groupID
|
||||
if (path === '/api/playlists/1/tracks/order') {
|
||||
const tracks = model.library[0]!.tracks
|
||||
model.library[0]!.tracks = data.trackIDs.map((id: number) =>
|
||||
@@ -679,17 +719,26 @@ test('отмена подготовки не ждёт общий refresh и не
|
||||
}
|
||||
const cancelled = f.online.act({ action: 'check', gameId: 7 }).then(
|
||||
() => 'completed',
|
||||
(error: Error) => { assert.match(error.message, /отмен|ABORTED/); return 'cancelled' },
|
||||
(error: Error) => {
|
||||
assert.match(error.message, /отмен|ABORTED/)
|
||||
return 'cancelled'
|
||||
},
|
||||
)
|
||||
await refreshStarted
|
||||
const background = f.online.refreshPlan()
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
await f.online.act({ action: 'cancel' })
|
||||
try {
|
||||
assert.equal(await Promise.race([
|
||||
cancelled,
|
||||
new Promise<string>((resolve) => { const timer = setTimeout(() => resolve('still waiting'), 1000); timer.unref() }),
|
||||
]), 'cancelled')
|
||||
assert.equal(
|
||||
await Promise.race([
|
||||
cancelled,
|
||||
new Promise<string>((resolve) => {
|
||||
const timer = setTimeout(() => resolve('still waiting'), 1000)
|
||||
timer.unref()
|
||||
}),
|
||||
]),
|
||||
'cancelled',
|
||||
)
|
||||
assert.equal(f.model.refreshed, false)
|
||||
} finally {
|
||||
release()
|
||||
@@ -2247,46 +2296,271 @@ test('список команд обновляется, сохраняется
|
||||
assert.equal(c.state.teams, null)
|
||||
})
|
||||
|
||||
function groupedMusic(version: number, index = 0, endMode = 'PLAYLIST_GROUP_END_MODE_REPEAT') {
|
||||
return {
|
||||
...objectMusic(version, index),
|
||||
groupId: index === 0 ? 42 : 43,
|
||||
playlist: {
|
||||
ID: 1,
|
||||
name: 'Группы',
|
||||
groups: [
|
||||
{ ID: 42, name: 'Сбор', position: 0, endMode },
|
||||
{ ID: 44, name: 'Пустая', position: 1 },
|
||||
{ ID: 43, name: 'Финал', position: 2, endMode: 'PLAYLIST_GROUP_END_MODE_STOP' },
|
||||
],
|
||||
tracks: [0, 1].map((i) => ({
|
||||
ID: i + 1,
|
||||
name: `Трек ${i}`,
|
||||
filename: `https://media.test/${i}.wav`,
|
||||
position: i,
|
||||
groupId: i === 0 ? 42 : 43,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ['volume', 'pause', 'resume', 'replay'] as const) {
|
||||
test(`группа сохраняется при команде ${action} нативного плеера`, async (t) => {
|
||||
const f = await fixture(t)
|
||||
const music = groupedMusic(2)
|
||||
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
||||
f.model.remote.music = music
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
f.model.remote.music = { ...objectMusic(2), groupId: 42 }
|
||||
await f.online.poll()
|
||||
await f.online.planCommand(action === 'volume' ? { action, channel: 'music', volume: 55 } : { action, channel: 'music' })
|
||||
assert.equal((f.model.remote.music as { groupId: number }).groupId, 42)
|
||||
await assert.rejects(f.online.planCommand({ action: 'next', channel: 'music' }), /активной группы/)
|
||||
await assert.rejects(f.online.planCommand({ action: 'track', channel: 'music', trackIndex: 1 }), /активной группы/)
|
||||
await assert.rejects(f.online.planCommand({ action: 'loop', channel: 'music', enabled: true }), /активной группы/)
|
||||
await assert.rejects(
|
||||
f.online.planCommand({ action: 'loop', channel: 'music', enabled: true }),
|
||||
/редакторе/,
|
||||
)
|
||||
await f.online.planCommand({ action: 'track', channel: 'music', trackIndex: 1 })
|
||||
assert.equal(f.controller.state.channels.music.groupId, 43)
|
||||
})
|
||||
}
|
||||
|
||||
test('повтор одного трека группы передаёт точную версию и перезапускает трек', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const music = { ...groupedMusic(2), stateVersion: '9007199254740995', positionMs: 0 }
|
||||
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
||||
f.model.remote.music = music
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
const version = '9007199254740995'
|
||||
f.model.remote.music = { ...objectMusic(2), stateVersion: version, groupId: 42, positionMs: 0 }
|
||||
await f.online.poll()
|
||||
send(f.controller, { action: 'play', channel: 'music' })
|
||||
const before = f.controller.state.channels.music.operation
|
||||
f.model.advanceResponse = { ...objectMusic(3), stateVersion: '9007199254740996', groupId: 42, positionMs: 0 }
|
||||
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: before, status: 'ended', position: 0.1, duration: 0.1 })
|
||||
assert.equal(f.model.advanceRequest?.expectedStateVersion, version)
|
||||
f.model.advanceResponse = { ...groupedMusic(3), stateVersion: '9007199254740996', positionMs: 0 }
|
||||
await f.online.musicEnded({
|
||||
session: f.controller.state.session,
|
||||
channel: 'music',
|
||||
operation: before,
|
||||
status: 'ended',
|
||||
position: 0.1,
|
||||
duration: 0.1,
|
||||
})
|
||||
assert.equal(f.model.advanceRequest?.expectedStateVersion, music.stateVersion)
|
||||
assert.ok(f.controller.state.channels.music.operation > before)
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, 0)
|
||||
assert.equal(f.controller.state.channels.music.groupId, 42)
|
||||
})
|
||||
|
||||
test('группа останавливается при потере связи вместо выхода в общий список', async (t) => {
|
||||
for (const [mode, index, groupId, desired] of [
|
||||
['PLAYLIST_GROUP_END_MODE_REPEAT', 0, 42, 'playing'],
|
||||
['PLAYLIST_GROUP_END_MODE_STOP', 0, 42, 'stopped'],
|
||||
['PLAYLIST_GROUP_END_MODE_NEXT', 1, 43, 'playing'],
|
||||
] as const) {
|
||||
test(`офлайн-завершение группы: ${mode}`, async (t) => {
|
||||
const f = await fixture(t)
|
||||
const music = groupedMusic(2, 0, mode)
|
||||
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
||||
f.model.remote.music = music
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
send(f.controller, { action: 'play', channel: 'music' })
|
||||
f.model.advanceError = 503
|
||||
await f.online.musicEnded({
|
||||
session: f.controller.state.session,
|
||||
channel: 'music',
|
||||
operation: f.controller.state.channels.music.operation,
|
||||
status: 'ended',
|
||||
position: 4,
|
||||
duration: 4,
|
||||
})
|
||||
assert.equal(f.controller.state.channels.music.desired, desired)
|
||||
assert.equal(f.controller.state.channels.music.groupId, groupId)
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, index)
|
||||
await f.online.poll()
|
||||
assert.equal(
|
||||
f.controller.state.channels.music.groupId,
|
||||
42,
|
||||
'server wins on reconnect even with unchanged music version',
|
||||
)
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('редактор сохраняет группы и переносит треки через существующие API', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.login()
|
||||
await f.online.act({
|
||||
action: 'group-save',
|
||||
playlistId: 1,
|
||||
groupId: 0,
|
||||
name: 'Сбор',
|
||||
endMode: 'repeat',
|
||||
})
|
||||
await f.online.act({
|
||||
action: 'group-save',
|
||||
playlistId: 1,
|
||||
groupId: 0,
|
||||
name: 'Финал',
|
||||
endMode: 'stop',
|
||||
})
|
||||
const groups = f.online.snapshot().playlists[0]!.groups!
|
||||
assert.deepEqual(
|
||||
groups.map((g) => g.endMode),
|
||||
['repeat', 'stop'],
|
||||
)
|
||||
await f.online.act({ action: 'track-group', playlistId: 1, trackId: 1, groupId: groups[0]!.id })
|
||||
assert.equal(f.online.snapshot().playlists[0]!.tracks[0]!.groupId, groups[0]!.id)
|
||||
await f.online.act({
|
||||
action: 'group-reorder',
|
||||
playlistId: 1,
|
||||
groupIds: [groups[1]!.id, groups[0]!.id],
|
||||
})
|
||||
assert.equal(f.online.snapshot().playlists[0]!.groups![0]!.name, 'Финал')
|
||||
await f.online.act({
|
||||
action: 'group-save',
|
||||
playlistId: 1,
|
||||
groupId: groups[0]!.id,
|
||||
name: 'Гости',
|
||||
endMode: 'next',
|
||||
})
|
||||
assert.equal(f.online.snapshot().playlists[0]!.groups![1]!.endMode, 'next')
|
||||
await f.online.act({ action: 'group-delete', playlistId: 1, groupId: groups[0]!.id })
|
||||
assert.equal(f.online.snapshot().playlists[0]!.tracks[0]!.groupId, undefined)
|
||||
assert.equal(f.online.snapshot().playlists[0]!.tracks.length, 2)
|
||||
})
|
||||
|
||||
test('запоздалая ошибка подготовки группы не останавливает новую группу', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const music = groupedMusic(2)
|
||||
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
||||
f.model.remote.music = music
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
const pkg = f.store.description(f.store.list()[0]!.key)!.package
|
||||
const target = pkg.playlists[0]!.tracks[0]!.assetId
|
||||
const canRead = f.store.canRead.bind(f.store),
|
||||
ensure = f.store.ensureAsset.bind(f.store)
|
||||
let reject!: (error: Error) => void,
|
||||
started = false,
|
||||
shouldDefer = true
|
||||
const pending = new Promise<void>((_resolve, failure) => {
|
||||
reject = failure
|
||||
})
|
||||
f.store.canRead = (key, id) => (id === target && shouldDefer ? false : canRead(key, id))
|
||||
f.store.ensureAsset = (...args) => {
|
||||
if (args[1] === target && shouldDefer) {
|
||||
shouldDefer = false
|
||||
started = true
|
||||
return pending
|
||||
}
|
||||
return ensure(...args)
|
||||
}
|
||||
f.model.offline = true
|
||||
const old = f.online.planCommand({ action: 'group', channel: 'music', groupId: 42 })
|
||||
await eventually(() => started, 'старая загрузка началась')
|
||||
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 43 })
|
||||
const operation = f.controller.state.channels.music.operation
|
||||
reject(new Error('old preparation failure'))
|
||||
await old
|
||||
assert.equal(f.controller.state.channels.music.groupId, 43)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
assert.equal(f.controller.state.channels.music.operation, operation)
|
||||
})
|
||||
|
||||
test('запоздалая ошибка офлайн-перехода не останавливает новый запуск', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const music = groupedMusic(2, 0, 'PLAYLIST_GROUP_END_MODE_NEXT')
|
||||
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
|
||||
f.model.remote.music = music
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
f.model.remote.music = { ...objectMusic(2), groupId: 42 }
|
||||
await f.online.poll()
|
||||
send(f.controller, { action: 'play', channel: 'music' })
|
||||
const pkg = f.store.description(f.store.list()[0]!.key)!.package
|
||||
const target = pkg.playlists[0]!.tracks[1]!.assetId
|
||||
const ensure = f.store.ensureAsset.bind(f.store)
|
||||
let reject!: (error: Error) => void,
|
||||
started = false,
|
||||
shouldDefer = true
|
||||
const pending = new Promise<void>((_resolve, failure) => {
|
||||
reject = failure
|
||||
})
|
||||
f.store.ensureAsset = (...args) => {
|
||||
if (args[1] === target && shouldDefer) {
|
||||
shouldDefer = false
|
||||
started = true
|
||||
return pending
|
||||
}
|
||||
return ensure(...args)
|
||||
}
|
||||
f.model.advanceError = 503
|
||||
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: f.controller.state.channels.music.operation, status: 'ended', position: 4, duration: 4 })
|
||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, 0)
|
||||
const old = f.online.musicEnded({
|
||||
session: f.controller.state.session,
|
||||
channel: 'music',
|
||||
operation: f.controller.state.channels.music.operation,
|
||||
status: 'ended',
|
||||
position: 4,
|
||||
duration: 4,
|
||||
})
|
||||
await eventually(() => started, 'офлайн-переход готовит файл')
|
||||
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 42 })
|
||||
const operation = f.controller.state.channels.music.operation
|
||||
reject(new Error('old next failure'))
|
||||
await old
|
||||
assert.equal(f.controller.state.channels.music.groupId, 42)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'playing')
|
||||
assert.equal(f.controller.state.channels.music.operation, operation)
|
||||
assert.equal(f.online.snapshot().connection, 'connected')
|
||||
})
|
||||
|
||||
test('ожидающее окончание старой группы не блокирует окончание новой короткой группы', async (t) => {
|
||||
const f = await fixture(t); const music = groupedMusic(2)
|
||||
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }; f.model.remote.music = music
|
||||
await f.prepare(); f.controller.output(true); await f.online.poll()
|
||||
send(f.controller, { action: 'play', channel: 'music' })
|
||||
let resolve!: (response: Response) => void
|
||||
const pending = new Promise<Response>((done) => { resolve = done })
|
||||
let calls = 0
|
||||
f.model.advanceHandler = () => ++calls === 1 ? pending : Promise.resolve(Response.json({ state: { ...groupedMusic(21, 1), playback: 'MUSIC_PLAYBACK_STOPPED', positionMs: 0 } }))
|
||||
const report = () => ({ session: f.controller.state.session, channel: 'music' as const, operation: f.controller.state.channels.music.operation, status: 'ended' as const, position: .1, duration: .1 })
|
||||
const old = f.online.musicEnded(report())
|
||||
await eventually(() => calls === 1, 'старый RPC ожидает ответа')
|
||||
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 43 })
|
||||
await f.online.musicEnded(report())
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(f.controller.state.channels.music.groupId, 43)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||
resolve(Response.json({ state: groupedMusic(3) })); await old
|
||||
assert.equal(f.controller.state.channels.music.groupId, 43)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||
})
|
||||
|
||||
test('группа без подготовленного локального описания не продолжает общий плейлист', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare(); f.controller.output(true); await f.online.poll()
|
||||
send(f.controller, { action: 'play', channel: 'music' })
|
||||
assert.equal(f.controller.state.channels.music.groupId, 0)
|
||||
f.model.remote.music = groupedMusic(2)
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||
assert.match(f.online.snapshot().preparation.error, /Группа/)
|
||||
f.model.offline = true
|
||||
await f.online.poll()
|
||||
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: f.controller.state.channels.music.operation, status: 'ended', position: 4, duration: 4 })
|
||||
assert.equal(f.controller.state.channels.music.trackIndex, 0)
|
||||
assert.equal(f.model.advanceCalls, 0)
|
||||
})
|
||||
|
||||
+141
-32
@@ -1,3 +1,5 @@
|
||||
import { nextMusicTrack } from '../src/utils/musicGroups.js'
|
||||
import { parsePlaylistGroups, apiGroupEndMode } from './playlist-groups.js'
|
||||
import { requestErrorContext } from './request-error.js'
|
||||
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
||||
import { apiScreenMode, apiScreenCommand, apiPlan, apiTimerType } from './api-values.js'
|
||||
@@ -369,6 +371,7 @@ export class Online {
|
||||
const channel = this.controller.state.channels.music
|
||||
return (
|
||||
next.music?.playlistId === channel.playlistId &&
|
||||
next.music?.groupId === channel.groupId &&
|
||||
(id ? `screen-app://app/packages/${this.selectedGame()?.key}/${id}` : undefined) ===
|
||||
channel.source?.url
|
||||
)
|
||||
@@ -461,9 +464,17 @@ export class Online {
|
||||
if (next.command === 0) this.controller.markStep(this.plan.currentStep)
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === this.generation && !this.materialAbort.signal.aborted)
|
||||
this.state.preparation.error =
|
||||
error instanceof Error ? error.message : 'Материал не подготовлен'
|
||||
if (
|
||||
generation === this.generation &&
|
||||
session === this.controller.state.session &&
|
||||
version === this.planVersion &&
|
||||
!this.materialAbort.signal.aborted
|
||||
) {
|
||||
const current = this.controller.state.channels.music
|
||||
if (music && next.music && next.music.groupId !== current.groupId && current.source && this.received?.music?.version === next.music.version)
|
||||
send(this.controller, { action: 'stop', channel: 'music' })
|
||||
this.state.preparation.error = error instanceof Error ? error.message : 'Материал не подготовлен'
|
||||
}
|
||||
}
|
||||
}
|
||||
private synchronize() {
|
||||
@@ -490,7 +501,7 @@ export class Online {
|
||||
private teamsBusy = false
|
||||
private lastResults = -Infinity
|
||||
private resultsBusy = false
|
||||
private advanceBusy = false
|
||||
private musicAdvance: { session: string; operation: number } | null = null
|
||||
private plan: ScreenPlan = { steps: [], currentStep: 0 }
|
||||
private planBusy = 0
|
||||
private planCommands: Promise<boolean> = Promise.resolve(true)
|
||||
@@ -603,8 +614,12 @@ export class Online {
|
||||
: intent.action === 'playlist'
|
||||
? 0
|
||||
: channel.trackIndex
|
||||
if (intent.action === 'group' && playlist)
|
||||
index = intent.groupId
|
||||
? playlist.tracks.findIndex((track) => track.groupId === intent.groupId)
|
||||
: 0
|
||||
if (intent.action === 'next' && playlist)
|
||||
index = (index + 1) % Math.max(1, playlist.tracks.length)
|
||||
index = nextMusicTrack(playlist, index, channel.groupId, channel.loop)?.index ?? -1
|
||||
const track = playlist?.tracks[index]
|
||||
return track ? [track.assetId] : []
|
||||
}
|
||||
@@ -624,14 +639,13 @@ export class Online {
|
||||
async planCommand(intent: Intent) {
|
||||
if (this.busy) throw new Error('Подождите завершения текущего действия')
|
||||
const pkg = this.selectedGame()
|
||||
const activeMusic = this.received?.music
|
||||
// shortcut: native track navigation and completion controls have no group catalogue; use website group controls until it does.
|
||||
if (
|
||||
activeMusic?.groupId &&
|
||||
'channel' in intent && intent.channel === 'music' &&
|
||||
(intent.action === 'next' || intent.action === 'track' || intent.action === 'loop')
|
||||
'channel' in intent &&
|
||||
intent.channel === 'music' &&
|
||||
intent.action === 'loop' &&
|
||||
this.controller.state.channels.music.groupId
|
||||
)
|
||||
throw new Error('Треки и режим завершения активной группы меняются через сайт. Для обычного списка выберите весь плейлист.')
|
||||
throw new Error('Режим завершения группы меняется в редакторе')
|
||||
const localVersion = ++this.localCommandVersion
|
||||
const localGeneration = this.generation
|
||||
const localSession = this.controller.state.session
|
||||
@@ -641,9 +655,25 @@ export class Online {
|
||||
this.controller.state.scene.status === 'error' ||
|
||||
('channel' in intent && this.controller.state.channels[intent.channel].status === 'error')
|
||||
if (hasError || ids.some((id) => !this.packages.canRead(pkg.key, id))) {
|
||||
await Promise.all(
|
||||
ids.map((id) => this.packages.ensureAsset(pkg.package, id, this.materialAbort.signal)),
|
||||
)
|
||||
try {
|
||||
await Promise.all(
|
||||
ids.map((id) => this.packages.ensureAsset(pkg.package, id, this.materialAbort.signal)),
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
localVersion !== this.localCommandVersion ||
|
||||
localGeneration !== this.generation ||
|
||||
localSession !== this.controller.state.session
|
||||
)
|
||||
return
|
||||
if (
|
||||
'channel' in intent &&
|
||||
intent.channel === 'music' &&
|
||||
(intent.action === 'group' || this.controller.state.channels.music.groupId)
|
||||
)
|
||||
send(this.controller, { action: 'stop', channel: 'music' })
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
localVersion !== this.localCommandVersion ||
|
||||
localGeneration !== this.generation ||
|
||||
@@ -679,9 +709,18 @@ export class Online {
|
||||
if (generation !== this.generation || session !== this.controller.state.session) return
|
||||
if (
|
||||
version !== this.planVersion &&
|
||||
['step', 'mode', 'text', 'qr', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
|
||||
intent.action,
|
||||
)
|
||||
[
|
||||
'step',
|
||||
'mode',
|
||||
'text',
|
||||
'qr',
|
||||
'select',
|
||||
'timer-set',
|
||||
'playlist',
|
||||
'track',
|
||||
'next',
|
||||
'group',
|
||||
].includes(intent.action)
|
||||
)
|
||||
return
|
||||
if (hasPendingCommand && !wasAvailable) {
|
||||
@@ -715,7 +754,7 @@ export class Online {
|
||||
await this.client(true, AbortSignal.timeout(5000)).SetGameMusic({
|
||||
gameID: pkg.package.game.id,
|
||||
playlistID: c.playlistId ?? 0,
|
||||
groupId: c.playlistId === activeMusic?.playlistId ? activeMusic.groupId : 0,
|
||||
groupId: c.groupId,
|
||||
trackIndex: c.trackIndex,
|
||||
positionMs: Math.max(0, Math.round(c.position * 1000)),
|
||||
volume: Math.round(c.volume),
|
||||
@@ -876,6 +915,8 @@ export class Online {
|
||||
remote.music?.playlistId,
|
||||
remote.music?.trackIds,
|
||||
remote.music?.filenames,
|
||||
remote.music?.groups,
|
||||
remote.music?.trackGroupIds,
|
||||
])
|
||||
if (!('channel' in intent && intent.channel === 'music')) {
|
||||
if (needsMainSync) {
|
||||
@@ -897,6 +938,7 @@ export class Online {
|
||||
m.playlistId === c.playlistId &&
|
||||
this.musicSourceMatches(remote) &&
|
||||
m.trackId === expectedTrack &&
|
||||
m.groupId === c.groupId &&
|
||||
m.volume === Math.round(c.volume) &&
|
||||
m.loop === c.loop &&
|
||||
m.playback === (c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1) &&
|
||||
@@ -921,6 +963,8 @@ export class Online {
|
||||
this.previous = null
|
||||
this.planNotice = 'Команда выполнена локально; сервер не подтвердил обновление'
|
||||
if (!(error instanceof NetworkError)) throw error
|
||||
if ('channel' in intent && intent.channel === 'music') this.musicApplied = null
|
||||
this.state.connection = 'offline'
|
||||
} finally {
|
||||
this.planBusy--
|
||||
complete(isAvailable)
|
||||
@@ -1302,6 +1346,10 @@ export class Online {
|
||||
'playlist-use': 'Подключение плейлиста к игре…',
|
||||
'playlist-track-delete': 'Удаление трека…',
|
||||
'playlist-reorder': 'Сохранение порядка треков…',
|
||||
'group-save': 'Сохранение группы…',
|
||||
'group-delete': 'Удаление группы…',
|
||||
'group-reorder': 'Сохранение порядка групп…',
|
||||
'track-group': 'Перенос трека…',
|
||||
'plan-save': 'Сохранение плана экранов…',
|
||||
'plan-refresh': 'Загрузка плана экранов…',
|
||||
login: 'Вход на сервер…',
|
||||
@@ -1364,6 +1412,10 @@ export class Online {
|
||||
case 'playlist-upload':
|
||||
case 'playlist-track-delete':
|
||||
case 'playlist-reorder':
|
||||
case 'group-save':
|
||||
case 'group-delete':
|
||||
case 'group-reorder':
|
||||
case 'track-group':
|
||||
case 'playlist-use':
|
||||
return this.editPlaylist(data)
|
||||
case 'plan-save':
|
||||
@@ -1626,12 +1678,17 @@ export class Online {
|
||||
this.state.playlists = (result.playlists ?? []).map((value) => {
|
||||
const p = object(value)
|
||||
if (!Array.isArray(p.tracks ?? [])) throw new Error('Некорректный список треков')
|
||||
const groups = parsePlaylistGroups(p.groups)
|
||||
return {
|
||||
id: integer(p.ID, 1),
|
||||
name: text(p.name),
|
||||
...(groups.length ? { groups } : {}),
|
||||
tracks: ((p.tracks ?? []) as unknown[]).map((value) => {
|
||||
const t = object(value)
|
||||
return { id: integer(t.ID, 1), name: text(t.name) }
|
||||
const groupId = integer(t.groupId ?? 0, 0, 2147483647)
|
||||
if (groupId && !groups.some((group) => group.id === groupId))
|
||||
throw new Error('Группа трека не найдена')
|
||||
return { id: integer(t.ID, 1), name: text(t.name), ...(groupId ? { groupId } : {}) }
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -1646,6 +1703,34 @@ export class Online {
|
||||
if (!name || Buffer.byteLength(name) > 255) throw new Error('Имя плейлиста: от 1 до 255 байт')
|
||||
if (data.action === 'playlist-create') await client.CreatePlaylist({ name })
|
||||
else await client.UpdatePlaylist({ ID: id, name })
|
||||
} else if (data.action === 'group-save') {
|
||||
const name = text(data.name).trim()
|
||||
if (!name || Buffer.byteLength(name) > 255) throw new Error('Имя группы: от 1 до 255 байт')
|
||||
const endMode = text(data.endMode)
|
||||
if (endMode !== 'repeat' && endMode !== 'stop' && endMode !== 'next')
|
||||
throw new Error('Некорректный режим группы')
|
||||
await client.SavePlaylistGroup({
|
||||
playlistID: id,
|
||||
groupID: integer(data.groupId, 0, 2147483647),
|
||||
name,
|
||||
endMode: apiGroupEndMode(endMode),
|
||||
})
|
||||
} else if (data.action === 'group-delete') {
|
||||
await client.DeletePlaylistGroup({
|
||||
playlistID: id,
|
||||
groupID: integer(data.groupId, 1, 2147483647),
|
||||
})
|
||||
} else if (data.action === 'group-reorder') {
|
||||
if (!Array.isArray(data.groupIds)) throw new Error('Некорректный порядок групп')
|
||||
const ids = data.groupIds.map((value) => integer(value, 1, 2147483647))
|
||||
if (new Set(ids).size !== ids.length) throw new Error('Группы не должны повторяться')
|
||||
await client.ReorderPlaylistGroups({ playlistID: id, groupIDs: ids })
|
||||
} else if (data.action === 'track-group') {
|
||||
await client.SetPlaylistTrackGroup({
|
||||
playlistID: id,
|
||||
trackID: integer(data.trackId, 1, 2147483647),
|
||||
groupID: integer(data.groupId, 0, 2147483647),
|
||||
})
|
||||
} else if (data.action === 'playlist-delete') await client.DeletePlaylist({ ID: id })
|
||||
else if (data.action === 'playlist-track-delete')
|
||||
await client.DeletePlaylistTrack({
|
||||
@@ -1690,6 +1775,7 @@ export class Online {
|
||||
playback: 'MUSIC_PLAYBACK_STOPPED',
|
||||
})
|
||||
}
|
||||
if (generation !== this.generation) throw new SessionChangedError()
|
||||
await this.loadPlaylists()
|
||||
this.lastMaterials = -Infinity
|
||||
await this.refreshMaterials()
|
||||
@@ -1855,6 +1941,8 @@ export class Online {
|
||||
next.music?.playlistId,
|
||||
next.music?.trackIds,
|
||||
next.music?.filenames,
|
||||
next.music?.groups,
|
||||
next.music?.trackGroupIds,
|
||||
])
|
||||
this.state.soundEnabled = next.sound
|
||||
if (this.lastSound !== next.sound)
|
||||
@@ -1896,14 +1984,15 @@ export class Online {
|
||||
async musicEnded(report: Report) {
|
||||
const c = this.controller.state.channels.music
|
||||
if (
|
||||
this.advanceBusy ||
|
||||
(this.musicAdvance?.session === report.session && this.musicAdvance.operation === report.operation) ||
|
||||
report.session !== this.controller.state.session ||
|
||||
report.operation !== c.operation ||
|
||||
c.desired !== 'playing'
|
||||
)
|
||||
return
|
||||
const generation = this.generation
|
||||
this.advanceBusy = true
|
||||
const advance = { session: report.session, operation: report.operation }
|
||||
this.musicAdvance = advance
|
||||
try {
|
||||
if (!this.screen || this.state.connection !== 'connected') throw new NetworkError('Нет связи')
|
||||
if (this.received && !this.musicSourceMatches(this.received)) {
|
||||
@@ -1915,7 +2004,12 @@ export class Online {
|
||||
accessToken: this.screen.token,
|
||||
expectedStateVersion: this.received?.music?.version.toString(),
|
||||
})
|
||||
if (generation !== this.generation || report.session !== this.controller.state.session) return
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
report.session !== this.controller.state.session ||
|
||||
report.operation !== this.controller.state.channels.music.operation
|
||||
)
|
||||
return
|
||||
const music = parseRemote({ music: result.state }).music
|
||||
if (this.received) {
|
||||
if (music && this.received.music && music.version > this.received.music.version && music.trackId === this.received.music.trackId)
|
||||
@@ -1924,20 +2018,35 @@ export class Online {
|
||||
await this.applyReceived(true)
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== this.generation || report.session !== this.controller.state.session) return
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
report.session !== this.controller.state.session ||
|
||||
report.operation !== this.controller.state.channels.music.operation
|
||||
)
|
||||
return
|
||||
this.musicApplied = null
|
||||
const pkg = this.selectedGame()?.package
|
||||
const playlist = pkg?.playlists.find((p) => p.id === c.playlistId)
|
||||
if (this.received?.music?.groupId) {
|
||||
send(this.controller, { action: 'stop', channel: 'music' })
|
||||
} else if (pkg && playlist) {
|
||||
const nextIndex = c.trackIndex + 1
|
||||
const track =
|
||||
playlist.tracks[nextIndex < playlist.tracks.length ? nextIndex : c.loop ? 0 : -1]
|
||||
if (pkg && playlist) {
|
||||
const next = nextMusicTrack(playlist, c.trackIndex, c.groupId, c.loop)
|
||||
const track = next ? playlist.tracks[next.index] : undefined
|
||||
try {
|
||||
if (track) await this.packages.ensureAsset(pkg, track.assetId, this.materialAbort.signal)
|
||||
if (generation === this.generation && report.session === this.controller.state.session)
|
||||
send(this.controller, { action: 'next', channel: 'music' })
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
report.session !== this.controller.state.session ||
|
||||
report.operation !== this.controller.state.channels.music.operation
|
||||
)
|
||||
return
|
||||
send(this.controller, { action: 'next', channel: 'music' })
|
||||
} catch (failure) {
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
report.session !== this.controller.state.session ||
|
||||
report.operation !== this.controller.state.channels.music.operation
|
||||
)
|
||||
return
|
||||
send(this.controller, { action: 'stop', channel: 'music' })
|
||||
this.state.preparation.error =
|
||||
failure instanceof Error ? failure.message : 'Следующий трек недоступен'
|
||||
}
|
||||
@@ -1946,7 +2055,7 @@ export class Online {
|
||||
this.state.error =
|
||||
error instanceof Error ? error.message : 'Сервер не подтвердил переход музыки'
|
||||
} finally {
|
||||
this.advanceBusy = false
|
||||
if (this.musicAdvance === advance) this.musicAdvance = null
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { MusicGroup } from '../src/types/screen.js'
|
||||
import { fieldError } from './request-error.js'
|
||||
|
||||
export function groupEndMode(value: unknown): MusicGroup['endMode'] {
|
||||
if (value == null || value === 0 || value === 'PLAYLIST_GROUP_END_MODE_REPEAT') return 'repeat'
|
||||
if (value === 1 || value === 'PLAYLIST_GROUP_END_MODE_STOP') return 'stop'
|
||||
if (value === 2 || value === 'PLAYLIST_GROUP_END_MODE_NEXT') return 'next'
|
||||
throw fieldError('groups.endMode', 'режим завершения группы', value, 'Некорректный режим группы')
|
||||
}
|
||||
|
||||
export function apiGroupEndMode(value: MusicGroup['endMode']) {
|
||||
return value === 'stop'
|
||||
? ('PLAYLIST_GROUP_END_MODE_STOP' as const)
|
||||
: value === 'next'
|
||||
? ('PLAYLIST_GROUP_END_MODE_NEXT' as const)
|
||||
: ('PLAYLIST_GROUP_END_MODE_REPEAT' as const)
|
||||
}
|
||||
|
||||
export function parsePlaylistGroups(value: unknown): MusicGroup[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value))
|
||||
throw fieldError('groups', 'массив', value, 'Некорректный список групп')
|
||||
const seen = new Set<number>()
|
||||
return value
|
||||
.map((raw, index) => {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
||||
throw fieldError(`groups[${index}]`, 'объект', raw, 'Некорректная группа')
|
||||
const group = raw as Record<string, unknown>
|
||||
if (!Number.isSafeInteger(group.ID) || Number(group.ID) <= 0 || seen.has(Number(group.ID)))
|
||||
throw fieldError(
|
||||
`groups[${index}].ID`,
|
||||
'уникальный положительный ID',
|
||||
group.ID,
|
||||
'Некорректная группа',
|
||||
)
|
||||
if (
|
||||
typeof group.name !== 'string' ||
|
||||
!group.name.trim() ||
|
||||
Buffer.byteLength(group.name) > 255
|
||||
)
|
||||
throw fieldError(
|
||||
`groups[${index}].name`,
|
||||
'название группы',
|
||||
group.name,
|
||||
'Некорректная группа',
|
||||
)
|
||||
const position = group.position ?? 0
|
||||
if (!Number.isSafeInteger(position) || Number(position) < 0)
|
||||
throw fieldError(
|
||||
`groups[${index}].position`,
|
||||
'неотрицательное целое',
|
||||
position,
|
||||
'Некорректный порядок групп',
|
||||
)
|
||||
const id = Number(group.ID)
|
||||
seen.add(id)
|
||||
return {
|
||||
id,
|
||||
name: group.name,
|
||||
position: Number(position),
|
||||
endMode: groupEndMode(group.endMode),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.position - b.position || a.id - b.id)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parsePlaylistGroups } from './playlist-groups.js'
|
||||
import type { MusicGroup } from '../src/types/screen.js'
|
||||
import { parseTimerType } from './screen-plan.js'
|
||||
import { timerLabels, validQR } from '../src/utils/presentation.js'
|
||||
import type { TimerType } from '../src/types/screen.js'
|
||||
@@ -82,6 +84,8 @@ export interface RemoteState {
|
||||
groupId: number
|
||||
trackId: number | null
|
||||
trackIds: number[]
|
||||
groups: MusicGroup[]
|
||||
trackGroupIds: number[]
|
||||
filenames: string[]
|
||||
position: number
|
||||
volume: number
|
||||
@@ -161,6 +165,15 @@ export function parseRemote(value: unknown): RemoteState {
|
||||
playlistId: p ? integer(p.ID, 1, Number.MAX_SAFE_INTEGER, 'state.music.playlist.ID') : null,
|
||||
trackId: trackIds[index] ?? null,
|
||||
trackIds,
|
||||
groups: parsePlaylistGroups(p?.groups),
|
||||
trackGroupIds: tracks.map((track, i) =>
|
||||
integer(
|
||||
object(track).groupId ?? 0,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
`state.music.playlist.tracks[${i}].groupId`,
|
||||
),
|
||||
),
|
||||
filenames: tracks.map((t, i) =>
|
||||
text(object(t).filename ?? '', `state.music.playlist.tracks[${i}].filename`),
|
||||
),
|
||||
@@ -300,6 +313,7 @@ function applyRemoteState(controller: Controller, previous: RemoteState, next: R
|
||||
!old ||
|
||||
old.playlistId !== m.playlistId ||
|
||||
old.trackId !== m.trackId ||
|
||||
old.groupId !== m.groupId ||
|
||||
old.playback !== m.playback ||
|
||||
old.position !== m.position
|
||||
if (transportChanged) {
|
||||
@@ -312,6 +326,7 @@ function applyRemoteState(controller: Controller, previous: RemoteState, next: R
|
||||
)
|
||||
send(controller, { action: 'track', channel: 'music', trackIndex: index })
|
||||
}
|
||||
controller.synchronizeMusicGroup(m.groupId)
|
||||
send(controller, { action: 'volume', channel: 'music', volume: m.volume })
|
||||
send(controller, { action: 'loop', channel: 'music', enabled: m.loop })
|
||||
if (transportChanged) {
|
||||
|
||||
@@ -501,3 +501,68 @@ test('ссылки состояния экрана определяют меди
|
||||
assert.equal(coverOnly.media.background, null)
|
||||
for (const value of [pkg, cleared, coverOnly]) assert.deepEqual(parseScreenPackage(value), value)
|
||||
})
|
||||
|
||||
test('группы сохраняются в пакете и его отпечатке, старые пакеты остаются совместимыми', () => {
|
||||
const legacy = build()
|
||||
assert.ok(
|
||||
legacy.playlists.every(
|
||||
(playlist) =>
|
||||
!('groups' in playlist) && playlist.tracks.every((track) => !('groupId' in track)),
|
||||
),
|
||||
)
|
||||
assert.deepEqual(parseScreenPackage(JSON.parse(JSON.stringify(legacy))), legacy)
|
||||
const data = fixture()
|
||||
const selected = data.music.state.playlist
|
||||
const playlist = {
|
||||
...selected,
|
||||
groups: [
|
||||
{ ID: 42, name: 'First', position: 1 },
|
||||
{ ID: 43, name: 'Final', position: 0, endMode: 'PLAYLIST_GROUP_END_MODE_STOP' },
|
||||
],
|
||||
tracks: selected.tracks.map((track) => ({ ...track, groupId: 42 })),
|
||||
}
|
||||
const grouped = buildScreenPackage(server, 7, {
|
||||
...data,
|
||||
music: { ...data.music, state: { ...data.music.state, playlist } },
|
||||
})
|
||||
assert.notEqual(grouped.metadataVersion, legacy.metadataVersion)
|
||||
assert.deepEqual(parseScreenPackage(JSON.parse(JSON.stringify(grouped))), grouped)
|
||||
assert.deepEqual(
|
||||
grouped.playlists.find((p) => p.id === 3)!.groups!.map((group) => [group.id, group.endMode]),
|
||||
[
|
||||
[43, 'stop'],
|
||||
[42, 'repeat'],
|
||||
],
|
||||
)
|
||||
const renamed = buildScreenPackage(server, 7, {
|
||||
...data,
|
||||
music: {
|
||||
...data.music,
|
||||
state: {
|
||||
...data.music.state,
|
||||
playlist: {
|
||||
...playlist,
|
||||
groups: playlist.groups.map((group) => ({ ...group, name: group.name + ' changed' })),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.notEqual(renamed.metadataVersion, grouped.metadataVersion)
|
||||
assert.throws(
|
||||
() =>
|
||||
buildScreenPackage(server, 7, {
|
||||
...data,
|
||||
music: {
|
||||
...data.music,
|
||||
state: {
|
||||
...data.music.state,
|
||||
playlist: {
|
||||
...playlist,
|
||||
tracks: playlist.tracks.map((track) => ({ ...track, groupId: 999 })),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
/Группа/,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ import { parseTimerType } from './screen-plan.js'
|
||||
import { validQR } from '../src/utils/presentation.js'
|
||||
import type { TimerType } from '../src/types/screen.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { MusicGroup } from '../src/types/screen.js'
|
||||
import { parsePlaylistGroups, apiGroupEndMode } from './playlist-groups.js'
|
||||
|
||||
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
|
||||
|
||||
@@ -24,7 +26,14 @@ export interface ScreenPackage {
|
||||
intro: string | null
|
||||
conclusion: string | null
|
||||
}
|
||||
steps: { mode: number; text: string; timerLabel: string; timerDurationSeconds: number; qrUrl?: string; timerType?: TimerType }[]
|
||||
steps: {
|
||||
mode: number
|
||||
text: string
|
||||
timerLabel: string
|
||||
timerDurationSeconds: number
|
||||
qrUrl?: string
|
||||
timerType?: TimerType
|
||||
}[]
|
||||
music: {
|
||||
scenarioPlaylistId: number | null
|
||||
gamePlaylistId: number | null
|
||||
@@ -33,7 +42,8 @@ export interface ScreenPackage {
|
||||
playlists: {
|
||||
id: number
|
||||
name: string
|
||||
tracks: { id: number; name: string; position: number; assetId: string }[]
|
||||
groups?: MusicGroup[]
|
||||
tracks: { id: number; name: string; position: number; groupId?: number; assetId: string }[]
|
||||
}[]
|
||||
assets: { id: string; url: string }[]
|
||||
}
|
||||
@@ -283,6 +293,7 @@ export function buildScreenPackage(
|
||||
)
|
||||
const playlist = entry.value
|
||||
const field = entry.field
|
||||
const groups = parsePlaylistGroups(playlist.groups)
|
||||
const tracks = list(playlist.tracks, `${field}.tracks`)
|
||||
.map((value, index) => {
|
||||
const trackField = `${field}.tracks[${index}]`
|
||||
@@ -299,11 +310,23 @@ export function buildScreenPackage(
|
||||
id: id(track.ID, `${trackField}.ID`),
|
||||
name: text(track.name, `${trackField}.name`),
|
||||
position: integer(track.position, 0, `${trackField}.position`),
|
||||
...(integer(track.groupId, 0, `${trackField}.groupId`)
|
||||
? { groupId: integer(track.groupId, 0, `${trackField}.groupId`) }
|
||||
: {}),
|
||||
assetId,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.position - b.position || a.id - b.id)
|
||||
return { id: playlistId, name: text(playlist.name, `${field}.name`), tracks }
|
||||
for (const track of tracks) {
|
||||
if (track.groupId && !groups.some((group) => group.id === track.groupId))
|
||||
throw new Error('Группа трека отсутствует в плейлисте')
|
||||
}
|
||||
return {
|
||||
id: playlistId,
|
||||
name: text(playlist.name, `${field}.name`),
|
||||
tracks,
|
||||
...(groups.length ? { groups } : {}),
|
||||
}
|
||||
})
|
||||
const data = {
|
||||
formatVersion: 1 as const,
|
||||
@@ -480,12 +503,28 @@ export function parseScreenPackage(value: unknown): ScreenPackage {
|
||||
return {
|
||||
ID: id(playlist.id),
|
||||
name: text(playlist.name),
|
||||
...(playlist.groups === undefined
|
||||
? {}
|
||||
: {
|
||||
groups: list(playlist.groups).map((value) => {
|
||||
const group = object(value)
|
||||
if (!['repeat', 'stop', 'next'].includes(String(group.endMode)))
|
||||
throw new Error('Некорректный режим группы пакета')
|
||||
return {
|
||||
ID: id(group.id),
|
||||
name: text(group.name),
|
||||
position: integer(group.position),
|
||||
endMode: apiGroupEndMode(group.endMode as MusicGroup['endMode']),
|
||||
}
|
||||
}),
|
||||
}),
|
||||
tracks: list(playlist.tracks).map((value) => {
|
||||
const track = object(value)
|
||||
return {
|
||||
ID: id(track.id),
|
||||
name: text(track.name),
|
||||
position: integer(track.position),
|
||||
...(track.groupId === undefined ? {} : { groupId: integer(track.groupId) }),
|
||||
filename: ref(track.assetId),
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user