diff --git a/electron/api/generated/crabs/evening_detective_server/index.ts b/electron/api/generated/crabs/evening_detective_server/index.ts index 6b0220e..044bda8 100644 --- a/electron/api/generated/crabs/evening_detective_server/index.ts +++ b/electron/api/generated/crabs/evening_detective_server/index.ts @@ -2,6 +2,10 @@ /* eslint-disable camelcase */ // @ts-nocheck +export type PlaylistGroupEndMode = + | "PLAYLIST_GROUP_END_MODE_REPEAT" + | "PLAYLIST_GROUP_END_MODE_STOP" + | "PLAYLIST_GROUP_END_MODE_NEXT"; export type ScreenMode = | "SCREEN_MODE_UNSPECIFIED" | "SCREEN_MODE_COVER" @@ -208,17 +212,65 @@ export type DownloadFileReq = { filename: string | undefined; }; -export type PlaylistTrack = { +export type PlaylistGroup = { ID: number | undefined; name: string | undefined; - filename: string | undefined; position: number | undefined; + endMode: PlaylistGroupEndMode | undefined; +}; + +export type SavePlaylistGroupReq = { + playlistID: number | undefined; + groupID: number | undefined; + name: string | undefined; + endMode: PlaylistGroupEndMode | undefined; +}; + +export type SavePlaylistGroupRsp = { + playlist: Playlist | undefined; }; export type Playlist = { ID: number | undefined; name: string | undefined; tracks: PlaylistTrack[] | undefined; + groups: PlaylistGroup[] | undefined; +}; + +export type PlaylistTrack = { + ID: number | undefined; + name: string | undefined; + filename: string | undefined; + position: number | undefined; + groupId: number | undefined; +}; + +export type DeletePlaylistGroupReq = { + playlistID: number | undefined; + groupID: number | undefined; +}; + +export type DeletePlaylistGroupRsp = { + playlist: Playlist | undefined; +}; + +export type ReorderPlaylistGroupsReq = { + playlistID: number | undefined; + groupIDs: number[] | undefined; +}; + +export type ReorderPlaylistGroupsRsp = { + playlist: Playlist | undefined; +}; + +export type SetPlaylistTrackGroupReq = { + playlistID: number | undefined; + trackID: number | undefined; + groupID: number | undefined; +}; + +export type SetPlaylistTrackGroupRsp = { + playlist: Playlist | undefined; }; export type GetPlaylistsReq = { @@ -649,6 +701,7 @@ export type MusicState = { loop: boolean | undefined; playback: MusicPlaybackState | undefined; stateVersion: number | undefined; + groupId: number | undefined; }; export type CreateScreenSessionReq = { @@ -695,6 +748,7 @@ export type SetGameMusicReq = { volume: number | undefined; loop: boolean | undefined; playback: MusicPlaybackState | undefined; + groupId: number | undefined; }; export type SetGameMusicRsp = { @@ -704,6 +758,7 @@ export type SetGameMusicRsp = { export type AdvanceScreenMusicReq = { screenID: string | undefined; accessToken: string | undefined; + expectedStateVersion: string | undefined; }; export type AdvanceScreenMusicRsp = { @@ -1429,6 +1484,10 @@ export interface EveningDetectiveServer { GetPermissions(request: GetPermissionsReq): Promise; UploadFile(request: UploadFileReq): Promise; DownloadFile(request: DownloadFileReq): Promise; + SavePlaylistGroup(request: SavePlaylistGroupReq): Promise; + DeletePlaylistGroup(request: DeletePlaylistGroupReq): Promise; + ReorderPlaylistGroups(request: ReorderPlaylistGroupsReq): Promise; + SetPlaylistTrackGroup(request: SetPlaylistTrackGroupReq): Promise; GetPlaylists(request: GetPlaylistsReq): Promise; CreatePlaylist(request: CreatePlaylistReq): Promise; UpdatePlaylist(request: UpdatePlaylistReq): Promise; @@ -2066,6 +2125,92 @@ export function createEveningDetectiveServerClient( method: "DownloadFile", }) as Promise; }, + SavePlaylistGroup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.playlistID) { + throw new Error("missing required field request.playlistID"); + } + const path = `api/playlists/${request.playlistID}/groups`; // eslint-disable-line quotes + const body = JSON.stringify(request); + const queryParams: string[] = []; + let uri = path; + if (queryParams.length > 0) { + uri += `?${queryParams.join("&")}` + } + return handler({ + path: uri, + method: "POST", + body, + }, { + service: "EveningDetectiveServer", + method: "SavePlaylistGroup", + }) as Promise; + }, + DeletePlaylistGroup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.playlistID) { + throw new Error("missing required field request.playlistID"); + } + if (!request.groupID) { + throw new Error("missing required field request.groupID"); + } + const path = `api/playlists/${request.playlistID}/groups/${request.groupID}`; // eslint-disable-line quotes + const body = null; + const queryParams: string[] = []; + let uri = path; + if (queryParams.length > 0) { + uri += `?${queryParams.join("&")}` + } + return handler({ + path: uri, + method: "DELETE", + body, + }, { + service: "EveningDetectiveServer", + method: "DeletePlaylistGroup", + }) as Promise; + }, + ReorderPlaylistGroups(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.playlistID) { + throw new Error("missing required field request.playlistID"); + } + const path = `api/playlists/${request.playlistID}/groups/order`; // eslint-disable-line quotes + const body = JSON.stringify(request); + const queryParams: string[] = []; + let uri = path; + if (queryParams.length > 0) { + uri += `?${queryParams.join("&")}` + } + return handler({ + path: uri, + method: "PUT", + body, + }, { + service: "EveningDetectiveServer", + method: "ReorderPlaylistGroups", + }) as Promise; + }, + SetPlaylistTrackGroup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars + if (!request.playlistID) { + throw new Error("missing required field request.playlistID"); + } + if (!request.trackID) { + throw new Error("missing required field request.trackID"); + } + const path = `api/playlists/${request.playlistID}/tracks/${request.trackID}/group`; // eslint-disable-line quotes + const body = JSON.stringify(request); + const queryParams: string[] = []; + let uri = path; + if (queryParams.length > 0) { + uri += `?${queryParams.join("&")}` + } + return handler({ + path: uri, + method: "PUT", + body, + }, { + service: "EveningDetectiveServer", + method: "SetPlaylistTrackGroup", + }) as Promise; + }, GetPlaylists(request) { // eslint-disable-line @typescript-eslint/no-unused-vars const path = `api/playlists`; // eslint-disable-line quotes const body = null; diff --git a/electron/online.test.ts b/electron/online.test.ts index 97d0ce1..699ce17 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -67,6 +67,9 @@ async function fixture(t: TestContext, secure = true) { refreshCalls: 0, refreshResponse: null as (() => Promise) | null, advanceCalls: 0, + advanceRequest: null as Record | null, + advanceResponse: null as Record | null, + advanceError: 0, resultError: false, teamsError: false, screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }], @@ -140,7 +143,9 @@ async function fixture(t: TestContext, secure = true) { } if (path === '/api/screens/music/advance') { model.advanceCalls++ - return json({ state: objectMusic(3, 1) }) + model.advanceRequest = JSON.parse(String(init?.body)) as Record + if (model.advanceError) return json({}, model.advanceError) + return json({ state: model.advanceResponse ?? objectMusic(3, 1) }) } if (path === '/api/screens/teams') return model.teamsError ? json({}, 500) : json({ teams: model.screenTeams }) if (path === '/api/screens/results') { @@ -258,6 +263,7 @@ async function fixture(t: TestContext, secure = true) { playback: body.playback, volume: body.volume, loop: body.loop, + groupId: body.groupId, positionMs: body.positionMs, } return json({ state: model.remote.music }) @@ -2240,3 +2246,47 @@ test('список команд обновляется, сохраняется await restored.select(null) assert.equal(c.state.teams, null) }) + +for (const action of ['volume', 'pause', 'resume', 'replay'] as const) { + test(`группа сохраняется при команде ${action} нативного плеера`, async (t) => { + const f = await fixture(t) + 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 }), /активной группы/) + }) +} + +test('повтор одного трека группы передаёт точную версию и перезапускает трек', async (t) => { + const f = await fixture(t) + 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) + assert.ok(f.controller.state.channels.music.operation > before) + assert.equal(f.controller.state.channels.music.trackIndex, 0) +}) + +test('группа останавливается при потере связи вместо выхода в общий список', async (t) => { + const f = await fixture(t) + 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' }) + 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) +}) diff --git a/electron/online.ts b/electron/online.ts index 55368db..2d90d41 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -624,6 +624,14 @@ 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') + ) + throw new Error('Треки и режим завершения активной группы меняются через сайт. Для обычного списка выберите весь плейлист.') const localVersion = ++this.localCommandVersion const localGeneration = this.generation const localSession = this.controller.state.session @@ -707,6 +715,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, trackIndex: c.trackIndex, positionMs: Math.max(0, Math.round(c.position * 1000)), volume: Math.round(c.volume), @@ -1673,6 +1682,7 @@ export class Online { await client.SetGameMusic({ gameID: pkg.package.game.id, playlistID: id, + groupId: 0, trackIndex: 0, positionMs: 0, volume: this.controller.state.channels.music.volume, @@ -1903,10 +1913,13 @@ export class Online { const result = await this.client(false, AbortSignal.timeout(5000)).AdvanceScreenMusic({ screenID: this.screen.id, accessToken: this.screen.token, + expectedStateVersion: this.received?.music?.version.toString(), }) if (generation !== this.generation || report.session !== this.controller.state.session) 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) + this.musicApplied = null this.received = { ...this.received, music } await this.applyReceived(true) } @@ -1914,7 +1927,9 @@ export class Online { if (generation !== this.generation || report.session !== this.controller.state.session) return const pkg = this.selectedGame()?.package const playlist = pkg?.playlists.find((p) => p.id === c.playlistId) - if (pkg && playlist) { + 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] diff --git a/electron/remote-state.ts b/electron/remote-state.ts index 6218516..2827d4a 100644 --- a/electron/remote-state.ts +++ b/electron/remote-state.ts @@ -79,6 +79,7 @@ export interface RemoteState { music: { version: bigint playlistId: number | null + groupId: number trackId: number | null trackIds: number[] filenames: string[] @@ -156,6 +157,7 @@ export function parseRemote(value: unknown): RemoteState { const index = integer(m.trackIndex ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.trackIndex') music = { version: version(m.stateVersion, 'state.music.stateVersion'), + groupId: integer(m.groupId ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.groupId'), playlistId: p ? integer(p.ID, 1, Number.MAX_SAFE_INTEGER, 'state.music.playlist.ID') : null, trackId: trackIds[index] ?? null, trackIds,