Files
astra-mobile/src/app/_layout.tsx
T
2026-07-14 01:14:16 -04:00

438 lines
17 KiB
TypeScript

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 { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
import { useTheme } from '@/theme/themed';
import { SessionLifecycle } from '@/session/SessionLifecycle';
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
// 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<typeof setTimeout> | null = null;
let startupRetryTimer: ReturnType<typeof setTimeout> | 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 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(() => {
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 (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
<SafeAreaProvider>
<StatusBar style={theme.statusBarStyle} />
{/* 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 ? (
<>
<ThemeSystemSync />
<SleepTimerLifecycle />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
<LastFmScrobbler />
<PlaybackTargetSync />
<DesktopRemoteMediaSessionSync />
<DesktopSyncAutoTrigger />
</>
) : null}
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: theme.colors.bgPrimary },
}}
>
<Stack.Screen name="(tabs)" />
</Stack>
{onboardingComplete ? <SessionLifecycle onReady={handleSessionReady} /> : null}
{onboardingComplete ? (
<>
{/* Always-mounted player overlay (store-gated); open/close is a pure
UI-thread slide with zero mount cost after the first mount. */}
<NowPlayingHost />
<QuickSearchOverlay />
<SyncConflictPrompt />
</>
) : (
// First-run gate: opaque full-screen wizard over the (hidden) navigator.
// markComplete flips the flag → this unmounts, revealing the app.
<View style={StyleSheet.absoluteFill}>
<OnboardingFlow
onDone={() => {
void useOnboardingStore.getState().markComplete();
}}
/>
</View>
)}
</SafeAreaProvider>
</GestureHandlerRootView>
);
}