Improve mini-player and add periodic sync

- Redesign mini-player: progress bar on top, centered controls
- Add vertical volume slider with popup on hover
- Add volume percentage display
- Add custom speaker SVG icons
- Add periodic sync every 10 seconds for playback synchronization
- Broadcast user_joined when connecting via WebSocket
- Disable nginx proxy buffering for streaming
- Allow extra env variables in pydantic settings

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-12 16:53:56 +03:00
parent f77a453158
commit 487da10365
11 changed files with 383 additions and 75 deletions

View File

@@ -25,6 +25,7 @@ class Settings(BaseSettings):
class Config: class Config:
env_file = ".env" env_file = ".env"
extra = "ignore"
@lru_cache() @lru_cache()

View File

@@ -1,8 +1,69 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from .routers import auth, rooms, tracks, websocket, messages from .routers import auth, rooms, tracks, websocket, messages
from .database import async_session
from .models.room import Room
from .services.sync import manager
app = FastAPI(title="EnigFM", description="Listen to music together with friends")
async def periodic_sync():
"""Send sync updates to all rooms every 10 seconds"""
while True:
await asyncio.sleep(10)
# Get all active rooms
for room_id in list(manager.active_connections.keys()):
try:
async with async_session() as db:
result = await db.execute(select(Room).where(Room.id == room_id))
room = result.scalar_one_or_none()
if not room or not room.is_playing:
continue
# Calculate current position
current_position = room.playback_position or 0
if room.playback_started_at:
elapsed = (datetime.utcnow() - room.playback_started_at).total_seconds() * 1000
current_position = int((room.playback_position or 0) + elapsed)
track_url = None
if room.current_track_id:
track_url = f"/api/tracks/{room.current_track_id}/stream"
await manager.broadcast_to_room(
room_id,
{
"type": "sync_state",
"is_playing": room.is_playing,
"position": current_position,
"current_track_id": str(room.current_track_id) if room.current_track_id else None,
"track_url": track_url,
"server_time": datetime.utcnow().isoformat(),
},
)
except Exception:
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
# Start background sync task
sync_task = asyncio.create_task(periodic_sync())
yield
# Cleanup
sync_task.cancel()
try:
await sync_task
except asyncio.CancelledError:
pass
app = FastAPI(title="EnigFM", description="Listen to music together with friends", lifespan=lifespan)
# CORS # CORS
app.add_middleware( app.add_middleware(

View File

@@ -203,6 +203,8 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": str(content_length), "Content-Length": str(content_length),
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}", "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache",
} }
) )
@@ -214,5 +216,7 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": str(file_size), "Content-Length": str(file_size),
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}", "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache",
} }
) )

View File

@@ -46,6 +46,13 @@ async def room_websocket(websocket: WebSocket, room_id: UUID):
await manager.connect(websocket, room_id, user.id) await manager.connect(websocket, room_id, user.id)
# Notify others that user joined
await manager.broadcast_to_room(
room_id,
{"type": "user_joined", "user": {"id": str(user.id), "username": user.username}},
exclude_user=user.id
)
try: try:
while True: while True:
data = await websocket.receive_text() data = await websocket.receive_text()

View File

@@ -110,5 +110,11 @@ def stream_file_chunks(s3_key: str, start: int = 0, end: int = None, chunk_size:
Range=range_header Range=range_header
) )
for chunk in response["Body"].iter_chunks(chunk_size=chunk_size): # Use raw stream read instead of iter_chunks for true streaming
body = response["Body"]
while True:
chunk = body.read(chunk_size)
if not chunk:
break
yield chunk yield chunk
body.close()

View File

@@ -4,6 +4,9 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Max upload size
client_max_body_size 15M;
# Gzip compression # Gzip compression
gzip on; gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
@@ -12,10 +15,18 @@ server {
location /api { location /api {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_redirect off;
# Disable buffering for streaming
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
} }
# WebSocket proxy # WebSocket proxy

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 8.99998C16.5 9.49998 17 10.5 17 12C17 13.5 16.5 14.5 16 15M3 10.5V13.5C3 14.6046 3.5 15.5 5.5 16C7.5 16.5 9 21 12 21C14 21 14 3 12 3C9 3 7.5 7.5 5.5 8C3.5 8.5 3 9.39543 3 10.5Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6C20.5 7.5 21 10 21 12C21 14 20.5 16.5 19 18M16 8.99998C16.5 9.49998 17 10.5 17 12C17 13.5 16.5 14.5 16 15M3 10.5V13.5C3 14.6046 3.5 15.5 5.5 16C7.5 16.5 9 21 12 21C14 21 14 3 12 3C9 3 7.5 7.5 5.5 8C3.5 8.5 3 9.39543 3 10.5Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 551 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 8.14307C3.4148 8.66137 3 9.49393 3 10.5V13.5C3 14.6046 3.5 15.5 5.5 16C7.5 16.5 9 21 12 21C12.6098 21 13.0337 19.3265 13.2717 17M3 3L21 21M9 4.60756C9.84604 3.71548 10.8038 3 12 3C12.7739 3 13.2484 5.69533 13.4233 9" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@@ -1,35 +1,74 @@
<template> <template>
<div class="mini-player" v-if="activeRoomStore.isInRoom"> <div class="mini-player" v-if="activeRoomStore.isInRoom">
<div class="mini-player-info" @click="goToRoom"> <!-- Progress bar at top -->
<div class="room-name">{{ activeRoomStore.roomName }}</div> <div class="progress-bar-top" @click="handleSeek">
<div class="track-info" v-if="currentTrack"> <div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
{{ currentTrack.title }} - {{ currentTrack.artist }} </div>
<div class="mini-player-content">
<!-- Track info - left side -->
<div class="mini-player-info" @click="goToRoom">
<div class="track-title" v-if="currentTrack">{{ currentTrack.title }}</div>
<div class="track-title" v-else>Нет трека</div>
<div class="track-artist" v-if="currentTrack">{{ currentTrack.artist }}</div>
<div class="room-name">{{ activeRoomStore.roomName }}</div>
</div> </div>
<div class="track-info" v-else>Нет трека</div>
</div>
<div class="mini-player-controls"> <!-- Controls - center -->
<button class="control-btn" @click="handlePrev"> <div class="mini-player-controls">
<span></span> <button class="control-btn" @click="handlePrev">
</button> <span></span>
<button class="control-btn play-btn" @click="togglePlay"> </button>
<span>{{ playerStore.isPlaying ? '⏸' : '▶' }}</span> <button class="control-btn play-btn" @click="togglePlay">
</button> <span>{{ playerStore.isPlaying ? '⏸' : '▶' }}</span>
<button class="control-btn" @click="handleNext"> </button>
<span></span> <button class="control-btn" @click="handleNext">
</button> <span></span>
</div> </button>
<div class="mini-player-progress">
<div class="progress-bar" @click="handleSeek">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div> </div>
<span class="time">{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}</span>
</div>
<button class="leave-btn" @click="handleLeave" title="Выйти из комнаты"> <!-- Right side - time, volume, leave -->
<div class="mini-player-right">
</button> <span class="time">{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}</span>
<div class="volume-control">
<img
v-if="playerStore.volume === 0"
src="/speaker-disabled-svgrepo-com.svg"
class="volume-icon"
@click="toggleMute"
/>
<img
v-else-if="playerStore.volume < 50"
src="/speaker-1-svgrepo-com.svg"
class="volume-icon"
@click="toggleMute"
/>
<img
v-else
src="/speaker-2-svgrepo-com.svg"
class="volume-icon"
@click="toggleMute"
/>
<div class="volume-popup">
<span class="volume-value">{{ playerStore.volume }}%</span>
<input
type="range"
min="0"
max="100"
:value="playerStore.volume"
@input="handleVolume"
class="volume-slider"
orient="vertical"
/>
</div>
</div>
<button class="leave-btn" @click="handleLeave" title="Выйти из комнаты">
</button>
</div>
</div>
</div> </div>
</template> </template>
@@ -89,6 +128,22 @@ function goToRoom() {
router.push(`/room/${activeRoomStore.roomId}`) router.push(`/room/${activeRoomStore.roomId}`)
} }
let previousVolume = 100
function handleVolume(e) {
activeRoomStore.setVolume(Number(e.target.value))
}
function toggleMute() {
if (playerStore.volume > 0) {
previousVolume = playerStore.volume
activeRoomStore.setVolume(0)
} else {
activeRoomStore.setVolume(previousVolume)
}
}
async function handleLeave() { async function handleLeave() {
await activeRoomStore.leaveRoom() await activeRoomStore.leaveRoom()
router.push('/') router.push('/')
@@ -102,39 +157,66 @@ async function handleLeave() {
left: 0; left: 0;
right: 0; right: 0;
background: #1a1a2e; background: #1a1a2e;
border-top: 1px solid #333;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 20px;
z-index: 1000; z-index: 1000;
} }
.mini-player-info { .mini-player-content {
flex: 0 0 200px; position: relative;
cursor: pointer; display: flex;
overflow: hidden; align-items: center;
} padding: 12px 20px;
.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 { .mini-player-controls {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.progress-bar-top {
height: 4px;
background: #333;
cursor: pointer;
width: 100%;
}
.progress-bar-top:hover {
height: 6px;
}
.progress-fill {
height: 100%;
background: #7c3aed;
transition: width 0.1s linear;
}
.mini-player-info {
flex: 1;
min-width: 0;
cursor: pointer;
}
.track-title {
font-size: 15px;
font-weight: 500;
color: #fff;
margin-bottom: 2px;
}
.track-artist {
font-size: 13px;
color: #aaa;
margin-bottom: 2px;
}
.room-name {
font-size: 11px;
color: #7c3aed;
}
.control-btn { .control-btn {
background: transparent; background: transparent;
border: none; border: none;
@@ -152,8 +234,8 @@ async function handleLeave() {
.play-btn { .play-btn {
background: #7c3aed; background: #7c3aed;
width: 40px; width: 44px;
height: 40px; height: 44px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -163,34 +245,116 @@ async function handleLeave() {
background: #6d28d9; background: #6d28d9;
} }
.mini-player-progress { .mini-player-right {
flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
} flex-shrink: 0;
.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 { .time {
font-size: 12px; font-size: 12px;
color: #888; color: #888;
min-width: 90px; min-width: 80px;
text-align: right; text-align: center;
}
.volume-control {
position: relative;
display: flex;
align-items: center;
}
.volume-icon {
cursor: pointer;
width: 32px;
height: 32px;
padding: 8px;
filter: invert(60%);
transition: filter 0.2s;
}
.volume-icon:hover {
filter: invert(100%);
}
.volume-popup {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 120px;
margin-bottom: 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.volume-value {
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #fff;
font-weight: 500;
white-space: nowrap;
}
.volume-control:hover .volume-popup {
opacity: 1;
visibility: visible;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
width: 100px;
height: 8px;
background: #444;
border-radius: 4px;
cursor: pointer;
transform: rotate(-90deg);
margin: 0;
padding: 0;
}
.volume-slider::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
background: #444;
border-radius: 4px;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: #7c3aed;
border-radius: 50%;
cursor: pointer;
margin-top: -5px;
}
.volume-slider::-moz-range-track {
width: 100%;
height: 8px;
background: #444;
border-radius: 4px;
}
.volume-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: #7c3aed;
border-radius: 50%;
cursor: pointer;
border: none;
} }
.leave-btn { .leave-btn {
@@ -208,4 +372,18 @@ async function handleLeave() {
background: #ff4444; background: #ff4444;
color: white; color: white;
} }
@media (max-width: 768px) {
.volume-control {
display: none;
}
.time {
display: none;
}
.mini-player-content {
padding: 10px 16px;
}
}
</style> </style>

View File

@@ -19,6 +19,8 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
// Audio element // Audio element
let audio = null let audio = null
let onTrackEndedCallback = null let onTrackEndedCallback = null
let pendingPlay = false
let pendingPosition = null
const isInRoom = computed(() => roomId.value !== null) const isInRoom = computed(() => roomId.value !== null)
@@ -27,13 +29,27 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
audio = new Audio() audio = new Audio()
audio.volume = playerStore.volume / 100 audio.volume = playerStore.volume / 100
audio.preload = 'auto'
audio.addEventListener('timeupdate', () => { audio.addEventListener('timeupdate', () => {
playerStore.setPosition(Math.floor(audio.currentTime * 1000)) playerStore.setPosition(Math.floor(audio.currentTime * 1000))
}) })
// Set position once metadata is loaded
audio.addEventListener('loadedmetadata', () => { audio.addEventListener('loadedmetadata', () => {
playerStore.setDuration(Math.floor(audio.duration * 1000)) playerStore.setDuration(Math.floor(audio.duration * 1000))
if (pendingPosition !== null) {
audio.currentTime = pendingPosition / 1000
pendingPosition = null
}
})
// Play as soon as enough data is available
audio.addEventListener('canplay', () => {
if (pendingPlay) {
audio.play().catch(() => {})
pendingPlay = false
}
}) })
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
@@ -159,8 +175,20 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
const apiUrl = import.meta.env.VITE_API_URL || '' const apiUrl = import.meta.env.VITE_API_URL || ''
const fullUrl = state.track_url.startsWith('/') ? `${apiUrl}${state.track_url}` : state.track_url const fullUrl = state.track_url.startsWith('/') ? `${apiUrl}${state.track_url}` : state.track_url
audio.src = fullUrl audio.src = fullUrl
audio.load()
playerStore.currentTrackUrl = state.track_url playerStore.currentTrackUrl = state.track_url
// Set pending play if should be playing
if (state.is_playing) {
pendingPlay = true
}
// Store position to set after metadata loads
if (state.position !== undefined && state.position > 0) {
pendingPosition = state.position
}
audio.load()
return // Wait for canplay event
} }
if (state.position !== undefined) { if (state.position !== undefined) {