generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -1,17 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Edit, Pause, Play, Repeat, Reset, Stop } from '@vicons/carbon'
|
import { Edit, Pause, Play, Repeat, Reset, Stop } from '@vicons/carbon'
|
||||||
import { Icon } from '@vicons/utils'
|
import { Icon } from '@vicons/utils'
|
||||||
import {
|
import { NButton, NSelect, NSlider, NSpace } from 'naive-ui'
|
||||||
NAlert,
|
|
||||||
NButton,
|
|
||||||
NInput,
|
|
||||||
NPopconfirm,
|
|
||||||
NSelect,
|
|
||||||
NSlider,
|
|
||||||
NSpace,
|
|
||||||
NUpload,
|
|
||||||
type UploadFileInfo,
|
|
||||||
} from 'naive-ui'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -19,7 +9,7 @@ import type {
|
|||||||
Playlist,
|
Playlist,
|
||||||
PlaylistGroup,
|
PlaylistGroup,
|
||||||
} from '@/api/generated/crabs/evening_detective_server'
|
} 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'
|
import UiBlock from '@/components/UiBlock.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -52,57 +42,14 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isEditing = ref(false)
|
const isEditing = ref(false)
|
||||||
const selectedId = ref<number | null>(null)
|
const editor = ref<InstanceType<typeof PlaylistEditor>>()
|
||||||
const name = ref('')
|
|
||||||
const newName = ref('')
|
|
||||||
const newGroupName = ref('')
|
|
||||||
const dirtyGroups = ref<Record<number, boolean>>({})
|
|
||||||
const hasGroupDraft = computed(() => Object.values(dirtyGroups.value).some(Boolean))
|
|
||||||
const volume = ref(props.music?.volume ?? 100)
|
const 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(() =>
|
const options = computed(() =>
|
||||||
props.playlists.map((item) => ({ label: item.name, value: item.ID })),
|
props.playlists.map((item) => ({ label: item.name, value: item.ID })),
|
||||||
)
|
)
|
||||||
const isOrderDirty = computed(
|
function finish() {
|
||||||
() => JSON.stringify(order.value) !== JSON.stringify(originalOrder.value),
|
if (editor.value?.confirmDiscard()) isEditing.value = false
|
||||||
)
|
}
|
||||||
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 playbackSections = computed(() => [
|
const playbackSections = computed(() => [
|
||||||
...(props.music?.playlist?.groups ?? []).map((group) => ({
|
...(props.music?.playlist?.groups ?? []).map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
@@ -111,22 +58,6 @@ const playbackSections = computed(() => [
|
|||||||
})),
|
})),
|
||||||
{ ID: 0, name: 'Без группы', endMode: undefined, position: undefined },
|
{ 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) {
|
function playbackTracks(groupID: number) {
|
||||||
return (props.music?.playlist?.tracks ?? [])
|
return (props.music?.playlist?.tracks ?? [])
|
||||||
.map((track, index) => ({ track, index }))
|
.map((track, index) => ({ track, index }))
|
||||||
@@ -142,21 +73,6 @@ function playGroup(groupID: number) {
|
|||||||
playback: 'MUSIC_PLAYBACK_PLAYING',
|
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']) {
|
function modeLabel(mode: PlaylistGroup['endMode']) {
|
||||||
if (mode === 'PLAYLIST_GROUP_END_MODE_STOP') return 'После группы — тишина'
|
if (mode === 'PLAYLIST_GROUP_END_MODE_STOP') return 'После группы — тишина'
|
||||||
if (mode === 'PLAYLIST_GROUP_END_MODE_NEXT') 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])
|
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) {
|
function selectTrack(index: number, groupID: number) {
|
||||||
if (index === (props.music?.trackIndex ?? 0) && groupID === (props.music?.groupId ?? 0)) return
|
if (index === (props.music?.trackIndex ?? 0) && groupID === (props.music?.groupId ?? 0)) return
|
||||||
emit('setMusic', { trackIndex: index, groupId: groupID, positionMs: 0 })
|
emit('setMusic', { trackIndex: index, groupId: groupID, positionMs: 0 })
|
||||||
@@ -223,75 +99,7 @@ watch(
|
|||||||
volume.value = value ?? 100
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UiBlock class="music-panel" data-testid="music-plan">
|
<UiBlock class="music-panel" data-testid="music-plan">
|
||||||
<n-space justify="space-between" align="center">
|
<n-space justify="space-between" align="center">
|
||||||
@@ -303,210 +111,35 @@ watch(
|
|||||||
aria-label="Редактировать музыку"
|
aria-label="Редактировать музыку"
|
||||||
title="Редактировать музыку"
|
title="Редактировать музыку"
|
||||||
:disabled="isBusy"
|
:disabled="isBusy"
|
||||||
@click="edit"
|
@click="isEditing = true"
|
||||||
>
|
>
|
||||||
<Icon :size="18"><Edit /></Icon>
|
<Icon :size="18"><Edit /></Icon>
|
||||||
</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
|
<PlaylistEditor
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
vertical
|
ref="editor"
|
||||||
:wrap-item="false"
|
:playlists="playlists"
|
||||||
class="music-content"
|
:music="music"
|
||||||
data-testid="music-editor"
|
:is-busy="isBusy"
|
||||||
>
|
@set-music="(change) => emit('setMusic', change)"
|
||||||
<n-space :wrap="false">
|
@save-group="
|
||||||
<n-input
|
(playlistID, groupID, name, endMode) =>
|
||||||
v-model:value="newName"
|
emit('saveGroup', playlistID, groupID, name, endMode)
|
||||||
placeholder="Новый плейлист"
|
"
|
||||||
aria-label="Название нового плейлиста"
|
@delete-group="(playlistID, groupID) => emit('deleteGroup', playlistID, groupID)"
|
||||||
:disabled="isBusy"
|
@reorder-groups="(playlistID, groupIDs) => emit('reorderGroups', playlistID, groupIDs)"
|
||||||
/>
|
@set-track-group="
|
||||||
<n-button
|
(playlistID, trackID, groupID) => emit('setTrackGroup', playlistID, trackID, groupID)
|
||||||
:disabled="isBusy || hasGroupDraft || !!newGroupName.trim() || !newName.trim()"
|
"
|
||||||
@click="emit('createPlaylist', newName.trim())"
|
@create-playlist="(name) => emit('createPlaylist', name)"
|
||||||
>Создать</n-button
|
@rename-playlist="(id, name) => emit('renamePlaylist', id, name)"
|
||||||
>
|
@reorder-tracks="(playlistID, trackIDs) => emit('reorderTracks', playlistID, trackIDs)"
|
||||||
</n-space>
|
@delete-playlist="(id) => emit('deletePlaylist', id)"
|
||||||
<n-select
|
@upload-track="(playlistID, file) => emit('uploadTrack', playlistID, file)"
|
||||||
v-model:value="selectedId"
|
@delete-track="(playlistID, trackID) => emit('deleteTrack', playlistID, trackID)"
|
||||||
: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>
|
|
||||||
<n-space v-else vertical class="music-content">
|
<n-space v-else vertical class="music-content">
|
||||||
<div class="playlist-picker">
|
<div class="playlist-picker">
|
||||||
<n-select
|
<n-select
|
||||||
@@ -660,11 +293,6 @@ p {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-groups {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.music-group {
|
.music-group {
|
||||||
border-top: 1px solid var(--brand-border);
|
border-top: 1px solid var(--brand-border);
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+19
-156
@@ -33,7 +33,6 @@ import type {
|
|||||||
MusicPlaybackState,
|
MusicPlaybackState,
|
||||||
MusicState,
|
MusicState,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistGroup,
|
|
||||||
Question,
|
Question,
|
||||||
ResultsQuestion,
|
ResultsQuestion,
|
||||||
ScreenCommand,
|
ScreenCommand,
|
||||||
@@ -60,6 +59,7 @@ import TicketCheckModal from '@/components/TicketCheckModal.vue'
|
|||||||
import UiBlock from '@/components/UiBlock.vue'
|
import UiBlock from '@/components/UiBlock.vue'
|
||||||
import { useDeviceContext } from '@/composables/useDeviceContext'
|
import { useDeviceContext } from '@/composables/useDeviceContext'
|
||||||
import { useGameState } from '@/composables/useGameState'
|
import { useGameState } from '@/composables/useGameState'
|
||||||
|
import { usePlaylistActions } from '@/composables/usePlaylistActions'
|
||||||
import { useSimplePolling } from '@/composables/useSimplePolling'
|
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
@@ -253,6 +253,24 @@ const music = ref<MusicState>()
|
|||||||
const isMusicBusy = ref(false)
|
const isMusicBusy = ref(false)
|
||||||
let musicRequestVersion = 0
|
let musicRequestVersion = 0
|
||||||
let musicSyncedAt = Date.now()
|
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 isScreenBusy = ref(false)
|
||||||
const isScreensLoading = ref(true)
|
const isScreensLoading = ref(true)
|
||||||
const screensError = ref('')
|
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) {
|
async function unbindScreen(screen: ScreenState) {
|
||||||
if (isScreenBusy.value) return
|
if (isScreenBusy.value) return
|
||||||
isScreenBusy.value = true
|
isScreenBusy.value = true
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ const openMenu = ref(false)
|
|||||||
>
|
>
|
||||||
Экран
|
Экран
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="menu-item-block"
|
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({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/playlists',
|
||||||
|
name: 'playlists',
|
||||||
|
component: () => import('../views/PlaylistsView.vue'),
|
||||||
|
},
|
||||||
...(import.meta.env.DEV
|
...(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