From 9f658c3de7e2cf9ae67d1c6b56046386fbe6a93b Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 20 Apr 2026 01:55:42 -0600 Subject: [PATCH] feat(reader): improve audiobook UI with skip bubbles and custom scrubber --- .../reader/audiobook/AudiobookReaderView.vue | 280 ++++++++++++------ 1 file changed, 197 insertions(+), 83 deletions(-) diff --git a/client/src/features/reader/audiobook/AudiobookReaderView.vue b/client/src/features/reader/audiobook/AudiobookReaderView.vue index 747b87201..6c4147c65 100644 --- a/client/src/features/reader/audiobook/AudiobookReaderView.vue +++ b/client/src/features/reader/audiobook/AudiobookReaderView.vue @@ -43,6 +43,11 @@ const showSettings = ref(false) const showSleepTimer = ref(false) const showSpeedPicker = ref(false) +const sleepButtonRef = ref(null) +const speedButtonRef = ref(null) +const sleepPopoverStyle = ref>({}) +const speedPopoverStyle = ref>({}) + // Reset chapters tab to default when sheet closes watch(showChapters, (val) => { if (!val) chaptersTab.value = 'chapters' @@ -244,12 +249,38 @@ function skipBack() { if (!queue) return queue.seek(queue.position() - settings.skipBackSeconds.value) session.onActivity() + spawnSkipBubble(skipBackBtnRef.value, `-${settings.skipBackSeconds.value}s`) } function skipForward() { if (!queue) return queue.seek(queue.position() + settings.skipForwardSeconds.value) session.onActivity() + spawnSkipBubble(skipForwardBtnRef.value, `+${settings.skipForwardSeconds.value}s`) +} + +// ── Skip bubbles ────────────────────────────────────────────────────────────── + +interface SkipBubble { + id: number + label: string + x: number + y: number +} + +const skipBackBtnRef = ref(null) +const skipForwardBtnRef = ref(null) +let bubbleIdCounter = 0 +const skipBubbles = ref([]) + +function spawnSkipBubble(btnEl: HTMLButtonElement | null, label: string) { + if (!btnEl) return + const rect = btnEl.getBoundingClientRect() + const id = ++bubbleIdCounter + skipBubbles.value.push({ id, label, x: rect.left + rect.width / 2, y: rect.top }) + setTimeout(() => { + skipBubbles.value = skipBubbles.value.filter((b) => b.id !== id) + }, 700) } function prevTrack() { @@ -313,17 +344,13 @@ function seekToAbsoluteSeconds(absoluteSecs: number, closeSheet = false) { session.onActivity() } -function handleTotalSeek(event: Event) { - if (!queue) return - const targetSecs = parseFloat((event.target as HTMLInputElement).value) - seekToAbsoluteSeconds(targetSecs) -} - function handleVolumeChange(event: Event) { const val = parseFloat((event.target as HTMLInputElement).value) settings.setVolume(val) } +const volumeTrackStyle = computed(() => ({ '--volume-pct': Math.round(settings.volume.value * 100) + '%' })) + function selectSpeed(speed: number) { settings.setPlaybackSpeed(speed) showSpeedPicker.value = false @@ -339,6 +366,36 @@ function speedUp() { settings.setPlaybackSpeed(Math.min(3.0, next)) } +function openSleepTimer() { + if (sleepButtonRef.value) { + const rect = sleepButtonRef.value.getBoundingClientRect() + const popoverWidth = 172 + const left = Math.max(12, Math.min(window.innerWidth - popoverWidth - 12, rect.left + rect.width / 2 - popoverWidth / 2)) + sleepPopoverStyle.value = { + top: rect.top + 'px', + left: left + 'px', + width: popoverWidth + 'px', + transform: 'translateY(calc(-100% - 8px))', + } + } + showSleepTimer.value = !showSleepTimer.value +} + +function openSpeedPicker() { + if (speedButtonRef.value) { + const rect = speedButtonRef.value.getBoundingClientRect() + const popoverWidth = 156 + const left = Math.max(12, Math.min(window.innerWidth - popoverWidth - 12, rect.left + rect.width / 2 - popoverWidth / 2)) + speedPopoverStyle.value = { + top: rect.top + 'px', + left: left + 'px', + width: popoverWidth + 'px', + transform: 'translateY(calc(-100% - 8px))', + } + } + showSpeedPicker.value = !showSpeedPicker.value +} + function seekToChapter(chapter: AudiobookChapter) { seekToAbsoluteSeconds(chapter.startMs / 1000, true) } @@ -444,20 +501,46 @@ function cancelSleepTimer() { showSleepTimer.value = false } -// ── Scrubber hover tooltip ──────────────────────────────────────────────────── +// ── Scrubber drag ───────────────────────────────────────────────────────────── const scrubberEl = ref(null) const scrubberHoverSeconds = ref(null) +const isDragging = ref(false) +const dragPositionPct = ref(null) +const scrubberProgressPct = computed(() => (dragPositionPct.value !== null ? dragPositionPct.value : progressPct.value)) -function handleScrubberMove(e: MouseEvent) { - if (!scrubberEl.value || !totalBookDuration.value) return +function computeScrubberPct(clientX: number): number { + if (!scrubberEl.value) return 0 const rect = scrubberEl.value.getBoundingClientRect() - const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)) - scrubberHoverSeconds.value = (x / rect.width) * totalBookDuration.value + return Math.max(0, Math.min((clientX - rect.left) / rect.width, 1)) +} + +function handleScrubberPointerDown(e: PointerEvent) { + if (!scrubberEl.value || !totalBookDuration.value) return + e.preventDefault() + isDragging.value = true + scrubberEl.value.setPointerCapture(e.pointerId) + dragPositionPct.value = computeScrubberPct(e.clientX) * 100 + scrubberHoverSeconds.value = (dragPositionPct.value / 100) * totalBookDuration.value +} + +function handleScrubberPointerMove(e: PointerEvent) { + if (!scrubberEl.value || !totalBookDuration.value) return + const pct = computeScrubberPct(e.clientX) + scrubberHoverSeconds.value = pct * totalBookDuration.value + if (isDragging.value) dragPositionPct.value = pct * 100 +} + +function handleScrubberPointerUp(e: PointerEvent) { + if (!isDragging.value || !scrubberEl.value || !totalBookDuration.value) return + isDragging.value = false + const pct = computeScrubberPct(e.clientX) + dragPositionPct.value = null + seekToAbsoluteSeconds(pct * totalBookDuration.value) } function handleScrubberLeave() { - scrubberHoverSeconds.value = null + if (!isDragging.value) scrubberHoverSeconds.value = null } // ── Computed helpers ────────────────────────────────────────────────────────── @@ -726,7 +809,14 @@ onMounted(async () => {
-
+
{
-
+
- - +
@@ -789,7 +875,11 @@ onMounted(async () => { -
- - + + +
+ {{ bubble.label }} +
@@ -1138,65 +1245,66 @@ onMounted(async () => { animation: pulse-ring 1.8s ease-out infinite; } -/* ── Scrubber (progress range input) ─────────────────────── */ -.scrubber { - -webkit-appearance: none; - appearance: none; - background: transparent; - height: 24px; +/* ── Scrubber (custom drag) ───────────────────────────────── */ +.scrubber-rail { cursor: pointer; + touch-action: none; + user-select: none; } -.scrubber::-webkit-slider-runnable-track { - background: transparent; - height: 24px; -} - -.scrubber::-webkit-slider-thumb { - -webkit-appearance: none; +.scrubber-thumb { width: 14px; height: 14px; - border-radius: 50%; - background: white; - margin-top: 5px; box-shadow: 0 0 8px rgba(255, 255, 255, 0.5), 0 2px 4px rgba(0, 0, 0, 0.4); - cursor: pointer; opacity: 0; transition: opacity 0.15s, - transform 0.15s; + width 0.1s, + height 0.1s; } -.scrubber:hover::-webkit-slider-thumb, -.scrubber:active::-webkit-slider-thumb { +.scrubber-rail:hover .scrubber-thumb, +.scrubber-thumb-dragging { opacity: 1; - transform: scale(1.2); } -/* Always show thumb on touch devices (no hover state) */ +.scrubber-thumb-dragging { + width: 18px !important; + height: 18px !important; + box-shadow: + 0 0 12px rgba(255, 255, 255, 0.7), + 0 2px 6px rgba(0, 0, 0, 0.5) !important; +} + @media (hover: none) { - .scrubber::-webkit-slider-thumb { + .scrubber-thumb { opacity: 1; } } -.scrubber::-moz-range-track { - background: transparent; - height: 24px; +/* ── Skip bubble animation ────────────────────────────────── */ +@keyframes skip-fly { + 0% { + opacity: 1; + transform: translate(-50%, -100%); + } + 100% { + opacity: 0; + transform: translate(-50%, calc(-100% - 40px)); + } } -.scrubber::-moz-range-thumb { - width: 14px; - height: 14px; - border-radius: 50%; - background: white; - border: none; - box-shadow: - 0 0 8px rgba(255, 255, 255, 0.5), - 0 2px 4px rgba(0, 0, 0, 0.4); - cursor: pointer; +.skip-bubble { + animation: skip-fly 0.6s ease-out forwards; + font-size: 0.8rem; + font-weight: 700; + color: white; + text-shadow: + 0 1px 4px rgba(0, 0, 0, 0.6), + 0 0 8px rgba(0, 0, 0, 0.4); + white-space: nowrap; } /* ── Volume slider ────────────────────────────────────────── */ @@ -1210,7 +1318,7 @@ onMounted(async () => { .volume-slider::-webkit-slider-runnable-track { height: 3px; - background: rgba(255, 255, 255, 0.25); + background: linear-gradient(to right, white var(--volume-pct, 50%), rgba(255, 255, 255, 0.25) var(--volume-pct, 50%)); border-radius: 9999px; } @@ -1224,6 +1332,12 @@ onMounted(async () => { cursor: pointer; } +.volume-slider::-moz-range-progress { + background: white; + height: 3px; + border-radius: 9999px; +} + .volume-slider::-moz-range-track { height: 3px; background: rgba(255, 255, 255, 0.25);