-
{{ activeRoomStore.roomName }}
-
- {{ currentTrack.title }} - {{ currentTrack.artist }}
+
+
+
+
+
+
+
{{ currentTrack.title }}
+
Нет трека
+
{{ currentTrack.artist }}
+
{{ activeRoomStore.roomName }}
-
Нет трека
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}
-
-
+
+
+
{{ formatTime(playerStore.position) }} / {{ formatTime(playerStore.duration) }}
+
+
+
+
+
+
@@ -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) {