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>