diff --git a/src/components/AppInitializer.svelte b/src/components/AppInitializer.svelte index 6ecdd24f..3f6c84db 100644 --- a/src/components/AppInitializer.svelte +++ b/src/components/AppInitializer.svelte @@ -10,30 +10,40 @@ * - Sets up global error handlers * - Initializes telemetry (if needed) */ + import { useBalanceAutoRefresh } from "../hooks/useBalanceAutoRefresh.svelte"; + + // Global hooks + useBalanceAutoRefresh(); onMount(() => { try { // Validate environment variables - console.log('[AppInit] Validating environment configuration...'); + console.log("[AppInit] Validating environment configuration..."); validateEnvironmentOrThrow(); - console.log('[AppInit] ✅ Environment validation passed'); + console.log("[AppInit] ✅ Environment validation passed"); } catch (error) { - console.error('[AppInit] ❌ Environment validation failed:', error); + console.error("[AppInit] ❌ Environment validation failed:", error); // Don't throw - let the app try to run anyway, but log the error // The user will see errors when they try to use features } // Set up global unhandled rejection handler const handleUnhandledRejection = (event: PromiseRejectionEvent) => { - console.error('[AppInit] Unhandled promise rejection:', event.reason); + console.error( + "[AppInit] Unhandled promise rejection:", + event.reason, + ); // Don't prevent default - let browser handle it }; - window.addEventListener('unhandledrejection', handleUnhandledRejection); + window.addEventListener("unhandledrejection", handleUnhandledRejection); // Cleanup on unmount return () => { - window.removeEventListener('unhandledrejection', handleUnhandledRejection); + window.removeEventListener( + "unhandledrejection", + handleUnhandledRejection, + ); }; }); diff --git a/src/components/audio/BarAudioPlayer.svelte b/src/components/audio/BarAudioPlayer.svelte index b6026769..9e650401 100644 --- a/src/components/audio/BarAudioPlayer.svelte +++ b/src/components/audio/BarAudioPlayer.svelte @@ -269,13 +269,30 @@ `[Audio] Audio mode changed: ${isExternalAudio ? "AGGRESSIVE (external)" : "POLITE (device speakers)"}`, ); - // If we just connected to Bluetooth and were interrupted, try to resume - if (isExternalAudio && audioState.wasInterrupted) { + // CAR MODE ACTIVATION: When Bluetooth connects and we have a song loaded, START playing + // This is different from interrupt recovery - this is "I got in my car, play my music" + if (isExternalAudio && audioState.currentSong) { console.log( - "[Audio] Bluetooth connected while interrupted - attempting resume", + "[Audio] Bluetooth connected with song loaded - activating car mode", ); currentPoliteCooldown = MIN_POLITE_COOLDOWN; // Reset on device change - trackedTimeout(attemptResume, 500); + interruptionCount = 0; // Fresh start for car mode + interruptionTime = null; // Clear any cooldown + + // If we were interrupted, resume. If we weren't playing, START playing. + if (audioState.wasInterrupted) { + console.log("[Audio] Car mode: resuming interrupted playback"); + trackedTimeout(attemptResume, 300); + } else if (!audioState.playingId && audioState.currentSong.Id) { + // Not playing but have a song - auto-start for car mode + console.log("[Audio] Car mode: auto-starting playback"); + trackedTimeout(() => { + if (audioState.currentSong && !audioState.playingId) { + audioState.playingId = audioState.currentSong.Id; + audioState.playIntentId = audioState.currentSong.Id; + } + }, 300); + } } } }; @@ -598,11 +615,24 @@ console.log( "[Audio] iOS audio session ending inactive state (CarPlay reconnect?)", ); - // CarPlay reconnect - attempt resume after brief delay (skip cooldown for CarPlay) - interruptionTime = null; // Clear cooldown for CarPlay reconnect - trackedTimeout(attemptResume, 100); - trackedTimeout(attemptResume, 500); - trackedTimeout(attemptResume, 1000); + // CarPlay reconnect - CAR MODE ACTIVATION (skip cooldown) + interruptionTime = null; // Clear cooldown for CarPlay + interruptionCount = 0; // Fresh start + + // If interrupted, resume. If not playing but have song, auto-start (car mode). + if (audioState.wasInterrupted) { + trackedTimeout(attemptResume, 100); + trackedTimeout(attemptResume, 500); + trackedTimeout(attemptResume, 1000); + } else if (!audioState.playingId && audioState.currentSong?.Id) { + console.log("[Audio] CarPlay reconnect: auto-starting playback (car mode)"); + trackedTimeout(() => { + if (audioState.currentSong && !audioState.playingId) { + audioState.playingId = audioState.currentSong.Id; + audioState.playIntentId = audioState.currentSong.Id; + } + }, 300); + } }; // Handle audio context state changes (Bluetooth connection, system audio changes) diff --git a/src/components/onboarding/PasskeySplash.svelte b/src/components/onboarding/PasskeySplash.svelte index 651cb79c..b054f780 100644 --- a/src/components/onboarding/PasskeySplash.svelte +++ b/src/components/onboarding/PasskeySplash.svelte @@ -211,6 +211,7 @@ const isNoCredentials = message.includes("failed to connect wallet") || message.includes("no credentials available") || + message.includes("no passkey found") || (message.includes("credential") && message.includes("not found")); @@ -221,10 +222,9 @@ !message.includes("interaction was not allowed") && !message.includes("timed out or was not allowed") ) { - // Provide more helpful error message for no credentials case + // Provide more helpful error message based on error type if (isNoCredentials) { - error = - "No passkey found. Please create a new account instead."; + error = "No passkey found for this device. Try creating a new account."; logger.debug( LogCategory.PASSKEY, "No passkey credentials found for login", @@ -240,7 +240,7 @@ error = `Login failed: ${e.message || "Unknown error"}`; } - // Auto-clear error after 5s to reset UI state + // Auto-clear error after 5s setTimeout(() => { error = null; }, 5000); @@ -458,15 +458,6 @@ Enter as Guest - -
- - - - We're upgrading passkeys.
Browse free, or use smol.xyz to create.
-
{:else if step === "username"}
{ if (liveDiscography.length > 0 && collageImages.length === 0) { const base = shuffleArray( liveDiscography .filter((s) => s.Id) .map((s) => `${API_URL}/image/${s.Id}.png?scale=16`) - ).slice(0, 40); - collageImages = [...base, ...base]; + ).slice(0, 12); // Reduced from 40 to 12 + collageImages = [...base, ...base]; // 24 total instead of 80 } }); @@ -267,17 +268,32 @@ new Date(a.Created_At || 0).getTime(), ); - liveDiscography = smols; + // MEMORY OPTIMIZATION: Strip heavy fields from in-memory representation + // Full data (lyrics, kv_do) can be fetched on-demand when viewing song details + liveDiscography = smols.map((s) => ({ + ...s, + unique_lyrics: undefined, // Can be 5-10KB per song + style: undefined, // Style descriptions can be long + Description: s.Description?.slice(0, 200), // Truncate long descriptions + })) as Smol[]; updateTopTags(smols); isUrlStateLoaded = true; // Data ready - // Update Cache (Safe Mode) + // Update Cache (Safe Mode) - MEMORY OPTIMIZED try { // Strip heavy fields to save space (lyrics are huge) - const lightSmols = smols.slice(0, 500).map((s) => ({ - ...s, - unique_lyrics: undefined, - style: undefined, // stylistic description can be long + // Reduced from 500 to 200 songs for faster load and less memory + const lightSmols = smols.slice(0, 200).map((s) => ({ + Id: s.Id, + Title: s.Title, + Song_1: s.Song_1, + Address: s.Address, + Created_At: s.Created_At, + Tags: s.Tags, + Mint_Token: s.Mint_Token, + Mint_Amm: s.Mint_Amm, + Liked: s.Liked, + // Strip: unique_lyrics, style, Description, kv_do (these can be fetched on demand) })); const dataToSave = JSON.stringify({ diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index 80c5e7d7..a1138937 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -16,28 +16,62 @@ export function useAuthentication() { async function login() { console.log('[Auth] Login attempt...'); + const hostname = window.location.hostname; + const primaryRpId = getSafeRpId(hostname); // Clear any stale credentials before logging in clearUserAuth(); + const { account } = await import('../utils/passkey-kit'); + + // Try primary RP ID first (smol.xyz for subdomains) + console.log('[Auth] Attempting login with RP ID:', primaryRpId, 'for hostname:', hostname); + try { - const { account } = await import('../utils/passkey-kit'); - const rpId = getSafeRpId(window.location.hostname); const result = await account.get().connectWallet({ - rpId, + rpId: primaryRpId, }); console.log('[Auth] connectWallet succeeded:', { contractId: result.contractId }); - const { - rawResponse, - keyIdBase64, - contractId: cid, - } = result; - + const { rawResponse, keyIdBase64, contractId: cid } = result; await performLogin(cid, keyIdBase64, rawResponse, 'connect'); + return; } catch (err: any) { - console.error("[Auth] Login failed:", err); + console.warn("[Auth] Login failed with RP ID:", primaryRpId, "Error:", err.message); + + // Check if this might be a "no credentials" error worth retrying with fallback + const message = err.message?.toLowerCase() || ''; + const isNoCredentials = + message.includes('no credentials') || + message.includes('not found') || + message.includes('no matching credentials') || + (message.includes('credential') && message.includes('not')); + + // If on a subdomain and no credentials found, try full hostname as fallback + // (for passkeys created before the RP ID unification) + const isSubdomain = hostname.endsWith('.smol.xyz') && hostname !== 'smol.xyz'; + + if (isNoCredentials && isSubdomain && primaryRpId !== hostname) { + console.log('[Auth] Trying fallback RP ID (full hostname):', hostname); + + try { + const result = await account.get().connectWallet({ + rpId: hostname, + }); + + console.log('[Auth] Fallback succeeded with hostname RP ID:', { contractId: result.contractId }); + + const { rawResponse, keyIdBase64, contractId: cid } = result; + await performLogin(cid, keyIdBase64, rawResponse, 'connect'); + return; + } catch (fallbackErr: any) { + console.warn("[Auth] Fallback also failed:", fallbackErr.message); + // Fall through to throw original error + } + } + + // If we get here, both attempts failed (or wasn't worth retrying) throw err; } } diff --git a/src/hooks/useBalanceAutoRefresh.svelte.ts b/src/hooks/useBalanceAutoRefresh.svelte.ts new file mode 100644 index 00000000..0b1995a5 --- /dev/null +++ b/src/hooks/useBalanceAutoRefresh.svelte.ts @@ -0,0 +1,49 @@ +import { onMount } from "svelte"; +import { userState } from "../stores/user.state.svelte"; +import { updateAllBalances, isTransactionInProgress } from "../stores/balance.svelte"; + +/** + * Hook to automatically refresh balances when: + * 1. The component mounts (and user is authenticated) + * 2. The window regains focus (visibilitychange) + * 3. The user logs in (reactive to userState.contractId) + */ +export function useBalanceAutoRefresh() { + // 1. Reactive effect to fetch on auth change + $effect(() => { + if (userState.contractId && !isTransactionInProgress()) { + // Slight delay to allow any pending state to settle + setTimeout(() => { + updateAllBalances(userState.contractId); + }, 100); + } + }); + + onMount(() => { + // 2. Handle visibility change (tab focus) + const handleVisibilityChange = () => { + if (document.visibilityState === "visible" && userState.contractId) { + console.log("[Balance] Window focused, refreshing balances..."); + updateAllBalances(userState.contractId); + } + }; + + // 3. Handle window focus (sometimes visibilitychange isn't enough) + const handleFocus = () => { + if (userState.contractId) { + // Debounce slightly to avoid double-firing with visibilitychange + if (!isTransactionInProgress()) { + updateAllBalances(userState.contractId); + } + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleFocus); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleFocus); + }; + }); +} diff --git a/src/stores/audio.svelte.ts b/src/stores/audio.svelte.ts index 161a70df..8592080b 100644 --- a/src/stores/audio.svelte.ts +++ b/src/stores/audio.svelte.ts @@ -115,9 +115,21 @@ if (typeof window !== "undefined") { window.addEventListener("beforeunload", forceSaveState); } -// Cross-tab synchronization (Golfed) -const bId = crypto.randomUUID(), ch = typeof window !== 'undefined' && 'BroadcastChannel' in window && new BroadcastChannel("smol_audio_sync"); -if (ch) ch.onmessage = ({ data: d }) => d.type == 'play' && d.src !== bId && audioState.playingId && (audioState.playingId = null); +// Cross-tab synchronization +const bId = crypto.randomUUID(); +let ch: BroadcastChannel | false = false; +if (typeof window !== 'undefined' && 'BroadcastChannel' in window) { + ch = new BroadcastChannel("smol_audio_sync"); + ch.onmessage = ({ data: d }) => { + if (d.type === 'play' && d.src !== bId && audioState.playingId) { + audioState.playingId = null; + } + }; + // Clean up BroadcastChannel on page unload to prevent memory leak + window.addEventListener('pagehide', () => { + if (ch) ch.close(); + }); +} function isIOSDevice() { if (typeof navigator === 'undefined') return false; @@ -275,10 +287,25 @@ export function registerSongPrevCallback(callback: (() => void) | null) { * Set the playlist context for fallback playback * This allows audio to continue through a playlist even when navigating * to pages that don't have their own playlist (e.g., swapper, settings) + * + * MEMORY OPTIMIZATION: Only store a window of songs around the current position + * to prevent memory bloat on low-end devices. */ +const PLAYLIST_WINDOW_SIZE = 50; // 25 before + 25 after current position + export function setPlaylistContext(playlist: Smol[], currentIndex: number) { - audioState.playlist = playlist; - audioState.playlistIndex = currentIndex; + // For small playlists, store as-is + if (playlist.length <= PLAYLIST_WINDOW_SIZE * 2) { + audioState.playlist = playlist; + audioState.playlistIndex = currentIndex; + return; + } + + // For large playlists, only store a window around current position + const start = Math.max(0, currentIndex - PLAYLIST_WINDOW_SIZE); + const end = Math.min(playlist.length, currentIndex + PLAYLIST_WINDOW_SIZE); + audioState.playlist = playlist.slice(start, end); + audioState.playlistIndex = currentIndex - start; // Adjust index relative to window } /** diff --git a/src/stores/balance.svelte.ts b/src/stores/balance.svelte.ts index 90ca9c01..888a26db 100644 --- a/src/stores/balance.svelte.ts +++ b/src/stores/balance.svelte.ts @@ -35,6 +35,28 @@ export function isTransactionInProgress(): boolean { return balanceState.transactionLock; } +// --- INTERNAL HELPERS (No state/locking logic) --- + +async function _fetchKale(address: string): Promise { + const { kale } = await import('../utils/passkey-kit'); + const { result } = await kale.get().balance({ id: address }); + return result; +} + +async function _fetchXlm(address: string): Promise { + const { xlm } = await import('../utils/passkey-kit'); + const { result } = await xlm.get().balance({ id: address }); + return result; +} + +async function _fetchUsdc(address: string): Promise { + const { usdc } = await import('../utils/passkey-kit'); + const { result } = await usdc.get().balance({ id: address }); + return result; +} + +// --- EXPORTED ACTIONS --- + /** * Update KALE balance for a given address */ @@ -51,13 +73,12 @@ export async function updateContractBalance(address: string | null): Promise { return; } + // NOTE: We don't typically show a global loader for just XLM updates try { - // DYNAMIC IMPORT - const { xlm } = await import('../utils/passkey-kit'); - const { result } = await xlm.get().balance({ id: address }); + const result = await _fetchXlm(address); balanceState.xlmBalance = result; balanceState.lastUpdated = new Date(); } catch (error) { - console.error('Failed to update XLM balance:', error); + console.error('[Balance] Failed to update XLM balance:', error); balanceState.xlmBalance = null; } } @@ -105,35 +125,68 @@ export async function updateUsdcBalance(address: string | null): Promise { } try { - // DYNAMIC IMPORT - const { usdc } = await import('../utils/passkey-kit'); - const { result } = await usdc.get().balance({ id: address }); + const result = await _fetchUsdc(address); balanceState.usdcBalance = result; balanceState.lastUpdated = new Date(); } catch (error) { - console.error('Failed to update USDC balance:', error); + console.error('[Balance] Failed to update USDC balance:', error); balanceState.usdcBalance = null; } } /** - * Update all balances (KALE + XLM) for a given address + * Update all balances (KALE + XLM + USDC) for a given address + * Handles loading state globally for the batch operation. */ export async function updateAllBalances(address: string | null): Promise { + if (!address) { + console.warn('[Balance] updateAllBalances called with null address'); + return; + } + if (balanceState.transactionLock) { console.log('[Balance] Skipping all balances update - transaction in progress'); return; } + console.log('[Balance] Updating all balances for:', address.slice(0, 4)); balanceState.loading = true; + try { - await Promise.all([ - updateContractBalance(address), - updateXlmBalance(address), - updateUsdcBalance(address), + // Run fetches in parallel using internal helpers to avoid individual loading state updates + const [kaleRes, xlmRes, usdcRes] = await Promise.allSettled([ + _fetchKale(address), + _fetchXlm(address), + _fetchUsdc(address) ]); + + // Apply Results + if (kaleRes.status === 'fulfilled') { + balanceState.balance = kaleRes.value; + } else { + console.error('[Balance] KALE fetch failed:', kaleRes.reason); + balanceState.balance = null; + } + + if (xlmRes.status === 'fulfilled') { + balanceState.xlmBalance = xlmRes.value; + } else { + console.error('[Balance] XLM fetch failed:', xlmRes.reason); + balanceState.xlmBalance = null; + } + + if (usdcRes.status === 'fulfilled') { + balanceState.usdcBalance = usdcRes.value; + } else { + console.error('[Balance] USDC fetch failed:', usdcRes.reason); + balanceState.usdcBalance = null; + } + + balanceState.lastUpdated = new Date(); + // console.log('[Balance] All updated.'); + } catch (error) { - console.error('[Balance] Failed to update all balances:', error); + console.error('[Balance] Critical error in updateAllBalances:', error); } finally { balanceState.loading = false; } @@ -150,3 +203,4 @@ export function resetBalance(): void { balanceState.lastUpdated = null; balanceState.transactionLock = false; } + diff --git a/src/utils/transaction-helpers.ts b/src/utils/transaction-helpers.ts index 71fd2605..f1b65347 100644 --- a/src/utils/transaction-helpers.ts +++ b/src/utils/transaction-helpers.ts @@ -5,7 +5,7 @@ import { account, send } from "./passkey-kit"; import { getLatestSequence } from "./base"; import { getSafeRpId } from "./domains"; -import { updateContractBalance } from "../stores/balance.svelte.ts"; +import { updateAllBalances } from "../stores/balance.svelte.ts"; export interface SignAndSendOptions { keyId: string; @@ -85,8 +85,8 @@ export async function signAndSend( const result = await send(signedTx, turnstileToken); if (updateBalance && contractId) { - console.log('[SignAndSend] Updating balance...'); - await updateContractBalance(contractId); + console.log('[SignAndSend] Updating all balances...'); + await updateAllBalances(contractId); } console.log('[SignAndSend] SUCCESS:', {