diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index ada8930..cbae78f 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -471,22 +471,68 @@ body { background: rgba(255, 255, 255, 0.08); border-radius: 4px; position: relative; - overflow: hidden; + overflow: visible; transition: var(--transition-smooth); border: 1px solid rgba(255, 255, 255, 0.03); } +.progress-time-tooltip { + position: absolute; + top: -30px; + transform: translateX(-50%); + padding: 3px 7px; + border-radius: 5px; + background: var(--bg-card, #1f1b2e); + border: 1px solid var(--border-glass); + color: var(--text-primary); + font-size: 0.7rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + pointer-events: none; + z-index: 2; +} + .track-progress-slider { position: absolute; inset: -7px 0; - width: 100%; + width: auto; height: 20px; margin: 0; + padding: 0; + appearance: none; + -webkit-appearance: none; + box-sizing: border-box; opacity: 0; cursor: pointer; z-index: 1; } +.track-progress-slider::-webkit-slider-runnable-track { + width: 100%; + height: 20px; + background: transparent; +} + +.track-progress-slider::-webkit-slider-thumb { + width: 0; + height: 20px; + border: 0; + -webkit-appearance: none; +} + +.track-progress-slider::-moz-range-track { + width: 100%; + height: 20px; + background: transparent; +} + +.track-progress-slider::-moz-range-thumb { + width: 0; + height: 20px; + border: 0; +} + .track-progress-slider:disabled { cursor: default; } diff --git a/frontend/src/components/PlayerPanel.vue b/frontend/src/components/PlayerPanel.vue index 0e5febd..af49eb6 100644 --- a/frontend/src/components/PlayerPanel.vue +++ b/frontend/src/components/PlayerPanel.vue @@ -28,7 +28,10 @@ const current = computed(() => props.status?.current ?? null) const playing = computed(() => props.status?.playing ?? false) const loopMode = computed(() => loopModeOf(props.status)) const seeking = ref(false) +const seekSubmitting = ref(false) const seekPosition = ref(0) +const hoverPosition = ref(null) +const hoverOffset = ref(0) const displayedElapsed = computed(() => seeking.value ? seekPosition.value : elapsed.value) /** Podczas przeciągania suwaka pokazujemy wartość lokalną, nie tę z serwera. */ @@ -100,12 +103,30 @@ function onSeekInput(event: Event) { seekPosition.value = Number((event.target as HTMLInputElement).value) } +function onSeekHover(event: MouseEvent) { + if (duration.value <= 0) { + hoverPosition.value = null + return + } + const element = event.target as HTMLElement + const container = event.currentTarget as HTMLElement + const slider = element.tagName === 'INPUT' ? element : container + const sliderRect = slider.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const { left, width } = sliderRect + const ratio = Math.min(Math.max((event.clientX - left) / width, 0), 1) + hoverPosition.value = ratio * duration.value + hoverOffset.value = event.clientX - containerRect.left +} + async function commitSeek() { - if (!current.value || duration.value <= 0) return + if (!current.value || duration.value <= 0 || seekSubmitting.value) return const position = seekPosition.value + seekSubmitting.value = true try { await run(() => api.seek(position), 'Przewinięto utwór', 'Błąd przewijania utworu') } finally { + seekSubmitting.value = false seeking.value = false } } @@ -182,7 +203,11 @@ async function cycleLoop() { class="track-progress-container" > {{ formatTime(displayedElapsed) }} -
+
+ + {{ formatTime(hoverPosition) }} +
{{ duration > 0 ? formatTime(duration) : 'Live' }}
diff --git a/tests/test_music_player.py b/tests/test_music_player.py index 61dd604..09f70c8 100644 --- a/tests/test_music_player.py +++ b/tests/test_music_player.py @@ -113,6 +113,26 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(state.current.elapsed, 4.5) self.assertIs(voice_client.playing, state.current) + async def test_concurrent_seek_is_rejected(self): + player, _, _ = await self._queue_abc() + started = asyncio.Event() + release = asyncio.Event() + + async def slow_recreate(track, loop=None, start_at=0.0): + started.set() + await release.wait() + return FakeTrack(track.id) + + player._tracks.recreate = slow_recreate + first_seek = asyncio.create_task(player.seek(GUILD, 2)) + await started.wait() + + with self.assertRaisesRegex(RuntimeError, "already in progress"): + await player.seek(GUILD, 3) + + release.set() + await first_seek + async def test_loop_track_replays_same_index(self): player, _, state = await self._queue_abc() await player.play_next(GUILD, advance=True) diff --git a/zyrarbot/music/music_player.py b/zyrarbot/music/music_player.py index 56c91d3..583b506 100644 --- a/zyrarbot/music/music_player.py +++ b/zyrarbot/music/music_player.py @@ -51,6 +51,7 @@ class MusicPlayer: self._states: dict[int, GuildMusicState] = {} self._playback_tokens: dict[int, int] = {} self._interrupted_playbacks: set[int] = set() + self._seek_locks: dict[int, asyncio.Lock] = {} self.on_state_change: StateChangeHook | None = None # --- Stan --------------------------------------------------------------- @@ -293,6 +294,13 @@ class MusicPlayer: await self._restart_playback(guild_id) async def seek(self, guild_id: int, seconds: float) -> float: + lock = self._seek_locks.setdefault(guild_id, asyncio.Lock()) + if lock.locked(): + raise RuntimeError("Another seek operation is already in progress") + async with lock: + return await self._seek_locked(guild_id, seconds) + + async def _seek_locked(self, guild_id: int, seconds: float) -> float: state = self.state(guild_id) current = state.current voice_client = self.voice_client(guild_id) diff --git a/zyrarbot/ui/api/routers/playback_router.py b/zyrarbot/ui/api/routers/playback_router.py index a7d8ced..318cbc5 100644 --- a/zyrarbot/ui/api/routers/playback_router.py +++ b/zyrarbot/ui/api/routers/playback_router.py @@ -121,6 +121,8 @@ async def seek( seconds = await player.seek(guild.id, request.seconds) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error + except RuntimeError as error: + raise HTTPException(status_code=409, detail=str(error)) from error await broadcaster.broadcast() return {"status": "seeked", "seconds": seconds}