generated from VLADIMIR/template_frontend
329 lines
8.8 KiB
Vue
329 lines
8.8 KiB
Vue
<script setup lang="ts">
|
||
import { NAlert, NButton, NFlex, NInput, NInputNumber, NSelect, NSwitch, NTag, NUpload, type UploadFileInfo } from 'naive-ui'
|
||
import { ref } from 'vue'
|
||
|
||
import type { MusicState, Playlist, ScreenState } from '@/api/generated/crabs/evening_detective_server'
|
||
import ScreenBindingModal from '@/components/ScreenBindingModal.vue'
|
||
import UiBlock from '@/components/UiBlock.vue'
|
||
|
||
const props = defineProps<{
|
||
screens: ScreenState[]
|
||
screenBinding: boolean
|
||
playlists: Playlist[]
|
||
music?: MusicState
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
bind: [screenID: string, accessToken: string]
|
||
setSound: [screen: ScreenState]
|
||
unbind: [screen: ScreenState]
|
||
setMusic: [change: { playlistID?: number; trackIndex?: number; positionMs?: number; playback?: MusicState['playback']; loop?: boolean; volume?: number }]
|
||
createPlaylist: [name: string]
|
||
deletePlaylist: [id: number]
|
||
uploadTrack: [file: File]
|
||
deleteTrack: [id: number]
|
||
}>()
|
||
|
||
const showBindingModal = ref(false)
|
||
const playlistName = ref('')
|
||
|
||
function uploadTrack(options: { file: UploadFileInfo }) {
|
||
if (options.file.file) emit('uploadTrack', options.file.file)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<UiBlock>
|
||
<n-flex align="center" justify="space-between" class="screen-header">
|
||
<p class="screens-title">Экраны</p>
|
||
<n-button size="small" @click="showBindingModal = true"> Привязать экран </n-button>
|
||
</n-flex>
|
||
<n-alert
|
||
v-if="screens.some((screen) => screen.status === 'disconnected')"
|
||
type="warning"
|
||
title="Проверьте подключение экрана"
|
||
>
|
||
Один или несколько экранов не выходят на связь более минуты.
|
||
</n-alert>
|
||
<p v-if="screens.length === 0" class="screen-empty">К игре пока не привязаны экраны.</p>
|
||
<template v-else>
|
||
<div v-for="screen in screens" :key="screen.ID" class="screen-row">
|
||
<div>
|
||
<div>Экран {{ screen.ID }}</div>
|
||
<div class="screen-status">
|
||
{{ screen.status === 'connected' ? 'Онлайн' : 'Оффлайн' }}
|
||
</div>
|
||
<div v-if="screen.error" class="screen-error">{{ screen.error }}</div>
|
||
</div>
|
||
<n-flex justify="end">
|
||
<n-tag v-if="screen.soundEnabled" type="success">Со звуком</n-tag>
|
||
<n-button v-else size="small" @click="emit('setSound', screen)">Назначить звук</n-button>
|
||
<n-button size="small" type="error" ghost @click="emit('unbind', screen)">
|
||
Отвязать
|
||
</n-button>
|
||
</n-flex>
|
||
</div>
|
||
</template>
|
||
</UiBlock>
|
||
<ScreenBindingModal
|
||
v-model:show="showBindingModal"
|
||
:binding="props.screenBinding"
|
||
@scanned="(screenID, accessToken) => emit('bind', screenID, accessToken)"
|
||
/>
|
||
<UiBlock class="music-block">
|
||
<p class="screens-title">Музыка</p>
|
||
<n-flex align="center" wrap class="playlist-actions">
|
||
<n-input v-model:value="playlistName" placeholder="Новый плейлист" style="max-width: 220px" />
|
||
<n-button
|
||
size="small"
|
||
:disabled="!playlistName.trim()"
|
||
@click="emit('createPlaylist', playlistName.trim()); playlistName = ''"
|
||
>
|
||
Создать
|
||
</n-button>
|
||
<n-button
|
||
v-if="props.music?.playlist?.ID"
|
||
size="small"
|
||
type="error"
|
||
ghost
|
||
@click="emit('deletePlaylist', props.music.playlist.ID)"
|
||
>
|
||
Удалить плейлист
|
||
</n-button>
|
||
<n-upload accept=".mp3,audio/mpeg" :default-upload="false" :show-file-list="false" @change="uploadTrack">
|
||
<n-button size="small">Добавить MP3</n-button>
|
||
</n-upload>
|
||
</n-flex>
|
||
<n-flex align="center" wrap>
|
||
<n-select
|
||
:value="props.music?.playlist?.ID"
|
||
:options="props.playlists.map((playlist) => ({ label: playlist.name, value: playlist.ID }))"
|
||
placeholder="Плейлист"
|
||
clearable
|
||
style="min-width: 220px"
|
||
@update:value="(value) => emit('setMusic', { playlistID: value || 0, trackIndex: 0, playback: 'MUSIC_PLAYBACK_STOPPED' })"
|
||
/>
|
||
<div v-if="props.music?.playlist" class="track-list">
|
||
<p v-if="!props.music.playlist.tracks?.length" class="track-empty">В плейлисте пока нет треков.</p>
|
||
<div
|
||
v-for="(track, index) in props.music.playlist.tracks"
|
||
:key="track.ID"
|
||
class="track-row"
|
||
:class="{ 'track-row-active': index === (props.music.trackIndex ?? 0) }"
|
||
>
|
||
<div class="track-main">
|
||
<span class="track-number">{{ index + 1 }}</span>
|
||
<span class="track-details">
|
||
<span class="track-name">{{ track.name || track.filename || `Трек ${index + 1}` }}</span>
|
||
<span class="track-filename">{{ track.filename }}</span>
|
||
</span>
|
||
</div>
|
||
<n-flex align="center" class="track-actions">
|
||
<n-tag v-if="index === (props.music.trackIndex ?? 0)" type="success" size="small">
|
||
{{ props.music.playback === 'MUSIC_PLAYBACK_PLAYING' ? 'Играет' : 'Выбран' }}
|
||
</n-tag>
|
||
<n-button
|
||
v-else
|
||
size="small"
|
||
type="primary"
|
||
ghost
|
||
@click="emit('setMusic', { trackIndex: index, positionMs: 0 })"
|
||
>
|
||
Включить
|
||
</n-button>
|
||
<n-button size="small" type="error" ghost @click="emit('deleteTrack', track.ID!)">
|
||
Удалить
|
||
</n-button>
|
||
</n-flex>
|
||
</div>
|
||
</div>
|
||
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_PLAYING' })">Старт</n-button>
|
||
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_PAUSED' })">Пауза</n-button>
|
||
<n-button size="small" @click="emit('setMusic', { playback: 'MUSIC_PLAYBACK_STOPPED', positionMs: 0 })">Стоп</n-button>
|
||
<div class="volume-control">
|
||
<span class="volume-label">Громкость</span>
|
||
<n-input-number
|
||
:value="props.music?.volume ?? 100"
|
||
:min="0"
|
||
:max="100"
|
||
:step="5"
|
||
size="small"
|
||
:show-button="true"
|
||
aria-label="Громкость музыки"
|
||
class="volume-input"
|
||
@update:value="(value) => value != null && emit('setMusic', { volume: value })"
|
||
>
|
||
<template #suffix>%</template>
|
||
</n-input-number>
|
||
</div>
|
||
<n-switch
|
||
:value="props.music?.loop"
|
||
:round="false"
|
||
@update:value="(value) => emit('setMusic', { loop: value })"
|
||
>
|
||
<template #checked>Loop</template>
|
||
<template #unchecked>Loop</template>
|
||
</n-switch>
|
||
</n-flex>
|
||
</UiBlock>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.screens-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.screen-header {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.music-block {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.playlist-actions {
|
||
margin: 8px 0;
|
||
}
|
||
|
||
.volume-control {
|
||
align-items: center;
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.volume-label {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.volume-input {
|
||
width: 96px;
|
||
}
|
||
|
||
.screen-row {
|
||
align-items: center;
|
||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: space-between;
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.screen-status {
|
||
color: #aaa;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.screen-empty {
|
||
color: #aaa;
|
||
margin: 16px 0 0;
|
||
}
|
||
|
||
.screen-error {
|
||
color: #f2a2a2;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.track-list {
|
||
flex-basis: 100%;
|
||
margin-top: 8px;
|
||
padding: 0 8px;
|
||
}
|
||
|
||
.track-empty {
|
||
color: #aaa;
|
||
margin: 0;
|
||
}
|
||
|
||
.track-row {
|
||
align-items: center;
|
||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: space-between;
|
||
min-height: 56px;
|
||
padding: 12px 8px;
|
||
}
|
||
|
||
.track-row-active {
|
||
background: rgb(255 255 255 / 12%);
|
||
}
|
||
|
||
.track-main {
|
||
align-items: center;
|
||
display: flex;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
padding-left: 8px;
|
||
}
|
||
|
||
.track-number {
|
||
color: #aaa;
|
||
flex: 0 0 28px;
|
||
font-variant-numeric: tabular-nums;
|
||
text-align: center;
|
||
}
|
||
|
||
.track-details {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
text-align: left;
|
||
}
|
||
|
||
.track-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.track-filename {
|
||
color: #aaa;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.track-actions {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
@media (width < 1024px) {
|
||
.track-list {
|
||
padding: 0;
|
||
}
|
||
|
||
.track-row {
|
||
align-items: stretch;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
}
|
||
|
||
.track-main {
|
||
padding-left: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.track-actions {
|
||
justify-content: flex-end;
|
||
width: 100%;
|
||
}
|
||
|
||
.track-actions .n-button {
|
||
flex: 1;
|
||
}
|
||
|
||
.volume-control {
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
}
|
||
|
||
.volume-input {
|
||
flex: 1;
|
||
width: auto;
|
||
}
|
||
}
|
||
</style>
|