Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions src/components/AppInitializer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
};
});
</script>
Expand Down
48 changes: 39 additions & 9 deletions src/components/audio/BarAudioPlayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
};
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 4 additions & 13 deletions src/components/onboarding/PasskeySplash.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand All @@ -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",
Expand All @@ -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);
Expand Down Expand Up @@ -458,15 +458,6 @@
Enter as Guest
</button>

<!-- Migration Warning -->
<div
class="mt-6 max-w-sm bg-amber-900/40 border border-amber-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-amber-200 text-[10px] font-pixel"
>
<svg class="w-3 h-3 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z" />
</svg>
<span>We're upgrading passkeys.<br/>Browse free, or use <a href="https://smol.xyz" class="underline hover:text-amber-100" target="_blank" rel="noopener">smol.xyz</a> to create.</span>
</div>
</div>
{:else if step === "username"}
<div
Expand Down
32 changes: 24 additions & 8 deletions src/components/player/GlobalPlayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,15 @@
return result;
}

// MEMORY OPTIMIZATION: Reduce collage images from 80 to 24 (12 unique x 2)
$effect(() => {
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
}
});

Expand Down Expand Up @@ -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({
Expand Down
54 changes: 44 additions & 10 deletions src/hooks/useAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
49 changes: 49 additions & 0 deletions src/hooks/useBalanceAutoRefresh.svelte.ts
Original file line number Diff line number Diff line change
@@ -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);
};
});
}
37 changes: 32 additions & 5 deletions src/stores/audio.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}

/**
Expand Down
Loading