From 4e3f5a72e0f4ddc70f4dc74aec61316af1085248 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Wed, 3 Jun 2026 23:27:46 +0200 Subject: [PATCH] feat(settings): catalog-driven index + migrate QuickConnect to expo-ui sheet --- app/(auth)/(tabs)/(home)/settings.tsx | 147 ++++++++++---------------- components/settings/QuickConnect.tsx | 123 ++++++++++----------- utils/expoUiBottomSheet.ts | 24 +++++ 3 files changed, 133 insertions(+), 161 deletions(-) create mode 100644 utils/expoUiBottomSheet.ts diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 69b980d3e..768aaf0ac 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -1,38 +1,37 @@ import { useNavigation } from "expo-router"; import { t } from "i18next"; -import { useAtom } from "jotai"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; -import { ListGroup } from "@/components/list/ListGroup"; -import { ListItem } from "@/components/list/ListItem"; import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; -import { QuickConnect } from "@/components/settings/QuickConnect"; +import { SettingsRow } from "@/components/settings/index/SettingsRow"; +import { SettingsSection } from "@/components/settings/index/SettingsSection"; +import { + SETTINGS_CATALOG, + type SettingsEntry, +} from "@/components/settings/index/settingsCatalog"; +import { QuickConnectSheet } from "@/components/settings/QuickConnect"; import { StorageSettings } from "@/components/settings/StorageSettings"; -import { UserInfo } from "@/components/settings/UserInfo"; import useRouter from "@/hooks/useAppRouter"; -import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; +import { useJellyfin } from "@/providers/JellyfinProvider"; +import type { BottomSheetMethods } from "@/utils/expoUiBottomSheet"; // TV-specific settings component const SettingsTV = Platform.isTV ? require("./settings.tv").default : null; -// Mobile settings component function SettingsMobile() { const router = useRouter(); const insets = useSafeAreaInsets(); - const [_user] = useAtom(userAtom); const { logout } = useJellyfin(); - const navigation = useNavigation(); + const quickConnectRef = useRef(null); + const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android"; + useEffect(() => { navigation.setOptions({ headerRight: () => ( - { - logout(); - }} - > + logout()}> {t("home.settings.log_out_button")} @@ -41,98 +40,64 @@ function SettingsMobile() { }); }, []); + const handleEntry = (entry: SettingsEntry) => { + if (entry.target.type === "action") { + if (entry.target.action === "quickConnect") { + quickConnectRef.current?.present(); + } + return; + } + router.push(entry.target.route as any); + }; + return ( - - - - - - - - {Platform.OS !== "ios" && ( - - - - router.push("/(auth)/(tabs)/(home)/companion-login") - } - title={t("pairing.pair_with_phone")} - textColor='blue' - /> - - - )} - - - - - - - - router.push("/settings/playback-controls/page")} - showArrow - title={t("home.settings.playback_controls.title")} - /> - router.push("/settings/audio-subtitles/page")} - showArrow - title={t("home.settings.audio_subtitles.title")} - /> - router.push("/settings/music/page")} - showArrow - title={t("home.settings.music.title")} - /> - router.push("/settings/appearance/page")} - showArrow - title={t("home.settings.appearance.title")} - /> - router.push("/settings/plugins/page")} - showArrow - title={t("home.settings.plugins.plugins_title")} - /> - router.push("/settings/intro/page")} - showArrow - title={t("home.settings.intro.title")} - /> - router.push("/settings/network/page")} - showArrow - title={t("home.settings.network.title")} - /> - router.push("/settings/logs/page")} - showArrow - title={t("home.settings.logs.logs_title")} - /> - - - - + + + + {SETTINGS_CATALOG.map((section) => { + const entries = section.entries.filter( + (e) => !e.platforms || e.platforms.includes(os), + ); + if (entries.length === 0) return null; + return ( + + {entries.map((e, i) => ( + handleEntry(e)} + isLast={i === entries.length - 1} + /> + ))} + + ); + })} + + + + + + + + ); } export default function settings() { - // Use TV settings component on TV platforms if (Platform.isTV && SettingsTV) { return ; } - return ; } diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx index 725e7e6fa..94560ac57 100644 --- a/components/settings/QuickConnect.tsx +++ b/components/settings/QuickConnect.tsx @@ -1,54 +1,48 @@ -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetView, -} from "@gorhom/bottom-sheet"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtom } from "jotai"; -import type React from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Platform, View, type ViewProps } from "react-native"; +import { Alert, Platform, View } from "react-native"; import { useHaptic } from "@/hooks/useHaptic"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + type BottomSheetMethods, + BottomSheetModal, + BottomSheetView, +} from "@/utils/expoUiBottomSheet"; import { Button } from "../Button"; import { Text } from "../common/Text"; import { PinInput } from "../inputs/PinInput"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; -interface Props extends ViewProps {} +export type QuickConnectSheetRef = BottomSheetMethods; -export const QuickConnect: React.FC = ({ ...props }) => { - const isTv = Platform.isTV; - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - const [quickConnectCode, setQuickConnectCode] = useState(); - const bottomSheetModalRef = useRef(null); - const successHapticFeedback = useHaptic("success"); - const errorHapticFeedback = useHaptic("error"); - const snapPoints = useMemo( - () => (Platform.OS === "android" ? ["100%"] : ["40%"]), - [], - ); - const isAndroid = Platform.OS === "android"; +export const QuickConnectSheet = forwardRef( + (_props, ref) => { + const isTv = Platform.isTV; + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const [quickConnectCode, setQuickConnectCode] = useState(); + const sheetRef = useRef(null); + const successHapticFeedback = useHaptic("success"); + const errorHapticFeedback = useHaptic("error"); + const snapPoints = useMemo( + () => (Platform.OS === "android" ? ["100%"] : ["40%"]), + [], + ); + const isAndroid = Platform.OS === "android"; + const { t } = useTranslation(); - const { t } = useTranslation(); + useImperativeHandle(ref, () => sheetRef.current as BottomSheetMethods, []); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - - ), - [], - ); - - const authorizeQuickConnect = useCallback(async () => { - if (quickConnectCode) { + const authorizeQuickConnect = useCallback(async () => { + if (!quickConnectCode) return; try { const res = await getQuickConnectApi(api!).authorizeQuickConnect({ code: quickConnectCode, @@ -61,7 +55,7 @@ export const QuickConnect: React.FC = ({ ...props }) => { t("home.settings.quick_connect.quick_connect_autorized"), ); setQuickConnectCode(undefined); - bottomSheetModalRef?.current?.close(); + sheetRef.current?.close(); } else { errorHapticFeedback(); Alert.alert( @@ -76,39 +70,26 @@ export const QuickConnect: React.FC = ({ ...props }) => { t("home.settings.quick_connect.invalid_code"), ); } - } - }, [api, user, quickConnectCode]); + }, [ + api, + user, + quickConnectCode, + t, + successHapticFeedback, + errorHapticFeedback, + ]); - if (isTv) return null; - - return ( - - - { - // Reset the code when opening the sheet - setQuickConnectCode(""); - bottomSheetModalRef?.current?.present(); - }} - title={t("home.settings.quick_connect.authorize_button")} - textColor='blue' - /> - + if (isTv) return null; + return ( @@ -142,6 +123,8 @@ export const QuickConnect: React.FC = ({ ...props }) => { - - ); -}; + ); + }, +); + +QuickConnectSheet.displayName = "QuickConnectSheet"; diff --git a/utils/expoUiBottomSheet.ts b/utils/expoUiBottomSheet.ts new file mode 100644 index 000000000..91ef322e3 --- /dev/null +++ b/utils/expoUiBottomSheet.ts @@ -0,0 +1,24 @@ +import { Platform } from "react-native"; + +/** + * TV-safe re-exports of `@expo/ui/community/bottom-sheet`. + * + * `@expo/ui` resolves its SwiftUI bridge at module load via + * `requireNativeModule('ExpoUI')`. That native module does not exist on tvOS, + * so a static top-level import from any route file crashes the whole route + * tree (expo-router eagerly loads every route). We `require()` it lazily and + * only when not on tvOS; on TV the exports are undefined, which is fine because + * every call site early-returns (`if (Platform.isTV) return null;`). + */ +type BottomSheetMod = typeof import("@expo/ui/community/bottom-sheet"); + +const mod: BottomSheetMod = Platform.isTV + ? ({} as BottomSheetMod) + : (require("@expo/ui/community/bottom-sheet") as BottomSheetMod); + +export const BottomSheetModal = mod.BottomSheetModal; +export const BottomSheetView = mod.BottomSheetView; +export const BottomSheetScrollView = mod.BottomSheetScrollView; +export const BottomSheetTextInput = mod.BottomSheetTextInput; + +export type { BottomSheetMethods } from "@expo/ui/community/bottom-sheet";