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 (обязательно смените!)
SECRET_KEY=your-secret-key-change-in-production

View File

@@ -1,6 +1,6 @@
import uuid
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 sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
@@ -11,7 +11,7 @@ from ..models.user import User
from ..models.track import Track
from ..schemas.track import TrackResponse, TrackWithUrl
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
settings = get_settings()
@@ -172,9 +172,11 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
# Get full file content
content = get_file_content(track.s3_key)
file_size = len(content)
# Get file size from S3 (without downloading)
file_size = get_file_size(track.s3_key)
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
# Parse Range header
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)
content_length = end - start + 1
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
return Response(
content=content[start:end + 1],
return StreamingResponse(
stream_file_chunks(track.s3_key, start, end),
status_code=206,
media_type="audio/mpeg",
headers={
@@ -207,12 +206,9 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
}
)
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
# No range - return full file
return Response(
content=content,
# No range - stream full file
return StreamingResponse(
stream_file_chunks(track.s3_key),
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",

View File

@@ -75,3 +75,40 @@ def get_file_content(s3_key: str) -> bytes:
client = get_s3_client()
response = client.get_object(Bucket=settings.s3_bucket_name, Key=s3_key)
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:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: enigfm
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "4002:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 5s
timeout: 5s
retries: 5
@@ -22,7 +22,7 @@ services:
context: ./backend
dockerfile: Dockerfile
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}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}

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>