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,3 +1,8 @@
# Database
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=enigfm
# JWT Secret (обязательно смените!) # JWT Secret (обязательно смените!)
SECRET_KEY=your-secret-key-change-in-production SECRET_KEY=your-secret-key-change-in-production

View File

@@ -1,6 +1,6 @@
import uuid import uuid
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request, Response from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
@@ -11,7 +11,7 @@ from ..models.user import User
from ..models.track import Track from ..models.track import Track
from ..schemas.track import TrackResponse, TrackWithUrl from ..schemas.track import TrackResponse, TrackWithUrl
from ..services.auth import get_current_user from ..services.auth import get_current_user
from ..services.s3 import upload_file, delete_file, generate_presigned_url, can_upload_file, get_file_content from ..services.s3 import upload_file, delete_file, generate_presigned_url, can_upload_file, get_file_size, stream_file_chunks
from ..config import get_settings from ..config import get_settings
settings = get_settings() settings = get_settings()
@@ -172,9 +172,11 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
if not track: if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
# Get full file content # Get file size from S3 (without downloading)
content = get_file_content(track.s3_key) file_size = get_file_size(track.s3_key)
file_size = len(content)
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
# Parse Range header # Parse Range header
range_header = request.headers.get("range") range_header = request.headers.get("range")
@@ -192,11 +194,8 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
end = min(end, file_size - 1) end = min(end, file_size - 1)
content_length = end - start + 1 content_length = end - start + 1
# Encode filename for non-ASCII characters return StreamingResponse(
encoded_filename = quote(f"{track.title}.mp3") stream_file_chunks(track.s3_key, start, end),
return Response(
content=content[start:end + 1],
status_code=206, status_code=206,
media_type="audio/mpeg", media_type="audio/mpeg",
headers={ headers={
@@ -207,12 +206,9 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
} }
) )
# Encode filename for non-ASCII characters # No range - stream full file
encoded_filename = quote(f"{track.title}.mp3") return StreamingResponse(
stream_file_chunks(track.s3_key),
# No range - return full file
return Response(
content=content,
media_type="audio/mpeg", media_type="audio/mpeg",
headers={ headers={
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",

View File

@@ -75,3 +75,40 @@ def get_file_content(s3_key: str) -> bytes:
client = get_s3_client() client = get_s3_client()
response = client.get_object(Bucket=settings.s3_bucket_name, Key=s3_key) response = client.get_object(Bucket=settings.s3_bucket_name, Key=s3_key)
return response["Body"].read() return response["Body"].read()
def get_file_size(s3_key: str) -> int:
"""Get file size from S3 without downloading"""
client = get_s3_client()
response = client.head_object(Bucket=settings.s3_bucket_name, Key=s3_key)
return response["ContentLength"]
def get_file_range(s3_key: str, start: int, end: int):
"""Get a range of bytes from S3 file"""
client = get_s3_client()
response = client.get_object(
Bucket=settings.s3_bucket_name,
Key=s3_key,
Range=f"bytes={start}-{end}"
)
return response["Body"].read()
def stream_file_chunks(s3_key: str, start: int = 0, end: int = None, chunk_size: int = 64 * 1024):
"""Stream file from S3 in chunks (default 64KB chunks)"""
client = get_s3_client()
if end is None:
range_header = f"bytes={start}-"
else:
range_header = f"bytes={start}-{end}"
response = client.get_object(
Bucket=settings.s3_bucket_name,
Key=s3_key,
Range=range_header
)
for chunk in response["Body"].iter_chunks(chunk_size=chunk_size):
yield chunk

View File

@@ -4,15 +4,15 @@ services:
db: db:
image: postgres:15-alpine image: postgres:15-alpine
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: enigfm POSTGRES_DB: ${DB_NAME}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
- "4002:5432" - "4002:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -22,7 +22,7 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
environment: environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/enigfm DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production} SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL} S3_ENDPOINT_URL: ${S3_ENDPOINT_URL}
S3_ACCESS_KEY: ${S3_ACCESS_KEY} S3_ACCESS_KEY: ${S3_ACCESS_KEY}

View File

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

View File

@@ -3,7 +3,7 @@
<h3>Чат</h3> <h3>Чат</h3>
<div class="messages" ref="messagesRef"> <div class="messages" ref="messagesRef">
<ChatMessage <ChatMessage
v-for="msg in messages" v-for="msg in allMessages"
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
/> />
@@ -13,7 +13,7 @@
type="text" type="text"
v-model="newMessage" v-model="newMessage"
placeholder="Написать сообщение..." placeholder="Написать сообщение..."
:disabled="!ws.connected" :disabled="!activeRoomStore.connected"
/> />
<button type="submit" class="btn-primary" :disabled="!newMessage.trim()"> <button type="submit" class="btn-primary" :disabled="!newMessage.trim()">
Отправить Отправить
@@ -23,53 +23,43 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, watch, nextTick } from 'vue'
import api from '../../composables/useApi' import api from '../../composables/useApi'
import { useActiveRoomStore } from '../../stores/activeRoom'
import ChatMessage from './ChatMessage.vue' import ChatMessage from './ChatMessage.vue'
const props = defineProps({ const props = defineProps({
roomId: { roomId: {
type: String, type: String,
required: true required: true
},
ws: {
type: Object,
required: true
} }
}) })
const messages = ref([]) const activeRoomStore = useActiveRoomStore()
const historyMessages = ref([])
const newMessage = ref('') const newMessage = ref('')
const messagesRef = ref(null) const messagesRef = ref(null)
onMounted(async () => { // Combine history + new messages
const response = await api.get(`/api/rooms/${props.roomId}/messages`) const allMessages = computed(() => {
messages.value = response.data return [...historyMessages.value, ...activeRoomStore.chatMessages]
scrollToBottom()
}) })
// Listen for new messages from WebSocket onMounted(async () => {
watch(() => props.ws, (wsObj) => { const response = await api.get(`/api/rooms/${props.roomId}/messages`)
if (wsObj?.messages) { historyMessages.value = response.data
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) nextTick(scrollToBottom)
} })
}, { deep: true })
} // Auto-scroll when new messages arrive
}, { immediate: true }) watch(() => activeRoomStore.chatMessages.length, () => {
nextTick(scrollToBottom)
})
function sendMessage() { function sendMessage() {
if (!newMessage.value.trim()) return if (!newMessage.value.trim()) return
props.ws.sendChatMessage(newMessage.value) activeRoomStore.sendChatMessage(newMessage.value)
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" v-for="(track, index) in queue"
:key="track.id" :key="track.id"
class="queue-item" class="queue-item"
@click="$emit('play-track', track)"
> >
<span class="queue-index">{{ index + 1 }}</span> <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-title">{{ track.title }}</span>
<span class="queue-track-artist">{{ track.artist }}</span> <span class="queue-track-artist">{{ track.artist }}</span>
</div> </div>
<span class="queue-duration">{{ formatDuration(track.duration) }}</span> <span class="queue-duration">{{ formatDuration(track.duration) }}</span>
<button class="btn-remove" @click.stop="$emit('remove-track', track)" title="Удалить из очереди">
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -27,7 +29,7 @@ defineProps({
} }
}) })
defineEmits(['play-track']) defineEmits(['play-track', 'remove-track'])
function formatDuration(ms) { function formatDuration(ms) {
const seconds = Math.floor(ms / 1000) const seconds = Math.floor(ms / 1000)
@@ -95,4 +97,20 @@ function formatDuration(ms) {
color: #aaa; color: #aaa;
font-size: 12px; 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> </style>

View File

@@ -69,7 +69,13 @@ export function usePlayer(onTrackEnded = null) {
initAudio() 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) loadTrack(state.track_url)
playerStore.currentTrackUrl = 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 isPlaying.value = false
} }
function reset() {
isPlaying.value = false
currentTrack.value = null
currentTrackUrl.value = null
position.value = 0
duration.value = 0
}
// Load saved volume // Load saved volume
const savedVolume = localStorage.getItem('volume') const savedVolume = localStorage.getItem('volume')
if (savedVolume) { if (savedVolume) {
@@ -67,5 +75,6 @@ export const usePlayerStore = defineStore('player', () => {
setVolume, setVolume,
play, play,
pause, pause,
reset,
} }
}) })

View File

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