From f77a45315856bd0deef39117add94be2c4a22dbe Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Fri, 12 Dec 2025 15:02:02 +0300 Subject: [PATCH] Add global mini-player and improve configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 5 + backend/app/routers/tracks.py | 28 +-- backend/app/services/s3.py | 37 +++ docker-compose.yml | 10 +- frontend/src/App.vue | 11 +- frontend/src/components/chat/ChatWindow.vue | 52 ++-- frontend/src/components/player/MiniPlayer.vue | 211 +++++++++++++++++ frontend/src/components/room/Queue.vue | 24 +- frontend/src/composables/usePlayer.js | 8 +- frontend/src/stores/activeRoom.js | 223 ++++++++++++++++++ frontend/src/stores/player.js | 9 + frontend/src/views/RoomView.vue | 73 +----- 12 files changed, 572 insertions(+), 119 deletions(-) create mode 100644 frontend/src/components/player/MiniPlayer.vue create mode 100644 frontend/src/stores/activeRoom.js diff --git a/.env.example b/.env.example index 5a6fca9..7d18f8b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ +# Database +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=enigfm + # JWT Secret (обязательно смените!) SECRET_KEY=your-secret-key-change-in-production diff --git a/backend/app/routers/tracks.py b/backend/app/routers/tracks.py index d49586b..1187742 100644 --- a/backend/app/routers/tracks.py +++ b/backend/app/routers/tracks.py @@ -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", diff --git a/backend/app/services/s3.py b/backend/app/services/s3.py index 7b90cb3..b877171 100644 --- a/backend/app/services/s3.py +++ b/backend/app/services/s3.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 7f1dc6e..af66655 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 99721d6..85c2c40 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,14 +1,19 @@ diff --git a/frontend/src/components/chat/ChatWindow.vue b/frontend/src/components/chat/ChatWindow.vue index ae9391f..4ed240f 100644 --- a/frontend/src/components/chat/ChatWindow.vue +++ b/frontend/src/components/chat/ChatWindow.vue @@ -3,7 +3,7 @@

Чат

@@ -13,7 +13,7 @@ type="text" v-model="newMessage" placeholder="Написать сообщение..." - :disabled="!ws.connected" + :disabled="!activeRoomStore.connected" />
@@ -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; +} diff --git a/frontend/src/composables/usePlayer.js b/frontend/src/composables/usePlayer.js index d1003af..1dd8824 100644 --- a/frontend/src/composables/usePlayer.js +++ b/frontend/src/composables/usePlayer.js @@ -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 } diff --git a/frontend/src/stores/activeRoom.js b/frontend/src/stores/activeRoom.js new file mode 100644 index 0000000..1f46eca --- /dev/null +++ b/frontend/src/stores/activeRoom.js @@ -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, + } +}) diff --git a/frontend/src/stores/player.js b/frontend/src/stores/player.js index d188eaa..4c5c5c8 100644 --- a/frontend/src/stores/player.js +++ b/frontend/src/stores/player.js @@ -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, } }) diff --git a/frontend/src/views/RoomView.vue b/frontend/src/views/RoomView.vue index 0f1551e..8784d86 100644 --- a/frontend/src/views/RoomView.vue +++ b/frontend/src/views/RoomView.vue @@ -2,28 +2,22 @@

{{ room.name }}

-
- -

Очередь

- +
- +
@@ -39,14 +33,11 @@