This commit is contained in:
2026-10-27 13:07:24 +07:00
parent 066134f6ef
commit 6e83c014e5
18 changed files with 1445 additions and 174 deletions
+18 -2
View File
@@ -156,8 +156,24 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова
добавлять и удалять треки. При перестановке текущий файл продолжает играть;
следующий трек выбирается по новому порядку. Если состав треков изменился
на сервере, отмените черновик и повторите перестановку по актуальному списку.
Используемый игрой или сценарием плейлист и его треки удалить нельзя.
Редактирование требует сети; сохранённая музыка продолжает играть без неё.
Назначенный игре или сценарию плейлист удалить нельзя. Изменение состава,
порядка треков, режима завершения и удаление активной или приостановленной группы
требуют остановки музыки во всех использующих её играх; переименование доступно.
Создание и настройка групп находятся только в режиме редактирования. У группы
есть название и режим завершения: «Повторять группу», «Остановиться» или
«Следующая группа». Группы переставляются кнопками ↑ / ↓; переход следует этому
порядку, пропускает пустые группы и останавливается после последней. Треки
переносятся выбором группы; удаление группы оставляет её треки в «Без группы».
Редактор сохраняет изменения на сервере. При закрытии несохранённого черновика
приложение предлагает отменить изменения; потеря связи не стирает черновик.
В обычном режиме доступны группы и «Играть группу»: сразу запускается её первый
трек. «Играть весь плейлист» снимает ограничение группы и возвращает обычный
повтор плейлиста. Подготовленные группы, их порядок и режимы доступны без сети;
если следующего локального файла нет, музыка останавливается с ошибкой. После
восстановления связи применяется серверное состояние без повторной отправки
офлайн-команд. После перезапуска музыка остаётся остановленной.
### Текст, QR-код, команды, таймер и результаты
+34 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+53
View File
@@ -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')
})
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+65
View File
@@ -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)
}
+15
View File
@@ -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) {
+65
View File
@@ -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 })),
},
},
},
}),
/Группа/,
)
})
+42 -3
View File
@@ -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),
}
}),
+1
View File
@@ -487,6 +487,7 @@ onUnmounted(() => {
</n-space>
</n-card>
<MusicPlanPanel
:server="onlineState.server"
:playlists="onlineState.playlists"
:local-playlists="state.catalog.playlists"
:channel="state.channels.music"
+526 -101
View File
@@ -12,11 +12,12 @@ import {
} from 'naive-ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { MusicPlaylist, OnlineIntent } from '../types/online'
import type { ChannelState, Intent, Playback, Playlist } from '../types/screen'
import type { ChannelState, Intent, Playback, Playlist, MusicGroup } from '../types/screen'
import { playbackControl } from '../utils/playbackControl'
import { formatTimer } from '../utils/presentation'
const props = defineProps<{
server: string
playlists: MusicPlaylist[]
localPlaylists: Playlist[]
channel: ChannelState
@@ -51,39 +52,256 @@ function isCurrent(index: number) {
const canPlay = computed(() => !!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<number | null>(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<GroupDraft[]>([])
const lastSelected = ref<MusicPlaylist>()
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<number[]>([])
const original = ref<number[]>([])
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<typeof setInterval> | 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) {
<template>
<n-card title="План музыки" data-testid="music-plan">
<template #header-extra>
<n-button size="small" v-if="!isEditing" square aria-label="Редактировать" title="Редактировать" :disabled="isDisabled" @click="edit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<n-button
size="small"
v-if="!isEditing"
square
aria-label="Редактировать"
title="Редактировать"
:disabled="isDisabled"
@click="edit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="m16 3 5 5M3 21l5-1L21 7a2.8 2.8 0 0 0-4-4L4 16z" />
</svg>
</n-button>
<n-space v-else>
<template v-if="isDirty || hasConflict">
<n-button size="small"
<n-button
size="small"
:disabled="isDisabled || !selected || hasConflict"
@click="
selected &&
@@ -152,24 +393,29 @@ function remove(trackId?: number) {
"
>Сохранить порядок</n-button
>
<n-button size="small" :disabled="props.isBusy" @click="reset">Отменить порядок</n-button>
<n-button size="small" :disabled="props.isBusy" @click="resetOrder"
>Отменить порядок</n-button
>
</template>
<n-button size="small" :disabled="isBusy || isDirty || !!hasConflict" @click="isEditing = false"
>Готово</n-button
>
<n-button size="small" :disabled="isBusy" @click="finish">Готово</n-button>
</n-space>
</template>
<n-space v-if="isEditing" vertical data-testid="music-plan-editor">
<n-space v-if="isEditing" vertical :wrap-item="false" data-testid="music-plan-editor">
<p v-if="!canEdit">Для редактирования библиотеки подключитесь к серверу.</p>
<template v-else>
<n-select
v-model:value="selectedId"
:options="playlists.map((p) => ({ label: p.name, value: p.id }))"
:disabled="isDisabled || isDirty"
:disabled="isDisabled || hasDraft"
placeholder="Плейлист для редактирования"
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
/>
<p v-if="!playlists.length">Плейлистов пока нет.</p>
<n-alert
v-if="selectedId && !playlists.some((item) => item.id === selectedId)"
type="warning"
>Плейлист удалён на сервере. Черновик сохранён до отмены изменений.</n-alert
>
<template v-if="selected">
<label for="playlist-name">Название плейлиста</label>
<n-input
@@ -178,7 +424,8 @@ function remove(trackId?: number) {
:disabled="isDisabled"
/>
<n-space>
<n-button size="small"
<n-button
size="small"
:disabled="isDisabled || !name.trim() || name === selected.name"
@click="emit('action', { action: 'playlist-rename', playlistId: selected.id, name })"
>Сохранить название</n-button
@@ -186,44 +433,174 @@ function remove(trackId?: number) {
<n-button size="small" :disabled="isDisabled || isDirty" @click="remove()"
>Удалить плейлист</n-button
>
<n-button size="small"
<n-button
size="small"
:disabled="isDisabled || isDirty"
@click="emit('action', { action: 'playlist-upload', playlistId: selected.id })"
>Добавить MP3</n-button
>
<n-button size="small"
<n-button
size="small"
:disabled="isDisabled || isDirty || !canUse || !selected.tracks.length"
@click="emit('action', { action: 'playlist-use', playlistId: selected.id })"
>Использовать в игре</n-button
>
</n-space>
<p v-if="!order.length">Треков пока нет.</p>
<ul class="track-list">
<li v-for="(id, index) in order" :key="id" class="track-row">
<div class="track-content track-edit-content">
<span>{{
selected.tracks.find((t) => t.id === id)?.name ?? `Трек №${id} удалён на сервере`
}}</span>
<n-space :wrap="false">
<n-input
v-model:value="newGroupName"
placeholder="Новая группа"
aria-label="Название новой группы"
:maxlength="255"
:disabled="isDisabled"
/>
<n-button :disabled="isDisabled || isDirty || !newGroupName.trim()" @click="createGroup"
>Добавить группу</n-button
>
</n-space>
<p v-if="isPlaylistActive">
Остановите музыку для изменения активной группы и порядка групп. Другие игры также могут
блокировать изменения.
</p>
<div class="music-groups">
<section
v-for="(section, groupIndex) in editorSections"
:key="section.id"
class="music-group"
:aria-label="section.name"
>
<h4>{{ section.name }}</h4>
<template v-if="section.draft">
<n-input
v-model:value="section.draft.name"
:maxlength="255"
:disabled="isDisabled"
:input-props="{ 'aria-label': `Название группы ${section.name}` }"
/>
<n-select
v-model:value="section.draft.endMode"
:options="groupModes"
:disabled="isDisabled || groupLocked(section.id)"
:input-props="{ 'aria-label': `Завершение группы ${section.name}` }"
/>
<n-alert v-if="groupConflict(section.draft)" type="warning"
>Группа изменена или удалена на сервере. Черновик сохранён до отмены
изменений.</n-alert
>
<n-space>
<n-button size="small"
:disabled="isDisabled || index === 0"
:aria-label="`Поднять трек ${index + 1}`"
@click="move(index, -1)"
<n-button
size="small"
:disabled="
isDisabled ||
!section.draft.name.trim() ||
!isGroupDirty(section.draft) ||
groupConflict(section.draft)
"
@click="saveGroup(section.draft)"
>Сохранить группу</n-button
>
<n-button
v-if="isGroupDirty(section.draft) || groupConflict(section.draft)"
size="small"
:disabled="isBusy"
@click="cancelGroup(section.draft)"
>Отменить изменения группы</n-button
>
<n-button
size="small"
:disabled="
isDisabled ||
isPlaylistActive ||
isDirty ||
groupIndex === 0 ||
groupConflict(section.draft)
"
:aria-label="`Поднять группу ${section.name}`"
@click="moveGroup(section.id, -1)"
>↑</n-button
>
<n-button size="small"
:disabled="isDisabled || index === order.length - 1"
:aria-label="`Опустить трек ${index + 1}`"
@click="move(index, 1)"
<n-button
size="small"
:disabled="
isDisabled ||
isPlaylistActive ||
isDirty ||
groupIndex >= (selected.groups?.length ?? 0) - 1 ||
groupConflict(section.draft)
"
:aria-label="`Опустить группу ${section.name}`"
@click="moveGroup(section.id, 1)"
>↓</n-button
>
<n-button size="small" :disabled="isDisabled || isDirty" @click="remove(id)"
>Удалить трек</n-button
<n-button
size="small"
type="error"
:disabled="
isDisabled || groupLocked(section.id) || groupConflict(section.draft)
"
@click="removeGroup(section.draft)"
>Удалить группу</n-button
>
</n-space>
</div>
</li>
</ul>
</template>
<p v-if="!sectionTracks(section.id).length">Треков пока нет.</p>
<ul class="track-list">
<li v-for="(id, index) in sectionTracks(section.id)" :key="id" class="track-row">
<div class="track-content track-edit-content">
<span>{{
selected.tracks.find((track) => track.id === id)?.name ??
`Трек №${id} удалён на сервере`
}}</span>
<n-select
:value="section.id"
:options="
(selected.groups ?? [])
.map((group) => ({
label: group.name,
value: group.id,
disabled: groupLocked(group.id),
}))
.concat([{ label: 'Без группы', value: 0, disabled: false }])
"
:disabled="isDisabled || isDirty || groupLocked(section.id)"
:input-props="{
'aria-label': `Группа трека ${selected.tracks.find((track) => track.id === id)?.name}`,
}"
@update:value="assignGroup(id, $event)"
/>
<n-space>
<n-button
size="small"
:disabled="
isDisabled || (section.id !== 0 && groupLocked(section.id)) || index === 0
"
:aria-label="`Поднять трек ${index + 1} в группе ${section.name}`"
@click="move(id, section.id, -1)"
>↑</n-button
>
<n-button
size="small"
:disabled="
isDisabled ||
(section.id !== 0 && groupLocked(section.id)) ||
index === sectionTracks(section.id).length - 1
"
:aria-label="`Опустить трек ${index + 1} в группе ${section.name}`"
@click="move(id, section.id, 1)"
>↓</n-button
>
<n-button
size="small"
:disabled="isDisabled || isDirty || groupLocked(section.id)"
@click="remove(id)"
>Удалить трек</n-button
>
</n-space>
</div>
</li>
</ul>
</section>
</div>
<n-alert v-if="hasConflict" type="warning"
>Состав треков изменился на сервере. Отмените черновик, чтобы принять актуальный
список.</n-alert
@@ -243,8 +620,10 @@ function remove(trackId?: number) {
emit('command', { action: 'playlist', channel: 'music', playlistId: $event })
"
/>
<n-button size="small"
<n-button
size="small"
square
v-if="!channel.groupId"
:type="channel.loop ? 'primary' : 'default'"
:disabled="isBusy"
:aria-pressed="channel.loop"
@@ -252,53 +631,51 @@ function remove(trackId?: number) {
title="Повторять плейлист"
@click="emit('command', { action: 'loop', channel: 'music', enabled: !channel.loop })"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="m17 2 4 4-4 4M3 11V8a2 2 0 0 1 2-2h16M7 22l-4-4 4-4m14-1v3a2 2 0 0 1-2 2H3" />
</svg>
</n-button>
</div>
<p v-if="channel.source" class="current-track" data-testid="current-music-track">{{ channel.source.name }}</p>
<p v-if="channel.source" class="current-track" data-testid="current-music-track">
{{ channel.source.name }}
</p>
<div class="track-actions">
<n-space align="center">
<n-button size="small"
<n-button
size="small"
square
:disabled="!canPlay"
:aria-label="control.label"
:title="control.label"
@click="emit('command', { action: control.action, channel: 'music' })"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path
v-if="control.action === 'pause'"
d="M6 4h4v16H6zM14 4h4v16h-4z"
/>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path v-if="control.action === 'pause'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
<path v-else d="M7 4v16l13-8z" />
</svg>
</n-button>
<n-button size="small"
<n-button
size="small"
square
:disabled="!canPlay"
aria-label="Стоп"
title="Стоп"
@click="emit('command', { action: 'stop', channel: 'music' })"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M5 5h14v14H5z" />
</svg>
</n-button>
<n-button size="small"
<n-button
size="small"
square
:disabled="!canPlay"
aria-label="Сначала"
@@ -363,41 +740,90 @@ function remove(trackId?: number) {
{{ channel.duration === null ? '—' : formatTimer(channel.duration * 1000) }}
</span>
</div>
<n-alert v-if="channel.error" type="error">{{
channel.error
}}</n-alert>
<n-alert v-if="channel.error" type="error">{{ channel.error }}</n-alert>
<p v-if="!localPlaylists.length">Плейлистов в игре пока нет.</p>
<p v-else-if="!activePlaylist">Выберите плейлист.</p>
<p v-else-if="!activePlaylist.tracks.length">Треков пока нет.</p>
<ul v-if="activePlaylist" class="track-list">
<li
v-for="(track, index) in activePlaylist.tracks"
:key="track.id"
class="track-row"
:class="{ 'track-current': isCurrent(index) }"
:data-track-index="index"
:aria-current="isCurrent(index) ? 'true' : undefined"
<n-button
v-if="channel.groupId"
size="small"
:disabled="isBusy || !outputOpen"
@click="emit('command', { action: 'group', channel: 'music', groupId: 0 })"
>Играть весь плейлист</n-button
>
<div v-if="activePlaylist" class="music-groups">
<section
v-for="section in playbackSections"
:key="section.id"
class="music-group"
:aria-label="section.name"
>
<div class="track-content">
<div class="track-title">
<span>{{ track.name || `Трек ${index + 1}` }}</span>
<n-button
data-testid="select-track"
size="small"
:disabled="isBusy || isCurrent(index)"
@click="emit('command', { action: 'track', channel: 'music', trackIndex: index })"
>{{ isCurrent(index) ? 'Выбран' : 'Выбрать' }}</n-button
>
</div>
</div>
</li>
</ul>
<n-space align="center" justify="space-between">
<h4>
{{ section.name
}}{{ section.id && channel.groupId === section.id ? ' · Выбрана' : '' }}
</h4>
<n-button
v-if="section.id"
size="small"
:disabled="isBusy || !outputOpen || !playbackTracks(section.id).length"
@click="emit('command', { action: 'group', channel: 'music', groupId: section.id })"
>Играть группу</n-button
>
</n-space>
<p v-if="section.id">{{ completionLabel(section.endMode) }}</p>
<p v-if="!playbackTracks(section.id).length">Треков пока нет.</p>
<ul class="track-list">
<li
v-for="{ track, index } in playbackTracks(section.id)"
:key="track.id"
class="track-row"
:class="{ 'track-current': isCurrent(index) }"
:data-track-index="index"
:aria-current="isCurrent(index) ? 'true' : undefined"
>
<div class="track-content">
<div class="track-title">
<span>{{ track.name || `Трек ${index + 1}` }}</span>
<n-button
data-testid="select-track"
size="small"
:disabled="isBusy || (isCurrent(index) && channel.groupId === section.id)"
@click="
emit('command', { action: 'track', channel: 'music', trackIndex: index })
"
>{{
isCurrent(index) && channel.groupId === section.id ? 'Выбран' : 'Выбрать'
}}</n-button
>
</div>
</div>
</li>
</ul>
</section>
</div>
</div>
</n-card>
</template>
<style scoped>
.music-groups {
display: grid;
gap: 12px;
}
.music-group {
border-top: 1px solid var(--n-border-color);
display: grid;
gap: 8px;
padding-top: 12px;
}
.music-group h4 {
margin: 0;
}
.music-plan-view {
display: flex;
flex-direction: column;
@@ -432,7 +858,6 @@ function remove(trackId?: number) {
border-color: var(--brand-text);
}
.track-content {
display: flex;
flex: 1 1 380px;
+7 -2
View File
@@ -1,4 +1,4 @@
import type { ScreenStep } from './screen.js'
import type { ScreenStep, MusicGroup } from './screen.js'
export const DEFAULT_SERVER = 'https://evening-detective.crabs-games.art'
export type GameStatus = 'draft' | 'run' | 'pause' | 'finish'
@@ -7,7 +7,8 @@ export type GameCommand = 'start' | 'pause' | 'play' | 'finish' | 'reset'
export interface MusicPlaylist {
id: number
name: string
tracks: { id: number; name: string }[]
groups?: MusicGroup[]
tracks: { id: number; name: string; groupId?: number }[]
}
export interface GameMaterial {
@@ -27,6 +28,10 @@ export type OnlineIntent =
| { action: 'playlist-delete' | 'playlist-upload' | 'playlist-use'; playlistId: number }
| { action: 'playlist-track-delete'; playlistId: number; trackId: number }
| { action: 'playlist-reorder'; playlistId: number; trackIds: number[] }
| { action: 'group-save'; playlistId: number; groupId: number; name: string; endMode: MusicGroup['endMode'] }
| { action: 'group-delete'; playlistId: number; groupId: number }
| { action: 'group-reorder'; playlistId: number; groupIds: number[] }
| { action: 'track-group'; playlistId: number; trackId: number; groupId: number }
| { action: 'plan-save'; key: string; steps: ScreenStep[] }
| { action: 'plan-refresh' }
| { action: 'login'; server: string; email: string; password: string }
+10 -1
View File
@@ -10,6 +10,7 @@ export type Intent =
| { action: 'select'; channel: 'main'; sourceId: string }
| { action: 'playlist'; channel: 'music'; playlistId: number }
| { action: 'track'; channel: 'music'; trackIndex: number }
| { action: 'group'; channel: 'music'; groupId: number }
| { action: 'next'; channel: 'music' }
| { action: 'loop'; channel: 'music'; enabled: boolean }
| { action: 'mode'; mode: ScreenMode }
@@ -26,10 +27,17 @@ export interface MediaSource {
url: string
ready?: boolean
}
export interface MusicGroup {
id: number
name: string
position: number
endMode: 'repeat' | 'stop' | 'next'
}
export interface Playlist {
id: number
name: string
tracks: { id: number; name: string; source: MediaSource }[]
groups?: MusicGroup[]
tracks: { id: number; name: string; groupId?: number; source: MediaSource }[]
}
export type TimerType = 0 | 1 | 2
export type ScreenMode = 1 | 2 | 4 | 6 | 7 | 8 | 9 | 10
@@ -103,6 +111,7 @@ export interface ChannelState {
error: string
playlistId: number | null
trackIndex: number
groupId: number
loop: boolean
}
export interface TeamsSnapshot {
+4 -2
View File
@@ -1,3 +1,4 @@
import { nextMusicTrack } from './musicGroups.js'
import type { Channel, ChannelState, Report, State } from '../types/screen.js'
export function waitUntil(
@@ -165,8 +166,9 @@ export class MediaPlayer {
const active = this.channels.music.media
if (!state || !active) return
const c = state.channels.music
const tracks = state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks ?? []
const next = tracks[c.trackIndex + 1] ?? (c.loop ? tracks[0] : undefined)
const playlist = state.catalog.playlists.find((p) => p.id === c.playlistId)
const transition = playlist ? nextMusicTrack(playlist, c.trackIndex, c.groupId, c.loop) : null
const next = transition ? playlist?.tracks[transition.index] : undefined
const spare = this.elements.music.find((media) => media !== active)!
spare.pause()
spare.muted = true
+33
View File
@@ -0,0 +1,33 @@
import type { MusicGroup } from '../types/screen.js'
// The catalogue supplies groups in playback order and tracks in their global order.
export function nextMusicTrack(
playlist: { groups?: MusicGroup[]; tracks: { groupId?: number }[] },
index: number,
groupId: number,
loop: boolean,
): { index: number; groupId: number } | null {
const tracks = playlist.tracks
if (!tracks.length) return null
if (!groupId) {
if (index + 1 < tracks.length) return { index: index + 1, groupId: 0 }
return loop ? { index: 0, groupId: 0 } : null
}
const groups = playlist.groups ?? []
const position = groups.findIndex((group) => group.id === groupId)
const group = groups[position]
if (!group) return null
const following = tracks.findIndex((track, i) => i > index && track.groupId === groupId)
if (following >= 0) return { index: following, groupId }
if (group.endMode === 'repeat') {
const first = tracks.findIndex((track) => track.groupId === groupId)
return first < 0 ? null : { index: first, groupId }
}
if (group.endMode === 'next') {
for (const next of groups.slice(position + 1)) {
const first = tracks.findIndex((track) => track.groupId === next.id)
if (first >= 0) return { index: first, groupId: next.id }
}
}
return null
}