<script lang="ts">
	/**
	 * AudioPlayer — controller component.
	 *
	 * Does NOT own an <audio> element.  Instead it reads/writes `audioStore`,
	 * which is shared with the layout's persistent <audio> element so audio
	 * survives SvelteKit navigations.
	 *
	 * ── Play flow ────────────────────────────────────────────────────────────
	 * On "Play narration" click / auto-start:
	 *   1. Populate store metadata (slug, chapter, titles, voice, speed).
	 *   2. If the pre-fetch already landed (nextStatus='prefetched' AND
	 *      nextChapterPrefetched === chapter), use the cached URL immediately.
	 *   3. Otherwise try GET /api/presign/audio — if 200, set audioUrl → layout plays.
	 *   4. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar.
	 *      On success (200 or 202→done), presign and set audioUrl directly from MinIO.
	 *
	 * ── Voice selection ──────────────────────────────────────────────────────
	 * A "Change voice" panel lets users pick from the available Kokoro voices.
	 * Each voice shows a play button that streams a pre-generated sample from
	 * MinIO (GET /api/presign/voice-sample?voice=...). Samples are generated
	 * server-side via POST /api/audio/voice-samples.
	 *
	 * Changing voice updates audioStore.voice (saved to settings via layout).
	 * The currently loaded chapter audio is NOT re-generated automatically —
	 * the new voice takes effect on next "Play narration" click.
	 *
	 * ── Pre-fetch (immediate + 90% fallback) ────────────────────────────────
	 * When autoNext is on, prefetchNext() is called as soon as the current
	 * chapter starts playing (via maybeStartPrefetch() at the end of
	 * startPlayback()).  This gives the maximum lead time for Kokoro to
	 * generate the next chapter so the transition is seamless.
	 *
	 * A $effect also watches currentTime/duration and fires prefetchNext() at
	 * the 90% mark as a fallback — covering the case where autoNext was toggled
	 * on mid-playback after startPlayback() had already returned.
	 * The nextStatus !== 'none' guard prevents double-runs in all cases.
	 *
	 * prefetchNext():
	 *   • Calls POST /api/audio for next chapter (sets nextStatus='prefetching')
	 *   • On success, presigns and stores URL in audioStore.nextAudioUrl
	 *     (sets nextStatus='prefetched')
	 *   • On failure, sets nextStatus='failed'
	 *
	 * ── Auto-next ────────────────────────────────────────────────────────────
	 * layout.svelte onended → sets autoStartPending=true → navigates.
	 * New chapter's AudioPlayer mounts → sees autoStartPending → startPlayback()
	 * which uses the prefetched URL if available.
	 */

	import { audioStore } from '$lib/audio.svelte';
	import { goto } from '$app/navigation';
	import { Button } from '$lib/components/ui/button';
	import { cn } from '$lib/utils';
	import type { Voice } from '$lib/types';
	import * as m from '$lib/paraglide/messages.js';

	interface Props {
		slug: string;
		chapter: number;
		chapterTitle?: string;
		bookTitle?: string;
		/** Cover image URL for the book (used in MediaSession for lock-screen art). */
		cover?: string;
		/** Next chapter number, or null/undefined if this is the last chapter. */
		nextChapter?: number | null;
		/** Full chapter list for the book (number + title). Written into the store. */
		chapters?: { number: number; title: string }[];
		/** List of available voices from the backend. */
		voices?: Voice[];
		/** Called when the server returns 402 (free daily limit reached). */
		onProRequired?: () => void;
	/** 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;
	}

	let {
		slug,
		chapter,
		chapterTitle = '',
		bookTitle = '',
		cover = '',
		nextChapter = null,
		chapters = [],
		voices = [],
		onProRequired = undefined,
		playerStyle = 'standard',
		wordCount = 0
	}: Props = $props();

	/** Estimated listen time in minutes at ~150 wpm average narration speed. */
	const estimatedMinutes = $derived(wordCount > 0 ? Math.max(1, Math.round(wordCount / 150)) : 0);

	// ── Derived: voices grouped by engine ──────────────────────────────────
	const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
	const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
	const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));

	// ── Voice selector state ────────────────────────────────────────────────
	let showVoicePanel = $state(false);
	/** Voice whose sample is currently being fetched or playing. */
	let samplePlayingVoice = $state<string | null>(null);
	/** Currently active sample <audio> element — one at a time. */
	let sampleAudio = $state<HTMLAudioElement | null>(null);

	// ── Chapter picker state ─────────────────────────────────────────────────
	let showChapterPanel = $state(false);
	let chapterSearch = $state('');
	const filteredChapters = $derived(
		chapterSearch.trim() === ''
			? audioStore.chapters
			: audioStore.chapters.filter((ch) =>
					(ch.title || `Chapter ${ch.number}`)
						.toLowerCase()
						.includes(chapterSearch.toLowerCase()) ||
					String(ch.number).includes(chapterSearch)
			  )
	);

	function playChapter(chapterNumber: number) {
		audioStore.autoStartChapter = chapterNumber;
		showChapterPanel = false;
		chapterSearch = '';
		goto(`/books/${slug}/chapters/${chapterNumber}`);
	}

	/**
	 * Human-readable label for a voice.
	 * Kokoro: "af_bella" → "Bella (US F)"
	 * Pocket-TTS: "alba" → "Alba (EN F)"
	 * CF AI: "cfai:luna" → "Luna (EN F)"
	 * Falls back gracefully if called with a bare string (e.g. from the store default).
	 */
	function voiceLabel(v: Voice | string): string {
		// Handle plain string IDs stored in audioStore.voice
		if (typeof v === 'string') {
			// Try to match against the voices list
			const found = voices.find((x) => x.id === v);
			if (found) return voiceLabel(found);
			// Bare kokoro ID fallback (legacy / default "af_bella")
			return kokoroLabelFromId(v);
		}

		if (v.engine === 'cfai') {
			// "cfai:luna" → "Luna (EN F)"
			const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
			const name = speaker.replace(/\b\w/g, (c) => c.toUpperCase());
			const genderLabel = v.gender.toUpperCase();
			return `${name} (EN ${genderLabel})`;
		}

		if (v.engine === 'pocket-tts') {
			const langLabel = v.lang.toUpperCase().replace('-', '');
			const genderLabel = v.gender.toUpperCase();
			const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
			return `${name} (${langLabel} ${genderLabel})`;
		}

		// Kokoro
		return kokoroLabelFromId(v.id);
	}

	function kokoroLabelFromId(id: string): string {
		const langMap: Record<string, string> = {
			af: 'US', am: 'US',
			bf: 'UK', bm: 'UK',
			ef: 'ES', em: 'ES',
			ff: 'FR',
			hf: 'IN', hm: 'IN',
			'if': 'IT', im: 'IT',
			jf: 'JP', jm: 'JP',
			pf: 'PT', pm: 'PT',
			zf: 'ZH', zm: 'ZH',
		};
		const genderMap: Record<string, string> = {
			af: 'F', am: 'M',
			bf: 'F', bm: 'M',
			ef: 'F', em: 'M',
			ff: 'F',
			hf: 'F', hm: 'M',
			'if': 'F', im: 'M',
			jf: 'F', jm: 'M',
			pf: 'F', pm: 'M',
			zf: 'F', zm: 'M',
		};
		const prefix = id.slice(0, 2);
		const name = id.slice(3);
		const displayName = name
			.replace(/^v0/, '')
			.replace(/^([a-z])/, (c: string) => c.toUpperCase());
		const lang = langMap[prefix] ?? prefix.toUpperCase();
		const gender = genderMap[prefix] ?? '?';
		return `${displayName} (${lang} ${gender})`;
	}

	/** Stop any currently playing sample. */
	function stopSample() {
		if (sampleAudio) {
			sampleAudio.pause();
			sampleAudio.src = '';
			sampleAudio = null;
		}
		samplePlayingVoice = null;
	}

	/** Play a voice sample from MinIO. */
	async function playSample(voice: string) {
		// If this voice is already playing, stop it.
		if (samplePlayingVoice === voice) {
			stopSample();
			return;
		}
		stopSample();

		samplePlayingVoice = voice;
		try {
			const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voice)}`);
			if (res.status === 404) {
				// Sample not generated yet — silently ignore
				samplePlayingVoice = null;
				return;
			}
			if (!res.ok) throw new Error(`presign failed: ${res.status}`);
			const data = (await res.json()) as { url: string };

			const audio = new Audio(data.url);
			sampleAudio = audio;
			audio.onended = () => {
				if (samplePlayingVoice === voice) stopSample();
			};
			audio.onerror = () => {
				if (samplePlayingVoice === voice) stopSample();
			};
			await audio.play();
		} catch {
			samplePlayingVoice = null;
		}
	}

	/** Select a voice and close the panel. */
	function selectVoice(voice: string) {
		stopSample();
		audioStore.voice = voice;
		showVoicePanel = false;
	}

	// Keep nextChapter in the store so the layout's onended can navigate.
	// We write null on mount (before deriving the real value) so there is no
	// stale window where the previous chapter's nextChapter is still set while
	// this chapter's AudioPlayer hasn't written its own value yet.
	$effect(() => {
		audioStore.nextChapter = nextChapter ?? null;
	});

	// Keep chapters list in store up to date so the layout's onended announce
	// can find titles even if startPlayback() hasn't been called yet on this mount.
	$effect(() => {
		if (chapters.length > 0) audioStore.chapters = chapters;
	});

	// Keep voices in store up to date whenever prop changes.
	$effect(() => {
		if (voices.length > 0) audioStore.voices = voices;
	});

	// Auto-start: if the layout navigated here via auto-next, kick off playback.
	// We match against the chapter prop so the outgoing chapter's AudioPlayer
	// (still mounted during the brief navigation window) never reacts to this.
	$effect(() => {
		if (audioStore.autoStartChapter === chapter) {
			audioStore.autoStartChapter = null;
			startPlayback();
		}
	});

	// Reset next-chapter prefetch state when this chapter changes (new page).
	// Only reset if the prefetch belongs to neither the current chapter
	// (about to be consumed by startPlayback) nor the next chapter (still valid).
	// Any other value means stale data from a previous page.
	$effect(() => {
		const prefetchedFor = audioStore.nextChapterPrefetched;
		if (
			prefetchedFor !== null &&
			prefetchedFor !== chapter &&
			prefetchedFor !== (nextChapter ?? null)
		) {
			audioStore.resetNextPrefetch();
		}
	});

	// Close panels on Escape.
	function handleKeyDown(e: KeyboardEvent) {
		if (e.key === 'Escape') {
			if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
			else { stopSample(); showVoicePanel = false; }
		}
	}

	// ── 90% pre-fetch trigger ─────────────────────────────────────────────────
	// Watch playback progress; when >= 90% of current chapter, pre-generate
	// the next chapter's audio so it's ready when we navigate.
	$effect(() => {
		const ct = audioStore.currentTime;
		const dur = audioStore.duration;
		const isCurrentlyPlaying = audioStore.isCurrentChapter(slug, chapter);

		if (
			!isCurrentlyPlaying ||
			!audioStore.autoNext ||
			nextChapter === null ||
			nextChapter === undefined ||
			dur <= 0 ||
			ct / dur < 0.9 ||
			audioStore.nextStatus !== 'none'
		) {
			return;
		}

		// Trigger exactly once (nextStatus transitions away from 'none')
		prefetchNext();
	});

	// ── Pseudo progress helpers ────────────────────────────────────────────────
	let progressRafId = 0;

	function startProgress() {
		audioStore.progress = 0;
		let last = performance.now();

		function tick(now: number) {
			const dt = (now - last) / 1000;
			last = now;
			let rate: number;
			if (audioStore.progress < 30) rate = 4;
			else if (audioStore.progress < 60) rate = 12;
			else if (audioStore.progress < 80) rate = 4;
			else rate = 0.3;

			audioStore.progress = Math.min(audioStore.progress + rate * dt, 99);
			if (audioStore.progress < 99) {
				progressRafId = requestAnimationFrame(tick);
			}
		}
		progressRafId = requestAnimationFrame(tick);
	}

	function stopProgress() {
		if (progressRafId) {
			cancelAnimationFrame(progressRafId);
			progressRafId = 0;
		}
	}

	async function finishProgress() {
		stopProgress();
		const step = () => {
			audioStore.progress = Math.min(audioStore.progress + 8, 100);
			if (audioStore.progress < 100) {
				progressRafId = requestAnimationFrame(step);
			}
		};
		progressRafId = requestAnimationFrame(step);
		await new Promise((r) => setTimeout(r, 200));
		stopProgress();
	}

	// ── Next-chapter pseudo-progress helpers ──────────────────────────────────
	let nextProgressRafId = 0;

	function startNextProgress() {
		audioStore.nextProgress = 0;
		let last = performance.now();

		function tick(now: number) {
			const dt = (now - last) / 1000;
			last = now;
			let rate: number;
			if (audioStore.nextProgress < 30) rate = 4;
			else if (audioStore.nextProgress < 60) rate = 12;
			else if (audioStore.nextProgress < 80) rate = 4;
			else rate = 0.3;

			audioStore.nextProgress = Math.min(audioStore.nextProgress + rate * dt, 99);
			if (audioStore.nextProgress < 99) {
				nextProgressRafId = requestAnimationFrame(tick);
			}
		}
		nextProgressRafId = requestAnimationFrame(tick);
	}

	function stopNextProgress() {
		if (nextProgressRafId) {
			cancelAnimationFrame(nextProgressRafId);
			nextProgressRafId = 0;
		}
	}

	// ── API helpers ────────────────────────────────────────────────────────────

	type PresignResult =
		| { ready: true; url: string }
		| { ready: false; enqueued: boolean }; // enqueued=true → presign already POSTed

	async function tryPresign(
		targetSlug: string,
		targetChapter: number,
		targetVoice: string
	): Promise<PresignResult> {
		const params = new URLSearchParams({
			slug: targetSlug,
			n: String(targetChapter),
			voice: targetVoice
		});
		const res = await fetch(`/api/presign/audio?${params}`);
		// 202: presign endpoint already triggered TTS — skip the POST, go straight to polling.
		// 404: legacy fallback (should no longer occur after endpoint change).
		if (res.status === 202) return { ready: false, enqueued: true };
		if (res.status === 404) return { ready: false, enqueued: false };
		if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
		const data = (await res.json()) as { url: string };
		return { ready: true, url: data.url };
	}

	type AudioStatusResponse =
		| { status: 'done' }
		| { status: 'pending' | 'generating'; job_id: string }
		| { status: 'idle' }
		| { status: 'failed'; error?: string };

	/**
	 * Poll GET /api/audio/status/[slug]/[n]?voice=... every `intervalMs` ms
	 * until status is "done" or "failed" (or the caller cancels via signal).
	 *
	 * Returns the final status response, or throws on network error / cancellation.
	 */
	async function pollAudioStatus(
		targetSlug: string,
		targetChapter: number,
		targetVoice: string,
		intervalMs = 2000,
		signal?: AbortSignal
	): Promise<AudioStatusResponse> {
		const qs = new URLSearchParams();
		if (targetVoice) qs.set('voice', targetVoice);
		const url = `/api/audio/status/${targetSlug}/${targetChapter}?${qs.toString()}`;

		while (true) {
			if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');

			const res = await fetch(url, { signal });
			if (!res.ok) throw new Error(`Status poll HTTP ${res.status}`);
			const data = (await res.json()) as AudioStatusResponse;

			if (data.status === 'done' || data.status === 'failed') {
				return data;
			}

			// Still pending/generating — wait then retry.
			await new Promise<void>((resolve, reject) => {
				const timer = setTimeout(resolve, intervalMs);
				signal?.addEventListener('abort', () => {
					clearTimeout(timer);
					reject(new DOMException('Aborted', 'AbortError'));
				});
			});
		}
	}

	// ── Pre-fetch next chapter ─────────────────────────────────────────────────

	async function prefetchNext() {
		if (nextChapter === null || nextChapter === undefined) return;
		if (audioStore.nextStatus !== 'none') return; // already running or done

		const voice = audioStore.voice;

		audioStore.nextStatus = 'prefetching';
		audioStore.nextChapterPrefetched = nextChapter;
		startNextProgress();

		try {
			// Fast path: already generated
			const presignResult = await tryPresign(slug, nextChapter, voice);
			if (presignResult.ready) {
				stopNextProgress();
				audioStore.nextProgress = 100;
				audioStore.nextAudioUrl = presignResult.url;
				audioStore.nextStatus = 'prefetched';
				return;
			}

			// Slow path: trigger generation (or skip POST if presign already enqueued).
			if (!presignResult.enqueued) {
				const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
					method: 'POST',
					headers: { 'Content-Type': 'application/json' },
					body: JSON.stringify({ voice })
				});
				if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);

				if (res.status === 200) {
					// Body is { status: 'done' } — audio confirmed in MinIO. Presign it.
					await res.body?.cancel();
					stopNextProgress();
					audioStore.nextProgress = 100;
					const doneUrl = await tryPresign(slug, nextChapter, voice);
					if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404');
					audioStore.nextAudioUrl = doneUrl.url;
					audioStore.nextStatus = 'prefetched';
					return;
				}
				// 202: generation enqueued — fall through to poll.
			}

			// Poll until done (covers both: presign-enqueued and POST-enqueued paths).
			const final = await pollAudioStatus(slug, nextChapter, voice);
			stopNextProgress();
			audioStore.nextProgress = 100;

			if (final.status === 'failed') {
				throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
			}

			// Audio is ready in MinIO — get a direct presigned URL.
			const doneUrl = await tryPresign(slug, nextChapter, voice);
			if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404');

			audioStore.nextAudioUrl = doneUrl.url;
			audioStore.nextStatus = 'prefetched';
		} catch {
			stopNextProgress();
			audioStore.nextStatus = 'failed';
		}
	}

	// ── Media Session ──────────────────────────────────────────────────────────
	// Sets the OS-level media metadata so the book cover, title, and chapter
	// appear on the phone lock screen / notification center.

	function setMediaSession() {
		if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;

		const artwork: MediaImage[] = cover
			? [
					{ src: cover, sizes: '512x512', type: 'image/jpeg' },
					{ src: cover, sizes: '256x256', type: 'image/jpeg' }
			  ]
			: [];

		navigator.mediaSession.metadata = new MediaMetadata({
			title: chapterTitle || `Chapter ${chapter}`,
			artist: bookTitle,
			album: bookTitle,
			artwork
		});
	}

	// ── Core play flow ─────────────────────────────────────────────────────────

	async function startPlayback() {
		const voice = audioStore.voice;

		// Populate store metadata so layout + mini-bar have track info.
		audioStore.slug = slug;
		audioStore.chapter = chapter;
		audioStore.chapterTitle = chapterTitle;
		audioStore.bookTitle = bookTitle;
		audioStore.cover = cover;
		audioStore.chapters = chapters;
		if (voices.length > 0) audioStore.voices = voices;

		// Update OS media session (lock screen / notification center).
		setMediaSession();

		audioStore.status = 'loading';
		audioStore.errorMsg = '';

		try {
			// Fast path A: pre-fetch already confirmed audio is in MinIO for THIS chapter.
			// Re-presign instead of using the cached URL — it may have expired if the
			// user paused for a while between the prefetch and actually reaching this chapter.
			if (
				audioStore.nextStatus === 'prefetched' &&
				audioStore.nextChapterPrefetched === chapter
			) {
				// Consume the pre-fetch state first so it doesn't carry over on error.
				audioStore.resetNextPrefetch();
				// Fresh presign — audio is confirmed in MinIO so this is a fast, cheap call.
				const presigned = await tryPresign(slug, chapter, voice);
				if (presigned.ready) {
					audioStore.audioUrl = presigned.url;
					audioStore.status = 'ready';
					// Don't restore saved time for auto-next; position is 0.
					// Immediately start pre-generating the chapter after this one.
					maybeStartPrefetch();
					return;
				}
				// Presign returned not-ready (race: MinIO object vanished?).
				// Fall through to the normal slow path below.
			}

			// Fast path B: audio already in MinIO (presign check).
			const presignResult = await tryPresign(slug, chapter, voice);
			if (presignResult.ready) {
				audioStore.audioUrl = presignResult.url;
				audioStore.status = 'ready';
				// Restore last saved position after the audio element loads
				restoreSavedAudioTime();
				// Immediately start pre-generating the next chapter in background.
				maybeStartPrefetch();
				return;
			}

			// Slow path: audio not yet in MinIO.
			//
			// For Kokoro / PocketTTS: always use the streaming endpoint so audio
			// starts playing within seconds. The stream handler checks MinIO first
			// (fast redirect if already cached) and otherwise generates + uploads
			// concurrently. Even if the async runner is already working on this
			// chapter, the stream will redirect to MinIO the moment the runner
			// finishes — no harmful double-generation occurs because the backend
			// deduplications via AudioExists on the next request.
			if (!voice.startsWith('cfai:')) {
				// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
				// WAV (PCM) is natively supported on all platforms including iOS Safari.
				// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
				const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
				const format = isPocketTTS ? 'wav' : 'mp3';
				const qs = new URLSearchParams({ voice, format });
				const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
				// HEAD probe: check paywall without triggering generation.
				const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
				if (headRes?.status === 402) {
					audioStore.status = 'idle';
					onProRequired?.();
					return;
				}
				audioStore.audioUrl = streamUrl;
				audioStore.status = 'ready';
				maybeStartPrefetch();
				return;
			}

			// CF AI voices: use preview/swap strategy.
			// 1. Fetch a short ~1-2 min preview clip from the first text chunk
			//    so playback starts immediately — no more waiting behind a spinner.
			// 2. Meanwhile keep polling the full audio job; when it finishes,
			//    swap the <audio> src to the full URL preserving currentTime.
			audioStore.status = 'generating';
			audioStore.isPreview = false;
			startProgress();

			// Kick off the full audio generation task in the background
			// (presignResult.enqueued=true means the presign endpoint already did it).
			if (!presignResult.enqueued) {
				const res = await fetch(`/api/audio/${slug}/${chapter}`, {
					method: 'POST',
					headers: { 'Content-Type': 'application/json' },
					body: JSON.stringify({ voice })
				});

				if (res.status === 402) {
					audioStore.status = 'idle';
					stopProgress();
					onProRequired?.();
					return;
				}

				if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);

				if (res.status === 200) {
					// Already cached — fast path: presign and play directly.
					await res.body?.cancel();
					await finishProgress();
					const doneUrl = await tryPresign(slug, chapter, voice);
					if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
					audioStore.isPreview = false;
					audioStore.audioUrl = doneUrl.url;
					audioStore.status = 'ready';
					maybeStartPrefetch();
					return;
				}
				// 202 accepted — fall through: start preview while runner generates
			}

			// Fetch the preview clip (first ~1-2 min chunk).
			// Use an AbortController so we can cancel the background polling if the
			// user navigates away or stops playback before the full audio is ready.
			const previewAbort = new AbortController();
			const qs = new URLSearchParams({ voice });
			const previewUrl = `/api/audio-preview/${slug}/${chapter}?${qs}`;

			try {
				const previewRes = await fetch(previewUrl, { signal: previewAbort.signal });
				if (previewRes.status === 402) {
					audioStore.status = 'idle';
					stopProgress();
					onProRequired?.();
					return;
				}
				if (!previewRes.ok) throw new Error(`Preview failed: HTTP ${previewRes.status}`);

				// The backend responded with the MP3 bytes (or a redirect to MinIO).
				// Build a blob URL so we can swap it out later without reloading the page.
				const previewBlob = await previewRes.blob();
				const previewBlobUrl = URL.createObjectURL(previewBlob);

				audioStore.isPreview = true;
				audioStore.audioUrl = previewBlobUrl;
				audioStore.status = 'ready';
				// Don't restore saved time here — preview is always from 0.
				// Kick off prefetch of next chapter in the background.
				maybeStartPrefetch();
			} catch (previewErr: unknown) {
				if (previewErr instanceof DOMException && previewErr.name === 'AbortError') return;
				// Preview failed — fall through to the spinner (old behaviour).
				// We'll wait for the full audio to finish instead.
				audioStore.isPreview = false;
			}

			// Background: poll for full audio; when done, swap src preserving position.
			try {
				const final = await pollAudioStatus(slug, chapter, voice, 2000, previewAbort.signal);
				if (final.status === 'failed') {
					throw new Error(
						`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
					);
				}

				await finishProgress();

				const doneUrl = await tryPresign(slug, chapter, voice);
				if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');

				// Swap: save currentTime → update URL → seek to saved position.
				const savedTime = audioStore.currentTime;
				const blobUrlToRevoke = audioStore.audioUrl; // capture before overwrite
				audioStore.isPreview = false;
				audioStore.audioUrl = doneUrl.url;
				// If we never started a preview (preview fetch failed), switch to ready now.
				if (audioStore.status !== 'ready') audioStore.status = 'ready';
				// The layout $effect will load the new src and auto-play from 0.
				// We seek back to savedTime after a short delay to let the element
				// attach the new source before accepting a seek.
				if (savedTime > 0) {
					setTimeout(() => {
						audioStore.seekRequest = savedTime;
					}, 300);
				}
				// Revoke the preview blob URL to free memory.
				// (We need to wait until the new src is playing; 2 s is safe.)
				setTimeout(() => {
					if (blobUrlToRevoke.startsWith('blob:')) {
						URL.revokeObjectURL(blobUrlToRevoke);
					}
				}, 2000);
				maybeStartPrefetch();
			} catch (pollErr: unknown) {
				if (pollErr instanceof DOMException && pollErr.name === 'AbortError') return;
				throw pollErr;
			}
		} catch (e) {
			stopProgress();
			audioStore.progress = 0;
			audioStore.status = 'error';
			audioStore.errorMsg = String(e);
		}
	}

	/**
	 * Start pre-fetching the next chapter if autoNext is on, there is a next
	 * chapter, and no prefetch is already running or completed.
	 * Called as soon as current-chapter playback begins so that the next
	 * chapter's audio is ready before we need it (seamless transition).
	 * The 90%-mark $effect acts as a fallback for cases where autoNext is
	 * toggled on mid-playback.
	 */
	function maybeStartPrefetch() {
		if (
			audioStore.autoNext &&
			nextChapter !== null &&
			nextChapter !== undefined &&
			audioStore.nextStatus === 'none'
		) {
			prefetchNext();
		}
	}

	/**
	 * Fetch the saved audio time for this chapter and seek to it after a short
	 * delay (to allow the audio element to load the source).
	 */
	async function restoreSavedAudioTime() {
		try {
			const params = new URLSearchParams({ slug, chapter: String(chapter) });
			const res = await fetch(`/api/progress/audio-time?${params}`);
			if (!res.ok) return;
			const data = (await res.json()) as { audioTime: number | null };
			if (data.audioTime && data.audioTime > 5) {
				// Small delay to let the <audio> element fully load the src before seeking
				setTimeout(() => {
					audioStore.seekRequest = data.audioTime as number;
				}, 300);
			}
		} catch {
			// Non-critical — silently ignore
		}
	}

	async function handlePlay() {
		const isCurrent = audioStore.isCurrentChapter(slug, chapter);

		// Already loaded this chapter: toggle play/pause.
		if (isCurrent && audioStore.status === 'ready') {
			audioStore.toggleRequest = (audioStore.toggleRequest ?? 0) + 1;
			return;
		}

		// Not yet loaded — start the full flow.
		await startPlayback();

		// Track audio play after successful load.
		if (audioStore.status === 'ready') {
			window.umami?.track('audio_played', { slug, chapter });
		}
	}

	function formatDuration(s: number): string {
		if (!isFinite(s) || s <= 0) return '--:--';
		return formatTime(s);
	}

	function formatTime(s: number): string {
		if (!isFinite(s) || s < 0) return '0:00';
		const m = Math.floor(s / 60);
		const sec = Math.floor(s % 60);
		return `${m}:${sec.toString().padStart(2, '0')}`;
	}

	// ── Sleep timer ────────────────────────────────────────────────────────────
	const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes

	let _tick = $state(0);
	$effect(() => {
		if (!audioStore.sleepUntil) return;
		const id = setInterval(() => { _tick++; }, 1000);
		return () => clearInterval(id);
	});

	let sleepRemainingSec = $derived.by(() => {
		_tick; // subscribe to tick updates
		if (!audioStore.sleepUntil) return 0;
		return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
	});

	function cycleSleepTimer() {
		// Currently: no timer active at all
		if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
			audioStore.sleepAfterChapter = true;
			return;
		}
		// Currently: end-of-chapter mode — move to 15m
		if (audioStore.sleepAfterChapter) {
			audioStore.sleepAfterChapter = false;
			audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
			return;
		}
		// Currently: timed mode — cycle to next or turn off
		const remaining = audioStore.sleepUntil - Date.now();
		const currentMin = Math.round(remaining / 60000);
		const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
		if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
			audioStore.sleepUntil = 0;
		} else {
			audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
		}
	}

	function formatSleepRemaining(secs: number): string {
		if (secs <= 0) return '';
		const m = Math.floor(secs / 60);
		const s = secs % 60;
		if (m > 0) return `${m}m`;
		return `${s}s`;
	}

	// ── Compact player helpers ─────────────────────────────────────────────────
	const playPct = $derived(
		audioStore.duration > 0 ? (audioStore.currentTime / audioStore.duration) * 100 : 0
	);

	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; }
</script>

<svelte:window onkeydown={handleKeyDown} />

<!-- ── Voice row snippet (reused in both engine sections) ──────────────── -->
{#snippet voiceRow(v: import('$lib/types').Voice)}
	<div
		class={cn('flex items-center gap-2 px-3 py-2 hover:bg-(--color-surface-2) transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-(--color-brand)/10')}
		role="button"
		tabindex="0"
		onclick={() => selectVoice(v.id)}
		onkeydown={(e) => e.key === 'Enter' && selectVoice(v.id)}
	>
		<!-- Selected indicator -->
		<div class="w-4 flex-shrink-0">
			{#if audioStore.voice === v.id}
				<svg class="w-3.5 h-3.5 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
					<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
				</svg>
			{/if}
		</div>

		<!-- Voice name -->
		<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-(--color-brand) font-medium' : 'text-(--color-text)')}>
			{voiceLabel(v)}
		</span>
		<span class="text-(--color-muted) opacity-60 text-xs font-mono">{v.id}</span>

		<!-- Sample play button -->
		<Button
			variant="ghost"
			size="icon"
			class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted) hover:text-(--color-text)')}
			onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
			title={samplePlayingVoice === v.id ? m.reader_voice_stop_sample() : m.reader_voice_play_sample()}
			aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
		>
			{#if samplePlayingVoice === v.id}
				<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
					<path d="M6 6h12v12H6z"/>
				</svg>
			{:else}
				<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
					<path d="M8 5v14l11-7z"/>
				</svg>
			{/if}
		</Button>
	</div>
{/snippet}

<!-- ── Standard player ─────────────────────────────────────────────────────── -->

{#if
	(!audioStore.isCurrentChapter(slug, chapter) && !audioStore.active) ||
	(audioStore.isCurrentChapter(slug, chapter) && (audioStore.status === 'idle' || audioStore.status === 'error'))
}
<!-- ── Idle / not-yet-started pill ─────────────────────────────────────────── -->
<div class="px-3 py-2.5">
	{#if audioStore.isCurrentChapter(slug, chapter) && audioStore.status === 'error'}
		<p class="text-(--color-danger) text-xs mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
	{/if}
	<div class="flex items-center gap-3">
		<!-- Big play button -->
		<button
			type="button"
			onclick={handlePlay}
			class="w-11 h-11 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0 shadow-sm"
			aria-label={m.reader_play_narration()}
		>
			<svg class="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
				<path d="M8 5v14l11-7z"/>
			</svg>
		</button>

		<!-- Track info -->
		<div class="flex-1 min-w-0">
			<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
				{m.reader_play_narration()}
			</p>
			<div class="flex items-center gap-1.5 mt-0.5">
				<!-- Voice indicator -->
				{#if voices.length > 0}
					<button
						type="button"
						onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
						class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
						title={m.reader_change_voice()}
					>
						<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
							<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
						</svg>
						<span class="max-w-[90px] truncate">{voiceLabel(audioStore.voice)}</span>
						<svg class={cn('w-2.5 h-2.5 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
							<path d="M7 10l5 5 5-5z"/>
						</svg>
					</button>
				{/if}
				<!-- Estimated duration -->
				{#if estimatedMinutes > 0}
					{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
					<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
				{/if}
			</div>
		</div>

		<!-- Chapters button (right side) -->
		{#if chapters.length > 0}
			<button
				type="button"
				onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
				class={cn('flex items-center gap-1 px-2 py-1.5 rounded-md text-xs transition-colors flex-shrink-0', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)')}
				title="Browse chapters"
			>
				<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="M4 6h16M4 10h16M4 14h10"/>
				</svg>
				<span class="hidden sm:inline">Chapters</span>
			</button>
		{/if}
	</div>

	<!-- Voice selector panel (inline below pill) -->
	{#if showVoicePanel && voices.length > 0}
		<div class="mt-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
			<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
				<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
				<Button
					variant="ghost"
					size="icon"
					class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
					onclick={() => { stopSample(); showVoicePanel = false; }}
					aria-label={m.reader_close_voice_panel()}
				>
					<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="M6 18L18 6M6 6l12 12"/>
					</svg>
				</Button>
			</div>
			<div class="max-h-64 overflow-y-auto">
				{#if kokoroVoices.length > 0}
					<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
						<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
					</div>
					{#each kokoroVoices as v (v.id)}{@render voiceRow(v)}{/each}
				{/if}
				{#if pocketVoices.length > 0}
					<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
						<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
					</div>
					{#each pocketVoices as v (v.id)}{@render voiceRow(v)}{/each}
				{/if}
				{#if cfaiVoices.length > 0}
					<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
						<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
					</div>
					{#each cfaiVoices as v (v.id)}{@render voiceRow(v)}{/each}
				{/if}
			</div>
			<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
				<p class="text-xs text-(--color-muted)">
					{m.reader_voice_applies_next()}
					{#if voices.length > 0}
						<a
							href="/api/audio/voice-samples"
							class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
							onclick={(e) => {
								e.preventDefault();
								fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
							}}
						>{m.reader_generate_samples()}</a>
					{/if}
				</p>
			</div>
		</div>
	{/if}
</div>

{:else}
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
<div class="p-4">
	<div class="flex items-center justify-end gap-2 mb-3">
		<!-- Chapter picker button -->
		{#if audioStore.chapters.length > 0}
			<Button
				variant="ghost"
				size="sm"
				onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
				class={cn('gap-1.5 text-xs', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
				title="Browse chapters"
			>
				<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="M4 6h16M4 10h16M4 14h10"/>
				</svg>
				Chapters
			</Button>
		{/if}
		<!-- Voice selector button -->
		{#if voices.length > 0}
			<Button
				variant="ghost"
				size="sm"
				onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
				class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
				title={m.reader_change_voice()}
			>
				<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
					<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
				</svg>
				<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
				<svg class={cn('w-3 h-3 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
					<path d="M7 10l5 5 5-5z"/>
				</svg>
			</Button>
		{/if}
	</div>

	<!-- ── Voice selector panel ──────────────────────────────────────────── -->
	{#if showVoicePanel && voices.length > 0}
		<div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
			<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
				<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
				<Button
					variant="ghost"
					size="icon"
					class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
					onclick={() => { stopSample(); showVoicePanel = false; }}
					aria-label={m.reader_close_voice_panel()}
				>
					<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="M6 18L18 6M6 6l12 12"/>
					</svg>
				</Button>
			</div>
			<div class="max-h-64 overflow-y-auto">
				<!-- Kokoro (GPU) section -->
				{#if kokoroVoices.length > 0}
					<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
						<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
					</div>
					{#each kokoroVoices as v (v.id)}
						{@render voiceRow(v)}
					{/each}
				{/if}

				<!-- Pocket TTS (CPU) section -->
				{#if pocketVoices.length > 0}
					<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
						<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
					</div>
					{#each pocketVoices as v (v.id)}
						{@render voiceRow(v)}
					{/each}
				{/if}

				<!-- Cloudflare AI section -->
				{#if cfaiVoices.length > 0}
					<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
						<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
					</div>
					{#each cfaiVoices as v (v.id)}
						{@render voiceRow(v)}
					{/each}
				{/if}
			</div>
			<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
				<p class="text-xs text-(--color-muted)">
					{m.reader_voice_applies_next()}
					{#if voices.length > 0}
						<a
							href="/api/audio/voice-samples"
							class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
							onclick={(e) => {
								e.preventDefault();
								fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
							}}
						>{m.reader_generate_samples()}</a>
					{/if}
				</p>
			</div>
		</div>
	{/if}


	{#if audioStore.isCurrentChapter(slug, chapter)}
		<!-- ── This chapter is the active one (non-idle states) ── -->

		{#if audioStore.status === 'loading'}
			<Button variant="default" size="sm" disabled>
				<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
					<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
					<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
				</svg>
				{m.player_loading()}
			</Button>

		{:else if audioStore.status === 'generating'}
			<div class="space-y-2">
				<p class="text-xs text-(--color-muted)">{m.reader_generating_narration()}</p>
				<div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
					<div
						class="h-full bg-(--color-brand) rounded-full transition-none"
						style="width: {audioStore.progress}%"
					></div>
				</div>
				<p class="text-xs text-(--color-muted) opacity-60 tabular-nums">{Math.round(audioStore.progress)}%</p>
			</div>

		{:else if audioStore.status === 'ready'}
			<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
			<div class="flex items-center justify-between gap-3">
				<div class="flex items-center gap-2 text-xs text-(--color-muted)">
				{#if audioStore.isPlaying}
					<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
						<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
					</svg>
					<span>{m.reader_playing()}</span>
				{:else}
					<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
						<path d="M8 5v14l11-7z"/>
					</svg>
					<span>{m.reader_paused()}</span>
				{/if}
					<span class="tabular-nums text-(--color-muted) opacity-60">
						{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
					</span>
				</div>
			</div>

			<!-- Warm-up next chapter (only when autoNext is off and next exists) -->
			{#if !audioStore.autoNext && nextChapter != null}
				<div class="mt-2 flex items-center gap-2">
					{#if audioStore.nextStatus === 'none'}
						<button
							type="button"
							onclick={prefetchNext}
							class="flex items-center gap-1.5 text-xs text-(--color-muted) hover:text-(--color-brand) transition-colors"
							title="Pre-generate audio for the next chapter"
						>
							<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
								<path d="M7 2l10 10L7 22z"/>
							</svg>
							Warm up Ch. {nextChapter}
						</button>
					{:else if audioStore.nextStatus === 'prefetching'}
						<span class="flex items-center gap-1.5 text-xs text-(--color-muted)">
							<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
								<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
								<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
							</svg>
							Warming up Ch. {nextChapter}…
						</span>
					{:else if audioStore.nextStatus === 'prefetched'}
						<span class="flex items-center gap-1.5 text-xs text-(--color-brand)">
							<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
								<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
							</svg>
							Ch. {nextChapter} ready
						</span>
					{:else if audioStore.nextStatus === 'failed'}
						<span class="flex items-center gap-1.5 text-xs text-red-400">
							<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
								<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
							</svg>
							Warm-up failed
						</span>
					{/if}
				</div>
			{/if}
		{/if}

	{:else if audioStore.active}
		<!-- ── A different chapter is currently playing ── -->
		<div class="flex items-center justify-between gap-3">
			<p class="text-xs text-(--color-muted)">
				{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
			</p>
			<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
				{m.reader_load_this_chapter()}
			</Button>
		</div>
	{/if}
</div>
{/if}
{/if}

<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
     Rendered as a top-level sibling (outside all player containers) so that
     the fixed inset-0 positioning is never clipped by overflow-hidden or
     border-radius on any ancestor wrapping the AudioPlayer component.        -->
{#if showChapterPanel && audioStore.chapters.length > 0}
	<!-- svelte-ignore a11y_no_static_element_interactions -->
	<div
		class="fixed inset-0 z-[60] flex flex-col"
		style="background: var(--color-surface);"
	>
		<!-- Header -->
		<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
			<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
			<button
				type="button"
				onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
				class="w-9 h-9 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
				aria-label="Close chapter picker"
			>
				<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
				</svg>
			</button>
		</div>
		<!-- Search -->
		<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
			<div class="relative">
				<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
				</svg>
				<input
					type="search"
					placeholder="Search chapters…"
					bind:value={chapterSearch}
					class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
				/>
			</div>
		</div>
		<!-- Chapter list -->
		<div class="flex-1 overflow-y-auto">
			{#each filteredChapters as ch (ch.number)}
				<button
					type="button"
					onclick={() => playChapter(ch.number)}
					class={cn(
						'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
						ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
					)}
				>
					<!-- Chapter number badge -->
					<span class={cn(
						'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
						ch.number === chapter
							? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
							: 'border-(--color-border) text-(--color-muted)'
					)}>{ch.number}</span>
					<!-- Title -->
					<span class={cn(
						'flex-1 text-sm truncate',
						ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
					)}>{ch.title || `Chapter ${ch.number}`}</span>
					<!-- Now-playing indicator -->
					{#if ch.number === chapter}
						<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
							<path d="M8 5v14l11-7z"/>
						</svg>
					{/if}
				</button>
			{/each}
			{#if filteredChapters.length === 0}
				<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
			{/if}
		</div>
	</div>
{/if}

<!-- ── Float player overlay ──────────────────────────────────────────────────
     Rendered outside all containers so fixed positioning is never clipped.
     Visible when playerStyle='float' and audio is active for this chapter.  -->
{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
	<!-- svelte-ignore a11y_no_static_element_interactions -->
	<div
		class="fixed z-[55] select-none"
		style="
			bottom: calc(4.5rem + {-floatPos.y}px);
			right: calc(1rem + {-floatPos.x}px);
			touch-action: none;
		"
	>
		<div class="w-64 rounded-2xl bg-(--color-surface) border border-(--color-border) shadow-2xl overflow-hidden">
			<!-- Drag handle + title row -->
			<!-- svelte-ignore a11y_no_static_element_interactions -->
			<div
				class="flex items-center gap-2 px-3 pt-2.5 pb-1 cursor-grab active:cursor-grabbing"
				onpointerdown={onFloatPointerDown}
				onpointermove={onFloatPointerMove}
				onpointerup={onFloatPointerUp}
				onpointercancel={onFloatPointerUp}
			>
				<!-- Drag grip dots -->
				<svg class="w-3.5 h-3.5 text-(--color-muted)/50 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
					<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
					<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
					<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
				</svg>
				<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
					{audioStore.chapterTitle || `Chapter ${audioStore.chapter}`}
				</span>
				<!-- Status dot -->
				{#if audioStore.isPlaying}
					<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pulse"></span>
				{/if}
			</div>

			<!-- Seek bar -->
			<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
			<div
				role="none"
				class="mx-3 mb-2 h-1 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
				onclick={seekFromBar}
			>
				<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
			</div>

			<!-- Controls row -->
			<div class="flex items-center gap-1 px-3 pb-2.5">
				<!-- Skip back 15s -->
				<button
					type="button"
					onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
					class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
					title="-15s"
				>
					<svg class="w-4 h-4" 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"/>
					</svg>
				</button>

				<!-- Play/pause -->
				<button
					type="button"
					onclick={() => { audioStore.toggleRequest++; }}
					class="w-9 h-9 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0"
				>
					{#if audioStore.isPlaying}
						<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
					{:else}
						<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
					{/if}
				</button>

				<!-- Skip forward 30s -->
				<button
					type="button"
					onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
					class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
					title="+30s"
				>
					<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
						<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
					</svg>
				</button>

				<!-- Time -->
				<span class="flex-1 text-[11px] text-center tabular-nums text-(--color-muted)">
					{formatTime(audioStore.currentTime)}
					<span class="opacity-50">/</span>
					{formatDuration(audioStore.duration)}
				</span>

				<!-- Speed -->
				<span class="text-[11px] font-medium tabular-nums text-(--color-muted) flex-shrink-0">
					{audioStore.speed}×
				</span>
			</div>
		</div>
	</div>
{/if}
