mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-25 15:20:34 +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 { useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useEffect, useRef } from "react";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
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 { 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 { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
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
|
// TV-specific settings component
|
||||||
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||||
|
|
||||||
// Mobile settings component
|
|
||||||
function SettingsMobile() {
|
function SettingsMobile() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_user] = useAtom(userAtom);
|
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const quickConnectRef = useRef<BottomSheetMethods>(null);
|
||||||
|
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={() => logout()}>
|
||||||
onPress={() => {
|
|
||||||
logout();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-red-600 px-2'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 32,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View className='mx-3 mb-5'>
|
||||||
className='p-4 flex flex-col'
|
<AppLanguageSelector />
|
||||||
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>
|
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
// Use TV settings component on TV platforms
|
|
||||||
if (Platform.isTV && SettingsTV) {
|
if (Platform.isTV && SettingsTV) {
|
||||||
return <SettingsTV />;
|
return <SettingsTV />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SettingsMobile />;
|
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 { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import {
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
type BottomSheetMethods,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@/utils/expoUiBottomSheet";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { PinInput } from "../inputs/PinInput";
|
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 }) => {
|
export const QuickConnectSheet = forwardRef<BottomSheetMethods>(
|
||||||
const isTv = Platform.isTV;
|
(_props, ref) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const isTv = Platform.isTV;
|
||||||
const [user] = useAtom(userAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
const [user] = useAtom(userAtom);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const sheetRef = useRef<BottomSheetMethods>(null);
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const snapPoints = useMemo(
|
const errorHapticFeedback = useHaptic("error");
|
||||||
() => (Platform.OS === "android" ? ["100%"] : ["40%"]),
|
const snapPoints = useMemo(
|
||||||
[],
|
() => (Platform.OS === "android" ? ["100%"] : ["40%"]),
|
||||||
);
|
[],
|
||||||
const isAndroid = Platform.OS === "android";
|
);
|
||||||
|
const isAndroid = Platform.OS === "android";
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
useImperativeHandle(ref, () => sheetRef.current as BottomSheetMethods, []);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const authorizeQuickConnect = useCallback(async () => {
|
||||||
(props: BottomSheetBackdropProps) => (
|
if (!quickConnectCode) return;
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const authorizeQuickConnect = useCallback(async () => {
|
|
||||||
if (quickConnectCode) {
|
|
||||||
try {
|
try {
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
code: quickConnectCode,
|
code: quickConnectCode,
|
||||||
@@ -61,7 +55,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
sheetRef.current?.close();
|
||||||
} else {
|
} else {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -76,39 +70,26 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
t("home.settings.quick_connect.invalid_code"),
|
t("home.settings.quick_connect.invalid_code"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}, [
|
||||||
}, [api, user, quickConnectCode]);
|
api,
|
||||||
|
user,
|
||||||
|
quickConnectCode,
|
||||||
|
t,
|
||||||
|
successHapticFeedback,
|
||||||
|
errorHapticFeedback,
|
||||||
|
]);
|
||||||
|
|
||||||
if (isTv) return null;
|
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>
|
|
||||||
|
|
||||||
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={sheetRef}
|
||||||
|
enablePanDownToClose
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
handleIndicatorStyle={{
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
backgroundColor: "white",
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||||
keyboardBlurBehavior='restore'
|
keyboardBlurBehavior='restore'
|
||||||
android_keyboardInputMode='adjustResize'
|
|
||||||
topInset={isAndroid ? 0 : undefined}
|
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<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>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</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