feat: add tooltip for progress bar seeking

This commit is contained in:
mmacierzanka committed 2026-08-21 21:12:24 +02:00
1 parent 9e568e0581
commit ca17c742c9
5 files changed
+113 -5

No files matched your search

+48 -2
View File
@@ -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;
}
+35 -3
View File
@@ -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<number | null>(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"
>
<span class="time-text">{{ formatTime(displayedElapsed) }}</span>
<div class="progress-bar-bg">
<div
class="progress-bar-bg"
@mousemove="onSeekHover"
@mouseleave="hoverPosition = null"
>
<div
class="progress-bar-fill"
:style="{ width: `${seeking ? (duration > 0 ? (seekPosition / duration) * 100 : 0) : percent}%` }"
@@ -194,11 +219,18 @@ async function cycleLoop() {
:max="duration"
step="0.1"
:value="seeking ? seekPosition : elapsed"
:disabled="duration <= 0"
:disabled="duration <= 0 || seekSubmitting"
aria-label="Pozycja utworu"
@input="onSeekInput"
@change="commitSeek"
>
<span
v-if="hoverPosition !== null"
class="progress-time-tooltip"
:style="{ left: `${hoverOffset}px` }"
>
{{ formatTime(hoverPosition) }}
</span>
</div>
<span class="time-text">{{ duration > 0 ? formatTime(duration) : 'Live' }}</span>
</div>
+20
View File
@@ -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)
+8
View File
@@ -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)
@@ -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}