Add global mini-player and improve configuration

- Add global activeRoom store for persistent WebSocket connection
- Add MiniPlayer component for playback controls across pages
- Add chunked S3 streaming with 64KB chunks and Range support
- Add queue item removal button
- Move DB credentials to environment variables
- Update .env.example with DB configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-12 15:02:02 +03:00
parent 2f1e1f35e3
commit f77a453158
12 changed files with 572 additions and 119 deletions

View File

@@ -1,14 +1,19 @@
<template>
<div id="app">
<Header />
<main class="main-content">
<main class="main-content" :class="{ 'has-mini-player': activeRoomStore.isInRoom }">
<router-view />
</main>
<MiniPlayer />
</div>
</template>
<script setup>
import Header from './components/common/Header.vue'
import MiniPlayer from './components/player/MiniPlayer.vue'
import { useActiveRoomStore } from './stores/activeRoom'
const activeRoomStore = useActiveRoomStore()
</script>
<style scoped>
@@ -25,4 +30,8 @@ import Header from './components/common/Header.vue'
margin: 0 auto;
width: 100%;
}
.main-content.has-mini-player {
padding-bottom: 100px;
}
</style>

View File

@@ -3,7 +3,7 @@
<h3>Чат</h3>
<div class="messages" ref="messagesRef">
<ChatMessage
v-for="msg in messages"
v-for="msg in allMessages"
:key="msg.id"
:message="msg"
/>
@@ -13,7 +13,7 @@
type="text"
v-model="newMessage"
placeholder="Написать сообщение..."
:disabled="!ws.connected"
:disabled="!activeRoomStore.connected"
/>
<button type="submit" class="btn-primary" :disabled="!newMessage.trim()">
Отправить
@@ -23,53 +23,43 @@
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import api from '../../composables/useApi'
import { useActiveRoomStore } from '../../stores/activeRoom'
import ChatMessage from './ChatMessage.vue'
const props = defineProps({
roomId: {
type: String,
required: true
},
ws: {
type: Object,
required: true
}
})
const messages = ref([])
const activeRoomStore = useActiveRoomStore()
const historyMessages = ref([])
const newMessage = ref('')
const messagesRef = ref(null)
onMounted(async () => {
const response = await api.get(`/api/rooms/${props.roomId}/messages`)
messages.value = response.data
scrollToBottom()
// Combine history + new messages
const allMessages = computed(() => {
return [...historyMessages.value, ...activeRoomStore.chatMessages]
})
// Listen for new messages from WebSocket
watch(() => props.ws, (wsObj) => {
if (wsObj?.messages) {
watch(wsObj.messages, (msgs) => {
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.type === 'chat_message') {
messages.value.push({
id: lastMsg.id,
user_id: lastMsg.user_id,
username: lastMsg.username,
text: lastMsg.text,
created_at: lastMsg.created_at
})
nextTick(scrollToBottom)
}
}, { deep: true })
}
}, { immediate: true })
onMounted(async () => {
const response = await api.get(`/api/rooms/${props.roomId}/messages`)
historyMessages.value = response.data
nextTick(scrollToBottom)
})
// Auto-scroll when new messages arrive
watch(() => activeRoomStore.chatMessages.length, () => {
nextTick(scrollToBottom)
})
function sendMessage() {
if (!newMessage.value.trim()) return
props.ws.sendChatMessage(newMessage.value)
activeRoomStore.sendChatMessage(newMessage.value)
newMessage.value = ''
}

View File

@@ -0,0 +1,211 @@
<template>
<div class="mini-player" v-if="activeRoomStore.isInRoom">
<div class="mini-player-info" @click="goToRoom">
<div class="room-name">{{ activeRoomStore.roomName }}</div>
<div class="track-info" v-if="currentTrack">
{{ currentTrack.title }} - {{ currentTrack.artist }}
</div>
<div class="track-info" v-else>Нет трека</div>
</div>
<div class="mini-player-controls">
<button class="control-btn" @click="handlePrev">
<span></span>
</button>
<button class="control-btn play-btn" @click="togglePlay">
<span>{{ playerStore.isPlaying ? '⏸' : '▶' }}</span>
</button>
<button class="control-btn" @click="handleNext">
<span></span>
</button>
</div>
<div class="mini-player-progress">
<div class="progress-bar" @click="handleSeek">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<span class="time">{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}</span>
</div>
<button class="leave-btn" @click="handleLeave" title="Выйти из комнаты">
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useActiveRoomStore } from '../../stores/activeRoom'
import { usePlayerStore } from '../../stores/player'
import { useTracksStore } from '../../stores/tracks'
const router = useRouter()
const activeRoomStore = useActiveRoomStore()
const playerStore = usePlayerStore()
const tracksStore = useTracksStore()
const currentTrack = computed(() => {
if (!playerStore.currentTrack?.id) return null
return tracksStore.tracks.find(t => t.id === playerStore.currentTrack.id)
})
const progressPercent = computed(() => {
if (!playerStore.duration) return 0
return (playerStore.position / playerStore.duration) * 100
})
function formatTime(ms) {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
function togglePlay() {
if (playerStore.isPlaying) {
activeRoomStore.sendPlayerAction('pause', playerStore.position)
} else {
activeRoomStore.sendPlayerAction('play', playerStore.position)
}
}
function handlePrev() {
activeRoomStore.sendPlayerAction('prev')
}
function handleNext() {
activeRoomStore.sendPlayerAction('next')
}
function handleSeek(e) {
const rect = e.currentTarget.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const position = Math.floor(percent * playerStore.duration)
activeRoomStore.sendPlayerAction('seek', position)
}
function goToRoom() {
router.push(`/room/${activeRoomStore.roomId}`)
}
async function handleLeave() {
await activeRoomStore.leaveRoom()
router.push('/')
}
</script>
<style scoped>
.mini-player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #1a1a2e;
border-top: 1px solid #333;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 20px;
z-index: 1000;
}
.mini-player-info {
flex: 0 0 200px;
cursor: pointer;
overflow: hidden;
}
.room-name {
font-size: 12px;
color: #7c3aed;
margin-bottom: 2px;
}
.track-info {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini-player-controls {
display: flex;
align-items: center;
gap: 8px;
}
.control-btn {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 8px;
font-size: 16px;
border-radius: 50%;
transition: background 0.2s;
}
.control-btn:hover {
background: #333;
}
.play-btn {
background: #7c3aed;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.play-btn:hover {
background: #6d28d9;
}
.mini-player-progress {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.progress-bar {
flex: 1;
height: 6px;
background: #333;
border-radius: 3px;
cursor: pointer;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #7c3aed;
border-radius: 3px;
transition: width 0.1s linear;
}
.time {
font-size: 12px;
color: #888;
min-width: 90px;
text-align: right;
}
.leave-btn {
background: transparent;
border: none;
color: #666;
cursor: pointer;
padding: 8px 12px;
font-size: 18px;
border-radius: 4px;
transition: all 0.2s;
}
.leave-btn:hover {
background: #ff4444;
color: white;
}
</style>

View File

@@ -7,14 +7,16 @@
v-for="(track, index) in queue"
:key="track.id"
class="queue-item"
@click="$emit('play-track', track)"
>
<span class="queue-index">{{ index + 1 }}</span>
<div class="queue-track-info">
<div class="queue-track-info" @click="$emit('play-track', track)">
<span class="queue-track-title">{{ track.title }}</span>
<span class="queue-track-artist">{{ track.artist }}</span>
</div>
<span class="queue-duration">{{ formatDuration(track.duration) }}</span>
<button class="btn-remove" @click.stop="$emit('remove-track', track)" title="Удалить из очереди">
</button>
</div>
</div>
</template>
@@ -27,7 +29,7 @@ defineProps({
}
})
defineEmits(['play-track'])
defineEmits(['play-track', 'remove-track'])
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000)
@@ -95,4 +97,20 @@ function formatDuration(ms) {
color: #aaa;
font-size: 12px;
}
.btn-remove {
background: transparent;
border: none;
color: #666;
cursor: pointer;
padding: 4px 8px;
font-size: 14px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-remove:hover {
background: #ff4444;
color: white;
}
</style>

View File

@@ -69,7 +69,13 @@ export function usePlayer(onTrackEnded = null) {
initAudio()
}
if (state.track_url && state.track_url !== playerStore.currentTrackUrl) {
// Load track if URL changed OR if audio has no source (e.g. after returning to room)
const needsLoad = state.track_url && (
state.track_url !== playerStore.currentTrackUrl ||
!audio.value.src
)
if (needsLoad) {
loadTrack(state.track_url)
playerStore.currentTrackUrl = state.track_url
}

View File

@@ -0,0 +1,223 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './auth'
import { usePlayerStore } from './player'
import { useRoomStore } from './room'
import api from '../composables/useApi'
export const useActiveRoomStore = defineStore('activeRoom', () => {
const ws = ref(null)
const connected = ref(false)
const roomId = ref(null)
const roomName = ref(null)
const chatMessages = ref([])
const authStore = useAuthStore()
const playerStore = usePlayerStore()
const roomStore = useRoomStore()
// Audio element
let audio = null
let onTrackEndedCallback = null
const isInRoom = computed(() => roomId.value !== null)
function initAudio() {
if (audio) return
audio = new Audio()
audio.volume = playerStore.volume / 100
audio.addEventListener('timeupdate', () => {
playerStore.setPosition(Math.floor(audio.currentTime * 1000))
})
audio.addEventListener('loadedmetadata', () => {
playerStore.setDuration(Math.floor(audio.duration * 1000))
})
audio.addEventListener('ended', () => {
if (onTrackEndedCallback) {
onTrackEndedCallback()
}
})
}
function connect(id, name) {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
if (roomId.value === id) return
disconnect()
}
roomId.value = id
roomName.value = name
const wsUrl = import.meta.env.VITE_WS_URL || window.location.origin.replace('http', 'ws')
ws.value = new WebSocket(`${wsUrl}/ws/rooms/${id}?token=${authStore.token}`)
ws.value.onopen = () => {
connected.value = true
send({ type: 'sync_request' })
}
ws.value.onclose = () => {
connected.value = false
}
ws.value.onerror = () => {}
ws.value.onmessage = (event) => {
const data = JSON.parse(event.data)
handleMessage(data)
}
// Set callback for track ended
onTrackEndedCallback = () => {
sendPlayerAction('next')
}
}
function disconnect() {
if (ws.value) {
ws.value.close()
ws.value = null
}
connected.value = false
roomId.value = null
roomName.value = null
chatMessages.value = []
playerStore.reset()
if (audio) {
audio.pause()
audio.src = ''
}
}
function send(data) {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify(data))
}
}
function sendPlayerAction(action, position = null, trackId = null) {
send({
type: 'player_action',
action,
position,
track_id: trackId,
})
}
function sendChatMessage(text) {
send({
type: 'chat_message',
text,
})
}
function handleMessage(msg) {
switch (msg.type) {
case 'player_state':
case 'sync_state':
syncToState(msg)
playerStore.setPlayerState(msg)
break
case 'user_joined':
roomStore.addParticipant(msg.user)
break
case 'user_left':
roomStore.removeParticipant(msg.user_id)
break
case 'queue_updated':
if (roomId.value) {
roomStore.fetchQueue(roomId.value)
}
break
case 'chat_message':
chatMessages.value.push({
id: msg.id,
user_id: msg.user_id,
username: msg.username,
text: msg.text,
created_at: msg.created_at
})
break
}
}
function syncToState(state) {
if (!audio) {
initAudio()
}
const needsLoad = state.track_url && (
state.track_url !== playerStore.currentTrackUrl ||
!audio.src
)
if (needsLoad) {
const apiUrl = import.meta.env.VITE_API_URL || ''
const fullUrl = state.track_url.startsWith('/') ? `${apiUrl}${state.track_url}` : state.track_url
audio.src = fullUrl
audio.load()
playerStore.currentTrackUrl = state.track_url
}
if (state.position !== undefined) {
const diff = Math.abs(state.position - playerStore.position)
if (diff > 2000) {
audio.currentTime = state.position / 1000
}
}
if (state.is_playing) {
audio.play().catch(() => {})
} else {
audio.pause()
}
}
function play() {
if (audio) {
audio.play().catch(() => {})
}
}
function pause() {
if (audio) {
audio.pause()
}
}
function setVolume(vol) {
if (audio) {
audio.volume = vol / 100
}
playerStore.setVolume(vol)
}
async function leaveRoom() {
if (roomId.value) {
await api.post(`/api/rooms/${roomId.value}/leave`)
}
disconnect()
}
return {
ws,
connected,
roomId,
roomName,
chatMessages,
isInRoom,
connect,
disconnect,
send,
sendPlayerAction,
sendChatMessage,
leaveRoom,
play,
pause,
setVolume,
}
})

View File

@@ -47,6 +47,14 @@ export const usePlayerStore = defineStore('player', () => {
isPlaying.value = false
}
function reset() {
isPlaying.value = false
currentTrack.value = null
currentTrackUrl.value = null
position.value = 0
duration.value = 0
}
// Load saved volume
const savedVolume = localStorage.getItem('volume')
if (savedVolume) {
@@ -67,5 +75,6 @@ export const usePlayerStore = defineStore('player', () => {
setVolume,
play,
pause,
reset,
}
})

View File

@@ -2,28 +2,22 @@
<div class="room-page" v-if="room">
<div class="room-header">
<h1>{{ room.name }}</h1>
<button class="btn-secondary" @click="leaveAndGoHome">Выйти из комнаты</button>
</div>
<div class="room-layout">
<div class="main-section">
<AudioPlayer
:ws="websocket"
@player-action="handlePlayerAction"
/>
<div class="queue-section card">
<div class="queue-header">
<h3>Очередь</h3>
<button class="btn-secondary" @click="showAddTrack = true">Добавить</button>
</div>
<Queue :queue="roomStore.queue" @play-track="playTrack" />
<Queue :queue="roomStore.queue" @play-track="playTrack" @remove-track="removeFromQueue" />
</div>
</div>
<div class="side-section">
<ParticipantsList :participants="roomStore.participants" />
<ChatWindow :room-id="roomId" :ws="websocket" />
<ChatWindow :room-id="roomId" />
</div>
</div>
@@ -39,14 +33,11 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoomStore } from '../stores/room'
import { useTracksStore } from '../stores/tracks'
import { usePlayerStore } from '../stores/player'
import { useWebSocket } from '../composables/useWebSocket'
import { usePlayer } from '../composables/usePlayer'
import AudioPlayer from '../components/player/AudioPlayer.vue'
import { useActiveRoomStore } from '../stores/activeRoom'
import Queue from '../components/room/Queue.vue'
import ParticipantsList from '../components/room/ParticipantsList.vue'
import ChatWindow from '../components/chat/ChatWindow.vue'
@@ -54,45 +45,14 @@ import TrackList from '../components/tracks/TrackList.vue'
import Modal from '../components/common/Modal.vue'
const route = useRoute()
const router = useRouter()
const roomStore = useRoomStore()
const tracksStore = useTracksStore()
const playerStore = usePlayerStore()
const activeRoomStore = useActiveRoomStore()
const roomId = route.params.id
const room = ref(null)
const showAddTrack = ref(false)
const { syncToState, setOnTrackEnded } = usePlayer()
function handleTrackEnded() {
sendPlayerAction('next')
}
function handleWsMessage(msg) {
switch (msg.type) {
case 'player_state':
case 'sync_state':
// Call syncToState BEFORE updating store so it can detect URL changes
syncToState(msg)
playerStore.setPlayerState(msg)
break
case 'user_joined':
roomStore.addParticipant(msg.user)
break
case 'user_left':
roomStore.removeParticipant(msg.user_id)
break
case 'queue_updated':
roomStore.fetchQueue(roomId)
break
}
}
const { connect, disconnect, sendPlayerAction, connected } = useWebSocket(roomId, handleWsMessage)
const websocket = { sendPlayerAction, connected }
onMounted(async () => {
await roomStore.fetchRoom(roomId)
room.value = roomStore.currentRoom
@@ -101,22 +61,12 @@ onMounted(async () => {
await roomStore.fetchQueue(roomId)
await tracksStore.fetchTracks()
// Set callback for when track ends
setOnTrackEnded(handleTrackEnded)
connect()
// Connect to room via global store
activeRoomStore.connect(roomId, room.value.name)
})
onUnmounted(() => {
disconnect()
})
function handlePlayerAction(action, position) {
sendPlayerAction(action, position)
}
function playTrack(track) {
sendPlayerAction('set_track', null, track.id)
activeRoomStore.sendPlayerAction('set_track', null, track.id)
}
async function addTrackToQueue(track) {
@@ -124,9 +74,8 @@ async function addTrackToQueue(track) {
showAddTrack.value = false
}
async function leaveAndGoHome() {
await roomStore.leaveRoom(roomId)
router.push('/')
async function removeFromQueue(track) {
await roomStore.removeFromQueue(roomId, track.id)
}
</script>