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

@@ -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,
}
})