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:', {