diff --git a/src/lib/HeardleGame.svelte b/src/lib/HeardleGame.svelte index 2a33e37..bce98d8 100644 --- a/src/lib/HeardleGame.svelte +++ b/src/lib/HeardleGame.svelte @@ -9,7 +9,8 @@ InformationCircle, QuestionMarkCircle, Sun, - Moon + Moon, + ArrowPath } from 'svelte-hero-icons'; declare const SC: any; @@ -168,13 +169,10 @@ let fullDuration = 0; let skipInProgress = false; - + let isWarmingUp = false; let showHowTo = false; let showInfo = false; - let darkMode = - typeof window !== 'undefined' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - : false; + let darkMode = false; let userInput = ''; let suggestions: Track[] = []; let selectedTrack: Track | null = null; @@ -195,13 +193,12 @@ } // ─── FILL%&NEXTSEGMENT ─────────────────────────────────────────────────── - let fillPercent = 0; - $: { + $: fillPercent = (() => { const raw = gameOver ? (currentPosition / fullDuration) * 100 : (currentPosition / TOTAL_MS) * 100; - fillPercent = raw > 100 ? 100 : raw; - } + return Math.min(raw, 100); + })(); $: nextIncrementSec = attemptCount < SEGMENT_INCREMENTS.length - 1 ? SEGMENT_INCREMENTS[attemptCount + 1] : 0; @@ -215,6 +212,7 @@ skipInProgress = false; // clear guard once new snippet starts clearInterval(progressInterval); progressInterval = setInterval(() => { + if (!widget) return; widget.getPosition((pos: number) => { currentPosition = pos; }); @@ -227,8 +225,24 @@ clearTimeout(snippetTimeout); } + function ensurePlayState() { + if (!widget) return; + widget.isPaused((paused: boolean) => { + if (!paused && !isPlaying) { + // Widget is playing but our state says it's not + startPolling(); + } else if (paused && isPlaying) { + // Widget is paused but our state says it's playing + stopAllTimers(); + } + }); + } + // ─── WIDGET SET‑UP ─────────────────────────────────────────────────────────── onMount(async () => { + // Initialize dark mode + darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; + // load SC API if missing if (typeof window.SC === 'undefined') { await new Promise((resolve, reject) => { @@ -252,35 +266,65 @@ // READY widget.bind(SC.Widget.Events.READY, () => { - widget.getDuration((d: number) => (fullDuration = d)); - widget.getCurrentSound((sound: any) => { - artworkUrl = sound.artwork_url || ''; + widget.getDuration((d: number) => { + fullDuration = d; }); - // warm up + widget.getCurrentSound((sound: any) => { + artworkUrl = sound?.artwork_url || ''; + }); + // warm up - play/pause to enable mobile autoplay with longer delays + isWarmingUp = true; setTimeout(() => { widget.play(); - widget.pause(); - widget.seekTo(0); - loading = false; - widgetReady = true; - }, 750); + setTimeout(() => { + widget.pause(); + setTimeout(() => { + widget.seekTo(0); + loading = false; + widgetReady = true; + // Ensure we're in stopped state + isPlaying = false; + currentPosition = 0; + isWarmingUp = false; + }, 200); + }, 300); + }, 500); + }); + + // PLAY + widget.bind(SC.Widget.Events.PLAY, () => { + // Ignore warmup events + if (isWarmingUp) return; + + // Always sync state when widget starts playing + if (!isPlaying) { + startPolling(); + } }); // PAUSE widget.bind(SC.Widget.Events.PAUSE, () => { + // Ignore warmup events + if (isWarmingUp) return; + + // Skip logic takes priority if (skipInProgress) { - stopAllTimers(); // clean up polling + timeouts - - playSegment(); // startPolling() in there will clear skipInProgress + stopAllTimers(); + playSegment(false); return; } - /* Normal user pause or end‑of‑snippet pause */ - stopAllTimers(); + // Always sync state when widget pauses + if (isPlaying) { + stopAllTimers(); + } }); // FINISH - widget.bind(SC.Widget.Events.FINISH, stopAllTimers); + widget.bind(SC.Widget.Events.FINISH, () => { + stopAllTimers(); + currentPosition = gameOver ? fullDuration : segmentDurations[attemptCount]; + }); // PLAY_PROGRESS widget.bind(SC.Widget.Events.PLAY_PROGRESS, (e: { currentPosition: number }) => { @@ -301,18 +345,48 @@ }); // ─── GAME ACTIONS ─────────────────────────────────────────────────────────── - function playSegment() { + function playSegment(seekToStart = true) { if (!widgetReady || loading) return; stopAllTimers(); - currentPosition = 0; - widget.seekTo(0); - widget.play(); - startPolling(); + if (seekToStart && !gameOver) { + currentPosition = 0; + widget.seekTo(0); + } + // Longer delay to ensure seek completes and state is clean + setTimeout(() => { + widget.play(); + // Don't start polling immediately, let PLAY event handle it + }, 100); } function togglePlayPause() { if (!widgetReady || loading) return; - isPlaying ? widget.pause() : playSegment(); + + // Sync state before toggling + ensurePlayState(); + + if (isPlaying) { + widget.pause(); + } else { + // When game is over, continue from current position + // When game is active, restart from beginning + playSegment(!gameOver); + } + } + + function rewindSong() { + if (!widgetReady || !gameOver) return; + const wasPlaying = isPlaying; + stopAllTimers(); + currentPosition = 0; + widget.seekTo(0); + if (wasPlaying) { + // If it was playing, continue playing from start + setTimeout(() => { + widget.play(); + // Let PLAY event handle startPolling + }, 150); + } } function toggleDark() { @@ -338,13 +412,12 @@ widget.pause(); // PAUSE handler will launch the next snippet } else { stopAllTimers(); // just in case something is still polling - currentPosition = 0; - playSegment(); + playSegment(true); // start from beginning if not playing } } function submitGuess() { - if (!widgetReady || gameOver || !userInput) return; + if (!widgetReady || gameOver || !userInput.trim()) return; if (!selectedTrack && suggestions.length) { selectedTrack = suggestions.find((t) => t.title.toLowerCase() === userInput.toLowerCase()) || @@ -362,6 +435,7 @@ ? 'on the last try! Close one!' : `in ${attemptCount} ${attemptCount === 1 ? 'try' : 'tries'}.` }`; + stopAllTimers(); widget.pause(); } else { attemptInfos = [...attemptInfos, { status: 'wrong', title: selectedTrack.title }]; @@ -374,6 +448,7 @@ function revealAnswer() { gameOver = true; message = `❌ Out of tries! It was “${currentTrack.title}.”`; + stopAllTimers(); widget.pause(); } @@ -587,17 +662,36 @@ class="relative mb-2 w-full overflow-hidden rounded border" style="height:1.25rem; border-color: {darkMode ? COLORS.background : COLORS.text}" > + {#if !gameOver} + + {#each segmentDurations as segEnd, idx} + {@const segStart = idx === 0 ? 0 : segmentDurations[idx - 1]} + {@const isUnlocked = idx <= attemptCount} +
+ {/each} + {/if} +
{#if !gameOver} + {#each boundaries as b}
{/each} {/if} @@ -607,8 +701,23 @@ {formatTime(gameOver ? fullDuration : TOTAL_MS)} - -
+ +
+ {#if gameOver} + + {/if}