mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-04 04:58:30 +01:00
feat(settings): catalog-driven index + migrate QuickConnect to expo-ui sheet
This commit is contained in:
@@ -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<BottomSheetMethods>(null);
|
||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity onPress={() => logout()}>
|
||||
<Text className='text-red-600 px-2'>
|
||||
{t("home.settings.log_out_button")}
|
||||
</Text>
|
||||
@@ -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 (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 32,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<UserInfo />
|
||||
</View>
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
{Platform.OS !== "ios" && (
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
</View>
|
||||
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("home.settings.categories.title")}>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/playback-controls/page")}
|
||||
showArrow
|
||||
title={t("home.settings.playback_controls.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||
showArrow
|
||||
title={t("home.settings.audio_subtitles.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/music/page")}
|
||||
showArrow
|
||||
title={t("home.settings.music.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/appearance/page")}
|
||||
showArrow
|
||||
title={t("home.settings.appearance.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/page")}
|
||||
showArrow
|
||||
title={t("home.settings.plugins.plugins_title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/intro/page")}
|
||||
showArrow
|
||||
title={t("home.settings.intro.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/network/page")}
|
||||
showArrow
|
||||
title={t("home.settings.network.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
showArrow
|
||||
title={t("home.settings.logs.logs_title")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
<StorageSettings />
|
||||
<View className='mx-3 mb-5'>
|
||||
<AppLanguageSelector />
|
||||
</View>
|
||||
|
||||
{SETTINGS_CATALOG.map((section) => {
|
||||
const entries = section.entries.filter(
|
||||
(e) => !e.platforms || e.platforms.includes(os),
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<SettingsSection key={section.id} title={t(section.titleKey)}>
|
||||
{entries.map((e, i) => (
|
||||
<SettingsRow
|
||||
key={e.id}
|
||||
title={t(e.titleKey)}
|
||||
icon={e.icon}
|
||||
onPress={() => handleEntry(e)}
|
||||
isLast={i === entries.length - 1}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
})}
|
||||
|
||||
<SettingsSection>
|
||||
<View className='p-3'>
|
||||
<StorageSettings />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<QuickConnectSheet ref={quickConnectRef} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function settings() {
|
||||
// Use TV settings component on TV platforms
|
||||
if (Platform.isTV && SettingsTV) {
|
||||
return <SettingsTV />;
|
||||
}
|
||||
|
||||
return <SettingsMobile />;
|
||||
}
|
||||
|
||||
@@ -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> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(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<BottomSheetMethods>(
|
||||
(_props, ref) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||
const sheetRef = useRef<BottomSheetMethods>(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) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
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> = ({ ...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> = ({ ...props }) => {
|
||||
t("home.settings.quick_connect.invalid_code"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [api, user, quickConnectCode]);
|
||||
}, [
|
||||
api,
|
||||
user,
|
||||
quickConnectCode,
|
||||
t,
|
||||
successHapticFeedback,
|
||||
errorHapticFeedback,
|
||||
]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
// Reset the code when opening the sheet
|
||||
setQuickConnectCode("");
|
||||
bottomSheetModalRef?.current?.present();
|
||||
}}
|
||||
title={t("home.settings.quick_connect.authorize_button")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
ref={sheetRef}
|
||||
enablePanDownToClose
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||
keyboardBlurBehavior='restore'
|
||||
android_keyboardInputMode='adjustResize'
|
||||
topInset={isAndroid ? 0 : undefined}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
@@ -142,6 +123,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
QuickConnectSheet.displayName = "QuickConnectSheet";
|
||||
|
||||
24
utils/expoUiBottomSheet.ts
Normal file
24
utils/expoUiBottomSheet.ts
Normal file
@@ -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";
|
||||
Reference in New Issue
Block a user