generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -1,17 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Edit, Pause, Play, Repeat, Reset, Stop } from '@vicons/carbon'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NInput,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSlider,
|
||||
NSpace,
|
||||
NUpload,
|
||||
type UploadFileInfo,
|
||||
} from 'naive-ui'
|
||||
import { NButton, NSelect, NSlider, NSpace } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type {
|
||||
@@ -19,7 +9,7 @@ import type {
|
||||
Playlist,
|
||||
PlaylistGroup,
|
||||
} from '@/api/generated/crabs/evening_detective_server'
|
||||
import PlaylistGroupEditor from '@/components/PlaylistGroupEditor.vue'
|
||||
import PlaylistEditor from '@/components/PlaylistEditor.vue'
|
||||
import UiBlock from '@/components/UiBlock.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -52,57 +42,14 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
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 editor = ref<InstanceType<typeof PlaylistEditor>>()
|
||||
const volume = ref(props.music?.volume ?? 100)
|
||||
const originalName = ref('')
|
||||
const order = ref<number[]>([])
|
||||
const originalOrder = ref<number[]>([])
|
||||
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 })),
|
||||
)
|
||||
const isOrderDirty = computed(
|
||||
() => JSON.stringify(order.value) !== JSON.stringify(originalOrder.value),
|
||||
)
|
||||
const isNameDirty = computed(() => name.value !== originalName.value)
|
||||
const hasConflict = computed(
|
||||
() =>
|
||||
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 },
|
||||
])
|
||||
function finish() {
|
||||
if (editor.value?.confirmDiscard()) isEditing.value = false
|
||||
}
|
||||
const playbackSections = computed(() => [
|
||||
...(props.music?.playlist?.groups ?? []).map((group) => ({
|
||||
...group,
|
||||
@@ -111,22 +58,6 @@ const playbackSections = computed(() => [
|
||||
})),
|
||||
{ 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 }))
|
||||
@@ -142,21 +73,6 @@ function playGroup(groupID: number) {
|
||||
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 'После группы — следующая'
|
||||
@@ -173,46 +89,6 @@ const playbackLabel = computed(() =>
|
||||
)
|
||||
const currentTrack = computed(() => props.music?.playlist?.tracks?.[props.music.trackIndex ?? 0])
|
||||
|
||||
function reset() {
|
||||
if (!selected.value) selectedId.value = null
|
||||
name.value = selected.value?.name ?? ''
|
||||
originalName.value = name.value
|
||||
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 || hasGroupDraft.value || newGroupName.value.trim()) &&
|
||||
!window.confirm('Отменить несохранённые изменения плейлиста?')
|
||||
)
|
||||
return
|
||||
reset()
|
||||
isEditing.value = false
|
||||
dirtyGroups.value = {}
|
||||
}
|
||||
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, groupID: number) {
|
||||
if (index === (props.music?.trackIndex ?? 0) && groupID === (props.music?.groupId ?? 0)) return
|
||||
emit('setMusic', { trackIndex: index, groupId: groupID, positionMs: 0 })
|
||||
@@ -223,75 +99,7 @@ watch(
|
||||
volume.value = value ?? 100
|
||||
},
|
||||
)
|
||||
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 &&
|
||||
!hasGroupDraft.value &&
|
||||
!newGroupName.value.trim()
|
||||
) {
|
||||
selectedId.value = added.ID ?? null
|
||||
newName.value = ''
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if (!selected.value) {
|
||||
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
|
||||
}
|
||||
const ids =
|
||||
selected.value.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
|
||||
if (!isOrderDirty.value || JSON.stringify(ids) === JSON.stringify(order.value)) {
|
||||
order.value = ids
|
||||
originalOrder.value = [...ids]
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiBlock class="music-panel" data-testid="music-plan">
|
||||
<n-space justify="space-between" align="center">
|
||||
@@ -303,210 +111,35 @@ watch(
|
||||
aria-label="Редактировать музыку"
|
||||
title="Редактировать музыку"
|
||||
:disabled="isBusy"
|
||||
@click="edit"
|
||||
@click="isEditing = true"
|
||||
>
|
||||
<Icon :size="18"><Edit /></Icon>
|
||||
</n-button>
|
||||
<n-button v-else size="small" :disabled="isBusy" @click="finish">Готово</n-button>
|
||||
</n-space>
|
||||
<n-space
|
||||
<PlaylistEditor
|
||||
v-if="isEditing"
|
||||
vertical
|
||||
:wrap-item="false"
|
||||
class="music-content"
|
||||
data-testid="music-editor"
|
||||
>
|
||||
<n-space :wrap="false">
|
||||
<n-input
|
||||
v-model:value="newName"
|
||||
placeholder="Новый плейлист"
|
||||
aria-label="Название нового плейлиста"
|
||||
:disabled="isBusy"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || hasGroupDraft || !!newGroupName.trim() || !newName.trim()"
|
||||
@click="emit('createPlaylist', newName.trim())"
|
||||
>Создать</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-select
|
||||
v-model:value="selectedId"
|
||||
:options="options"
|
||||
placeholder="Плейлист для редактирования"
|
||||
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
|
||||
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
|
||||
/>
|
||||
<p v-if="!playlists.length">Плейлистов пока нет.</p>
|
||||
<n-alert v-if="isSelectedMissing" type="warning">
|
||||
Плейлист удалён на сервере. Несохранённый черновик сохранён до отмены.
|
||||
<n-button size="small" :disabled="isBusy" @click="reset"
|
||||
>Загрузить актуальный список</n-button
|
||||
>
|
||||
</n-alert>
|
||||
<template v-if="selected && selectedId">
|
||||
<label for="music-playlist-name">Название плейлиста</label>
|
||||
<n-input
|
||||
v-model:value="name"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ id: 'music-playlist-name' }"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || !name.trim() || !isNameDirty"
|
||||
@click="emit('renamePlaylist', selectedId, name.trim())"
|
||||
>Сохранить название</n-button
|
||||
>
|
||||
<n-upload
|
||||
accept=".mp3,audio/mpeg"
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
:disabled="isBusy || isOrderDirty || hasConflict"
|
||||
@change="uploadTrack"
|
||||
>
|
||||
<n-button size="small" :disabled="isBusy || isOrderDirty || hasConflict"
|
||||
>Добавить MP3</n-button
|
||||
>
|
||||
</n-upload>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || isOrderDirty || hasConflict || !order.length"
|
||||
@click="
|
||||
emit('setMusic', {
|
||||
playlistID: selectedId ?? undefined,
|
||||
trackIndex: 0,
|
||||
positionMs: 0,
|
||||
playback: 'MUSIC_PLAYBACK_STOPPED',
|
||||
})
|
||||
"
|
||||
>Использовать в игре</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('deletePlaylist', selectedId)">
|
||||
<template #trigger
|
||||
><n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
|
||||
>Удалить плейлист</n-button
|
||||
></template
|
||||
>
|
||||
Удалить плейлист «{{ selected.name }}»?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<n-alert v-if="hasConflict" type="warning"
|
||||
>Порядок или состав треков изменился на сервере. Загрузите актуальный список перед
|
||||
сохранением.</n-alert
|
||||
>
|
||||
<n-space v-if="isOrderDirty || isNameDirty || hasConflict">
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || !isOrderDirty || hasConflict"
|
||||
@click="emit('reorderTracks', selectedId, [...order])"
|
||||
>Сохранить порядок</n-button
|
||||
>
|
||||
<n-button size="small" :disabled="isBusy" @click="reset">{{
|
||||
hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения'
|
||||
}}</n-button>
|
||||
</n-space>
|
||||
<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>
|
||||
<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-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>
|
||||
ref="editor"
|
||||
:playlists="playlists"
|
||||
:music="music"
|
||||
:is-busy="isBusy"
|
||||
@set-music="(change) => emit('setMusic', change)"
|
||||
@save-group="
|
||||
(playlistID, groupID, name, endMode) =>
|
||||
emit('saveGroup', playlistID, groupID, name, endMode)
|
||||
"
|
||||
@delete-group="(playlistID, groupID) => emit('deleteGroup', playlistID, groupID)"
|
||||
@reorder-groups="(playlistID, groupIDs) => emit('reorderGroups', playlistID, groupIDs)"
|
||||
@set-track-group="
|
||||
(playlistID, trackID, groupID) => emit('setTrackGroup', playlistID, trackID, groupID)
|
||||
"
|
||||
@create-playlist="(name) => emit('createPlaylist', name)"
|
||||
@rename-playlist="(id, name) => emit('renamePlaylist', id, name)"
|
||||
@reorder-tracks="(playlistID, trackIDs) => emit('reorderTracks', playlistID, trackIDs)"
|
||||
@delete-playlist="(id) => emit('deletePlaylist', id)"
|
||||
@upload-track="(playlistID, file) => emit('uploadTrack', playlistID, file)"
|
||||
@delete-track="(playlistID, trackID) => emit('deleteTrack', playlistID, trackID)"
|
||||
/>
|
||||
<n-space v-else vertical class="music-content">
|
||||
<div class="playlist-picker">
|
||||
<n-select
|
||||
@@ -660,11 +293,6 @@ p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.music-groups {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.music-group {
|
||||
border-top: 1px solid var(--brand-border);
|
||||
display: grid;
|
||||
|
||||
+19
-156
@@ -33,7 +33,6 @@ import type {
|
||||
MusicPlaybackState,
|
||||
MusicState,
|
||||
Playlist,
|
||||
PlaylistGroup,
|
||||
Question,
|
||||
ResultsQuestion,
|
||||
ScreenCommand,
|
||||
@@ -60,6 +59,7 @@ import TicketCheckModal from '@/components/TicketCheckModal.vue'
|
||||
import UiBlock from '@/components/UiBlock.vue'
|
||||
import { useDeviceContext } from '@/composables/useDeviceContext'
|
||||
import { useGameState } from '@/composables/useGameState'
|
||||
import { usePlaylistActions } from '@/composables/usePlaylistActions'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
@@ -253,6 +253,24 @@ const music = ref<MusicState>()
|
||||
const isMusicBusy = ref(false)
|
||||
let musicRequestVersion = 0
|
||||
let musicSyncedAt = Date.now()
|
||||
const {
|
||||
saveGroup,
|
||||
deleteGroup,
|
||||
reorderGroups,
|
||||
setTrackGroup,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
reorderTracks,
|
||||
deletePlaylist,
|
||||
uploadTrack,
|
||||
deleteTrack,
|
||||
} = usePlaylistActions(
|
||||
isMusicBusy,
|
||||
() => getGameMusic(true),
|
||||
() => {
|
||||
musicRequestVersion += 1
|
||||
},
|
||||
)
|
||||
const isScreenBusy = ref(false)
|
||||
const isScreensLoading = ref(true)
|
||||
const screensError = ref('')
|
||||
@@ -704,161 +722,6 @@ 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
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.CreatePlaylist({ name: name.trim() })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renamePlaylist(id: number, name: string) {
|
||||
if (isMusicBusy.value || !name.trim()) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.UpdatePlaylist({ ID: id, name: name.trim() })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось переименовать плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderTracks(playlistID: number, trackIDs: number[]) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.ReorderPlaylistTracks({ playlistID, trackIDs })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось сохранить порядок треков')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlaylist(id: number) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.DeletePlaylist({ ID: id })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTrack(playlistID: number, file: File) {
|
||||
if (isMusicBusy.value) return
|
||||
if (!/\.mp3$/i.test(file.name) || file.size === 0 || file.size > 64 * 1024 * 1024) {
|
||||
message.error('Выберите непустой MP3 размером до 64 МиБ')
|
||||
return
|
||||
}
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer()))
|
||||
await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrack(playlistID: number, id: number) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
musicRequestVersion += 1
|
||||
try {
|
||||
await client.DeletePlaylistTrack({ playlistID, trackID: id })
|
||||
await getGameMusic(true)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить трек')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
function uint8ToBase64(data: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let i = 0; i < data.length; i += 0x8000) binary += String.fromCharCode(...data.subarray(i, i + 0x8000))
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
async function unbindScreen(screen: ScreenState) {
|
||||
if (isScreenBusy.value) return
|
||||
isScreenBusy.value = true
|
||||
|
||||
@@ -104,6 +104,15 @@ const openMenu = ref(false)
|
||||
>
|
||||
Экран
|
||||
</button>
|
||||
<button
|
||||
v-if="authStore.hasAnyRole(['author', 'organizer'])"
|
||||
type="button"
|
||||
class="menu-item-block"
|
||||
:class="{ active: props.active == 'playlists' }"
|
||||
@click="router.push('/playlists')"
|
||||
>
|
||||
Плейлисты
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-item-block"
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NInput,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NUpload,
|
||||
type UploadFileInfo,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import type {
|
||||
MusicState,
|
||||
Playlist,
|
||||
PlaylistGroup,
|
||||
} from '@/api/generated/crabs/evening_detective_server'
|
||||
import PlaylistGroupEditor from '@/components/PlaylistGroupEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
playlists: Playlist[]
|
||||
music?: MusicState
|
||||
isBusy: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
setMusic: [
|
||||
change: {
|
||||
groupId?: number
|
||||
playlistID?: number
|
||||
trackIndex?: number
|
||||
positionMs?: number
|
||||
playback?: MusicState['playback']
|
||||
loop?: boolean
|
||||
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[]]
|
||||
deletePlaylist: [id: number]
|
||||
uploadTrack: [playlistID: number, file: File]
|
||||
deleteTrack: [playlistID: number, trackID: number]
|
||||
}>()
|
||||
|
||||
const selectedId = ref<number | null>(props.music?.playlist?.ID ?? props.playlists[0]?.ID ?? 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 originalName = ref('')
|
||||
const order = ref<number[]>([])
|
||||
const originalOrder = ref<number[]>([])
|
||||
const lastSelected = ref(props.playlists.find((item) => item.ID === selectedId.value))
|
||||
const selected = computed(
|
||||
() =>
|
||||
props.playlists.find((item) => item.ID === selectedId.value) ??
|
||||
(hasGroupDraft.value || isNameDirty.value || isOrderDirty.value || newGroupName.value.trim()
|
||||
? lastSelected.value
|
||||
: undefined),
|
||||
)
|
||||
const isSelectedMissing = computed(
|
||||
() => !!selectedId.value && !props.playlists.some((item) => item.ID === selectedId.value),
|
||||
)
|
||||
const cachedGroups = ref<PlaylistGroup[]>(lastSelected.value?.groups ?? [])
|
||||
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 })),
|
||||
)
|
||||
const isOrderDirty = computed(
|
||||
() => JSON.stringify(order.value) !== JSON.stringify(originalOrder.value),
|
||||
)
|
||||
const isNameDirty = computed(() => name.value !== originalName.value)
|
||||
const hasConflict = computed(
|
||||
() =>
|
||||
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 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 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 reset() {
|
||||
if (isSelectedMissing.value || !selected.value) selectedId.value = null
|
||||
name.value = selected.value?.name ?? ''
|
||||
originalName.value = name.value
|
||||
order.value =
|
||||
selected.value?.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
|
||||
originalOrder.value = [...order.value]
|
||||
newGroupName.value = ''
|
||||
}
|
||||
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)
|
||||
}
|
||||
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) => {
|
||||
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 &&
|
||||
!hasGroupDraft.value &&
|
||||
!newGroupName.value.trim()
|
||||
) {
|
||||
selectedId.value = added.ID ?? null
|
||||
newName.value = ''
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if (!selected.value) {
|
||||
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
|
||||
}
|
||||
const ids =
|
||||
selected.value.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
|
||||
if (!isOrderDirty.value || JSON.stringify(ids) === JSON.stringify(order.value)) {
|
||||
order.value = ids
|
||||
originalOrder.value = [...ids]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function confirmDiscard() {
|
||||
return (
|
||||
!(
|
||||
isOrderDirty.value ||
|
||||
isNameDirty.value ||
|
||||
hasGroupDraft.value ||
|
||||
newGroupName.value.trim() ||
|
||||
newName.value.trim()
|
||||
) || window.confirm('Отменить несохранённые изменения плейлиста?')
|
||||
)
|
||||
}
|
||||
onBeforeRouteLeave(confirmDiscard)
|
||||
defineExpose({ confirmDiscard })
|
||||
reset()
|
||||
</script>
|
||||
<template>
|
||||
<n-space vertical :wrap-item="false" class="music-content" data-testid="music-editor">
|
||||
<n-space :wrap="false">
|
||||
<n-input
|
||||
v-model:value="newName"
|
||||
placeholder="Новый плейлист"
|
||||
aria-label="Название нового плейлиста"
|
||||
:disabled="isBusy"
|
||||
/>
|
||||
<n-button
|
||||
:disabled="isBusy || hasGroupDraft || !!newGroupName.trim() || !newName.trim()"
|
||||
@click="emit('createPlaylist', newName.trim())"
|
||||
>Создать</n-button
|
||||
>
|
||||
</n-space>
|
||||
<n-select
|
||||
v-model:value="selectedId"
|
||||
:options="options"
|
||||
placeholder="Плейлист для редактирования"
|
||||
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
|
||||
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
|
||||
/>
|
||||
<p v-if="!playlists.length">Плейлистов пока нет.</p>
|
||||
<n-alert v-if="isSelectedMissing" type="warning">
|
||||
Плейлист удалён на сервере. Несохранённый черновик сохранён до отмены.
|
||||
<n-button size="small" :disabled="isBusy" @click="reset"
|
||||
>Загрузить актуальный список</n-button
|
||||
>
|
||||
</n-alert>
|
||||
<template v-if="selected && selectedId">
|
||||
<label for="music-playlist-name">Название плейлиста</label>
|
||||
<n-input
|
||||
v-model:value="name"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ id: 'music-playlist-name' }"
|
||||
/>
|
||||
<n-space>
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || !name.trim() || !isNameDirty"
|
||||
@click="emit('renamePlaylist', selectedId, name.trim())"
|
||||
>Сохранить название</n-button
|
||||
>
|
||||
<n-upload
|
||||
accept=".mp3,audio/mpeg"
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
:disabled="isBusy || isOrderDirty || hasConflict"
|
||||
@change="uploadTrack"
|
||||
>
|
||||
<n-button size="small" :disabled="isBusy || isOrderDirty || hasConflict"
|
||||
>Добавить MP3</n-button
|
||||
>
|
||||
</n-upload>
|
||||
<n-button
|
||||
v-if="music"
|
||||
size="small"
|
||||
:disabled="isBusy || isOrderDirty || hasConflict || !order.length"
|
||||
@click="
|
||||
emit('setMusic', {
|
||||
playlistID: selectedId ?? undefined,
|
||||
trackIndex: 0,
|
||||
positionMs: 0,
|
||||
playback: 'MUSIC_PLAYBACK_STOPPED',
|
||||
})
|
||||
"
|
||||
>Использовать в игре</n-button
|
||||
>
|
||||
<n-popconfirm @positive-click="emit('deletePlaylist', selectedId)">
|
||||
<template #trigger
|
||||
><n-button
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
|
||||
>Удалить плейлист</n-button
|
||||
></template
|
||||
>
|
||||
Удалить плейлист «{{ selected.name }}»?
|
||||
</n-popconfirm>
|
||||
</n-space>
|
||||
<n-alert v-if="hasConflict" type="warning"
|
||||
>Порядок или состав треков изменился на сервере. Загрузите актуальный список перед
|
||||
сохранением.</n-alert
|
||||
>
|
||||
<n-space v-if="isOrderDirty || isNameDirty || hasConflict">
|
||||
<n-button
|
||||
size="small"
|
||||
:disabled="isBusy || !isOrderDirty || hasConflict"
|
||||
@click="emit('reorderTracks', selectedId, [...order])"
|
||||
>Сохранить порядок</n-button
|
||||
>
|
||||
<n-button size="small" :disabled="isBusy" @click="reset">{{
|
||||
hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения'
|
||||
}}</n-button>
|
||||
</n-space>
|
||||
<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>
|
||||
<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-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>
|
||||
</template>
|
||||
<style scoped>
|
||||
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-content {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
align-items: center;
|
||||
border: 1px solid var(--brand-border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NConfigProvider, NSpin, ruRU } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { Playlist } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import PlaylistEditor from '@/components/PlaylistEditor.vue'
|
||||
import { usePlaylistActions } from '@/composables/usePlaylistActions'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const client = getAuthClient()
|
||||
const canManage = computed(() => auth.hasAnyRole(['author', 'organizer']))
|
||||
const playlists = ref<Playlist[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isBusy = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const loadError = ref('')
|
||||
let requestVersion = 0
|
||||
|
||||
async function loadPlaylists() {
|
||||
if (!canManage.value) return
|
||||
const version = ++requestVersion
|
||||
isLoading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const response = await client.GetPlaylists({})
|
||||
if (version !== requestVersion) return
|
||||
playlists.value = response.playlists ?? []
|
||||
hasLoaded.value = true
|
||||
} catch (error) {
|
||||
if (version === requestVersion)
|
||||
loadError.value = error instanceof Error ? error.message : 'Не удалось загрузить плейлисты'
|
||||
} finally {
|
||||
if (version === requestVersion) isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
saveGroup,
|
||||
deleteGroup,
|
||||
reorderGroups,
|
||||
setTrackGroup,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
reorderTracks,
|
||||
deletePlaylist,
|
||||
uploadTrack,
|
||||
deleteTrack,
|
||||
} = usePlaylistActions(isBusy, loadPlaylists)
|
||||
|
||||
watch(
|
||||
() => auth.user,
|
||||
() => {
|
||||
requestVersion += 1
|
||||
playlists.value = []
|
||||
hasLoaded.value = false
|
||||
isLoading.value = false
|
||||
loadError.value = ''
|
||||
void loadPlaylists()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeaderMenu active="playlists" />
|
||||
<div class="center-block-custom">
|
||||
<n-config-provider :locale="ruRU">
|
||||
<main class="width1200 playlists-page">
|
||||
<h1>Плейлисты</h1>
|
||||
<n-alert v-if="!auth.isAuthenticated" type="warning"
|
||||
>Войдите как автор или организатор для работы с плейлистами.</n-alert
|
||||
>
|
||||
<n-spin v-else-if="!auth.user && !auth.error" />
|
||||
<n-alert v-else-if="!canManage" type="warning"
|
||||
>Нет доступа к плейлистам. Нужна роль автора или организатора.</n-alert
|
||||
>
|
||||
<template v-else>
|
||||
<n-alert v-if="loadError" type="error">
|
||||
{{ loadError }}
|
||||
<n-button :disabled="isBusy || isLoading" @click="loadPlaylists"
|
||||
>Повторить загрузку</n-button
|
||||
>
|
||||
</n-alert>
|
||||
<n-spin v-if="isLoading && !hasLoaded" />
|
||||
<PlaylistEditor
|
||||
v-if="hasLoaded"
|
||||
:playlists="playlists"
|
||||
:is-busy="isBusy || isLoading"
|
||||
@save-group="saveGroup"
|
||||
@delete-group="deleteGroup"
|
||||
@reorder-groups="reorderGroups"
|
||||
@set-track-group="setTrackGroup"
|
||||
@create-playlist="createPlaylist"
|
||||
@rename-playlist="renamePlaylist"
|
||||
@reorder-tracks="reorderTracks"
|
||||
@delete-playlist="deletePlaylist"
|
||||
@upload-track="uploadTrack"
|
||||
@delete-track="deleteTrack"
|
||||
/>
|
||||
</template>
|
||||
</main>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playlists-page {
|
||||
font-family: 'font_old_typer', serif;
|
||||
margin: 24px auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useMessage } from 'naive-ui'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { PlaylistGroup } from '@/api/generated/crabs/evening_detective_server'
|
||||
|
||||
export function usePlaylistActions(
|
||||
isMusicBusy: Ref<boolean>,
|
||||
onSaved: () => Promise<void>,
|
||||
onBeforeSave: () => void = () => {},
|
||||
) {
|
||||
const client = getAuthClient()
|
||||
const message = useMessage()
|
||||
async function saveGroup(
|
||||
playlistID: number,
|
||||
groupID: number,
|
||||
name: string,
|
||||
endMode: PlaylistGroup['endMode'],
|
||||
) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.SavePlaylistGroup({ playlistID, groupID, name, endMode })
|
||||
await onSaved()
|
||||
} 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
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.DeletePlaylistGroup({ playlistID, groupID })
|
||||
await onSaved()
|
||||
} 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
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.ReorderPlaylistGroups({ playlistID, groupIDs })
|
||||
await onSaved()
|
||||
} 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
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.SetPlaylistTrackGroup({ playlistID, trackID, groupID })
|
||||
await onSaved()
|
||||
} 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
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.CreatePlaylist({ name: name.trim() })
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renamePlaylist(id: number, name: string) {
|
||||
if (isMusicBusy.value || !name.trim()) return
|
||||
isMusicBusy.value = true
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.UpdatePlaylist({ ID: id, name: name.trim() })
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось переименовать плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderTracks(playlistID: number, trackIDs: number[]) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.ReorderPlaylistTracks({ playlistID, trackIDs })
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось сохранить порядок треков')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlaylist(id: number) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.DeletePlaylist({ ID: id })
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTrack(playlistID: number, file: File) {
|
||||
if (isMusicBusy.value) return
|
||||
if (!/\.mp3$/i.test(file.name) || file.size === 0 || file.size > 64 * 1024 * 1024) {
|
||||
message.error('Выберите непустой MP3 размером до 64 МиБ')
|
||||
return
|
||||
}
|
||||
isMusicBusy.value = true
|
||||
onBeforeSave()
|
||||
try {
|
||||
const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer()))
|
||||
await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data })
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrack(playlistID: number, id: number) {
|
||||
if (isMusicBusy.value) return
|
||||
isMusicBusy.value = true
|
||||
onBeforeSave()
|
||||
try {
|
||||
await client.DeletePlaylistTrack({ playlistID, trackID: id })
|
||||
await onSaved()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить трек')
|
||||
} finally {
|
||||
isMusicBusy.value = false
|
||||
}
|
||||
}
|
||||
function uint8ToBase64(data: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let i = 0; i < data.length; i += 0x8000)
|
||||
binary += String.fromCharCode(...data.subarray(i, i + 0x8000))
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
return {
|
||||
saveGroup,
|
||||
deleteGroup,
|
||||
reorderGroups,
|
||||
setTrackGroup,
|
||||
createPlaylist,
|
||||
renamePlaylist,
|
||||
reorderTracks,
|
||||
deletePlaylist,
|
||||
uploadTrack,
|
||||
deleteTrack,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/playlists',
|
||||
name: 'playlists',
|
||||
component: () => import('../views/PlaylistsView.vue'),
|
||||
},
|
||||
...(import.meta.env.DEV
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import PlaylistsPage from '@/components/PlaylistsPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PlaylistsPage />
|
||||
</template>
|
||||
Reference in New Issue
Block a user