This commit is contained in:
2026-10-27 12:03:29 +07:00
parent 1099c88015
commit 5234646edc
6 changed files with 809 additions and 86 deletions
+102
View File
@@ -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;
+327 -69
View File
@@ -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;
} }
+69 -1
View File
@@ -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"
+112
View File
@@ -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>
+52 -14
View File
@@ -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;
} }