diff --git a/proto/main.proto b/proto/main.proto index b437909..75db0a7 100644 --- a/proto/main.proto +++ b/proto/main.proto @@ -342,6 +342,49 @@ service EveningDetectiveServer { }; } + rpc SavePlaylistGroup(SavePlaylistGroupReq) returns (SavePlaylistGroupRsp) { + option (google.api.http) = { + post: "/api/playlists/{playlistID}/groups" + body: "*" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags : "Музыка"; + summary: "Сохранить группу плейлиста"; + }; + } + + rpc DeletePlaylistGroup(DeletePlaylistGroupReq) returns (DeletePlaylistGroupRsp) { + option (google.api.http) = { + delete: "/api/playlists/{playlistID}/groups/{groupID}" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags : "Музыка"; + summary: "Удалить группу, сохранив треки"; + }; + } + + rpc ReorderPlaylistGroups(ReorderPlaylistGroupsReq) returns (ReorderPlaylistGroupsRsp) { + option (google.api.http) = { + put: "/api/playlists/{playlistID}/groups/order" + body: "*" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags : "Музыка"; + summary: "Изменить порядок групп плейлиста"; + }; + } + + rpc SetPlaylistTrackGroup(SetPlaylistTrackGroupReq) returns (SetPlaylistTrackGroupRsp) { + option (google.api.http) = { + put: "/api/playlists/{playlistID}/tracks/{trackID}/group" + body: "*" + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags : "Музыка"; + summary: "Назначить треку группу"; + }; + } + rpc GetPlaylists(GetPlaylistsReq) returns (GetPlaylistsRsp) { option (google.api.http) = { get: "/api/playlists" }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { @@ -1400,17 +1443,71 @@ message DownloadFileReq { string filename = 1; } +enum PlaylistGroupEndMode { + PLAYLIST_GROUP_END_MODE_REPEAT = 0; + PLAYLIST_GROUP_END_MODE_STOP = 1; + PLAYLIST_GROUP_END_MODE_NEXT = 2; +} + +message PlaylistGroup { + int32 ID = 1; + string name = 2; + int32 position = 3; + PlaylistGroupEndMode end_mode = 4; +} + +message SavePlaylistGroupReq { + int32 playlistID = 1; + int32 groupID = 2; + string name = 3; + PlaylistGroupEndMode end_mode = 4; +} + +message SavePlaylistGroupRsp { + Playlist playlist = 1; +} + +message DeletePlaylistGroupReq { + int32 playlistID = 1; + int32 groupID = 2; +} + +message DeletePlaylistGroupRsp { + Playlist playlist = 1; +} + +message ReorderPlaylistGroupsReq { + int32 playlistID = 1; + repeated int32 groupIDs = 2; +} + +message ReorderPlaylistGroupsRsp { + Playlist playlist = 1; +} + +message SetPlaylistTrackGroupReq { + int32 playlistID = 1; + int32 trackID = 2; + int32 groupID = 3; +} + +message SetPlaylistTrackGroupRsp { + Playlist playlist = 1; +} + message PlaylistTrack { int32 ID = 1; string name = 2; string filename = 3; int32 position = 4; + int32 group_id = 5; } message Playlist { int32 ID = 1; string name = 2; repeated PlaylistTrack tracks = 3; + repeated PlaylistGroup groups = 4; } message GetPlaylistsReq {} @@ -1885,6 +1982,7 @@ message MusicState { bool loop = 5; MusicPlaybackState playback = 6; int64 state_version = 7; + int32 group_id = 8; } message GetGameMusicReq { @@ -1906,6 +2004,7 @@ message SetGameMusicReq { int32 volume = 5; bool loop = 6; MusicPlaybackState playback = 7; + int32 group_id = 8; } message SetGameMusicRsp { @@ -1915,6 +2014,7 @@ message SetGameMusicRsp { message AdvanceScreenMusicReq { string screenID = 1; string accessToken = 2; + string expected_state_version = 3; } message AdvanceScreenMusicRsp { @@ -2052,10 +2152,12 @@ message ScreenTeam { string ID = 1; string name = 2; } + message GetScreenTeamsReq { string screenID = 1; string access_token = 2; } + message GetScreenTeamsRsp { repeated ScreenTeam teams = 1; } diff --git a/src/api/generated/crabs/evening_detective_server/index.ts b/src/api/generated/crabs/evening_detective_server/index.ts index 6b0220e..044bda8 100644 --- a/src/api/generated/crabs/evening_detective_server/index.ts +++ b/src/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/src/components/GameMusicPlanPanel.vue b/src/components/GameMusicPlanPanel.vue index 8fc0ed6..d02a6fb 100644 --- a/src/components/GameMusicPlanPanel.vue +++ b/src/components/GameMusicPlanPanel.vue @@ -14,7 +14,12 @@ import { } from 'naive-ui' import { computed, ref, watch } from 'vue' -import type { MusicState, Playlist } from '@/api/generated/crabs/evening_detective_server' +import type { + MusicState, + Playlist, + PlaylistGroup, +} from '@/api/generated/crabs/evening_detective_server' +import PlaylistGroupEditor from '@/components/PlaylistGroupEditor.vue' import UiBlock from '@/components/UiBlock.vue' const props = defineProps<{ @@ -25,6 +30,7 @@ const props = defineProps<{ const emit = defineEmits<{ setMusic: [ change: { + groupId?: number playlistID?: number trackIndex?: number positionMs?: number @@ -33,6 +39,10 @@ const emit = defineEmits<{ volume?: number }, ] + saveGroup: [playlistID: number, groupID: number, name: string, endMode: PlaylistGroup['endMode']] + deleteGroup: [playlistID: number, groupID: number] + reorderGroups: [playlistID: number, groupIDs: number[]] + setTrackGroup: [playlistID: number, trackID: number, groupID: number] createPlaylist: [name: string] renamePlaylist: [id: number, name: string] reorderTracks: [playlistID: number, trackIDs: number[]] @@ -45,11 +55,32 @@ const isEditing = ref(false) const selectedId = ref(null) const name = ref('') const newName = ref('') +const newGroupName = ref('') +const dirtyGroups = ref>({}) +const hasGroupDraft = computed(() => Object.values(dirtyGroups.value).some(Boolean)) const volume = ref(props.music?.volume ?? 100) const originalName = ref('') const order = ref([]) const originalOrder = ref([]) -const selected = computed(() => props.playlists.find((item) => item.ID === selectedId.value)) +const lastSelected = ref() +const selected = computed( + () => + props.playlists.find((item) => item.ID === selectedId.value) ?? + (isEditing.value && hasGroupDraft.value ? lastSelected.value : undefined), +) +const isSelectedMissing = computed( + () => !!selectedId.value && !props.playlists.some((item) => item.ID === selectedId.value), +) +const cachedGroups = ref([]) +const editorGroups = computed(() => [ + ...(selected.value?.groups ?? []), + ...cachedGroups.value.filter( + (group) => + group.ID != null && + dirtyGroups.value[group.ID] && + !selected.value?.groups?.some((item) => item.ID === group.ID), + ), +]) const options = computed(() => props.playlists.map((item) => ({ label: item.name, value: item.ID })), ) @@ -59,10 +90,78 @@ const isOrderDirty = computed( const isNameDirty = computed(() => name.value !== originalName.value) const hasConflict = computed( () => - !!selected.value && - JSON.stringify(selected.value.tracks?.map((track) => track.ID) ?? []) !== - JSON.stringify(originalOrder.value), + isSelectedMissing.value || + (!!selected.value && + JSON.stringify(selected.value.tracks?.map((track) => track.ID) ?? []) !== + JSON.stringify(originalOrder.value)), ) +const sections = computed(() => [ + ...editorGroups.value.map((group) => ({ + ...group, + ID: group.ID ?? 0, + endMode: group.endMode ?? 'PLAYLIST_GROUP_END_MODE_REPEAT', + })), + { ID: 0, name: 'Без группы', endMode: undefined, position: undefined }, +]) +const playbackSections = computed(() => [ + ...(props.music?.playlist?.groups ?? []).map((group) => ({ + ...group, + ID: group.ID ?? 0, + endMode: group.endMode ?? 'PLAYLIST_GROUP_END_MODE_REPEAT', + })), + { ID: 0, name: 'Без группы', endMode: undefined, position: undefined }, +]) +const groupOptions = computed(() => + sections.value.map((group) => ({ label: group.name, value: group.ID })), +) +const isPlaylistActive = computed( + () => + selectedId.value === props.music?.playlist?.ID && + props.music?.playback !== 'MUSIC_PLAYBACK_STOPPED', +) +function isGroupLocked(groupID: number) { + return isPlaylistActive.value && (!props.music?.groupId || props.music.groupId === groupID) +} +function trackIds(groupID: number) { + return order.value.filter( + (id) => (selected.value?.tracks?.find((track) => track.ID === id)?.groupId ?? 0) === groupID, + ) +} +function playbackTracks(groupID: number) { + return (props.music?.playlist?.tracks ?? []) + .map((track, index) => ({ track, index })) + .filter(({ track }) => (track.groupId ?? 0) === groupID) +} +function playGroup(groupID: number) { + const index = props.music?.playlist?.tracks?.findIndex((track) => track.groupId === groupID) ?? -1 + if (index < 0) return + emit('setMusic', { + groupId: groupID, + trackIndex: index, + positionMs: 0, + playback: 'MUSIC_PLAYBACK_PLAYING', + }) +} +function saveGroup(groupID: number, groupName: string, endMode: PlaylistGroup['endMode']) { + const playlistID = selectedId.value + if (playlistID == null) return + emit('saveGroup', playlistID, groupID, groupName, endMode) +} +function moveGroup(groupID: number, offset: number) { + const ids = selected.value?.groups?.flatMap((group) => (group.ID == null ? [] : [group.ID])) ?? [] + const index = ids.indexOf(groupID) + const target = index + offset + if (!selectedId.value || index < 0 || target < 0 || target >= ids.length) return + const moved = ids.splice(index, 1)[0] + if (moved === undefined) return + ids.splice(target, 0, moved) + emit('reorderGroups', selectedId.value, ids) +} +function modeLabel(mode: PlaylistGroup['endMode']) { + if (mode === 'PLAYLIST_GROUP_END_MODE_STOP') return 'После группы — тишина' + if (mode === 'PLAYLIST_GROUP_END_MODE_NEXT') return 'После группы — следующая' + return 'Повтор группы' +} const canPlay = computed(() => !!props.music?.playlist?.tracks?.length && !props.isBusy) const isPlaying = computed(() => props.music?.playback === 'MUSIC_PLAYBACK_PLAYING') const playbackLabel = computed(() => @@ -81,34 +180,42 @@ function reset() { order.value = selected.value?.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? [] originalOrder.value = [...order.value] + newGroupName.value = '' } function edit() { selectedId.value = props.music?.playlist?.ID ?? props.playlists[0]?.ID ?? null + lastSelected.value = selected.value + cachedGroups.value = selected.value?.groups ?? [] reset() isEditing.value = true } function finish() { if ( - (isOrderDirty.value || isNameDirty.value) && + (isOrderDirty.value || isNameDirty.value || hasGroupDraft.value || newGroupName.value.trim()) && !window.confirm('Отменить несохранённые изменения плейлиста?') ) return reset() isEditing.value = false + dirtyGroups.value = {} } -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 = trackIds(groupID) + const index = ids.indexOf(id) + const targetID = ids[index + offset] + if (index < 0 || targetID == null) return + const from = order.value.indexOf(id) + const to = order.value.indexOf(targetID) + order.value[from] = targetID + order.value[to] = id } function uploadTrack(options: { file: UploadFileInfo }) { if (selectedId.value && options.file.file) emit('uploadTrack', selectedId.value, options.file.file) } -function selectTrack(index: number) { - if (index === (props.music?.trackIndex ?? 0)) return - emit('setMusic', { trackIndex: index, positionMs: 0 }) +function selectTrack(index: number, groupID: number) { + if (index === (props.music?.trackIndex ?? 0) && groupID === (props.music?.groupId ?? 0)) return + emit('setMusic', { trackIndex: index, groupId: groupID, positionMs: 0 }) } watch( () => props.music?.volume, @@ -116,18 +223,30 @@ watch( volume.value = value ?? 100 }, ) -watch(selectedId, reset) +watch(selectedId, () => { + lastSelected.value = props.playlists.find((item) => item.ID === selectedId.value) + cachedGroups.value = lastSelected.value?.groups ?? [] + dirtyGroups.value = {} + reset() +}) watch( () => props.playlists, (items, previous) => { if (!isEditing.value) return + const previousGroups = previous.find((item) => item.ID === selectedId.value)?.groups ?? [] + const addedGroup = selected.value?.groups?.find( + (group) => !previousGroups.some((old) => old.ID === group.ID), + ) + if (props.isBusy && addedGroup?.name === newGroupName.value.trim()) newGroupName.value = '' const added = items.find((item) => !previous.some((old) => old.ID === item.ID)) if ( added && props.isBusy && added.name === newName.value.trim() && !isOrderDirty.value && - !isNameDirty.value + !isNameDirty.value && + !hasGroupDraft.value && + !newGroupName.value.trim() ) { selectedId.value = added.ID ?? null newName.value = '' @@ -135,11 +254,30 @@ watch( return } if (!selected.value) { - if (isOrderDirty.value || isNameDirty.value) return + if ( + isOrderDirty.value || + isNameDirty.value || + hasGroupDraft.value || + newGroupName.value.trim() + ) + return selectedId.value = null reset() return } + const current = items.find((item) => item.ID === selectedId.value) + if (current) { + lastSelected.value = current + cachedGroups.value = [ + ...(current.groups ?? []), + ...cachedGroups.value.filter( + (group) => + group.ID != null && + dirtyGroups.value[group.ID] && + !current.groups?.some((item) => item.ID === group.ID), + ), + ] + } if (!isNameDirty.value || name.value.trim() === selected.value.name) { name.value = selected.value.name ?? '' originalName.value = name.value @@ -171,7 +309,13 @@ watch( Готово - + Создать @@ -189,11 +333,11 @@ watch( v-model:value="selectedId" :options="options" placeholder="Плейлист для редактирования" - :disabled="isBusy || isOrderDirty || isNameDirty" + :disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft" :input-props="{ 'aria-label': 'Плейлист для редактирования' }" />

Плейлистов пока нет.

- + Плейлист удалён на сервере. Несохранённый черновик сохранён до отмены. Загрузить актуальный списокУдалить плейлист @@ -265,42 +409,101 @@ watch( hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения' }}
-

Треков пока нет.

-
- {{ index + 1 }}. - {{ - selected.tracks?.find((track) => track.ID === id)?.name ?? 'Трек удалён на сервере' - }} + + Добавить группу - - ↑ - ↓ - - +

+ Для изменения активной группы и порядка групп остановите музыку. Другие игры также могут + блокировать редактирование. +

+ +
+
+

{{ section.name }}

+ +

Треков пока нет.

+
+ {{ index + 1 }}. + {{ + selected.tracks?.find((track) => track.ID === id)?.name ?? + 'Трек удалён на сервере' + }} - Удалить трек из плейлиста? - - + + + ↑ + ↓ + + + Удалить трек из плейлиста? + + +
+
@@ -324,6 +527,7 @@ watch( />

Выберите плейлист для игры.

В плейлисте пока нет треков.

-
Играть весь плейлист - {{ index + 1 }}. {{ track.name }} - {{ index === (music?.trackIndex ?? 0) ? 'Выбран' : 'Выбрать' }} + +

+ {{ section.name }}{{ section.ID && section.ID === music?.groupId ? ' · Выбрана' : '' }} +

+ Играть группу +
+

{{ modeLabel(section.endMode) }}

+

Треков пока нет.

+
-
+ {{ position + 1 }}. {{ track.name }} + {{ + index === (music?.trackIndex ?? 0) && section.ID === (music?.groupId ?? 0) + ? 'Выбран' + : 'Выбрать' + }} +
+