diff --git a/backend/app/config.py b/backend/app/config.py index a635b04..d5a585d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -25,6 +25,7 @@ class Settings(BaseSettings): class Config: env_file = ".env" + extra = "ignore" @lru_cache() diff --git a/backend/app/main.py b/backend/app/main.py index 2cbed54..54352c9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( diff --git a/backend/app/routers/tracks.py b/backend/app/routers/tracks.py index 1187742..439f193 100644 --- a/backend/app/routers/tracks.py +++ b/backend/app/routers/tracks.py @@ -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", } ) diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py index ee939c5..8ddb486 100644 --- a/backend/app/routers/websocket.py +++ b/backend/app/routers/websocket.py @@ -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() diff --git a/backend/app/services/s3.py b/backend/app/services/s3.py index b877171..044fa05 100644 --- a/backend/app/services/s3.py +++ b/backend/app/services/s3.py @@ -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() diff --git a/frontend/nginx.conf b/frontend/nginx.conf index f19549d..f40fa6b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,9 @@ server { root /usr/share/nginx/html; index index.html; + # Max upload size + client_max_body_size 15M; + # Gzip compression gzip on; 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 { proxy_pass http://backend:8000; 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-Forwarded-For $proxy_add_x_forwarded_for; 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 diff --git a/frontend/public/speaker-1-svgrepo-com.svg b/frontend/public/speaker-1-svgrepo-com.svg new file mode 100644 index 0000000..45cab34 --- /dev/null +++ b/frontend/public/speaker-1-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/speaker-2-svgrepo-com.svg b/frontend/public/speaker-2-svgrepo-com.svg new file mode 100644 index 0000000..99d388d --- /dev/null +++ b/frontend/public/speaker-2-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/speaker-disabled-svgrepo-com.svg b/frontend/public/speaker-disabled-svgrepo-com.svg new file mode 100644 index 0000000..5e58d96 --- /dev/null +++ b/frontend/public/speaker-disabled-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/components/player/MiniPlayer.vue b/frontend/src/components/player/MiniPlayer.vue index 6f6e2d5..72215d8 100644 --- a/frontend/src/components/player/MiniPlayer.vue +++ b/frontend/src/components/player/MiniPlayer.vue @@ -1,35 +1,74 @@ @@ -89,6 +128,22 @@ function goToRoom() { 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() { await activeRoomStore.leaveRoom() router.push('/') @@ -102,39 +157,66 @@ async function handleLeave() { 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-content { + position: relative; + display: flex; + align-items: center; + padding: 12px 20px; } .mini-player-controls { + position: absolute; + left: 50%; + transform: translateX(-50%); display: flex; align-items: center; 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 { background: transparent; border: none; @@ -152,8 +234,8 @@ async function handleLeave() { .play-btn { background: #7c3aed; - width: 40px; - height: 40px; + width: 44px; + height: 44px; display: flex; align-items: center; justify-content: center; @@ -163,34 +245,116 @@ async function handleLeave() { background: #6d28d9; } -.mini-player-progress { - flex: 1; +.mini-player-right { 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; + gap: 16px; + flex-shrink: 0; } .time { font-size: 12px; color: #888; - min-width: 90px; - text-align: right; + min-width: 80px; + 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 { @@ -208,4 +372,18 @@ async function handleLeave() { background: #ff4444; color: white; } + +@media (max-width: 768px) { + .volume-control { + display: none; + } + + .time { + display: none; + } + + .mini-player-content { + padding: 10px 16px; + } +} diff --git a/frontend/src/stores/activeRoom.js b/frontend/src/stores/activeRoom.js index 1f46eca..938c931 100644 --- a/frontend/src/stores/activeRoom.js +++ b/frontend/src/stores/activeRoom.js @@ -19,6 +19,8 @@ export const useActiveRoomStore = defineStore('activeRoom', () => { // Audio element let audio = null let onTrackEndedCallback = null + let pendingPlay = false + let pendingPosition = null const isInRoom = computed(() => roomId.value !== null) @@ -27,13 +29,27 @@ export const useActiveRoomStore = defineStore('activeRoom', () => { audio = new Audio() audio.volume = playerStore.volume / 100 + audio.preload = 'auto' audio.addEventListener('timeupdate', () => { playerStore.setPosition(Math.floor(audio.currentTime * 1000)) }) + // Set position once metadata is loaded audio.addEventListener('loadedmetadata', () => { 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', () => { @@ -159,8 +175,20 @@ export const useActiveRoomStore = defineStore('activeRoom', () => { 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 + + // 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) {