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