generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -27,6 +27,9 @@ import type {
|
||||
Game,
|
||||
GameReview,
|
||||
MapItem,
|
||||
MusicPlaybackState,
|
||||
MusicState,
|
||||
Playlist,
|
||||
Question,
|
||||
ResultsQuestion,
|
||||
ScreenCommand,
|
||||
@@ -114,6 +117,8 @@ const activeTeamCount = computed(
|
||||
() => (game.value.teams || []).filter((team) => team.participationStatus !== 'cancelled').length,
|
||||
)
|
||||
const screens = ref<ScreenState[]>([])
|
||||
const playlists = ref<Playlist[]>([])
|
||||
const music = ref<MusicState>()
|
||||
const screenPlan = ref<ScreenPlan>()
|
||||
const screenPlanDraft = ref<ScreenPlanStep[]>([])
|
||||
const screenPlanDirty = ref(false)
|
||||
@@ -185,6 +190,17 @@ async function getGameScreens() {
|
||||
}
|
||||
getGameScreens()
|
||||
|
||||
async function getGameMusic() {
|
||||
try {
|
||||
const res = await client.GetGameMusic({ gameID: Number(gameId) })
|
||||
playlists.value = res.playlists || []
|
||||
music.value = res.state
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось получить музыку игры')
|
||||
}
|
||||
}
|
||||
getGameMusic()
|
||||
|
||||
function isScreenPlanEditing() {
|
||||
return screenPlanMode.value === 'edit'
|
||||
}
|
||||
@@ -360,6 +376,81 @@ async function setSoundScreen(screen: ScreenState) {
|
||||
await getGameScreens()
|
||||
}
|
||||
|
||||
async function setGameMusic(change: {
|
||||
playlistID?: number
|
||||
trackIndex?: number
|
||||
positionMs?: number
|
||||
playback?: MusicPlaybackState
|
||||
loop?: boolean
|
||||
volume?: number
|
||||
}) {
|
||||
const current = music.value
|
||||
try {
|
||||
const res = await client.SetGameMusic({
|
||||
gameID: Number(gameId),
|
||||
playlistID: change.playlistID ?? current?.playlist?.ID ?? 0,
|
||||
trackIndex: change.trackIndex ?? current?.trackIndex ?? 0,
|
||||
positionMs: change.positionMs ?? current?.positionMs ?? 0,
|
||||
volume: change.volume ?? current?.volume ?? 100,
|
||||
loop: change.loop ?? current?.loop ?? false,
|
||||
playback: change.playback ?? current?.playback ?? 'MUSIC_PLAYBACK_STOPPED',
|
||||
})
|
||||
music.value = res.state
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось изменить музыку')
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlaylist(name: string) {
|
||||
try {
|
||||
await client.CreatePlaylist({ name })
|
||||
await getGameMusic()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист')
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlaylist(id: number) {
|
||||
try {
|
||||
await client.DeletePlaylist({ ID: id })
|
||||
await getGameMusic()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист')
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadTrack(file: File) {
|
||||
const playlistID = music.value?.playlist?.ID
|
||||
if (!playlistID) {
|
||||
message.warning('Сначала выберите плейлист')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer()))
|
||||
await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data })
|
||||
await getGameMusic()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrack(id: number) {
|
||||
const playlistID = music.value?.playlist?.ID
|
||||
if (!playlistID) return
|
||||
try {
|
||||
await client.DeletePlaylistTrack({ playlistID, trackID: id })
|
||||
await getGameMusic()
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить трек')
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
await client.UnbindScreen({ screenID: screen.ID })
|
||||
await getGameScreens()
|
||||
@@ -539,6 +630,7 @@ async function issueTickets() {
|
||||
useSimplePolling(() => {
|
||||
getGame(Number(gameId))
|
||||
getGameScreens()
|
||||
getGameMusic()
|
||||
getScreenPlan()
|
||||
getGameMap()
|
||||
}, 2000)
|
||||
@@ -811,9 +903,16 @@ useSimplePolling(() => {
|
||||
<GameScreensPanel
|
||||
:screens="screens"
|
||||
:screen-binding="screenBinding"
|
||||
:playlists="playlists"
|
||||
:music="music"
|
||||
@bind="bindScannedScreen"
|
||||
@set-sound="setSoundScreen"
|
||||
@unbind="unbindScreen"
|
||||
@set-music="setGameMusic"
|
||||
@create-playlist="createPlaylist"
|
||||
@delete-playlist="deletePlaylist"
|
||||
@upload-track="uploadTrack"
|
||||
@delete-track="deleteTrack"
|
||||
/>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NFlex, NTag } from 'naive-ui'
|
||||
import { NAlert, NButton, NFlex, NInput, NInputNumber, NSelect, NSwitch, NTag, NUpload, type UploadFileInfo } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ScreenState } from '@/api/generated/crabs/evening_detective_server'
|
||||
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>
|
||||
@@ -58,6 +70,79 @@ const showBindingModal = ref(false)
|
||||
: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' })"
|
||||
/>
|
||||
<n-button
|
||||
v-if="props.music?.playlist?.tracks?.[props.music.trackIndex || 0]?.ID"
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
@click="emit('deleteTrack', props.music.playlist.tracks[props.music.trackIndex || 0].ID!)"
|
||||
>
|
||||
Удалить трек
|
||||
</n-button>
|
||||
<n-select
|
||||
v-if="props.music?.playlist?.tracks?.length"
|
||||
:value="props.music.trackIndex"
|
||||
:options="props.music.playlist.tracks.map((track, index) => ({ label: track.name || track.filename || `Трек ${index + 1}`, value: index }))"
|
||||
style="min-width: 220px"
|
||||
@update:value="(value) => emit('setMusic', { trackIndex: value })"
|
||||
/>
|
||||
<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>
|
||||
<n-input-number
|
||||
:value="props.music?.volume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="5"
|
||||
size="small"
|
||||
style="width: 110px"
|
||||
@update:value="(value) => value != null && emit('setMusic', { volume: value })"
|
||||
>
|
||||
<template #prefix>Громкость</template>
|
||||
</n-input-number>
|
||||
<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>
|
||||
@@ -71,6 +156,14 @@ const showBindingModal = ref(false)
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.music-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.screen-row {
|
||||
align-items: center;
|
||||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NFlex,
|
||||
NInput,
|
||||
NModal,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NTag,
|
||||
NText,
|
||||
@@ -19,7 +20,7 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import { getBinaryClient } from '@/api/binary_client'
|
||||
import type { Conclusion, Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||
import type { Conclusion, Playlist, Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import IntroBlock from '@/components/IntroBlock.vue'
|
||||
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||
@@ -72,6 +73,8 @@ const conclusion = ref<Conclusion>({ text: '', video: '' })
|
||||
const conclusionFiles = ref<UploadFileInfo[]>([])
|
||||
const savingConclusion = ref(false)
|
||||
const conclusionVideoURL = computed(() => applicationFileURL(conclusion.value.video))
|
||||
const playlists = ref<Playlist[]>([])
|
||||
const selectedPlaylistID = ref<number | null>(null)
|
||||
|
||||
function resetConclusion() {
|
||||
conclusion.value = { text: '', video: '', ...scenario.value.story?.conclusion }
|
||||
@@ -146,6 +149,7 @@ const editorQuestions = computed<EditableQuestion[]>(() => {
|
||||
async function getScenario(id: number) {
|
||||
const res = await client.GetFullScenario({ ID: id })
|
||||
scenario.value = res.scenario!
|
||||
selectedPlaylistID.value = scenario.value.music_playlist_id || null
|
||||
resetConclusion()
|
||||
scenario.value.story?.places?.forEach((item) => {
|
||||
const editable = item as EditablePlace
|
||||
@@ -162,6 +166,25 @@ async function getScenario(id: number) {
|
||||
}
|
||||
getScenario(Number(scenarioId))
|
||||
|
||||
async function loadPlaylists() {
|
||||
try {
|
||||
const res = await client.GetPlaylists({})
|
||||
playlists.value = res.playlists || []
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось загрузить плейлисты')
|
||||
}
|
||||
}
|
||||
loadPlaylists()
|
||||
|
||||
async function saveScenarioPlaylist(value: number | null) {
|
||||
try {
|
||||
await client.SetScenarioPlaylist({ ID: Number(scenarioId), playlistID: value || 0 })
|
||||
message.success('Плейлист сценария сохранён')
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось сохранить плейлист')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScenario(id: number) {
|
||||
await client.DeleteScenario({ ID: id })
|
||||
router.push('/scenarios')
|
||||
@@ -584,6 +607,16 @@ function getRandomComplimentForNastya() {
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<n-card title="Музыка сценария">
|
||||
<n-select
|
||||
v-model:value="selectedPlaylistID"
|
||||
:options="playlists.map((playlist) => ({ label: playlist.name, value: playlist.ID }))"
|
||||
clearable
|
||||
placeholder="Плейлист по умолчанию"
|
||||
@update:value="saveScenarioPlaylist"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<n-card title="Фон дела">
|
||||
<n-space vertical>
|
||||
<img
|
||||
|
||||
@@ -38,6 +38,7 @@ const timerSyncedAt = ref(Date.now())
|
||||
const timerStateAtSync = ref<string>()
|
||||
const pollingReady = ref(false)
|
||||
const introAudioElement = ref<HTMLAudioElement>()
|
||||
const musicAudioElement = ref<HTMLAudioElement>()
|
||||
const conclusionVideoElement = ref<HTMLVideoElement>()
|
||||
let appliedCommandVersion = 0
|
||||
let timerTick: number | null = null
|
||||
@@ -68,6 +69,11 @@ const timerDisplay = computed(() => {
|
||||
})
|
||||
const introAudio = computed(() => applicationFileURL(state.value?.introAudio))
|
||||
const conclusionVideo = computed(() => applicationFileURL(state.value?.conclusionVideo))
|
||||
const musicTrack = computed(() => {
|
||||
const tracks = state.value?.music?.playlist?.tracks || []
|
||||
const index = state.value?.music?.trackIndex || 0
|
||||
return applicationFileURL(tracks[index]?.filename)
|
||||
})
|
||||
const screenBackground = computed(() => {
|
||||
const image = state.value?.backgroundImage || state.value?.coverImage
|
||||
return image ? { backgroundImage: `linear-gradient(rgb(0 0 0 / 55%), rgb(0 0 0 / 55%)), url("${image}")` } : {}
|
||||
@@ -188,6 +194,35 @@ watch(state, async (value) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => state.value?.music,
|
||||
async (value) => {
|
||||
const media = musicAudioElement.value
|
||||
if (!media || !value || !value.playlist?.tracks?.length) return
|
||||
await nextTick()
|
||||
media.volume = Math.max(0, Math.min(1, (value.volume ?? 100) / 100))
|
||||
media.muted = !state.value?.soundEnabled
|
||||
media.currentTime = (value.positionMs || 0) / 1000
|
||||
if (value.playback === 'MUSIC_PLAYBACK_PLAYING') {
|
||||
void media.play().catch(() => {
|
||||
mediaError.value = 'Браузер заблокировал воспроизведение музыки. Нажмите «Подготовить экран».'
|
||||
})
|
||||
} else {
|
||||
media.pause()
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function advanceMusic() {
|
||||
if (!screenId.value || !accessToken.value) return
|
||||
try {
|
||||
await screenClient.AdvanceScreenMusic({ screenID: screenId.value, accessToken: accessToken.value })
|
||||
} catch {
|
||||
mediaError.value = 'Не удалось синхронизировать музыку. Повторная попытка выполняется автоматически.'
|
||||
}
|
||||
}
|
||||
|
||||
async function bind() {
|
||||
if (!selectedGameId.value || !route.query.screenID || !route.query.accessToken) return
|
||||
binding.value = true
|
||||
@@ -213,7 +248,7 @@ async function prepareScreen() {
|
||||
}
|
||||
prepared.value = true
|
||||
await nextTick()
|
||||
for (const media of [introAudioElement.value, conclusionVideoElement.value]) {
|
||||
for (const media of [introAudioElement.value, conclusionVideoElement.value, musicAudioElement.value]) {
|
||||
if (!media) continue
|
||||
media.muted = true
|
||||
try {
|
||||
@@ -288,6 +323,13 @@ onUnmounted(() => {
|
||||
<div v-if="state.introAudio" v-show="screenMode === 'SCREEN_MODE_INTRO'" class="screen-media">
|
||||
<audio ref="introAudioElement" :src="introAudio" :muted="!state.soundEnabled" controls />
|
||||
</div>
|
||||
<audio
|
||||
v-if="state.music?.playlist?.tracks?.length"
|
||||
ref="musicAudioElement"
|
||||
:src="musicTrack"
|
||||
:muted="!state.soundEnabled"
|
||||
@ended="advanceMusic"
|
||||
/>
|
||||
<div v-if="state.conclusionVideo" v-show="screenMode === 'SCREEN_MODE_CONCLUSION'" class="conclusion-screen">
|
||||
<video ref="conclusionVideoElement" :src="conclusionVideo" :muted="!state.soundEnabled" playsinline />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user