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:
env_file = ".env"
extra = "ignore"
@lru_cache()

View File

@@ -1,8 +1,69 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
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
app.add_middleware(

View File

@@ -203,6 +203,8 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
"Accept-Ranges": "bytes",
"Content-Length": str(content_length),
"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",
"Content-Length": str(file_size),
"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)
# 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:
while True:
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
)
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
body.close()