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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<GetPermissionsRsp>;
|
||||
UploadFile(request: UploadFileReq): Promise<UploadFileRsp>;
|
||||
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>;
|
||||
CreatePlaylist(request: CreatePlaylistReq): Promise<CreatePlaylistRsp>;
|
||||
UpdatePlaylist(request: UpdatePlaylistReq): Promise<UpdatePlaylistRsp>;
|
||||
@@ -2066,6 +2125,92 @@ export function createEveningDetectiveServerClient(
|
||||
method: "DownloadFile",
|
||||
}) 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
|
||||
const path = `api/playlists`; // eslint-disable-line quotes
|
||||
const body = null;
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
const name = 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 originalName = ref('')
|
||||
const order = 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(() =>
|
||||
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(
|
||||
</n-button>
|
||||
<n-button v-else size="small" :disabled="isBusy" @click="finish">Готово</n-button>
|
||||
</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-input
|
||||
v-model:value="newName"
|
||||
@@ -180,7 +324,7 @@ watch(
|
||||
:disabled="isBusy"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || !newName.trim()"
|
||||
:disabled="isBusy || hasGroupDraft || !!newGroupName.trim() || !newName.trim()"
|
||||
@click="emit('createPlaylist', newName.trim())"
|
||||
>Создать</n-button
|
||||
>
|
||||
@@ -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': 'Плейлист для редактирования' }"
|
||||
/>
|
||||
<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
|
||||
@@ -243,7 +387,7 @@ watch(
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isOrderDirty || isNameDirty"
|
||||
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
|
||||
>Удалить плейлист</n-button
|
||||
></template
|
||||
>
|
||||
@@ -265,42 +409,101 @@ watch(
|
||||
hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения'
|
||||
}}</n-button>
|
||||
</n-space>
|
||||
<p v-if="!order.length">Треков пока нет.</p>
|
||||
<div v-for="(id, index) in order" :key="id" class="track-row">
|
||||
<span
|
||||
>{{ index + 1 }}.
|
||||
{{
|
||||
selected.tracks?.find((track) => track.ID === id)?.name ?? 'Трек удалён на сервере'
|
||||
}}</span
|
||||
<n-space :wrap="false">
|
||||
<n-input
|
||||
v-model:value="newGroupName"
|
||||
placeholder="Новая группа"
|
||||
aria-label="Название новой группы"
|
||||
:maxlength="255"
|
||||
: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-button
|
||||
size="small"
|
||||
:disabled="isBusy || hasConflict || index === 0"
|
||||
:aria-label="`Поднять трек ${index + 1}`"
|
||||
@click="move(index, -1)"
|
||||
>↑</n-button
|
||||
>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || hasConflict || index === order.length - 1"
|
||||
:aria-label="`Опустить трек ${index + 1}`"
|
||||
@click="move(index, 1)"
|
||||
>↓</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('deleteTrack', selectedId, id)">
|
||||
<template #trigger
|
||||
><n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isOrderDirty || hasConflict"
|
||||
>Удалить</n-button
|
||||
></template
|
||||
</n-space>
|
||||
<p v-if="isPlaylistActive">
|
||||
Для изменения активной группы и порядка групп остановите музыку. Другие игры также могут
|
||||
блокировать редактирование.
|
||||
</p>
|
||||
<!-- Keep keyed editors outside NSpace's positional item wrappers. -->
|
||||
<div key="music-groups" class="music-groups">
|
||||
<section
|
||||
v-for="(section, groupIndex) in sections"
|
||||
:key="section.ID"
|
||||
class="music-group"
|
||||
:aria-label="section.name"
|
||||
>
|
||||
<h4>{{ section.name }}</h4>
|
||||
<PlaylistGroupEditor
|
||||
v-if="section.ID"
|
||||
:group="section"
|
||||
:is-missing="
|
||||
isSelectedMissing || !selected.groups?.some((group) => group.ID === section.ID)
|
||||
"
|
||||
:is-busy="isBusy || isOrderDirty || hasConflict"
|
||||
:is-locked="isGroupLocked(section.ID)"
|
||||
:can-move-up="!isPlaylistActive && groupIndex > 0"
|
||||
: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-popconfirm>
|
||||
</n-space>
|
||||
<n-select
|
||||
:value="section.ID"
|
||||
: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>
|
||||
</template>
|
||||
</n-space>
|
||||
@@ -324,6 +527,7 @@ watch(
|
||||
/>
|
||||
<n-button
|
||||
square
|
||||
v-if="!music?.groupId"
|
||||
:type="music?.loop ? 'primary' : 'default'"
|
||||
:aria-pressed="music?.loop ?? false"
|
||||
aria-label="Повторять плейлист"
|
||||
@@ -389,31 +593,85 @@ watch(
|
||||
</n-space>
|
||||
<p v-if="!music?.playlist">Выберите плейлист для игры.</p>
|
||||
<p v-else-if="!music.playlist.tracks?.length">В плейлисте пока нет треков.</p>
|
||||
<div
|
||||
v-for="(track, index) in music?.playlist?.tracks ?? []"
|
||||
:key="track.ID"
|
||||
class="track-row"
|
||||
:class="{ 'track-current': index === (music?.trackIndex ?? 0) }"
|
||||
:aria-current="index === (music?.trackIndex ?? 0) ? 'true' : undefined"
|
||||
<n-button
|
||||
v-if="music?.groupId"
|
||||
size="small"
|
||||
:disabled="!canPlay"
|
||||
@click="
|
||||
emit('setMusic', {
|
||||
groupId: 0,
|
||||
trackIndex: 0,
|
||||
positionMs: 0,
|
||||
playback: 'MUSIC_PLAYBACK_PLAYING',
|
||||
})
|
||||
"
|
||||
>Играть весь плейлист</n-button
|
||||
>
|
||||
<span>{{ index + 1 }}. {{ track.name }}</span>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || index === (music?.trackIndex ?? 0)"
|
||||
@click="selectTrack(index)"
|
||||
>{{ index === (music?.trackIndex ?? 0) ? 'Выбран' : 'Выбрать' }}</n-button
|
||||
<section
|
||||
v-for="section in playbackSections"
|
||||
:key="section.ID"
|
||||
class="music-group"
|
||||
:aria-label="section.name"
|
||||
>
|
||||
<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>
|
||||
</UiBlock>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
MusicPlaybackState,
|
||||
MusicState,
|
||||
Playlist,
|
||||
PlaylistGroup,
|
||||
Question,
|
||||
ResultsQuestion,
|
||||
ScreenCommand,
|
||||
@@ -666,6 +667,7 @@ async function setSoundScreen(screen: ScreenState) {
|
||||
}
|
||||
|
||||
async function setGameMusic(change: {
|
||||
groupId?: number
|
||||
playlistID?: number
|
||||
trackIndex?: number
|
||||
positionMs?: number
|
||||
@@ -681,10 +683,11 @@ async function setGameMusic(change: {
|
||||
const res = await client.SetGameMusic({
|
||||
gameID: Number(gameId),
|
||||
playlistID: change.playlistID ?? current?.playlist?.ID ?? 0,
|
||||
groupId: change.groupId ?? (change.playlistID !== undefined ? 0 : (current?.groupId ?? 0)),
|
||||
trackIndex: change.trackIndex ?? current?.trackIndex ?? 0,
|
||||
positionMs:
|
||||
change.positionMs ??
|
||||
(current?.positionMs ?? 0) +
|
||||
Number(current?.positionMs ?? 0) +
|
||||
(current?.playback === 'MUSIC_PLAYBACK_PLAYING'
|
||||
? Math.max(0, Date.now() - musicSyncedAt)
|
||||
: 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) {
|
||||
if (isMusicBusy.value || !name.trim()) return
|
||||
isMusicBusy.value = true
|
||||
@@ -1638,6 +1702,10 @@ useSimplePolling(() => {
|
||||
:music="music"
|
||||
:is-busy="isMusicBusy"
|
||||
@set-music="setGameMusic"
|
||||
@save-group="saveGroup"
|
||||
@delete-group="deleteGroup"
|
||||
@reorder-groups="reorderGroups"
|
||||
@set-track-group="setTrackGroup"
|
||||
@create-playlist="createPlaylist"
|
||||
@rename-playlist="renamePlaylist"
|
||||
@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 musicAudioElement = ref<HTMLAudioElement>()
|
||||
const conclusionVideoElement = ref<HTMLVideoElement>()
|
||||
let isAdvancingMusic = false
|
||||
let appliedCommandVersion = 0
|
||||
let timerTick: number | null = null
|
||||
|
||||
@@ -81,13 +82,16 @@ const musicTrack = computed(() => {
|
||||
const index = state.value?.music?.trackIndex || 0
|
||||
return applicationFileURL(tracks[index]?.filename)
|
||||
})
|
||||
const musicSyncState = computed(() => [
|
||||
state.value?.music?.playlist?.ID,
|
||||
state.value?.music?.trackIndex,
|
||||
state.value?.music?.playback,
|
||||
state.value?.music?.stateVersion,
|
||||
state.value?.music?.positionMs,
|
||||
] as const)
|
||||
const musicSyncState = computed(
|
||||
() =>
|
||||
[
|
||||
state.value?.music?.playlist?.ID,
|
||||
state.value?.music?.playlist?.tracks?.[state.value?.music?.trackIndex ?? 0]?.ID,
|
||||
state.value?.music?.playback,
|
||||
state.value?.music?.stateVersion,
|
||||
state.value?.music?.positionMs,
|
||||
] as const,
|
||||
)
|
||||
const screenBackground = computed(() => {
|
||||
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}")` } : {}
|
||||
@@ -137,6 +141,13 @@ async function loadState(recreateOnUnauthorized = true) {
|
||||
screenTeamNames.value = undefined
|
||||
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
|
||||
syncTimer(state.value)
|
||||
if (state.value?.gameID && state.value.mode === 'SCREEN_MODE_RESULTS') await loadResults()
|
||||
@@ -199,8 +210,12 @@ async function loadGames() {
|
||||
}
|
||||
}
|
||||
|
||||
useSimplePolling(() => {
|
||||
if (pollingReady.value) void loadState()
|
||||
useSimplePolling(async () => {
|
||||
if (!pollingReady.value) return
|
||||
await loadState()
|
||||
await nextTick()
|
||||
if (musicAudioElement.value?.ended && state.value?.music?.playback === 'MUSIC_PLAYBACK_PLAYING')
|
||||
await advanceMusic()
|
||||
}, 1000)
|
||||
|
||||
watch(state, async (value) => {
|
||||
@@ -255,8 +270,13 @@ watch(
|
||||
if (!media) return
|
||||
const trackChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1]
|
||||
const playbackChanged = !previous || value[2] !== previous[2]
|
||||
const isRestart = !!previous && value[3] !== previous[3] && (value[4] ?? 0) < (previous[4] ?? 0)
|
||||
if (!trackChanged && !playbackChanged && !isRestart) return
|
||||
const isRestart =
|
||||
!!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)
|
||||
media.currentTime = trackChanged ? 0 : (music.positionMs || 0) / 1000
|
||||
if (music.playback === 'MUSIC_PLAYBACK_PLAYING') {
|
||||
@@ -282,11 +302,30 @@ watch(
|
||||
)
|
||||
|
||||
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 {
|
||||
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 {
|
||||
mediaError.value = 'Не удалось синхронизировать музыку. Повторная попытка выполняется автоматически.'
|
||||
} finally {
|
||||
isAdvancingMusic = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +665,6 @@ onUnmounted(() => {
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
|
||||
.static-screen {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user