mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
438 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|