fix(player): fill ListeningMode empty space + global audio context + chapter picker from mini-bar
Some checks failed
Release / Test backend (push) Successful in 49s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Docker / caddy (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
Some checks failed
Release / Test backend (push) Successful in 49s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Docker / caddy (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
- justify-between on scrollable body so cover art sits top, controls
sit bottom — no more half-empty screen on tall phones
- Move ListeningMode outside {#if audioStore.active} so pausing never
tears down the overlay and loses resume context
- Mini-bar time/track click now opens ListeningMode with chapter picker
pre-shown (same view as the Chapters button inside ListeningMode)
- Remove the old chapterDrawerOpen mini-bar drawer (replaced by above)
- Add openChapters prop to ListeningMode for pre-opening chapter modal
This commit is contained in:
@@ -7,9 +7,11 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
/** Called when the user closes the overlay. */
|
/** Called when the user closes the overlay. */
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
|
/** When true, open the chapter picker immediately on mount. */
|
||||||
|
openChapters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onclose }: Props = $props();
|
let { onclose, openChapters = false }: Props = $props();
|
||||||
|
|
||||||
// Voices come from the store (populated by AudioPlayer on mount/play)
|
// Voices come from the store (populated by AudioPlayer on mount/play)
|
||||||
const voices = $derived(audioStore.voices);
|
const voices = $derived(audioStore.voices);
|
||||||
@@ -18,7 +20,8 @@
|
|||||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||||
|
|
||||||
let showVoiceModal = $state(false);
|
let showVoiceModal = $state(false);
|
||||||
let showChapterModal = $state(false);
|
// svelte-ignore state_referenced_locally
|
||||||
|
let showChapterModal = $state(openChapters && audioStore.chapters.length > 0);
|
||||||
let voiceSearch = $state('');
|
let voiceSearch = $state('');
|
||||||
let samplePlayingVoice = $state<string | null>(null);
|
let samplePlayingVoice = $state<string | null>(null);
|
||||||
let sampleAudio: HTMLAudioElement | null = null;
|
let sampleAudio: HTMLAudioElement | null = null;
|
||||||
@@ -419,19 +422,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body — fills remaining height, content spread vertically -->
|
||||||
<div class="relative flex-1 overflow-y-auto flex flex-col">
|
<div class="relative flex-1 overflow-y-auto flex flex-col justify-between py-4">
|
||||||
|
|
||||||
<!-- Cover art + track info -->
|
<!-- Cover art + track info -->
|
||||||
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
|
<div class="flex flex-col items-center px-8 shrink-0">
|
||||||
{#if audioStore.cover}
|
{#if audioStore.cover}
|
||||||
<img
|
<img
|
||||||
src={audioStore.cover}
|
src={audioStore.cover}
|
||||||
alt=""
|
alt=""
|
||||||
class="w-40 h-56 object-cover rounded-xl shadow-2xl mb-5"
|
class="w-44 h-64 object-cover rounded-xl shadow-2xl mb-5"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-40 h-56 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
|
<div class="w-44 h-64 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
|
||||||
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
|
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -444,165 +447,170 @@
|
|||||||
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
|
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Seek bar -->
|
<!-- Bottom controls cluster: seek + transport + secondary -->
|
||||||
<div class="px-6 shrink-0">
|
<div class="flex flex-col gap-0 px-6 shrink-0">
|
||||||
<input
|
|
||||||
type="range"
|
<!-- Seek bar -->
|
||||||
aria-label="Seek"
|
<div class="shrink-0">
|
||||||
min="0"
|
<input
|
||||||
max={audioStore.duration || 0}
|
type="range"
|
||||||
value={audioStore.currentTime}
|
aria-label="Seek"
|
||||||
oninput={seek}
|
min="0"
|
||||||
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
|
max={audioStore.duration || 0}
|
||||||
style="accent-color: var(--color-brand);"
|
value={audioStore.currentTime}
|
||||||
/>
|
oninput={seek}
|
||||||
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
|
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
|
||||||
<span>{formatTime(audioStore.currentTime)}</span>
|
style="accent-color: var(--color-brand);"
|
||||||
<span>{formatTime(audioStore.duration)}</span>
|
/>
|
||||||
|
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
|
||||||
|
<span>{formatTime(audioStore.currentTime)}</span>
|
||||||
|
<span>{formatTime(audioStore.duration)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transport controls -->
|
<!-- Transport controls -->
|
||||||
<div class="flex items-center justify-center gap-4 px-6 pt-5 pb-2 shrink-0">
|
<div class="flex items-center justify-center gap-4 pt-5 pb-3 shrink-0">
|
||||||
<!-- Prev chapter -->
|
<!-- Prev chapter -->
|
||||||
{#if audioStore.chapter > 1 && audioStore.slug}
|
{#if audioStore.chapter > 1 && audioStore.slug}
|
||||||
<a
|
<a
|
||||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
|
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
|
||||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||||
title="Previous chapter"
|
title="Previous chapter"
|
||||||
aria-label="Previous chapter"
|
aria-label="Previous chapter"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
|
||||||
<div class="w-9 h-9"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Skip back 15s -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={skipBack}
|
|
||||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
|
||||||
aria-label="Skip back 15 seconds"
|
|
||||||
title="Back 15s"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
|
||||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Play / Pause -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={togglePlay}
|
|
||||||
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
|
|
||||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
|
||||||
>
|
|
||||||
{#if audioStore.isPlaying}
|
|
||||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
{:else}
|
||||||
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
<div class="w-9 h-9"></div>
|
||||||
<path d="M8 5v14l11-7z"/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Skip forward 30s -->
|
<!-- Skip back 15s -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={skipForward}
|
onclick={skipBack}
|
||||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
|
||||||
aria-label="Skip forward 30 seconds"
|
|
||||||
title="Forward 30s"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
|
||||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Next chapter -->
|
|
||||||
{#if audioStore.nextChapter !== null && audioStore.slug}
|
|
||||||
<a
|
|
||||||
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
|
|
||||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||||
title="Next chapter"
|
aria-label="Skip back 15 seconds"
|
||||||
aria-label="Next chapter"
|
title="Back 15s"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||||
|
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Play / Pause -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={togglePlay}
|
||||||
|
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
|
||||||
|
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{#if audioStore.isPlaying}
|
||||||
|
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Skip forward 30s -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={skipForward}
|
||||||
|
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||||
|
aria-label="Skip forward 30 seconds"
|
||||||
|
title="Forward 30s"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||||
|
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Next chapter -->
|
||||||
|
{#if audioStore.nextChapter !== null && audioStore.slug}
|
||||||
|
<a
|
||||||
|
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
|
||||||
|
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||||
|
title="Next chapter"
|
||||||
|
aria-label="Next chapter"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div class="w-9 h-9"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secondary controls: Speed · Auto-next · Sleep -->
|
||||||
|
<div class="flex items-center justify-center gap-3 pb-3 shrink-0 flex-wrap">
|
||||||
|
<!-- Speed -->
|
||||||
|
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
|
||||||
|
{#each SPEED_OPTIONS as s}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (audioStore.speed = s)}
|
||||||
|
class={cn(
|
||||||
|
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
|
||||||
|
audioStore.speed === s
|
||||||
|
? 'bg-(--color-brand) text-(--color-surface)'
|
||||||
|
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||||
|
)}
|
||||||
|
aria-pressed={audioStore.speed === s}
|
||||||
|
>{s}×</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-next -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||||
|
class={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||||
|
audioStore.autoNext
|
||||||
|
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||||
|
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||||
|
)}
|
||||||
|
aria-pressed={audioStore.autoNext}
|
||||||
|
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
Auto
|
||||||
{:else}
|
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||||
<div class="w-9 h-9"></div>
|
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
||||||
{/if}
|
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||||
</div>
|
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Secondary controls: Speed · Auto-next · Sleep -->
|
<!-- Sleep timer -->
|
||||||
<div class="flex items-center justify-center gap-3 px-6 py-3 shrink-0">
|
<button
|
||||||
<!-- Speed -->
|
type="button"
|
||||||
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
|
onclick={cycleSleepTimer}
|
||||||
{#each SPEED_OPTIONS as s}
|
class={cn(
|
||||||
<button
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||||
type="button"
|
audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||||
onclick={() => (audioStore.speed = s)}
|
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||||
class={cn(
|
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||||
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
|
)}
|
||||||
audioStore.speed === s
|
title="Sleep timer"
|
||||||
? 'bg-(--color-brand) text-(--color-surface)'
|
>
|
||||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
)}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||||
aria-pressed={audioStore.speed === s}
|
</svg>
|
||||||
>{s}×</button>
|
{sleepLabel}
|
||||||
{/each}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-next -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
|
||||||
class={cn(
|
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
|
||||||
audioStore.autoNext
|
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
|
||||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
|
||||||
)}
|
|
||||||
aria-pressed={audioStore.autoNext}
|
|
||||||
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
|
||||||
>
|
|
||||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
|
||||||
</svg>
|
|
||||||
Auto
|
|
||||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
|
||||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Sleep timer -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={cycleSleepTimer}
|
|
||||||
class={cn(
|
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
|
||||||
audioStore.sleepUntil || audioStore.sleepAfterChapter
|
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
|
||||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
|
||||||
)}
|
|
||||||
title="Sleep timer"
|
|
||||||
>
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
|
||||||
</svg>
|
|
||||||
{sleepLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,28 +33,13 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Chapter list drawer state for the mini-player
|
// Chapter list drawer state for the mini-player
|
||||||
let chapterDrawerOpen = $state(false);
|
|
||||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
|
||||||
let listeningModeOpen = $state(false);
|
let listeningModeOpen = $state(false);
|
||||||
|
let listeningModeChapters = $state(false);
|
||||||
|
|
||||||
// Build time formatted in the user's local timezone (populated on mount so
|
// Build time formatted in the user's local timezone (populated on mount so
|
||||||
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
||||||
let buildTimeLocal = $state('');
|
let buildTimeLocal = $state('');
|
||||||
|
|
||||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
|
||||||
if (isActive) activeChapterEl = node;
|
|
||||||
return {
|
|
||||||
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
|
|
||||||
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (chapterDrawerOpen && activeChapterEl) {
|
|
||||||
activeChapterEl.scrollIntoView({ block: 'center' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
|
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
|
||||||
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
|
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
|
||||||
@@ -792,52 +777,6 @@
|
|||||||
{#if audioStore.active}
|
{#if audioStore.active}
|
||||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||||
|
|
||||||
<!-- Chapter list drawer (slides up above the mini-bar) -->
|
|
||||||
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
|
|
||||||
<div class="border-b border-(--color-border) bg-(--color-surface) flex justify-center md:justify-end md:pr-4">
|
|
||||||
<div class="w-full md:w-80 flex flex-col max-h-72">
|
|
||||||
<!-- Sticky header -->
|
|
||||||
<div class="flex items-center justify-between px-4 py-2 border-b border-(--color-border) shrink-0">
|
|
||||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onclick={() => (chapterDrawerOpen = false)}
|
|
||||||
aria-label={m.player_close_chapter_list()}
|
|
||||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
|
||||||
>
|
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<!-- Scrollable list -->
|
|
||||||
<div class="overflow-y-auto px-4">
|
|
||||||
{#each audioStore.chapters as ch (ch.number)}
|
|
||||||
<a
|
|
||||||
use:setIfActive={ch.number === audioStore.chapter}
|
|
||||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
|
||||||
onclick={() => (chapterDrawerOpen = false)}
|
|
||||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
|
||||||
? 'text-(--color-brand) font-semibold'
|
|
||||||
: 'text-(--color-muted)'}"
|
|
||||||
>
|
|
||||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
|
||||||
{ch.number}
|
|
||||||
</span>
|
|
||||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
|
||||||
{#if ch.number === audioStore.chapter}
|
|
||||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z"/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||||
<div class="h-0.5 bg-(--color-surface-2)">
|
<div class="h-0.5 bg-(--color-surface-2)">
|
||||||
@@ -864,10 +803,10 @@
|
|||||||
|
|
||||||
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
|
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
|
||||||
|
|
||||||
<!-- Track info (click to open chapter list drawer) -->
|
<!-- Track info (click to open chapter list in listening mode) -->
|
||||||
<button
|
<button
|
||||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
|
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
|
||||||
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
|
onclick={() => { if (audioStore.chapters.length > 0) { listeningModeChapters = true; listeningModeOpen = true; } }}
|
||||||
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
|
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
|
||||||
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
|
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
|
||||||
>
|
>
|
||||||
@@ -970,7 +909,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onclick={() => (listeningModeOpen = true)}
|
onclick={() => { listeningModeChapters = false; listeningModeOpen = true; }}
|
||||||
title="Listening mode"
|
title="Listening mode"
|
||||||
aria-label="Open listening mode"
|
aria-label="Open listening mode"
|
||||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||||
@@ -996,9 +935,13 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Listening mode full-screen overlay -->
|
|
||||||
{#if listeningModeOpen}
|
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
||||||
<ListeningMode onclose={() => (listeningModeOpen = false)} />
|
so closing/pausing audio never tears it down and loses context. -->
|
||||||
{/if}
|
{#if listeningModeOpen}
|
||||||
|
<ListeningMode
|
||||||
|
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||||
|
openChapters={listeningModeChapters}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user