import { useCallback, useEffect, useState } from 'react'; import { AppState, StyleSheet, View } from 'react-native'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import * as SplashScreen from 'expo-splash-screen'; import { useFonts } from 'expo-font'; import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, } from '@expo-google-fonts/inter'; import { JetBrainsMono_400Regular, JetBrainsMono_500Medium, } from '@expo-google-fonts/jetbrains-mono'; import { usePlaybackSync } from '@/audio/usePlaybackSync'; import { NowPlayingHost } from '@/components/player/NowPlayingHost'; import { QuickSearchOverlay } from '@/components/search/QuickSearchOverlay'; import { useScopeLifecycle } from '@/scope/useScopeLifecycle'; import { useLibraryStore } from '@/stores/libraryStore'; import { ensureEQRouteSyncStarted } from '@/audio/eqRouteSync'; import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { useNormalizationSync } from '@/audio/useNormalizationSync'; import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler'; import { clearDesktopRemoteMediaSession, setDesktopRemoteMediaSession, subscribeDesktopRemoteMediaSessionCommands, } from '@/services/desktopRemoteMediaSession'; import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy'; import { getDesktopRemoteConnection, setDesktopRemoteConnection, } from '@/services/desktopRemoteCredentials'; import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt'; import { useThemeStore } from '@/stores/themeStore'; import { useOnboardingStore } from '@/stores/onboardingStore'; import { useSettingsStore } from '@/stores/settingsStore'; import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow'; import { ArtistImageDisclosurePrompt } from '@/components/onboarding/ArtistImageDisclosurePrompt'; import { useTheme } from '@/theme/themed'; import { SessionLifecycle } from '@/session/SessionLifecycle'; import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore'; import { Text } from '@/components/Text'; import { AppDialogHost } from '@/components/dialogs/AppDialog'; import { startArtistImageLookupCoordinator } from '@/library/artistImageLookup'; // Anchor the root stack at the tabs so a deep link straight to a top-level route // (the widget's `recently-played`, the notification-click redirect) builds // `[(tabs), route]` instead of just `[route]` — backing out of a deep-linked // route must never land on an empty stack. Only affects deep-link/launch // ordering; normal nav is unchanged. (Now-playing itself is no longer a route — // it's the store-gated NowPlayingHost overlay.) export const unstable_settings = { initialRouteName: '(tabs)', }; SplashScreen.preventAutoHideAsync(); /** Mirrors RNTP state into the player store. Renders nothing. */ function PlaybackSync() { usePlaybackSync(); return null; } /** * Re-reads system theme inputs (OS scheme + monet wallpaper ramps) on each * return to foreground — wallpaper changes can only happen while backgrounded. * OS dark/light toggles are covered by the Appearance listener in themeStore. */ function ThemeSystemSync() { useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') useThemeStore.getState().refreshSystemInputs(); }); return () => subscription.remove(); }, []); return null; } function SleepTimerLifecycle() { useEffect(() => { void useSleepTimerStore.getState().hydrate(); const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') void useSleepTimerStore.getState().reconcile(); }); return () => subscription.remove(); }, []); return null; } /** Owns the visualizer on/off gate (foreground + playing + motion). Renders nothing. */ function ScopeLifecycle() { useScopeLifecycle(); return null; } /** Pushes per-track normalization gain to native on track/settings change. */ function NormalizationSync() { useNormalizationSync(); return null; } /** Feeds playback snapshots to the Last.fm scrobble service. Renders nothing. */ function LastFmScrobbler() { useLastFmScrobbler(); return null; } /** Loads the selected playback target and connects remote only when desktop is selected. */ function PlaybackTargetSync() { const target = usePlaybackTargetStore((s) => s.target); const loadTarget = usePlaybackTargetStore((s) => s.load); const initDesktopRemote = useDesktopRemoteStore((s) => s.init); useEffect(() => { void loadTarget(); }, [loadTarget]); useEffect(() => { if (target === 'desktop') { if (useSleepTimerStore.getState().timer) void useSleepTimerStore.getState().cancel(); void initDesktopRemote(); } }, [target, initDesktopRemote]); return null; } /** Mirrors Desktop Remote now-playing into a separate Android MediaSession. */ function DesktopRemoteMediaSessionSync() { const connection = useDesktopRemoteStore((s) => s.connection); const connectionState = useDesktopRemoteStore((s) => s.connectionState); const snapshot = useDesktopRemoteStore((s) => s.snapshot); const sendControl = useDesktopRemoteStore((s) => s.sendControl); useEffect(() => { const subscription = subscribeDesktopRemoteMediaSessionCommands((event) => { if (event.command === 'toggle-play') { const playing = useDesktopRemoteStore.getState().snapshot?.playbackState === 'playing'; void useDesktopRemoteStore.getState().sendControl(playing ? 'pause' : 'play'); return; } if (event.command === 'stop') { void useDesktopRemoteStore.getState().sendControl('pause'); return; } if (event.command === 'seek') { void useDesktopRemoteStore.getState().sendControl('seek', event.position); return; } void useDesktopRemoteStore.getState().sendControl(event.command); }); return () => subscription.remove(); }, [sendControl]); useEffect(() => { if (!connection || connectionState === 'unpaired') { clearDesktopRemoteMediaSession(); return; } setDesktopRemoteMediaSession(snapshot, connection); }, [connection, connectionState, snapshot]); useEffect(() => clearDesktopRemoteMediaSession, []); return null; } const DESKTOP_DISCOVERY_BURST_MS = 20_000; const DESKTOP_SYNC_STARTUP_RETRY_MS = 2_500; const DESKTOP_SYNC_REQUEST_POLL_MS = 60_000; /** * Auto-syncs favorites/playlists with the paired desktop when it looks * reachable: on foreground (probe-guarded, min-interval limited), when mDNS * discovery spots the paired desktop, or when the remote screen connects. * Deliberately does NOT init the desktop-remote store here — its connect path * retries a powered-off desktop every 2 s forever, which we don't want running * from app launch. */ function DesktopSyncAutoTrigger() { const connectionState = useDesktopRemoteStore((s) => s.connectionState); const discovered = useDesktopRemoteStore((s) => s.discovered); const desktopSyncHydrated = useDesktopSyncStore((s) => s.hydrated); const desktopSyncEnabled = useDesktopSyncStore((s) => s.desktopSyncEnabled); useEffect(() => { void useDesktopSyncStore.getState().hydrate(); }, []); // Cold start + each return to foreground: attempt a (probe-guarded) sync and // run a short mDNS burst so a desktop that changed LAN address is found. useEffect(() => { let burstTimer: ReturnType | null = null; let startupRetryTimer: ReturnType | null = null; const onActive = () => { void (async () => { const sync = useDesktopSyncStore.getState(); if (!sync.hydrated || !sync.desktopSyncEnabled) return; const connection = await getDesktopRemoteConnection(); if (!connection) return; useDesktopSyncStore.getState().maybeAutoSync('foreground'); const remote = useDesktopRemoteStore.getState(); if (remote.discoveryAvailable && !remote.discoveryRunning) { void remote.startDiscovery(); burstTimer = setTimeout(() => { burstTimer = null; void useDesktopRemoteStore.getState().stopDiscovery(); }, DESKTOP_DISCOVERY_BURST_MS); } })(); }; if (AppState.currentState === 'active') { onActive(); } else { startupRetryTimer = setTimeout(() => { startupRetryTimer = null; if (AppState.currentState === 'active') onActive(); }, DESKTOP_SYNC_STARTUP_RETRY_MS); } const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') onActive(); }); return () => { subscription.remove(); if (burstTimer !== null) clearTimeout(burstTimer); if (startupRetryTimer !== null) clearTimeout(startupRetryTimer); if (!useDesktopSyncStore.getState().desktopSyncEnabled) { void useDesktopRemoteStore.getState().stopDiscovery(); } }; }, [desktopSyncEnabled, desktopSyncHydrated]); // Desktop-initiated "Sync now" pickup: a cheap identity poll while // foregrounded (the SSE nudge only reaches us while the remote screen's // stream happens to be connected). fetchDesktopRemoteIdentity swallows // errors, so a powered-off desktop costs one timed-out request per minute. useEffect(() => { if (!desktopSyncHydrated || !desktopSyncEnabled) return; const timer = setInterval(() => { if (AppState.currentState !== 'active') return; void (async () => { const sync = useDesktopSyncStore.getState(); if (!sync.hydrated || !sync.desktopSyncEnabled) return; const connection = await getDesktopRemoteConnection(); if (!connection) return; const identity = await fetchDesktopRemoteIdentity( connection.baseUrl, connection.certificateFingerprint ); if (identity?.syncRequestedAt) { useDesktopSyncStore.getState().handleSyncRequest(); } })(); }, DESKTOP_SYNC_REQUEST_POLL_MS); return () => clearInterval(timer); }, [desktopSyncEnabled, desktopSyncHydrated]); // Paired desktop spotted on the LAN: refresh a stale baseUrl (DHCP moves) // and trigger a sync. useEffect(() => { if (!desktopSyncHydrated || !desktopSyncEnabled) return; if (discovered.length === 0) return; void (async () => { const connection = await getDesktopRemoteConnection(); if (!connection?.endpointUuid) return; const match = discovered.find((desktop) => desktop.endpointUuid === connection.endpointUuid); if (!match) return; if (match.baseUrl && match.baseUrl !== connection.baseUrl) { const identity = await fetchDesktopRemoteIdentity( match.baseUrl, connection.certificateFingerprint ); if (!identity || !identityMatchesPinnedConnection( connection.endpointUuid, identity.protocolVersion, identity.endpointUuid )) return; const updated = { ...connection, baseUrl: match.baseUrl }; await setDesktopRemoteConnection(updated); if (useDesktopRemoteStore.getState().connection) { useDesktopRemoteStore.setState({ connection: updated }); } } useDesktopSyncStore.getState().maybeAutoSync('discovery'); })(); }, [desktopSyncEnabled, desktopSyncHydrated, discovered]); // The remote screen connected — the desktop is definitely reachable. useEffect(() => { if (desktopSyncHydrated && desktopSyncEnabled && connectionState === 'connected') { useDesktopSyncStore.getState().maybeAutoSync('connected'); } }, [desktopSyncEnabled, desktopSyncHydrated, connectionState]); return null; } export default function RootLayout() { const [fontsLoaded] = useFonts({ Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, JetBrainsMono_400Regular, JetBrainsMono_500Medium, }); // Failsafe so the splash can never hang the UI blank. `preventAutoHideAsync` runs at // module scope — including in the headless JS context Android Auto spins up — so when // the process is started from the car first and the app is opened later, the normal // "hide once fonts load" path can get stuck. Render (and hide the splash) anyway after // a short timeout even if fonts haven't reported in. const [splashTimedOut, setSplashTimedOut] = useState(false); useEffect(() => { const timer = setTimeout(() => setSplashTimedOut(true), 2000); return () => clearTimeout(timer); }, []); // Theme joins the gate so the first painted frame is already in the // persisted theme (no flash). The failsafe path paints the default theme // and snaps once the SQLite read lands — accepted degradation. const themeLoaded = useThemeStore((s) => s.loaded); const onboardingLoaded = useOnboardingStore((s) => s.loaded); const onboardingComplete = useOnboardingStore((s) => s.onboardingComplete); const libraryStatus = useLibraryStore((s) => s.status); const fatalUserData = libraryStatus === 'fatalUserData'; const theme = useTheme(); const renderReady = (fontsLoaded && themeLoaded && onboardingLoaded) || splashTimedOut; const [sessionReady, setSessionReady] = useState(false); const handleSessionReady = useCallback(() => setSessionReady(true), []); const splashReady = renderReady && (!onboardingComplete || sessionReady || splashTimedOut); useEffect(() => { if (splashReady) { void SplashScreen.hideAsync().catch(() => {}); } }, [splashReady]); // Eager library init: SQLite open + initial reads are tens of ms, and the // Library tab + playback adapters get data immediately. EQ + audio settings load // alongside so the native EQ/gain reflect persisted prefs from the first play. useEffect(() => { startArtistImageLookupCoordinator(); useThemeStore .getState() .load() .catch((err) => console.error('[theme] load failed', err)); useOnboardingStore .getState() .load() .catch((err) => console.error('[onboarding] load failed', err)); useLibraryStore .getState() .initialize() .catch((err) => console.error('[library] init failed', err)); ensureEQRouteSyncStarted().catch((err) => console.error('[eq-route] init failed', err)); useAudioSettingsStore .getState() .load() .catch((err) => console.error('[audioSettings] load failed', err)); useLyricsSettingsStore .getState() .load() .catch((err) => console.error('[lyricsSettings] load failed', err)); // Remote sources: load server rows + hydrate the URL registry from cached // config/token (no network on launch). Runs after library init reads first. useRemoteSourcesStore .getState() .init() .catch((err) => console.error('[remoteSources] init failed', err)); // Last.fm: construct the scrobble service + drain any persisted offline queue, // even if the user never opens the settings screen this session. useLastFmSettingsStore .getState() .init() .catch((err) => console.error('[lastfm] init failed', err)); }, []); if (!renderReady) return null; return ( {/* The navigator stays mounted whatever the onboarding state (expo-router needs a root navigator), but the playback/sync/desktop side-effects and overlays are gated off during the wizard — no LAN-discovery bursts or scrobbler running mid-onboarding. */} {onboardingComplete && !fatalUserData ? ( <> ) : null} {onboardingComplete && !fatalUserData ? : null} {fatalUserData ? ( Library data unavailable Astra could not restore your playlists, favorites, and settings from either safety snapshot. Nothing was silently reset, and your music files were not changed. ) : onboardingComplete ? ( <> {/* Always-mounted player overlay (store-gated); open/close is a pure UI-thread slide with zero mount cost after the first mount. */} ) : ( // First-run gate: opaque full-screen wizard over the (hidden) navigator. // markComplete flips the flag → this unmounts, revealing the app. { void (async () => { await useSettingsStore.getState().acknowledgeArtistImageDisclosure(); await useOnboardingStore.getState().markComplete(); })(); }} /> )} ); }