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

@@ -1,35 +1,74 @@
<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 }}
<!-- Progress bar at top -->
<div class="progress-bar-top" @click="handleSeek">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
</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 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>
<!-- Controls - center -->
<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>
<span class="time">{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}</span>
</div>
<button class="leave-btn" @click="handleLeave" title="Выйти из комнаты">
</button>
<!-- Right side - time, volume, leave -->
<div class="mini-player-right">
<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>
</template>
@@ -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;
}
}
</style>

View File

@@ -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) {