From 956594ae7b1b761812613353e18aa3f83f0fea7b Mon Sep 17 00:00:00 2001 From: root Date: Wed, 8 Apr 2026 20:28:47 +0500 Subject: [PATCH] feat: replace compact player with draggable floating overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove compact player mode (seekable bar inline was undifferentiated from standard; didn't have voice/chapter/warm-up controls) - Add 'float' player style: a fixed, draggable mini-overlay positioned above the bottom mini-bar (bottom-right corner, z-55) · Seek bar, skip ±15/30s, play/pause, time, speed indicator · Drag handle (pointer capture) to reposition anywhere on screen · Animated playing pulse dot in the title row · Suppresses the standard non-idle inline block when active so there is no duplicate UI - PlayerStyle type: 'standard' | 'float' (was 'compact') - Bump localStorage key to reader_layout_v2 so old 'compact' pref does not pollute the new default - Listening tab Style row now shows Standard / Float --- ui/src/lib/components/AudioPlayer.svelte | 252 +++++++++--------- .../books/[slug]/chapters/[n]/+page.svelte | 6 +- 2 files changed, 130 insertions(+), 128 deletions(-) diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index a1a6b63..a4ddbe0 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -70,8 +70,8 @@ voices?: Voice[]; /** Called when the server returns 402 (free daily limit reached). */ onProRequired?: () => void; - /** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */ - playerStyle?: 'standard' | 'compact'; + /** Visual style of the player card. 'standard' = inline card; 'float' = draggable overlay. */ + playerStyle?: 'standard' | 'float'; /** Approximate word count for the chapter, used to show estimated listen time in the idle state. */ wordCount?: number; } @@ -891,18 +891,32 @@ audioStore.duration > 0 ? (audioStore.currentTime / audioStore.duration) * 100 : 0 ); - const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const; - function cycleSpeed() { - const idx = SPEED_OPTIONS.indexOf(audioStore.speed as (typeof SPEED_OPTIONS)[number]); - audioStore.speed = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length]; - } - - function seekFromCompactBar(e: MouseEvent) { + function seekFromBar(e: MouseEvent) { if (audioStore.duration <= 0) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); audioStore.seekRequest = pct * audioStore.duration; } + + // ── Float player drag state ────────────────────────────────────────────── + /** Position of the floating overlay (bottom-right anchor by default). */ + let floatPos = $state({ x: 0, y: 0 }); + let floatDragging = $state(false); + let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 }); + + function onFloatPointerDown(e: PointerEvent) { + floatDragging = true; + floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y }; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + } + function onFloatPointerMove(e: PointerEvent) { + if (!floatDragging) return; + floatPos = { + x: floatDragStart.ox + (e.clientX - floatDragStart.mx), + y: floatDragStart.oy + (e.clientY - floatDragStart.my) + }; + } + function onFloatPointerUp() { floatDragging = false; } @@ -953,121 +967,6 @@ {/snippet} -{#if playerStyle === 'compact'} - -
- {#if audioStore.isCurrentChapter(slug, chapter)} - {#if audioStore.status === 'idle' || audioStore.status === 'error'} - {#if audioStore.status === 'error'} -

{audioStore.errorMsg || 'Failed to load audio.'}

- {/if} - - - {:else if audioStore.status === 'loading'} -
- - - - - {m.player_loading()} -
- - {:else if audioStore.status === 'generating'} -
-
- {m.reader_generating_narration()} - {Math.round(audioStore.progress)}% -
-
-
-
-
- - {:else if audioStore.status === 'ready'} -
- - -
-
-
- -
- - - - - - - - - {formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)} - - - -
-
- {/if} - - {:else if audioStore.active} -
-

- {m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })} -

- -
- - {:else} - - {/if} -
-{:else} {#if @@ -1197,6 +1096,7 @@ {:else} +{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
@@ -1402,7 +1302,6 @@
{/if} {/if} - +{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active} + +
+
+ + +
+ + + + + + + + {audioStore.chapterTitle || `Chapter ${audioStore.chapter}`} + + + {#if audioStore.isPlaying} + + {/if} +
+ + + +
+
+
+ + +
+ + + + + + + + + + + + {formatTime(audioStore.currentTime)} + / + {formatDuration(audioStore.duration)} + + + + + {audioStore.speed}× + +
+
+
+{/if} diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte index 9f201aa..c9e88bb 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -52,7 +52,7 @@ type LineSpacing = 'compact' | 'normal' | 'relaxed'; type ReadWidth = 'narrow' | 'normal' | 'wide'; type ParaStyle = 'spaced' | 'indented'; - type PlayerStyle = 'standard' | 'compact'; + type PlayerStyle = 'standard' | 'float'; /** Controls how many lines fit on a page by adjusting the container height offset. */ type PageLines = 'less' | 'normal' | 'more'; @@ -66,7 +66,7 @@ pageLines: PageLines; } - const LAYOUT_KEY = 'reader_layout_v1'; + const LAYOUT_KEY = 'reader_layout_v2'; const LINE_HEIGHTS: Record = { compact: 1.55, normal: 1.85, relaxed: 2.2 }; const READ_WIDTHS: Record = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' }; /** @@ -950,7 +950,7 @@
Style
- {#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, lbl]} + {#each ([['standard', 'Standard'], ['float', 'Float']] as const) as [s, lbl]}