diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 57797c3..d3e1c1c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -156,8 +156,24 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова добавлять и удалять треки. При перестановке текущий файл продолжает играть; следующий трек выбирается по новому порядку. Если состав треков изменился на сервере, отмените черновик и повторите перестановку по актуальному списку. -Используемый игрой или сценарием плейлист и его треки удалить нельзя. -Редактирование требует сети; сохранённая музыка продолжает играть без неё. +Назначенный игре или сценарию плейлист удалить нельзя. Изменение состава, +порядка треков, режима завершения и удаление активной или приостановленной группы +требуют остановки музыки во всех использующих её играх; переименование доступно. + +Создание и настройка групп находятся только в режиме редактирования. У группы +есть название и режим завершения: «Повторять группу», «Остановиться» или +«Следующая группа». Группы переставляются кнопками ↑ / ↓; переход следует этому +порядку, пропускает пустые группы и останавливается после последней. Треки +переносятся выбором группы; удаление группы оставляет её треки в «Без группы». +Редактор сохраняет изменения на сервере. При закрытии несохранённого черновика +приложение предлагает отменить изменения; потеря связи не стирает черновик. + +В обычном режиме доступны группы и «Играть группу»: сразу запускается её первый +трек. «Играть весь плейлист» снимает ограничение группы и возвращает обычный +повтор плейлиста. Подготовленные группы, их порядок и режимы доступны без сети; +если следующего локального файла нет, музыка останавливается с ошибкой. После +восстановления связи применяется серверное состояние без повторной отправки +офлайн-команд. После перезапуска музыка остаётся остановленной. ### Текст, QR-код, команды, таймер и результаты diff --git a/electron/controller.test.ts b/electron/controller.test.ts index e024152..6d6865f 100644 --- a/electron/controller.test.ts +++ b/electron/controller.test.ts @@ -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') + }) +} diff --git a/electron/controller.ts b/electron/controller.ts index 0df0124..85f5d0b 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -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 diff --git a/electron/main.ts b/electron/main.ts index 261a318..5be1705 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 diff --git a/electron/media-player.test.ts b/electron/media-player.test.ts index 23c6e77..8eea2a8 100644 --- a/electron/media-player.test.ts +++ b/electron/media-player.test.ts @@ -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') +}) diff --git a/electron/music-groups.test.ts b/electron/music-groups.test.ts new file mode 100644 index 0000000..cede8fd --- /dev/null +++ b/electron/music-groups.test.ts @@ -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)) +}) diff --git a/electron/online.test.ts b/electron/online.test.ts index 699ce17..5e7c964 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -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[], @@ -67,6 +72,7 @@ async function fixture(t: TestContext, secure = true) { refreshCalls: 0, refreshResponse: null as (() => Promise) | null, advanceCalls: 0, + advanceHandler: null as (() => Promise) | null, advanceRequest: null as Record | null, advanceResponse: null as Record | 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 + 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((resolve) => setImmediate(resolve)) await f.online.act({ action: 'cancel' }) try { - assert.equal(await Promise.race([ - cancelled, - new Promise((resolve) => { const timer = setTimeout(() => resolve('still waiting'), 1000); timer.unref() }), - ]), 'cancelled') + assert.equal( + await Promise.race([ + cancelled, + new Promise((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((_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((_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((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) }) diff --git a/electron/online.ts b/electron/online.ts index 2d90d41..7799956 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -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 = 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() { diff --git a/electron/playlist-groups.ts b/electron/playlist-groups.ts new file mode 100644 index 0000000..e079351 --- /dev/null +++ b/electron/playlist-groups.ts @@ -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() + return value + .map((raw, index) => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) + throw fieldError(`groups[${index}]`, 'объект', raw, 'Некорректная группа') + const group = raw as Record + 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) +} diff --git a/electron/remote-state.ts b/electron/remote-state.ts index 2827d4a..7348728 100644 --- a/electron/remote-state.ts +++ b/electron/remote-state.ts @@ -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) { diff --git a/electron/screen-package.test.ts b/electron/screen-package.test.ts index a6d0190..6fc37cb 100644 --- a/electron/screen-package.test.ts +++ b/electron/screen-package.test.ts @@ -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 })), + }, + }, + }, + }), + /Группа/, + ) +}) diff --git a/electron/screen-package.ts b/electron/screen-package.ts index c7d6f5d..ab2fc4d 100644 --- a/electron/screen-package.ts +++ b/electron/screen-package.ts @@ -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), } }), diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue index b6b1c89..6c2eab7 100644 --- a/src/components/ControlPage.vue +++ b/src/components/ControlPage.vue @@ -487,6 +487,7 @@ onUnmounted(() => { !!props.channel.source && props.outputOpen && !props.isBusy) function edit() { if (props.isBusy || !props.canEdit) return - selectedId.value = props.channel.playlistId + selectedId.value = props.channel.playlistId ?? props.playlists[0]?.id ?? null + lastSelected.value = selected.value reset() isEditing.value = true } const selectedId = ref(null) const name = ref('') +const originalName = ref('') +const newGroupName = ref('') +const pendingGroupName = ref('') +const pendingDeletedGroupId = ref(0) +type GroupDraft = MusicGroup & { originalName: string; originalMode: MusicGroup['endMode'] } +const groupDrafts = ref([]) +const lastSelected = ref() +const isNameDirty = computed(() => name.value !== originalName.value) +function isGroupDirty(group: GroupDraft) { + return group.name !== group.originalName || group.endMode !== group.originalMode +} +const hasDraft = computed( + () => + isDirty.value || + isNameDirty.value || + !!newGroupName.value.trim() || + groupDrafts.value.some(isGroupDirty), +) +const groupModes = [ + { label: 'Повторять группу', value: 'repeat' }, + { label: 'Остановиться', value: 'stop' }, + { label: 'Следующая группа', value: 'next' }, +] +function groupDraft(group: MusicGroup): GroupDraft { + return { ...group, originalName: group.name, originalMode: group.endMode } +} +function groupConflict(group: GroupDraft) { + const current = selected.value?.groups?.find((item) => item.id === group.id) + return ( + !current || + (isGroupDirty(group) && + (current.name !== group.originalName || current.endMode !== group.originalMode)) + ) +} +const isPlaylistActive = computed( + () => selectedId.value === props.channel.playlistId && props.channel.desired !== 'stopped', +) +function groupLocked(groupId: number) { + return isPlaylistActive.value && (!props.channel.groupId || props.channel.groupId === groupId) +} +const editorSections = computed(() => [ + ...groupDrafts.value.map((group) => ({ id: group.id, name: group.originalName, draft: group })), + { id: 0, name: 'Без группы', draft: undefined }, +]) +const playbackSections = computed(() => [ + ...(activePlaylist.value?.groups ?? []).map((group) => ({ + id: group.id, + name: group.name, + endMode: group.endMode, + })), + { id: 0, name: 'Без группы', endMode: undefined }, +]) +function sectionTracks(groupId: number) { + return order.value.filter( + (id) => (selected.value?.tracks.find((track) => track.id === id)?.groupId ?? 0) === groupId, + ) +} +function playbackTracks(groupId: number) { + return (activePlaylist.value?.tracks ?? []) + .map((track, index) => ({ track, index })) + .filter(({ track }) => (track.groupId ?? 0) === groupId) +} +function completionLabel(mode: MusicGroup['endMode'] | undefined) { + return groupModes.find((item) => item.value === (mode ?? 'repeat'))?.label +} +function saveGroup(group: GroupDraft) { + if (!selected.value || !group.name.trim() || groupConflict(group)) return + emit('action', { + action: 'group-save', + playlistId: selected.value.id, + groupId: group.id, + name: group.name.trim(), + endMode: group.endMode, + }) +} +function createGroup() { + if (!selected.value || !newGroupName.value.trim()) return + pendingGroupName.value = newGroupName.value.trim() + emit('action', { + action: 'group-save', + playlistId: selected.value.id, + groupId: 0, + name: newGroupName.value.trim(), + endMode: 'repeat', + }) +} +function cancelGroup(group: GroupDraft) { + const current = selected.value?.groups?.find((item) => item.id === group.id) + groupDrafts.value = current + ? groupDrafts.value.map((item) => (item.id === group.id ? groupDraft(current) : item)) + : groupDrafts.value.filter((item) => item.id !== group.id) +} +function moveGroup(id: number, offset: number) { + if (!selected.value) return + const ids = selected.value.groups?.map((group) => group.id) ?? [] + const index = ids.indexOf(id), + target = index + offset + if (index < 0 || target < 0 || target >= ids.length) return + ids.splice(index, 1) + ids.splice(target, 0, id) + emit('action', { action: 'group-reorder', playlistId: selected.value.id, groupIds: ids }) +} +function removeGroup(group: GroupDraft) { + const playlistId = selected.value?.id + if (!playlistId) return + dialog.warning({ + title: 'Удалить группу?', + content: `Группа «${group.name}» будет удалена. Все её треки останутся в плейлисте без группы.`, + positiveText: 'Удалить', + negativeText: 'Отмена', + onPositiveClick: () => { + pendingDeletedGroupId.value = group.id + emit('action', { action: 'group-delete', playlistId, groupId: group.id }) + }, + }) +} +function assignGroup(trackId: number, groupId: number) { + if (selected.value) + emit('action', { action: 'track-group', playlistId: selected.value.id, trackId, groupId }) +} +function finish() { + const close = () => { + isEditing.value = false + reset() + } + if (!hasDraft.value) { + close() + return + } + dialog.warning({ + title: 'Отменить несохранённые изменения?', + content: 'Черновики плейлиста и групп будут отменены.', + positiveText: 'Отменить изменения', + negativeText: 'Продолжить редактирование', + onPositiveClick: close, + }) +} +watch( + () => props.server, + () => { + isEditing.value = false + selectedId.value = null + lastSelected.value = undefined + reset() + }, +) const order = ref([]) const original = ref([]) -const selected = computed(() => props.playlists.find((p) => p.id === selectedId.value)) +const selected = computed( + () => + props.playlists.find((p) => p.id === selectedId.value) ?? + (isEditing.value && hasDraft.value ? lastSelected.value : undefined), +) const isDirty = computed(() => JSON.stringify(order.value) !== JSON.stringify(original.value)) +const isSelectedMissing = computed( + () => !!selectedId.value && !props.playlists.some((item) => item.id === selectedId.value), +) const hasConflict = computed( () => - selected.value && - JSON.stringify(selected.value.tracks.map((t) => t.id).sort((a, b) => a - b)) !== - JSON.stringify([...original.value].sort((a, b) => a - b)), + isSelectedMissing.value || + (!!selected.value && + JSON.stringify(selected.value.tracks.map((track) => track.id)) !== + JSON.stringify(original.value)), ) -const isDisabled = computed(() => !props.canEdit || props.isBusy) +const isDisabled = computed(() => !props.canEdit || props.isBusy || isSelectedMissing.value) let timer: ReturnType | undefined function reset() { original.value = selected.value?.tracks.map((t) => t.id) ?? [] order.value = [...original.value] name.value = selected.value?.name ?? '' + originalName.value = name.value + newGroupName.value = '' + pendingGroupName.value = '' + pendingDeletedGroupId.value = 0 + groupDrafts.value = (selected.value?.groups ?? []).map(groupDraft) } -watch(selectedId, reset) +function resetOrder() { + original.value = selected.value?.tracks.map((track) => track.id) ?? [] + order.value = [...original.value] +} +watch(selectedId, () => { + lastSelected.value = props.playlists.find((item) => item.id === selectedId.value) + reset() +}) watch( () => props.playlists, (items, old) => { + if (!isEditing.value) return const added = items.find((p) => !old.some((o) => o.id === p.id)) - if (!isDirty.value && added) selectedId.value = added.id - if (selectedId.value && !selected.value) selectedId.value = null - const oldName = old.find((p) => p.id === selectedId.value)?.name - if (name.value === oldName) name.value = selected.value?.name ?? '' - const ids = selected.value?.tracks.map((t) => t.id) ?? [] + if (!hasDraft.value && added) selectedId.value = added.id + const current = items.find((item) => item.id === selectedId.value) + if (!current) { + if (!hasDraft.value) { + selectedId.value = null + lastSelected.value = undefined + reset() + } + return + } + lastSelected.value = current + const previous = old.find((item) => item.id === current.id) + const created = current.groups?.find( + (group) => + group.name === pendingGroupName.value && + !previous?.groups?.some((item) => item.id === group.id), + ) + if ( + pendingGroupName.value && + created?.name === pendingGroupName.value && + newGroupName.value.trim() === pendingGroupName.value + ) { + newGroupName.value = '' + pendingGroupName.value = '' + } + if (!isNameDirty.value || name.value.trim() === current.name) { + name.value = current.name + originalName.value = current.name + } + const drafts = groupDrafts.value + groupDrafts.value = [ + ...(current.groups ?? []).map((group) => { + const draft = drafts.find((item) => item.id === group.id) + if ( + !draft || + !isGroupDirty(draft) || + (draft.name.trim() === group.name && draft.endMode === group.endMode) + ) + return groupDraft(group) + return { ...draft, position: group.position } + }), + ...drafts.filter( + (draft) => + draft.id !== pendingDeletedGroupId.value && + isGroupDirty(draft) && + !current.groups?.some((group) => group.id === draft.id), + ), + ] + if ( + pendingDeletedGroupId.value && + !current.groups?.some((group) => group.id === pendingDeletedGroupId.value) + ) + pendingDeletedGroupId.value = 0 + const ids = current.tracks.map((track) => track.id) if (!isDirty.value || JSON.stringify(order.value) === JSON.stringify(ids)) { original.value = ids order.value = [...ids] @@ -105,18 +323,24 @@ onMounted(() => { timer = setInterval(refresh, 10000) }) onUnmounted(() => clearInterval(timer)) -function move(index: number, offset: number) { - const target = index + offset - if (target < 0 || target >= order.value.length) return - const [id] = order.value.splice(index, 1) - if (id !== undefined) order.value.splice(target, 0, id) +function move(id: number, groupId: number, offset: number) { + const ids = sectionTracks(groupId), + target = ids[ids.indexOf(id) + offset] + if (target === undefined) return + const from = order.value.indexOf(id), + to = order.value.indexOf(target) + order.value[from] = target + order.value[to] = id } function remove(trackId?: number) { if (!selected.value) return const playlistId = selected.value.id dialog.warning({ title: trackId === undefined ? 'Удалить плейлист?' : 'Удалить трек?', - content: 'Изменение сохранится на сервере. Используемые плейлисты и треки удалить нельзя.', + content: + trackId === undefined + ? 'Изменение сохранится на сервере. Назначенный игре или сценарию плейлист удалить нельзя.' + : 'Трек будет удалён из плейлиста и с сервера. Для удаления трека активной группы сначала остановите музыку.', positiveText: 'Удалить', negativeText: 'Отмена', onPositiveClick: () => @@ -133,14 +357,31 @@ function remove(trackId?: number) {