generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -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) {
|
rpc GetPlaylists(GetPlaylistsReq) returns (GetPlaylistsRsp) {
|
||||||
option (google.api.http) = { get: "/api/playlists" };
|
option (google.api.http) = { get: "/api/playlists" };
|
||||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
@@ -1400,17 +1443,71 @@ message DownloadFileReq {
|
|||||||
string filename = 1;
|
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 {
|
message PlaylistTrack {
|
||||||
int32 ID = 1;
|
int32 ID = 1;
|
||||||
string name = 2;
|
string name = 2;
|
||||||
string filename = 3;
|
string filename = 3;
|
||||||
int32 position = 4;
|
int32 position = 4;
|
||||||
|
int32 group_id = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Playlist {
|
message Playlist {
|
||||||
int32 ID = 1;
|
int32 ID = 1;
|
||||||
string name = 2;
|
string name = 2;
|
||||||
repeated PlaylistTrack tracks = 3;
|
repeated PlaylistTrack tracks = 3;
|
||||||
|
repeated PlaylistGroup groups = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetPlaylistsReq {}
|
message GetPlaylistsReq {}
|
||||||
@@ -1885,6 +1982,7 @@ message MusicState {
|
|||||||
bool loop = 5;
|
bool loop = 5;
|
||||||
MusicPlaybackState playback = 6;
|
MusicPlaybackState playback = 6;
|
||||||
int64 state_version = 7;
|
int64 state_version = 7;
|
||||||
|
int32 group_id = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetGameMusicReq {
|
message GetGameMusicReq {
|
||||||
@@ -1906,6 +2004,7 @@ message SetGameMusicReq {
|
|||||||
int32 volume = 5;
|
int32 volume = 5;
|
||||||
bool loop = 6;
|
bool loop = 6;
|
||||||
MusicPlaybackState playback = 7;
|
MusicPlaybackState playback = 7;
|
||||||
|
int32 group_id = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SetGameMusicRsp {
|
message SetGameMusicRsp {
|
||||||
@@ -1915,6 +2014,7 @@ message SetGameMusicRsp {
|
|||||||
message AdvanceScreenMusicReq {
|
message AdvanceScreenMusicReq {
|
||||||
string screenID = 1;
|
string screenID = 1;
|
||||||
string accessToken = 2;
|
string accessToken = 2;
|
||||||
|
string expected_state_version = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdvanceScreenMusicRsp {
|
message AdvanceScreenMusicRsp {
|
||||||
@@ -2052,10 +2152,12 @@ message ScreenTeam {
|
|||||||
string ID = 1;
|
string ID = 1;
|
||||||
string name = 2;
|
string name = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetScreenTeamsReq {
|
message GetScreenTeamsReq {
|
||||||
string screenID = 1;
|
string screenID = 1;
|
||||||
string access_token = 2;
|
string access_token = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetScreenTeamsRsp {
|
message GetScreenTeamsRsp {
|
||||||
repeated ScreenTeam teams = 1;
|
repeated ScreenTeam teams = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
|
export type PlaylistGroupEndMode =
|
||||||
|
| "PLAYLIST_GROUP_END_MODE_REPEAT"
|
||||||
|
| "PLAYLIST_GROUP_END_MODE_STOP"
|
||||||
|
| "PLAYLIST_GROUP_END_MODE_NEXT";
|
||||||
export type ScreenMode =
|
export type ScreenMode =
|
||||||
| "SCREEN_MODE_UNSPECIFIED"
|
| "SCREEN_MODE_UNSPECIFIED"
|
||||||
| "SCREEN_MODE_COVER"
|
| "SCREEN_MODE_COVER"
|
||||||
@@ -208,17 +212,65 @@ export type DownloadFileReq = {
|
|||||||
filename: string | undefined;
|
filename: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlaylistTrack = {
|
export type PlaylistGroup = {
|
||||||
ID: number | undefined;
|
ID: number | undefined;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
filename: string | undefined;
|
|
||||||
position: number | 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 = {
|
export type Playlist = {
|
||||||
ID: number | undefined;
|
ID: number | undefined;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
tracks: PlaylistTrack[] | 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 = {
|
export type GetPlaylistsReq = {
|
||||||
@@ -649,6 +701,7 @@ export type MusicState = {
|
|||||||
loop: boolean | undefined;
|
loop: boolean | undefined;
|
||||||
playback: MusicPlaybackState | undefined;
|
playback: MusicPlaybackState | undefined;
|
||||||
stateVersion: number | undefined;
|
stateVersion: number | undefined;
|
||||||
|
groupId: number | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateScreenSessionReq = {
|
export type CreateScreenSessionReq = {
|
||||||
@@ -695,6 +748,7 @@ export type SetGameMusicReq = {
|
|||||||
volume: number | undefined;
|
volume: number | undefined;
|
||||||
loop: boolean | undefined;
|
loop: boolean | undefined;
|
||||||
playback: MusicPlaybackState | undefined;
|
playback: MusicPlaybackState | undefined;
|
||||||
|
groupId: number | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetGameMusicRsp = {
|
export type SetGameMusicRsp = {
|
||||||
@@ -704,6 +758,7 @@ export type SetGameMusicRsp = {
|
|||||||
export type AdvanceScreenMusicReq = {
|
export type AdvanceScreenMusicReq = {
|
||||||
screenID: string | undefined;
|
screenID: string | undefined;
|
||||||
accessToken: string | undefined;
|
accessToken: string | undefined;
|
||||||
|
expectedStateVersion: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdvanceScreenMusicRsp = {
|
export type AdvanceScreenMusicRsp = {
|
||||||
@@ -1429,6 +1484,10 @@ export interface EveningDetectiveServer {
|
|||||||
GetPermissions(request: GetPermissionsReq): Promise<GetPermissionsRsp>;
|
GetPermissions(request: GetPermissionsReq): Promise<GetPermissionsRsp>;
|
||||||
UploadFile(request: UploadFileReq): Promise<UploadFileRsp>;
|
UploadFile(request: UploadFileReq): Promise<UploadFileRsp>;
|
||||||
DownloadFile(request: DownloadFileReq): Promise<googleapi_HttpBody>;
|
DownloadFile(request: DownloadFileReq): Promise<googleapi_HttpBody>;
|
||||||
|
SavePlaylistGroup(request: SavePlaylistGroupReq): Promise<SavePlaylistGroupRsp>;
|
||||||
|
DeletePlaylistGroup(request: DeletePlaylistGroupReq): Promise<DeletePlaylistGroupRsp>;
|
||||||
|
ReorderPlaylistGroups(request: ReorderPlaylistGroupsReq): Promise<ReorderPlaylistGroupsRsp>;
|
||||||
|
SetPlaylistTrackGroup(request: SetPlaylistTrackGroupReq): Promise<SetPlaylistTrackGroupRsp>;
|
||||||
GetPlaylists(request: GetPlaylistsReq): Promise<GetPlaylistsRsp>;
|
GetPlaylists(request: GetPlaylistsReq): Promise<GetPlaylistsRsp>;
|
||||||
CreatePlaylist(request: CreatePlaylistReq): Promise<CreatePlaylistRsp>;
|
CreatePlaylist(request: CreatePlaylistReq): Promise<CreatePlaylistRsp>;
|
||||||
UpdatePlaylist(request: UpdatePlaylistReq): Promise<UpdatePlaylistRsp>;
|
UpdatePlaylist(request: UpdatePlaylistReq): Promise<UpdatePlaylistRsp>;
|
||||||
@@ -2066,6 +2125,92 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DownloadFile",
|
method: "DownloadFile",
|
||||||
}) as Promise<googleapi_HttpBody>;
|
}) as Promise<googleapi_HttpBody>;
|
||||||
},
|
},
|
||||||
|
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<SavePlaylistGroupRsp>;
|
||||||
|
},
|
||||||
|
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<DeletePlaylistGroupRsp>;
|
||||||
|
},
|
||||||
|
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<ReorderPlaylistGroupsRsp>;
|
||||||
|
},
|
||||||
|
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<SetPlaylistTrackGroupRsp>;
|
||||||
|
},
|
||||||
GetPlaylists(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
GetPlaylists(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/playlists`; // eslint-disable-line quotes
|
const path = `api/playlists`; // eslint-disable-line quotes
|
||||||
const body = null;
|
const body = null;
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
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'
|
import UiBlock from '@/components/UiBlock.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -25,6 +30,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
setMusic: [
|
setMusic: [
|
||||||
change: {
|
change: {
|
||||||
|
groupId?: number
|
||||||
playlistID?: number
|
playlistID?: number
|
||||||
trackIndex?: number
|
trackIndex?: number
|
||||||
positionMs?: number
|
positionMs?: number
|
||||||
@@ -33,6 +39,10 @@ const emit = defineEmits<{
|
|||||||
volume?: number
|
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]
|
createPlaylist: [name: string]
|
||||||
renamePlaylist: [id: number, name: string]
|
renamePlaylist: [id: number, name: string]
|
||||||
reorderTracks: [playlistID: number, trackIDs: number[]]
|
reorderTracks: [playlistID: number, trackIDs: number[]]
|
||||||
@@ -45,11 +55,32 @@ const isEditing = ref(false)
|
|||||||
const selectedId = ref<number | null>(null)
|
const selectedId = ref<number | null>(null)
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
|
const newGroupName = ref('')
|
||||||
|
const dirtyGroups = ref<Record<number, boolean>>({})
|
||||||
|
const hasGroupDraft = computed(() => Object.values(dirtyGroups.value).some(Boolean))
|
||||||
const volume = ref(props.music?.volume ?? 100)
|
const volume = ref(props.music?.volume ?? 100)
|
||||||
const originalName = ref('')
|
const originalName = ref('')
|
||||||
const order = ref<number[]>([])
|
const order = ref<number[]>([])
|
||||||
const originalOrder = ref<number[]>([])
|
const originalOrder = ref<number[]>([])
|
||||||
const selected = computed(() => props.playlists.find((item) => item.ID === selectedId.value))
|
const lastSelected = ref<Playlist>()
|
||||||
|
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<PlaylistGroup[]>([])
|
||||||
|
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(() =>
|
const options = computed(() =>
|
||||||
props.playlists.map((item) => ({ label: item.name, value: item.ID })),
|
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 isNameDirty = computed(() => name.value !== originalName.value)
|
||||||
const hasConflict = computed(
|
const hasConflict = computed(
|
||||||
() =>
|
() =>
|
||||||
!!selected.value &&
|
isSelectedMissing.value ||
|
||||||
JSON.stringify(selected.value.tracks?.map((track) => track.ID) ?? []) !==
|
(!!selected.value &&
|
||||||
JSON.stringify(originalOrder.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 canPlay = computed(() => !!props.music?.playlist?.tracks?.length && !props.isBusy)
|
||||||
const isPlaying = computed(() => props.music?.playback === 'MUSIC_PLAYBACK_PLAYING')
|
const isPlaying = computed(() => props.music?.playback === 'MUSIC_PLAYBACK_PLAYING')
|
||||||
const playbackLabel = computed(() =>
|
const playbackLabel = computed(() =>
|
||||||
@@ -81,34 +180,42 @@ function reset() {
|
|||||||
order.value =
|
order.value =
|
||||||
selected.value?.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
|
selected.value?.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
|
||||||
originalOrder.value = [...order.value]
|
originalOrder.value = [...order.value]
|
||||||
|
newGroupName.value = ''
|
||||||
}
|
}
|
||||||
function edit() {
|
function edit() {
|
||||||
selectedId.value = props.music?.playlist?.ID ?? props.playlists[0]?.ID ?? null
|
selectedId.value = props.music?.playlist?.ID ?? props.playlists[0]?.ID ?? null
|
||||||
|
lastSelected.value = selected.value
|
||||||
|
cachedGroups.value = selected.value?.groups ?? []
|
||||||
reset()
|
reset()
|
||||||
isEditing.value = true
|
isEditing.value = true
|
||||||
}
|
}
|
||||||
function finish() {
|
function finish() {
|
||||||
if (
|
if (
|
||||||
(isOrderDirty.value || isNameDirty.value) &&
|
(isOrderDirty.value || isNameDirty.value || hasGroupDraft.value || newGroupName.value.trim()) &&
|
||||||
!window.confirm('Отменить несохранённые изменения плейлиста?')
|
!window.confirm('Отменить несохранённые изменения плейлиста?')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
reset()
|
reset()
|
||||||
isEditing.value = false
|
isEditing.value = false
|
||||||
|
dirtyGroups.value = {}
|
||||||
}
|
}
|
||||||
function move(index: number, offset: number) {
|
function move(id: number, groupID: number, offset: number) {
|
||||||
const target = index + offset
|
const ids = trackIds(groupID)
|
||||||
if (target < 0 || target >= order.value.length) return
|
const index = ids.indexOf(id)
|
||||||
const [id] = order.value.splice(index, 1)
|
const targetID = ids[index + offset]
|
||||||
if (id !== undefined) order.value.splice(target, 0, id)
|
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 }) {
|
function uploadTrack(options: { file: UploadFileInfo }) {
|
||||||
if (selectedId.value && options.file.file)
|
if (selectedId.value && options.file.file)
|
||||||
emit('uploadTrack', selectedId.value, options.file.file)
|
emit('uploadTrack', selectedId.value, options.file.file)
|
||||||
}
|
}
|
||||||
function selectTrack(index: number) {
|
function selectTrack(index: number, groupID: number) {
|
||||||
if (index === (props.music?.trackIndex ?? 0)) return
|
if (index === (props.music?.trackIndex ?? 0) && groupID === (props.music?.groupId ?? 0)) return
|
||||||
emit('setMusic', { trackIndex: index, positionMs: 0 })
|
emit('setMusic', { trackIndex: index, groupId: groupID, positionMs: 0 })
|
||||||
}
|
}
|
||||||
watch(
|
watch(
|
||||||
() => props.music?.volume,
|
() => props.music?.volume,
|
||||||
@@ -116,18 +223,30 @@ watch(
|
|||||||
volume.value = value ?? 100
|
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(
|
watch(
|
||||||
() => props.playlists,
|
() => props.playlists,
|
||||||
(items, previous) => {
|
(items, previous) => {
|
||||||
if (!isEditing.value) return
|
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))
|
const added = items.find((item) => !previous.some((old) => old.ID === item.ID))
|
||||||
if (
|
if (
|
||||||
added &&
|
added &&
|
||||||
props.isBusy &&
|
props.isBusy &&
|
||||||
added.name === newName.value.trim() &&
|
added.name === newName.value.trim() &&
|
||||||
!isOrderDirty.value &&
|
!isOrderDirty.value &&
|
||||||
!isNameDirty.value
|
!isNameDirty.value &&
|
||||||
|
!hasGroupDraft.value &&
|
||||||
|
!newGroupName.value.trim()
|
||||||
) {
|
) {
|
||||||
selectedId.value = added.ID ?? null
|
selectedId.value = added.ID ?? null
|
||||||
newName.value = ''
|
newName.value = ''
|
||||||
@@ -135,11 +254,30 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!selected.value) {
|
if (!selected.value) {
|
||||||
if (isOrderDirty.value || isNameDirty.value) return
|
if (
|
||||||
|
isOrderDirty.value ||
|
||||||
|
isNameDirty.value ||
|
||||||
|
hasGroupDraft.value ||
|
||||||
|
newGroupName.value.trim()
|
||||||
|
)
|
||||||
|
return
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
reset()
|
reset()
|
||||||
return
|
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) {
|
if (!isNameDirty.value || name.value.trim() === selected.value.name) {
|
||||||
name.value = selected.value.name ?? ''
|
name.value = selected.value.name ?? ''
|
||||||
originalName.value = name.value
|
originalName.value = name.value
|
||||||
@@ -171,7 +309,13 @@ watch(
|
|||||||
</n-button>
|
</n-button>
|
||||||
<n-button v-else size="small" :disabled="isBusy" @click="finish">Готово</n-button>
|
<n-button v-else size="small" :disabled="isBusy" @click="finish">Готово</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
<n-space v-if="isEditing" vertical class="music-content" data-testid="music-editor">
|
<n-space
|
||||||
|
v-if="isEditing"
|
||||||
|
vertical
|
||||||
|
:wrap-item="false"
|
||||||
|
class="music-content"
|
||||||
|
data-testid="music-editor"
|
||||||
|
>
|
||||||
<n-space :wrap="false">
|
<n-space :wrap="false">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="newName"
|
v-model:value="newName"
|
||||||
@@ -180,7 +324,7 @@ watch(
|
|||||||
:disabled="isBusy"
|
:disabled="isBusy"
|
||||||
/>
|
/>
|
||||||
<n-button
|
<n-button
|
||||||
:disabled="isBusy || !newName.trim()"
|
:disabled="isBusy || hasGroupDraft || !!newGroupName.trim() || !newName.trim()"
|
||||||
@click="emit('createPlaylist', newName.trim())"
|
@click="emit('createPlaylist', newName.trim())"
|
||||||
>Создать</n-button
|
>Создать</n-button
|
||||||
>
|
>
|
||||||
@@ -189,11 +333,11 @@ watch(
|
|||||||
v-model:value="selectedId"
|
v-model:value="selectedId"
|
||||||
:options="options"
|
:options="options"
|
||||||
placeholder="Плейлист для редактирования"
|
placeholder="Плейлист для редактирования"
|
||||||
:disabled="isBusy || isOrderDirty || isNameDirty"
|
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
|
||||||
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
|
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
|
||||||
/>
|
/>
|
||||||
<p v-if="!playlists.length">Плейлистов пока нет.</p>
|
<p v-if="!playlists.length">Плейлистов пока нет.</p>
|
||||||
<n-alert v-if="selectedId && !selected" type="warning">
|
<n-alert v-if="isSelectedMissing" type="warning">
|
||||||
Плейлист удалён на сервере. Несохранённый черновик сохранён до отмены.
|
Плейлист удалён на сервере. Несохранённый черновик сохранён до отмены.
|
||||||
<n-button size="small" :disabled="isBusy" @click="reset"
|
<n-button size="small" :disabled="isBusy" @click="reset"
|
||||||
>Загрузить актуальный список</n-button
|
>Загрузить актуальный список</n-button
|
||||||
@@ -243,7 +387,7 @@ watch(
|
|||||||
size="small"
|
size="small"
|
||||||
type="error"
|
type="error"
|
||||||
ghost
|
ghost
|
||||||
:disabled="isBusy || isOrderDirty || isNameDirty"
|
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
|
||||||
>Удалить плейлист</n-button
|
>Удалить плейлист</n-button
|
||||||
></template
|
></template
|
||||||
>
|
>
|
||||||
@@ -265,42 +409,101 @@ watch(
|
|||||||
hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения'
|
hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения'
|
||||||
}}</n-button>
|
}}</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
<p v-if="!order.length">Треков пока нет.</p>
|
<n-space :wrap="false">
|
||||||
<div v-for="(id, index) in order" :key="id" class="track-row">
|
<n-input
|
||||||
<span
|
v-model:value="newGroupName"
|
||||||
>{{ index + 1 }}.
|
placeholder="Новая группа"
|
||||||
{{
|
aria-label="Название новой группы"
|
||||||
selected.tracks?.find((track) => track.ID === id)?.name ?? 'Трек удалён на сервере'
|
:maxlength="255"
|
||||||
}}</span
|
:disabled="isBusy"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
:disabled="isBusy || isOrderDirty || hasConflict || !newGroupName.trim()"
|
||||||
|
@click="saveGroup(0, newGroupName.trim(), 'PLAYLIST_GROUP_END_MODE_REPEAT')"
|
||||||
|
>Добавить группу</n-button
|
||||||
>
|
>
|
||||||
<n-space>
|
</n-space>
|
||||||
<n-button
|
<p v-if="isPlaylistActive">
|
||||||
size="small"
|
Для изменения активной группы и порядка групп остановите музыку. Другие игры также могут
|
||||||
:disabled="isBusy || hasConflict || index === 0"
|
блокировать редактирование.
|
||||||
:aria-label="`Поднять трек ${index + 1}`"
|
</p>
|
||||||
@click="move(index, -1)"
|
<!-- Keep keyed editors outside NSpace's positional item wrappers. -->
|
||||||
>↑</n-button
|
<div key="music-groups" class="music-groups">
|
||||||
>
|
<section
|
||||||
<n-button
|
v-for="(section, groupIndex) in sections"
|
||||||
size="small"
|
:key="section.ID"
|
||||||
:disabled="isBusy || hasConflict || index === order.length - 1"
|
class="music-group"
|
||||||
:aria-label="`Опустить трек ${index + 1}`"
|
:aria-label="section.name"
|
||||||
@click="move(index, 1)"
|
>
|
||||||
>↓</n-button
|
<h4>{{ section.name }}</h4>
|
||||||
>
|
<PlaylistGroupEditor
|
||||||
<n-popconfirm @positive-click="emit('deleteTrack', selectedId, id)">
|
v-if="section.ID"
|
||||||
<template #trigger
|
:group="section"
|
||||||
><n-button
|
:is-missing="
|
||||||
size="small"
|
isSelectedMissing || !selected.groups?.some((group) => group.ID === section.ID)
|
||||||
type="error"
|
"
|
||||||
ghost
|
:is-busy="isBusy || isOrderDirty || hasConflict"
|
||||||
:disabled="isBusy || isOrderDirty || hasConflict"
|
:is-locked="isGroupLocked(section.ID)"
|
||||||
>Удалить</n-button
|
:can-move-up="!isPlaylistActive && groupIndex > 0"
|
||||||
></template
|
:can-move-down="!isPlaylistActive && groupIndex < (selected.groups?.length ?? 0) - 1"
|
||||||
|
@save="(groupName, endMode) => saveGroup(section.ID, groupName, endMode)"
|
||||||
|
@remove="emit('deleteGroup', selectedId, section.ID)"
|
||||||
|
@dirty="dirtyGroups[section.ID] = $event"
|
||||||
|
@move="moveGroup(section.ID, $event)"
|
||||||
|
/>
|
||||||
|
<p v-if="!trackIds(section.ID).length">Треков пока нет.</p>
|
||||||
|
<div v-for="(id, index) in trackIds(section.ID)" :key="id" class="track-row">
|
||||||
|
<span
|
||||||
|
>{{ index + 1 }}.
|
||||||
|
{{
|
||||||
|
selected.tracks?.find((track) => track.ID === id)?.name ??
|
||||||
|
'Трек удалён на сервере'
|
||||||
|
}}</span
|
||||||
>
|
>
|
||||||
Удалить трек из плейлиста?
|
<n-select
|
||||||
</n-popconfirm>
|
:value="section.ID"
|
||||||
</n-space>
|
:options="groupOptions"
|
||||||
|
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
|
||||||
|
:input-props="{
|
||||||
|
'aria-label': `Группа трека ${selected.tracks?.find((track) => track.ID === id)?.name}`,
|
||||||
|
}"
|
||||||
|
@update:value="emit('setTrackGroup', selectedId!, id, $event)"
|
||||||
|
/>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:disabled="isBusy || hasConflict || isGroupLocked(section.ID) || index === 0"
|
||||||
|
:aria-label="`Поднять трек ${index + 1} в группе ${section.name}`"
|
||||||
|
@click="move(id, section.ID, -1)"
|
||||||
|
>↑</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:disabled="
|
||||||
|
isBusy ||
|
||||||
|
hasConflict ||
|
||||||
|
isGroupLocked(section.ID) ||
|
||||||
|
index === trackIds(section.ID).length - 1
|
||||||
|
"
|
||||||
|
:aria-label="`Опустить трек ${index + 1} в группе ${section.name}`"
|
||||||
|
@click="move(id, section.ID, 1)"
|
||||||
|
>↓</n-button
|
||||||
|
>
|
||||||
|
<n-popconfirm @positive-click="emit('deleteTrack', selectedId!, id)">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
type="error"
|
||||||
|
ghost
|
||||||
|
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
|
||||||
|
>Удалить</n-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
Удалить трек из плейлиста?
|
||||||
|
</n-popconfirm>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-space>
|
</n-space>
|
||||||
@@ -324,6 +527,7 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<n-button
|
<n-button
|
||||||
square
|
square
|
||||||
|
v-if="!music?.groupId"
|
||||||
:type="music?.loop ? 'primary' : 'default'"
|
:type="music?.loop ? 'primary' : 'default'"
|
||||||
:aria-pressed="music?.loop ?? false"
|
:aria-pressed="music?.loop ?? false"
|
||||||
aria-label="Повторять плейлист"
|
aria-label="Повторять плейлист"
|
||||||
@@ -389,31 +593,85 @@ watch(
|
|||||||
</n-space>
|
</n-space>
|
||||||
<p v-if="!music?.playlist">Выберите плейлист для игры.</p>
|
<p v-if="!music?.playlist">Выберите плейлист для игры.</p>
|
||||||
<p v-else-if="!music.playlist.tracks?.length">В плейлисте пока нет треков.</p>
|
<p v-else-if="!music.playlist.tracks?.length">В плейлисте пока нет треков.</p>
|
||||||
<div
|
<n-button
|
||||||
v-for="(track, index) in music?.playlist?.tracks ?? []"
|
v-if="music?.groupId"
|
||||||
:key="track.ID"
|
size="small"
|
||||||
class="track-row"
|
:disabled="!canPlay"
|
||||||
:class="{ 'track-current': index === (music?.trackIndex ?? 0) }"
|
@click="
|
||||||
:aria-current="index === (music?.trackIndex ?? 0) ? 'true' : undefined"
|
emit('setMusic', {
|
||||||
|
groupId: 0,
|
||||||
|
trackIndex: 0,
|
||||||
|
positionMs: 0,
|
||||||
|
playback: 'MUSIC_PLAYBACK_PLAYING',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>Играть весь плейлист</n-button
|
||||||
>
|
>
|
||||||
<span>{{ index + 1 }}. {{ track.name }}</span>
|
<section
|
||||||
<n-button
|
v-for="section in playbackSections"
|
||||||
size="small"
|
:key="section.ID"
|
||||||
:disabled="isBusy || index === (music?.trackIndex ?? 0)"
|
class="music-group"
|
||||||
@click="selectTrack(index)"
|
:aria-label="section.name"
|
||||||
>{{ index === (music?.trackIndex ?? 0) ? 'Выбран' : 'Выбрать' }}</n-button
|
>
|
||||||
|
<n-space align="center" justify="space-between">
|
||||||
|
<h4>
|
||||||
|
{{ section.name }}{{ section.ID && section.ID === music?.groupId ? ' · Выбрана' : '' }}
|
||||||
|
</h4>
|
||||||
|
<n-button
|
||||||
|
v-if="section.ID"
|
||||||
|
size="small"
|
||||||
|
:disabled="isBusy || !playbackTracks(section.ID).length"
|
||||||
|
@click="playGroup(section.ID)"
|
||||||
|
>Играть группу</n-button
|
||||||
|
>
|
||||||
|
</n-space>
|
||||||
|
<p v-if="section.ID">{{ modeLabel(section.endMode) }}</p>
|
||||||
|
<p v-if="!playbackTracks(section.ID).length">Треков пока нет.</p>
|
||||||
|
<div
|
||||||
|
v-for="({ track, index }, position) in playbackTracks(section.ID)"
|
||||||
|
:key="track.ID"
|
||||||
|
class="track-row"
|
||||||
|
:class="{ 'track-current': index === (music?.trackIndex ?? 0) }"
|
||||||
|
:aria-current="index === (music?.trackIndex ?? 0) ? 'true' : undefined"
|
||||||
>
|
>
|
||||||
</div>
|
<span>{{ position + 1 }}. {{ track.name }}</span>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:disabled="
|
||||||
|
isBusy || (index === (music?.trackIndex ?? 0) && section.ID === (music?.groupId ?? 0))
|
||||||
|
"
|
||||||
|
@click="selectTrack(index, section.ID)"
|
||||||
|
>{{
|
||||||
|
index === (music?.trackIndex ?? 0) && section.ID === (music?.groupId ?? 0)
|
||||||
|
? 'Выбран'
|
||||||
|
: 'Выбрать'
|
||||||
|
}}</n-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</n-space>
|
</n-space>
|
||||||
</UiBlock>
|
</UiBlock>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
h3,
|
h3,
|
||||||
|
h4,
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.music-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-group {
|
||||||
|
border-top: 1px solid var(--brand-border);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.music-panel {
|
.music-panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
MusicPlaybackState,
|
MusicPlaybackState,
|
||||||
MusicState,
|
MusicState,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
PlaylistGroup,
|
||||||
Question,
|
Question,
|
||||||
ResultsQuestion,
|
ResultsQuestion,
|
||||||
ScreenCommand,
|
ScreenCommand,
|
||||||
@@ -666,6 +667,7 @@ async function setSoundScreen(screen: ScreenState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setGameMusic(change: {
|
async function setGameMusic(change: {
|
||||||
|
groupId?: number
|
||||||
playlistID?: number
|
playlistID?: number
|
||||||
trackIndex?: number
|
trackIndex?: number
|
||||||
positionMs?: number
|
positionMs?: number
|
||||||
@@ -681,10 +683,11 @@ async function setGameMusic(change: {
|
|||||||
const res = await client.SetGameMusic({
|
const res = await client.SetGameMusic({
|
||||||
gameID: Number(gameId),
|
gameID: Number(gameId),
|
||||||
playlistID: change.playlistID ?? current?.playlist?.ID ?? 0,
|
playlistID: change.playlistID ?? current?.playlist?.ID ?? 0,
|
||||||
|
groupId: change.groupId ?? (change.playlistID !== undefined ? 0 : (current?.groupId ?? 0)),
|
||||||
trackIndex: change.trackIndex ?? current?.trackIndex ?? 0,
|
trackIndex: change.trackIndex ?? current?.trackIndex ?? 0,
|
||||||
positionMs:
|
positionMs:
|
||||||
change.positionMs ??
|
change.positionMs ??
|
||||||
(current?.positionMs ?? 0) +
|
Number(current?.positionMs ?? 0) +
|
||||||
(current?.playback === 'MUSIC_PLAYBACK_PLAYING'
|
(current?.playback === 'MUSIC_PLAYBACK_PLAYING'
|
||||||
? Math.max(0, Date.now() - musicSyncedAt)
|
? Math.max(0, Date.now() - musicSyncedAt)
|
||||||
: 0),
|
: 0),
|
||||||
@@ -701,6 +704,67 @@ async function setGameMusic(change: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveGroup(
|
||||||
|
playlistID: number,
|
||||||
|
groupID: number,
|
||||||
|
name: string,
|
||||||
|
endMode: PlaylistGroup['endMode'],
|
||||||
|
) {
|
||||||
|
if (isMusicBusy.value) return
|
||||||
|
isMusicBusy.value = true
|
||||||
|
musicRequestVersion += 1
|
||||||
|
try {
|
||||||
|
await client.SavePlaylistGroup({ playlistID, groupID, name, endMode })
|
||||||
|
await getGameMusic(true)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
|
||||||
|
} finally {
|
||||||
|
isMusicBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(playlistID: number, groupID: number) {
|
||||||
|
if (isMusicBusy.value) return
|
||||||
|
isMusicBusy.value = true
|
||||||
|
musicRequestVersion += 1
|
||||||
|
try {
|
||||||
|
await client.DeletePlaylistGroup({ playlistID, groupID })
|
||||||
|
await getGameMusic(true)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
|
||||||
|
} finally {
|
||||||
|
isMusicBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderGroups(playlistID: number, groupIDs: number[]) {
|
||||||
|
if (isMusicBusy.value) return
|
||||||
|
isMusicBusy.value = true
|
||||||
|
musicRequestVersion += 1
|
||||||
|
try {
|
||||||
|
await client.ReorderPlaylistGroups({ playlistID, groupIDs })
|
||||||
|
await getGameMusic(true)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
|
||||||
|
} finally {
|
||||||
|
isMusicBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setTrackGroup(playlistID: number, trackID: number, groupID: number) {
|
||||||
|
if (isMusicBusy.value) return
|
||||||
|
isMusicBusy.value = true
|
||||||
|
musicRequestVersion += 1
|
||||||
|
try {
|
||||||
|
await client.SetPlaylistTrackGroup({ playlistID, trackID, groupID })
|
||||||
|
await getGameMusic(true)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
|
||||||
|
} finally {
|
||||||
|
isMusicBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createPlaylist(name: string) {
|
async function createPlaylist(name: string) {
|
||||||
if (isMusicBusy.value || !name.trim()) return
|
if (isMusicBusy.value || !name.trim()) return
|
||||||
isMusicBusy.value = true
|
isMusicBusy.value = true
|
||||||
@@ -1638,6 +1702,10 @@ useSimplePolling(() => {
|
|||||||
:music="music"
|
:music="music"
|
||||||
:is-busy="isMusicBusy"
|
:is-busy="isMusicBusy"
|
||||||
@set-music="setGameMusic"
|
@set-music="setGameMusic"
|
||||||
|
@save-group="saveGroup"
|
||||||
|
@delete-group="deleteGroup"
|
||||||
|
@reorder-groups="reorderGroups"
|
||||||
|
@set-track-group="setTrackGroup"
|
||||||
@create-playlist="createPlaylist"
|
@create-playlist="createPlaylist"
|
||||||
@rename-playlist="renamePlaylist"
|
@rename-playlist="renamePlaylist"
|
||||||
@reorder-tracks="reorderTracks"
|
@reorder-tracks="reorderTracks"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NInput, NPopconfirm, NSelect, NSpace } from 'naive-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { PlaylistGroup } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
group: PlaylistGroup
|
||||||
|
isBusy: boolean
|
||||||
|
isLocked: boolean
|
||||||
|
isMissing: boolean
|
||||||
|
canMoveUp: boolean
|
||||||
|
canMoveDown: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [name: string, endMode: PlaylistGroup['endMode']]
|
||||||
|
remove: []
|
||||||
|
move: [offset: number]
|
||||||
|
dirty: [isDirty: boolean]
|
||||||
|
}>()
|
||||||
|
const name = ref(props.group.name ?? '')
|
||||||
|
const endMode = ref(props.group.endMode)
|
||||||
|
const original = ref(JSON.stringify([props.group.name, props.group.endMode]))
|
||||||
|
const isDirty = computed(
|
||||||
|
() => name.value !== props.group.name || endMode.value !== props.group.endMode,
|
||||||
|
)
|
||||||
|
const hasConflict = computed(
|
||||||
|
() => isDirty.value && JSON.stringify([props.group.name, props.group.endMode]) !== original.value,
|
||||||
|
)
|
||||||
|
const modes = [
|
||||||
|
{ label: 'Повторять группу', value: 'PLAYLIST_GROUP_END_MODE_REPEAT' },
|
||||||
|
{ label: 'Остановиться', value: 'PLAYLIST_GROUP_END_MODE_STOP' },
|
||||||
|
{ label: 'Следующая группа', value: 'PLAYLIST_GROUP_END_MODE_NEXT' },
|
||||||
|
]
|
||||||
|
function reset() {
|
||||||
|
name.value = props.group.name ?? ''
|
||||||
|
endMode.value = props.group.endMode
|
||||||
|
original.value = JSON.stringify([props.group.name, props.group.endMode])
|
||||||
|
}
|
||||||
|
watch(isDirty, (value) => emit('dirty', value), { immediate: true })
|
||||||
|
watch(
|
||||||
|
() => props.group,
|
||||||
|
(group, previous) => {
|
||||||
|
if (
|
||||||
|
(name.value === previous.name && endMode.value === previous.endMode) ||
|
||||||
|
(name.value.trim() === group.name && endMode.value === group.endMode)
|
||||||
|
)
|
||||||
|
reset()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-space vertical>
|
||||||
|
<n-input
|
||||||
|
v-model:value="name"
|
||||||
|
:disabled="isBusy"
|
||||||
|
:maxlength="255"
|
||||||
|
:aria-label="`Название группы ${group.name}`"
|
||||||
|
/>
|
||||||
|
<n-select
|
||||||
|
v-model:value="endMode"
|
||||||
|
:options="modes"
|
||||||
|
:disabled="isBusy || isLocked || isMissing"
|
||||||
|
:input-props="{ 'aria-label': `Завершение группы ${group.name}` }"
|
||||||
|
/>
|
||||||
|
<n-alert v-if="isMissing" type="warning"
|
||||||
|
>Группа удалена на сервере. Черновик сохранён до отмены изменений.</n-alert
|
||||||
|
>
|
||||||
|
<n-alert v-if="hasConflict" type="warning">
|
||||||
|
Группа изменена на сервере. Черновик сохранён; загрузите актуальные данные перед сохранением.
|
||||||
|
</n-alert>
|
||||||
|
<n-space>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:disabled="isBusy || !name.trim() || !isDirty || hasConflict || isMissing"
|
||||||
|
@click="emit('save', name.trim(), endMode)"
|
||||||
|
>Сохранить группу</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
v-if="isDirty || hasConflict || isMissing"
|
||||||
|
size="small"
|
||||||
|
:disabled="isBusy"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
Отменить изменения
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:disabled="isBusy || isLocked || isMissing || !canMoveUp"
|
||||||
|
:aria-label="`Поднять группу ${group.name}`"
|
||||||
|
@click="emit('move', -1)"
|
||||||
|
>↑</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
:disabled="isBusy || isLocked || isMissing || !canMoveDown"
|
||||||
|
:aria-label="`Опустить группу ${group.name}`"
|
||||||
|
@click="emit('move', 1)"
|
||||||
|
>↓</n-button
|
||||||
|
>
|
||||||
|
<n-popconfirm @positive-click="emit('remove')">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" type="error" ghost :disabled="isBusy || isLocked || isMissing">
|
||||||
|
Удалить группу
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
Удалить группу «{{ group.name }}»? Треки останутся в плейлисте без группы.
|
||||||
|
</n-popconfirm>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
@@ -44,6 +44,7 @@ const pollingReady = ref(false)
|
|||||||
const introAudioElement = ref<HTMLAudioElement>()
|
const introAudioElement = ref<HTMLAudioElement>()
|
||||||
const musicAudioElement = ref<HTMLAudioElement>()
|
const musicAudioElement = ref<HTMLAudioElement>()
|
||||||
const conclusionVideoElement = ref<HTMLVideoElement>()
|
const conclusionVideoElement = ref<HTMLVideoElement>()
|
||||||
|
let isAdvancingMusic = false
|
||||||
let appliedCommandVersion = 0
|
let appliedCommandVersion = 0
|
||||||
let timerTick: number | null = null
|
let timerTick: number | null = null
|
||||||
|
|
||||||
@@ -81,13 +82,16 @@ const musicTrack = computed(() => {
|
|||||||
const index = state.value?.music?.trackIndex || 0
|
const index = state.value?.music?.trackIndex || 0
|
||||||
return applicationFileURL(tracks[index]?.filename)
|
return applicationFileURL(tracks[index]?.filename)
|
||||||
})
|
})
|
||||||
const musicSyncState = computed(() => [
|
const musicSyncState = computed(
|
||||||
state.value?.music?.playlist?.ID,
|
() =>
|
||||||
state.value?.music?.trackIndex,
|
[
|
||||||
state.value?.music?.playback,
|
state.value?.music?.playlist?.ID,
|
||||||
state.value?.music?.stateVersion,
|
state.value?.music?.playlist?.tracks?.[state.value?.music?.trackIndex ?? 0]?.ID,
|
||||||
state.value?.music?.positionMs,
|
state.value?.music?.playback,
|
||||||
] as const)
|
state.value?.music?.stateVersion,
|
||||||
|
state.value?.music?.positionMs,
|
||||||
|
] as const,
|
||||||
|
)
|
||||||
const screenBackground = computed(() => {
|
const screenBackground = computed(() => {
|
||||||
const image = state.value?.backgroundImage || state.value?.coverImage
|
const image = state.value?.backgroundImage || state.value?.coverImage
|
||||||
return image ? { backgroundImage: `linear-gradient(rgb(0 0 0 / 55%), rgb(0 0 0 / 55%)), url("${image}")` } : {}
|
return image ? { backgroundImage: `linear-gradient(rgb(0 0 0 / 55%), rgb(0 0 0 / 55%)), url("${image}")` } : {}
|
||||||
@@ -137,6 +141,13 @@ async function loadState(recreateOnUnauthorized = true) {
|
|||||||
screenTeamNames.value = undefined
|
screenTeamNames.value = undefined
|
||||||
lastTeamsLoaded = -Infinity
|
lastTeamsLoaded = -Infinity
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
state.value?.gameID === res.state?.gameID &&
|
||||||
|
state.value?.music &&
|
||||||
|
res.state?.music &&
|
||||||
|
BigInt(res.state.music.stateVersion ?? 0) < BigInt(state.value.music.stateVersion ?? 0)
|
||||||
|
)
|
||||||
|
res.state.music = state.value.music
|
||||||
state.value = res.state
|
state.value = res.state
|
||||||
syncTimer(state.value)
|
syncTimer(state.value)
|
||||||
if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_RESULTS') await loadResults()
|
if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_RESULTS') await loadResults()
|
||||||
@@ -199,8 +210,12 @@ async function loadGames() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useSimplePolling(() => {
|
useSimplePolling(async () => {
|
||||||
if (pollingReady.value) void loadState()
|
if (!pollingReady.value) return
|
||||||
|
await loadState()
|
||||||
|
await nextTick()
|
||||||
|
if (musicAudioElement.value?.ended && state.value?.music?.playback === 'MUSIC_PLAYBACK_PLAYING')
|
||||||
|
await advanceMusic()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
watch(state, async (value) => {
|
watch(state, async (value) => {
|
||||||
@@ -255,8 +270,13 @@ watch(
|
|||||||
if (!media) return
|
if (!media) return
|
||||||
const trackChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1]
|
const trackChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1]
|
||||||
const playbackChanged = !previous || value[2] !== previous[2]
|
const playbackChanged = !previous || value[2] !== previous[2]
|
||||||
const isRestart = !!previous && value[3] !== previous[3] && (value[4] ?? 0) < (previous[4] ?? 0)
|
const isRestart =
|
||||||
if (!trackChanged && !playbackChanged && !isRestart) return
|
!!previous &&
|
||||||
|
value[3] !== previous[3] &&
|
||||||
|
Number(value[4] ?? 0) < Number(previous[4] ?? 0)
|
||||||
|
const isCompletedRestart =
|
||||||
|
media.ended && !!previous && BigInt(value[3] ?? 0) > BigInt(previous[3] ?? 0)
|
||||||
|
if (!trackChanged && !playbackChanged && !isRestart && !isCompletedRestart) return
|
||||||
await waitForMusicMetadata(media)
|
await waitForMusicMetadata(media)
|
||||||
media.currentTime = trackChanged ? 0 : (music.positionMs || 0) / 1000
|
media.currentTime = trackChanged ? 0 : (music.positionMs || 0) / 1000
|
||||||
if (music.playback === 'MUSIC_PLAYBACK_PLAYING') {
|
if (music.playback === 'MUSIC_PLAYBACK_PLAYING') {
|
||||||
@@ -282,11 +302,30 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async function advanceMusic() {
|
async function advanceMusic() {
|
||||||
if (!screenId.value || !accessToken.value) return
|
if (isAdvancingMusic || !screenId.value || !accessToken.value || !state.value?.soundEnabled) return
|
||||||
|
isAdvancingMusic = true
|
||||||
|
const gameID = state.value.gameID
|
||||||
|
const sessionID = screenId.value
|
||||||
try {
|
try {
|
||||||
await screenClient.AdvanceScreenMusic({ screenID: screenId.value, accessToken: accessToken.value })
|
const result = await screenClient.AdvanceScreenMusic({
|
||||||
|
screenID: screenId.value,
|
||||||
|
accessToken: accessToken.value,
|
||||||
|
expectedStateVersion: String(state.value?.music?.stateVersion ?? 0),
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
state.value?.gameID === gameID &&
|
||||||
|
screenId.value === sessionID &&
|
||||||
|
state.value &&
|
||||||
|
result.state &&
|
||||||
|
BigInt(result.state.stateVersion ?? 0) >= BigInt(state.value.music?.stateVersion ?? 0)
|
||||||
|
) {
|
||||||
|
mediaError.value = ''
|
||||||
|
state.value.music = result.state
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
mediaError.value = 'Не удалось синхронизировать музыку. Повторная попытка выполняется автоматически.'
|
mediaError.value = 'Не удалось синхронизировать музыку. Повторная попытка выполняется автоматически.'
|
||||||
|
} finally {
|
||||||
|
isAdvancingMusic = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +665,6 @@ onUnmounted(() => {
|
|||||||
background: rgb(255 255 255 / 8%);
|
background: rgb(255 255 255 / 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.static-screen {
|
.static-screen {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user