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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
nextTick(scrollToBottom)
|
||||||
const lastMsg = msgs[msgs.length - 1]
|
})
|
||||||
if (lastMsg?.type === 'chat_message') {
|
|
||||||
messages.value.push({
|
// Auto-scroll when new messages arrive
|
||||||
id: lastMsg.id,
|
watch(() => activeRoomStore.chatMessages.length, () => {
|
||||||
user_id: lastMsg.user_id,
|
nextTick(scrollToBottom)
|
||||||
username: lastMsg.username,
|
})
|
||||||
text: lastMsg.text,
|
|
||||||
created_at: lastMsg.created_at
|
|
||||||
})
|
|
||||||
nextTick(scrollToBottom)
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
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 = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
211
frontend/src/components/player/MiniPlayer.vue
Normal file
211
frontend/src/components/player/MiniPlayer.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
223
frontend/src/stores/activeRoom.js
Normal file
223
frontend/src/stores/activeRoom.js
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user