diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 44459e8c..339db9c0 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -36,6 +36,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useSplashScreenLoading, useSplashScreenVisible } from "@/providers/SplashScreenProvider"; type ScrollingCollectionListSection = { type: "ScrollingCollectionList"; @@ -146,6 +147,10 @@ export default function index() { staleTime: 60 * 1000, }); + // show splash screen until query loaded + useSplashScreenLoading(l1) + const splashScreenVisible = useSplashScreenVisible() + const userViews = useMemo( () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)), [data, settings?.hiddenLibraries] @@ -399,7 +404,9 @@ export default function index() { ); - if (l1) + // this spinner should only show up, when user navigates here + // on launch the splash screen is used for loading + if (l1 && !splashScreenVisible) return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index 2092d722..cc0c0dd3 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,7 @@ import { } from "@/providers/JellyfinProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; +import { SplashScreenProvider, useSplashScreenLoading } from "@/providers/SplashScreenProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { orientationAtom } from "@/utils/atoms/orientation"; import { Settings, useSettings } from "@/utils/atoms/settings"; @@ -33,7 +34,6 @@ import * as Linking from "expo-linking"; import * as Notifications from "expo-notifications"; import { router, Stack } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; -import * as SplashScreen from "expo-splash-screen"; import * as TaskManager from "expo-task-manager"; import { Provider as JotaiProvider, useAtom } from "jotai"; import { useEffect, useRef } from "react"; @@ -46,8 +46,6 @@ import { getLocales } from "expo-localization"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; -SplashScreen.preventAutoHideAsync(); - Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, @@ -213,27 +211,15 @@ const checkAndRequestPermissions = async () => { }; export default function RootLayout() { - const [loaded] = useFonts({ - SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), - }); - - useEffect(() => { - if (loaded) { - SplashScreen.hideAsync(); - } - }, [loaded]); - Appearance.setColorScheme("dark"); - if (!loaded) { - return null; - } - return ( - - - + + + + + ); } @@ -319,6 +305,17 @@ function Layout() { const { hostname, path, queryParams } = Linking.parse(url); } + const [loaded] = useFonts({ + SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), + }); + + // show splash screen until everything loaded + useSplashScreenLoading(!loaded) + + if (!loaded) { + return null; + } + return ( diff --git a/bun.lockb b/bun.lockb index d3b67a76..2e5bd503 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 740d7fa3..e88a252e 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "expo-brightness": "~12.0.1", "expo-build-properties": "~0.12.5", "expo-constants": "~16.0.2", + "expo-crypto": "^14.0.2", "expo-dev-client": "~4.0.29", "expo-device": "~6.0.2", "expo-font": "~12.0.10", diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index dddebb10..13e77235 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -23,6 +23,7 @@ import { getDeviceName } from "react-native-device-info"; import { useTranslation } from "react-i18next"; import { useSettings } from "@/utils/atoms/settings"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; +import { useSplashScreenLoading, useSplashScreenVisible } from "./SplashScreenProvider"; interface Server { address: string; @@ -341,11 +342,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ initiateQuickConnect, }; - useProtectedRoute(user, isLoading || isFetching); + let isLoadingOrFetching = isLoading || isFetching; + useProtectedRoute(user, isLoadingOrFetching); + + // show splash screen until everything loaded + useSplashScreenLoading(isLoadingOrFetching) + const splashScreenVisible = useSplashScreenVisible() return ( - {children} + {/* don't render login page when loading and splash screen visible */} + {isLoadingOrFetching && splashScreenVisible ? undefined : children} ); }; diff --git a/providers/SplashScreenProvider.tsx b/providers/SplashScreenProvider.tsx new file mode 100644 index 00000000..d62e8c66 --- /dev/null +++ b/providers/SplashScreenProvider.tsx @@ -0,0 +1,115 @@ +import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from "react"; +import * as Crypto from 'expo-crypto'; +import * as SplashScreen from "expo-splash-screen"; + +class ChangeListenerMap extends Map { + constructor(private readonly onChange: (e: { self: ChangeListenerMap, key: K, oldValue: V | undefined, newValue: V }) => void) { + super() + } + + public set(key: K, value: V): this { + const oldValue = this.get(key); + super.set(key, value); + if(oldValue !== value) { + this.onChange({ self: this, key, oldValue, newValue: value }) + } + return this; + } +} + +type SplashScreenContextValue = { + splashScreenVisible: boolean, + componentLoaded: Map +} + +const SplashScreenContext = createContext(undefined) + +SplashScreen.preventAutoHideAsync(); + +export const SplashScreenProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + + const [splashScreenVisible, setSplashScreenVisible] = useState(true) + + const contextValue: SplashScreenContextValue = { + splashScreenVisible, + componentLoaded: new ChangeListenerMap(({ self }) => { + for(const entry of self.entries()) { + if(!entry[1]) { + // one component not loaded yet, not hiding splash screen + return + } + } + SplashScreen.hideAsync() + setSplashScreenVisible(false) + }) + } + + return ( + + {children} + + ) +} + +/** + * Show the Splash Screen until component is ready to be displayed. + * + * This only has an effect when component is mounted before Splash Screen is hidden, + * so it should only be used in components that show up on launch. + * + * @param isLoading The loading state of the component + * + * ## Usage + * ``` + * // Example 1: + * const isLoading = loadSomething() + * useSplashScreenLoading(isLoading) // splash screen visible until isLoading is false + * ``` + * ``` + * + * // Example 2: multiple loading states + * const isLoading1 = loadSomething() + * useSplashScreenLoading(isLoading1) // splash screen visible until isLoading1 and isLoading2 are false + * + * // this could be in different component and still have the same effect + * const isLoading2 = loadSomethingElse() + * useSplashScreenLoading(isLoading2) + * ``` + */ +export function useSplashScreenLoading(isLoading: boolean) { + const id = useMemo(() => Crypto.randomUUID(), []); + + const context = useContext(SplashScreenContext); + if(!context) { + throw new Error("useSplashScreenLoading must be used within a SplashScreenProvider"); + } + + useEffect(() => { + // update the loading state of component + context.componentLoaded.set(id, !isLoading) + + // cleanup when unmounting component + return () => { + context.componentLoaded.delete(id); + }; + }, [isLoading]) +} + +/** + * Get the visiblity of the Splash Screen. + * @returns the visibility of the Splash Screen + * + * ## Usage + * ``` + * const splashScreenIsVisible = useSplashScreenVisible() + * ``` + */ +export function useSplashScreenVisible() { + const context = useContext(SplashScreenContext); + if(!context) { + throw new Error("useSplashScreenVisible must be used within a SplashScreenProvider"); + } + return context.splashScreenVisible +} \ No newline at end of file