feat: add tooltip for progress bar seeking
This commit is contained in:
5 files changed
+113
-5
No files matched your search
@@ -471,22 +471,68 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
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 {
|
.track-progress-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -7px 0;
|
inset: -7px 0;
|
||||||
width: 100%;
|
width: auto;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 1;
|
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 {
|
.track-progress-slider:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ const current = computed(() => props.status?.current ?? null)
|
|||||||
const playing = computed(() => props.status?.playing ?? false)
|
const playing = computed(() => props.status?.playing ?? false)
|
||||||
const loopMode = computed(() => loopModeOf(props.status))
|
const loopMode = computed(() => loopModeOf(props.status))
|
||||||
const seeking = ref(false)
|
const seeking = ref(false)
|
||||||
|
const seekSubmitting = ref(false)
|
||||||
const seekPosition = ref(0)
|
const seekPosition = ref(0)
|
||||||
|
const hoverPosition = ref<number | null>(null)
|
||||||
|
const hoverOffset = ref(0)
|
||||||
const displayedElapsed = computed(() => seeking.value ? seekPosition.value : elapsed.value)
|
const displayedElapsed = computed(() => seeking.value ? seekPosition.value : elapsed.value)
|
||||||
|
|
||||||
/** Podczas przeciągania suwaka pokazujemy wartość lokalną, nie tę z serwera. */
|
/** 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)
|
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() {
|
async function commitSeek() {
|
||||||
if (!current.value || duration.value <= 0) return
|
if (!current.value || duration.value <= 0 || seekSubmitting.value) return
|
||||||
const position = seekPosition.value
|
const position = seekPosition.value
|
||||||
|
seekSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await run(() => api.seek(position), 'Przewinięto utwór', 'Błąd przewijania utworu')
|
await run(() => api.seek(position), 'Przewinięto utwór', 'Błąd przewijania utworu')
|
||||||
} finally {
|
} finally {
|
||||||
|
seekSubmitting.value = false
|
||||||
seeking.value = false
|
seeking.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +203,11 @@ async function cycleLoop() {
|
|||||||
class="track-progress-container"
|
class="track-progress-container"
|
||||||
>
|
>
|
||||||
<span class="time-text">{{ formatTime(displayedElapsed) }}</span>
|
<span class="time-text">{{ formatTime(displayedElapsed) }}</span>
|
||||||
<div class="progress-bar-bg">
|
<div
|
||||||
|
class="progress-bar-bg"
|
||||||
|
@mousemove="onSeekHover"
|
||||||
|
@mouseleave="hoverPosition = null"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="progress-bar-fill"
|
class="progress-bar-fill"
|
||||||
:style="{ width: `${seeking ? (duration > 0 ? (seekPosition / duration) * 100 : 0) : percent}%` }"
|
:style="{ width: `${seeking ? (duration > 0 ? (seekPosition / duration) * 100 : 0) : percent}%` }"
|
||||||
@@ -194,11 +219,18 @@ async function cycleLoop() {
|
|||||||
:max="duration"
|
:max="duration"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
:value="seeking ? seekPosition : elapsed"
|
:value="seeking ? seekPosition : elapsed"
|
||||||
:disabled="duration <= 0"
|
:disabled="duration <= 0 || seekSubmitting"
|
||||||
aria-label="Pozycja utworu"
|
aria-label="Pozycja utworu"
|
||||||
@input="onSeekInput"
|
@input="onSeekInput"
|
||||||
@change="commitSeek"
|
@change="commitSeek"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
v-if="hoverPosition !== null"
|
||||||
|
class="progress-time-tooltip"
|
||||||
|
:style="{ left: `${hoverOffset}px` }"
|
||||||
|
>
|
||||||
|
{{ formatTime(hoverPosition) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="time-text">{{ duration > 0 ? formatTime(duration) : 'Live' }}</span>
|
<span class="time-text">{{ duration > 0 ? formatTime(duration) : 'Live' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,6 +113,26 @@ class MusicPlayerQueueTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(state.current.elapsed, 4.5)
|
self.assertEqual(state.current.elapsed, 4.5)
|
||||||
self.assertIs(voice_client.playing, state.current)
|
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):
|
async def test_loop_track_replays_same_index(self):
|
||||||
player, _, state = await self._queue_abc()
|
player, _, state = await self._queue_abc()
|
||||||
await player.play_next(GUILD, advance=True)
|
await player.play_next(GUILD, advance=True)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class MusicPlayer:
|
|||||||
self._states: dict[int, GuildMusicState] = {}
|
self._states: dict[int, GuildMusicState] = {}
|
||||||
self._playback_tokens: dict[int, int] = {}
|
self._playback_tokens: dict[int, int] = {}
|
||||||
self._interrupted_playbacks: set[int] = set()
|
self._interrupted_playbacks: set[int] = set()
|
||||||
|
self._seek_locks: dict[int, asyncio.Lock] = {}
|
||||||
self.on_state_change: StateChangeHook | None = None
|
self.on_state_change: StateChangeHook | None = None
|
||||||
|
|
||||||
# --- Stan ---------------------------------------------------------------
|
# --- Stan ---------------------------------------------------------------
|
||||||
@@ -293,6 +294,13 @@ class MusicPlayer:
|
|||||||
await self._restart_playback(guild_id)
|
await self._restart_playback(guild_id)
|
||||||
|
|
||||||
async def seek(self, guild_id: int, seconds: float) -> float:
|
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)
|
state = self.state(guild_id)
|
||||||
current = state.current
|
current = state.current
|
||||||
voice_client = self.voice_client(guild_id)
|
voice_client = self.voice_client(guild_id)
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ async def seek(
|
|||||||
seconds = await player.seek(guild.id, request.seconds)
|
seconds = await player.seek(guild.id, request.seconds)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise HTTPException(status_code=400, detail=str(error)) from 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()
|
await broadcaster.broadcast()
|
||||||
return {"status": "seeked", "seconds": seconds}
|
return {"status": "seeked", "seconds": seconds}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user