From 07c7cb7ab50fa9ecaa2f0f1f9602f221676efaff Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 2 Jan 2025 16:49:04 +0100 Subject: [PATCH] feat: refactor settings --- app/(auth)/(tabs)/(custom-links)/index.tsx | 74 +- app/(auth)/(tabs)/(home)/_layout.tsx | 6 + app/(auth)/(tabs)/(home)/settings.tsx | 210 ++---- .../(home)/settings/jellyseerr/page.tsx | 78 +++ .../(tabs)/(home)/settings/logs/page.tsx | 33 + .../(home)/settings/optimized-server/page.tsx | 80 +++ augmentations/number.ts | 28 +- components/ListItem.tsx | 35 - components/list/ListGroup.tsx | 59 ++ components/list/ListItem.tsx | 124 ++++ components/settings/AudioToggles.tsx | 83 +-- components/settings/DownloadSettings.tsx | 103 +++ components/settings/Jellyseerr.tsx | 61 +- components/settings/MediaToggles.tsx | 138 ++-- components/settings/OptimizedServerForm.tsx | 43 ++ components/settings/OtherSettings.tsx | 401 +++++++++++ components/settings/QuickConnect.tsx | 59 ++ components/settings/SettingToggles.tsx | 643 ------------------ components/settings/StorageSettings.tsx | 109 +++ components/settings/SubtitleToggles.tsx | 112 ++- components/settings/UserInfo.tsx | 29 + 21 files changed, 1405 insertions(+), 1103 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx create mode 100644 app/(auth)/(tabs)/(home)/settings/logs/page.tsx create mode 100644 app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx delete mode 100644 components/ListItem.tsx create mode 100644 components/list/ListGroup.tsx create mode 100644 components/list/ListItem.tsx create mode 100644 components/settings/DownloadSettings.tsx create mode 100644 components/settings/OptimizedServerForm.tsx create mode 100644 components/settings/OtherSettings.tsx create mode 100644 components/settings/QuickConnect.tsx delete mode 100644 components/settings/SettingToggles.tsx create mode 100644 components/settings/StorageSettings.tsx create mode 100644 components/settings/UserInfo.tsx diff --git a/app/(auth)/(tabs)/(custom-links)/index.tsx b/app/(auth)/(tabs)/(custom-links)/index.tsx index 76b10fb8..bf6dc46b 100644 --- a/app/(auth)/(tabs)/(custom-links)/index.tsx +++ b/app/(auth)/(tabs)/(custom-links)/index.tsx @@ -1,27 +1,29 @@ -import {FlatList, TouchableOpacity, View} from "react-native"; -import {useSafeAreaInsets} from "react-native-safe-area-context"; -import React, {useCallback, useEffect, useState} from "react"; -import {useAtom} from "jotai/index"; -import {apiAtom} from "@/providers/JellyfinProvider"; -import {ListItem} from "@/components/ListItem"; -import * as WebBrowser from 'expo-web-browser'; -import Ionicons from '@expo/vector-icons/Ionicons'; -import {Text} from "@/components/common/Text"; +import { FlatList, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import React, { useCallback, useEffect, useState } from "react"; +import { useAtom } from "jotai/index"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { ListItem } from "@/components/list/ListItem"; +import * as WebBrowser from "expo-web-browser"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { Text } from "@/components/common/Text"; export interface MenuLink { - name: string, - url: string, - icon: string + name: string; + url: string; + icon: string; } export default function menuLinks() { const [api] = useAtom(apiAtom); - const insets = useSafeAreaInsets() - const [menuLinks, setMenuLinks] = useState([]) + const insets = useSafeAreaInsets(); + const [menuLinks, setMenuLinks] = useState([]); const getMenuLinks = useCallback(async () => { try { - const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json") + const response = await api?.axiosInstance.get( + api?.basePath + "/web/config.json" + ); const config = response?.data; if (!config && !config.hasOwnProperty("menuLinks")) { @@ -29,15 +31,15 @@ export default function menuLinks() { return; } - setMenuLinks(config?.menuLinks as MenuLink[]) - } catch (error) { - console.error("Failed to retrieve config:", error); - } - }, - [api] - ) + setMenuLinks(config?.menuLinks as MenuLink[]); + } catch (error) { + console.error("Failed to retrieve config:", error); + } + }, [api]); - useEffect(() => { getMenuLinks() }, []); + useEffect(() => { + getMenuLinks(); + }, []); return ( ( - WebBrowser.openBrowserAsync(item.url) }> + renderItem={({ item }) => ( + WebBrowser.openBrowserAsync(item.url)}> } + title={item.name} + iconAfter={} /> - ) - } + )} ItemSeparatorComponent={() => ( - )} + }} + /> + )} ListEmptyComponent={ - - No links - + + No links + } - /> + /> ); -} \ No newline at end of file +} diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index a7cc3cb3..4005586c 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -49,6 +49,12 @@ export default function IndexLayout() { title: "Settings", }} /> + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( ))} diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 46aecbae..63d1b5c9 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -1,176 +1,94 @@ -import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; -import { ListItem } from "@/components/ListItem"; -import { SettingToggles } from "@/components/settings/SettingToggles"; -import {useDownload} from "@/providers/DownloadProvider"; -import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -import { clearLogs, useLog } from "@/utils/log"; -import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; +import { ListGroup } from "@/components/list/ListGroup"; +import { ListItem } from "@/components/list/ListItem"; +import { AudioToggles } from "@/components/settings/AudioToggles"; +import { DownloadSettings } from "@/components/settings/DownloadSettings"; +import { MediaProvider } from "@/components/settings/MediaContext"; +import { MediaToggles } from "@/components/settings/MediaToggles"; +import { OtherSettings } from "@/components/settings/OtherSettings"; +import { QuickConnect } from "@/components/settings/QuickConnect"; +import { StorageSettings } from "@/components/settings/StorageSettings"; +import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; +import { UserInfo } from "@/components/settings/UserInfo"; +import { useJellyfin } from "@/providers/JellyfinProvider"; +import { clearLogs } from "@/utils/log"; import * as Haptics from "expo-haptics"; -import { useAtom } from "jotai"; -import { Alert, ScrollView, View } from "react-native"; -import * as Progress from "react-native-progress"; +import { useNavigation, useRouter } from "expo-router"; +import { useEffect } from "react"; +import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { toast } from "sonner-native"; export default function settings() { - const { logout } = useJellyfin(); - const { deleteAllFiles, appSizeUsage } = useDownload(); - const { logs } = useLog(); - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - + const router = useRouter(); const insets = useSafeAreaInsets(); - - const { data: size, isLoading: appSizeLoading } = useQuery({ - queryKey: ["appSize", appSizeUsage], - queryFn: async () => { - const app = await appSizeUsage; - - const remaining = await FileSystem.getFreeDiskStorageAsync(); - const total = await FileSystem.getTotalDiskCapacityAsync(); - - return { app, remaining, total, used: (total - remaining) / total }; - }, - }); - - const openQuickConnectAuthCodeInput = () => { - Alert.prompt( - "Quick connect", - "Enter the quick connect code", - async (text) => { - if (text) { - try { - const res = await getQuickConnectApi(api!).authorizeQuickConnect({ - code: text, - userId: user?.Id, - }); - if (res.status === 200) { - Haptics.notificationAsync( - Haptics.NotificationFeedbackType.Success - ); - Alert.alert("Success", "Quick connect authorized"); - } else { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - Alert.alert("Error", "Invalid code"); - } - } catch (e) { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - Alert.alert("Error", "Invalid code"); - } - } - } - ); - }; - - const onDeleteClicked = async () => { - try { - await deleteAllFiles(); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } catch (e) { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - toast.error("Error deleting files"); - } - }; + const { logout } = useJellyfin(); const onClearLogsClicked = async () => { clearLogs(); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }; + const navigation = useNavigation(); + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + { + logout(); + }} + > + Log out + + ), + }); + }, []); + return ( - {/* */} - - User Info + + - - - - - - - + + + + + + + + - Quick connect - + + router.push("/settings/jellyseerr/page")} + title={"Jellyseerr Settings"} + showArrow + > + - - - - Storage - - {size && App usage: {size.app.bytesToReadable()}} - + + router.push("/settings/logs/page")} + showArrow + title={"Logs"} /> - {size && ( - - Available: {size.remaining?.bytesToReadable()}, Total:{" "} - {size.total?.bytesToReadable()} - - )} - - - - - - Logs - - {logs?.map((log, index) => ( - - - {log.level} - - - {log.message} - - - ))} - {logs?.length === 0 && ( - No logs available - )} - + + + + ); diff --git a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx new file mode 100644 index 00000000..af4247d5 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx @@ -0,0 +1,78 @@ +import { Text } from "@/components/common/Text"; +import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; +import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getOrSetDeviceId } from "@/utils/device"; +import { getStatistics } from "@/utils/optimize-server"; +import { useMutation } from "@tanstack/react-query"; +import { useNavigation } from "expo-router"; +import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; +import { toast } from "sonner-native"; + +export default function page() { + const navigation = useNavigation(); + + const [api] = useAtom(apiAtom); + const [settings, updateSettings] = useSettings(); + + const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = + useState(settings?.optimizedVersionsServerUrl || ""); + + const saveMutation = useMutation({ + mutationFn: async (newVal: string) => { + if (newVal.length === 0 || !newVal.startsWith("http")) { + toast.error("Invalid URL"); + return; + } + + const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/"; + + updateSettings({ + optimizedVersionsServerUrl: updatedUrl, + }); + + return await getStatistics({ + url: settings?.optimizedVersionsServerUrl, + authHeader: api?.accessToken, + deviceId: getOrSetDeviceId(), + }); + }, + onSuccess: (data) => { + if (data) { + toast.success("Connected"); + } else { + toast.error("Could not connect"); + } + }, + onError: () => { + toast.error("Could not connect"); + }, + }); + + const onSave = (newVal: string) => { + saveMutation.mutate(newVal); + }; + + // useEffect(() => { + // navigation.setOptions({ + // title: "Optimized Server", + // headerRight: () => + // saveMutation.isPending ? ( + // + // ) : ( + // onSave(optimizedVersionsServerUrl)}> + // Save + // + // ), + // }); + // }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); + + return ( + + + + ); +} diff --git a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx new file mode 100644 index 00000000..2e023c7d --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx @@ -0,0 +1,33 @@ +import { Text } from "@/components/common/Text"; +import { useLog } from "@/utils/log"; +import { ScrollView, View } from "react-native"; + +export default function page() { + const { logs } = useLog(); + + return ( + + + {logs?.map((log, index) => ( + + + {log.level} + + + {log.message} + + + ))} + {logs?.length === 0 && ( + No logs available + )} + + + ); +} diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx new file mode 100644 index 00000000..b47d565f --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx @@ -0,0 +1,80 @@ +import { Text } from "@/components/common/Text"; +import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getOrSetDeviceId } from "@/utils/device"; +import { getStatistics } from "@/utils/optimize-server"; +import { useMutation } from "@tanstack/react-query"; +import { useNavigation } from "expo-router"; +import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; +import { toast } from "sonner-native"; + +export default function page() { + const navigation = useNavigation(); + + const [api] = useAtom(apiAtom); + const [settings, updateSettings] = useSettings(); + + const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = + useState(settings?.optimizedVersionsServerUrl || ""); + + const saveMutation = useMutation({ + mutationFn: async (newVal: string) => { + if (newVal.length === 0 || !newVal.startsWith("http")) { + toast.error("Invalid URL"); + return; + } + + const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/"; + + updateSettings({ + optimizedVersionsServerUrl: updatedUrl, + }); + + return await getStatistics({ + url: settings?.optimizedVersionsServerUrl, + authHeader: api?.accessToken, + deviceId: getOrSetDeviceId(), + }); + }, + onSuccess: (data) => { + if (data) { + toast.success("Connected"); + } else { + toast.error("Could not connect"); + } + }, + onError: () => { + toast.error("Could not connect"); + }, + }); + + const onSave = (newVal: string) => { + saveMutation.mutate(newVal); + }; + + useEffect(() => { + navigation.setOptions({ + title: "Optimized Server", + headerRight: () => + saveMutation.isPending ? ( + + ) : ( + onSave(optimizedVersionsServerUrl)}> + Save + + ), + }); + }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); + + return ( + + + + ); +} diff --git a/augmentations/number.ts b/augmentations/number.ts index 2b7e8dac..c0f53075 100644 --- a/augmentations/number.ts +++ b/augmentations/number.ts @@ -1,9 +1,9 @@ declare global { interface Number { bytesToReadable(): string; - secondsToMilliseconds(): number - minutesToMilliseconds(): number - hoursToMilliseconds(): number + secondsToMilliseconds(): number; + minutesToMilliseconds(): number; + hoursToMilliseconds(): number; } } @@ -11,27 +11,27 @@ Number.prototype.bytesToReadable = function () { const bytes = this.valueOf(); const gb = bytes / 1e9; - if (gb >= 1) return `${gb.toFixed(2)} GB`; + if (gb >= 1) return `${gb.toFixed(0)} GB`; const mb = bytes / 1024.0 / 1024.0; - if (mb >= 1) return `${mb.toFixed(2)} MB`; + if (mb >= 1) return `${mb.toFixed(0)} MB`; const kb = bytes / 1024.0; - if (kb >= 1) return `${kb.toFixed(2)} KB`; + if (kb >= 1) return `${kb.toFixed(0)} KB`; return `${bytes.toFixed(2)} B`; -} +}; Number.prototype.secondsToMilliseconds = function () { - return this.valueOf() * 1000 -} + return this.valueOf() * 1000; +}; Number.prototype.minutesToMilliseconds = function () { - return this.valueOf() * (60).secondsToMilliseconds() -} + return this.valueOf() * (60).secondsToMilliseconds(); +}; Number.prototype.hoursToMilliseconds = function () { - return this.valueOf() * (60).minutesToMilliseconds() -} + return this.valueOf() * (60).minutesToMilliseconds(); +}; -export {}; \ No newline at end of file +export {}; diff --git a/components/ListItem.tsx b/components/ListItem.tsx deleted file mode 100644 index 0287c690..00000000 --- a/components/ListItem.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { PropsWithChildren, ReactNode } from "react"; -import { View, ViewProps } from "react-native"; -import { Text } from "./common/Text"; - -interface Props extends ViewProps { - title?: string | null | undefined; - subTitle?: string | null | undefined; - children?: ReactNode; - iconAfter?: ReactNode; -} - -export const ListItem: React.FC> = ({ - title, - subTitle, - iconAfter, - children, - ...props -}) => { - return ( - - - {title} - {subTitle && ( - - {subTitle} - - )} - - {iconAfter} - - ); -}; diff --git a/components/list/ListGroup.tsx b/components/list/ListGroup.tsx new file mode 100644 index 00000000..87ca5441 --- /dev/null +++ b/components/list/ListGroup.tsx @@ -0,0 +1,59 @@ +import { + PropsWithChildren, + Children, + isValidElement, + cloneElement, + ReactElement, +} from "react"; +import { StyleSheet, View, ViewProps, ViewStyle } from "react-native"; +import { ListItem } from "./ListItem"; +import { Text } from "../common/Text"; + +interface Props extends ViewProps { + title?: string | null | undefined; + description?: ReactElement; +} + +export const ListGroup: React.FC> = ({ + title, + children, + description, + ...props +}) => { + const childrenArray = Children.toArray(children); + + return ( + + + {title} + + + {Children.map(childrenArray, (child, index) => { + if (isValidElement<{ style?: ViewStyle }>(child)) { + return cloneElement(child as any, { + style: StyleSheet.compose( + child.props.style, + index < childrenArray.length - 1 + ? styles.borderBottom + : undefined + ), + }); + } + return child; + })} + + {description && {description}} + + ); +}; + +const styles = StyleSheet.create({ + borderBottom: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#3D3C40", + }, +}); diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx new file mode 100644 index 00000000..46856b00 --- /dev/null +++ b/components/list/ListItem.tsx @@ -0,0 +1,124 @@ +import { PropsWithChildren, ReactNode } from "react"; +import { + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, +} from "react-native"; +import { Text } from "../common/Text"; +import { Ionicons } from "@expo/vector-icons"; + +interface Props extends TouchableOpacityProps, ViewProps { + title?: string | null | undefined; + value?: string | null | undefined; + children?: ReactNode; + iconAfter?: ReactNode; + icon?: keyof typeof Ionicons.glyphMap; + showArrow?: boolean; + textColor?: "default" | "blue" | "red"; + onPress?: () => void; +} + +export const ListItem: React.FC> = ({ + title, + value, + iconAfter, + children, + showArrow = false, + icon, + textColor = "default", + onPress, + disabled = false, + ...props +}) => { + if (onPress) + return ( + + + {children} + + + ); + return ( + + + {children} + + + ); +}; + +const ListItemContent = ({ + title, + textColor, + icon, + value, + showArrow, + iconAfter, + children, + ...props +}: Props) => { + return ( + <> + + {icon && ( + + + + )} + + {title} + + {value && ( + + + {value} + + + )} + {children && {children}} + {showArrow && ( + + + + )} + + {iconAfter} + + ); +}; diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index ec9d71ce..62aea437 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -3,6 +3,9 @@ import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "../common/Text"; import { useMedia } from "./MediaContext"; import { Switch } from "react-native-gesture-handler"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; +import { Ionicons } from "@expo/vector-icons"; interface Props extends ViewProps {} @@ -14,26 +17,35 @@ export const AudioToggles: React.FC = ({ ...props }) => { if (!settings) return null; return ( - - Audio - - - - Audio language - - Choose a default audio language. - - + + + Choose a default audio language. + + } + > + + + updateSettings({ rememberAudioSelections: value }) + } + /> + + - - + + {settings?.defaultAudioLanguage?.DisplayName || "None"} + = ({ ...props }) => { ))} - - - - - Use Default Audio - - Play default audio track regardless of language. - - - - updateSettings({ playDefaultAudioTrack: value }) - } - /> - - - - - - - Set Audio Track From Previous Item - - - Try to set the audio track to the closest match to the last - video. - - - - updateSettings({ rememberAudioSelections: value }) - } - /> - - - + + ); }; diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx new file mode 100644 index 00000000..ba2ac493 --- /dev/null +++ b/components/settings/DownloadSettings.tsx @@ -0,0 +1,103 @@ +import { Stepper } from "@/components/inputs/Stepper"; +import { useDownload } from "@/providers/DownloadProvider"; +import { Settings, useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import React from "react"; +import { Switch, TouchableOpacity } from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import { Text } from "../common/Text"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; + +export const DownloadSettings: React.FC = () => { + const [settings, updateSettings] = useSettings(); + const { setProcesses } = useDownload(); + const router = useRouter(); + const queryClient = useQueryClient(); + + if (!settings) return null; + + return ( + + + + + + + {settings.downloadMethod === "remux" ? "Default" : "Optimized"} + + + + + + Methods + { + updateSettings({ downloadMethod: "remux" }); + setProcesses([]); + }} + > + Default + + { + updateSettings({ downloadMethod: "optimized" }); + setProcesses([]); + queryClient.invalidateQueries({ queryKey: ["search"] }); + }} + > + Optimized + + + + + + + + updateSettings({ + remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"], + }) + } + /> + + + + updateSettings({ autoDownload: value })} + /> + + + router.push("/settings/optimized-server/page")} + showArrow + title="Optimized Versions Server" + > + + ); +}; diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index ad4f5af7..7dd41c73 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { Text } from "../common/Text"; import { useCallback, useRef, useState } from "react"; import { Input } from "../common/Input"; -import { ListItem } from "../ListItem"; +import { ListItem } from "../list/ListItem"; import { Loader } from "../Loader"; import { useSettings } from "@/utils/atoms/settings"; import { Button } from "../Button"; @@ -11,6 +11,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useAtom } from "jotai"; import { toast } from "sonner-native"; import { useMutation } from "@tanstack/react-query"; +import { ListGroup } from "../list/ListGroup"; export const JellyseerrSettings = () => { const { @@ -83,41 +84,43 @@ export const JellyseerrSettings = () => { }; return ( - - Jellyseerr + {jellyseerrUser ? ( - - - - - - + <> + + + + + + + + - + ) : ( diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index c92902e2..7e4c4346 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -1,5 +1,8 @@ -import { useSettings } from "@/utils/atoms/settings"; +import React from "react"; import { TouchableOpacity, View, ViewProps } from "react-native"; +import { useSettings } from "@/utils/atoms/settings"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; import { Text } from "../common/Text"; interface Props extends ViewProps {} @@ -9,86 +12,61 @@ export const MediaToggles: React.FC = ({ ...props }) => { if (!settings) return null; - return ( - - Media - - - - Forward skip length - - Choose length in seconds when skipping in video playback. - - - - - updateSettings({ - forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5), - }) - } - className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" - > - - - - - {settings.forwardSkipTime}s - - - updateSettings({ - forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5), - }) - } - > - + - - - + const renderSkipControl = ( + value: number, + onDecrease: () => void, + onIncrease: () => void + ) => ( + + + - + + + {value}s + + + + + + + ); - - - Rewind length - - Choose length in seconds when skipping in video playback. - - - - - updateSettings({ - rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5), - }) - } - className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" - > - - - - - {settings.rewindSkipTime}s - - - updateSettings({ - rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5), - }) - } - > - + - - - - + return ( + + + + {renderSkipControl( + settings.forwardSkipTime, + () => + updateSettings({ + forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5), + }), + () => + updateSettings({ + forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5), + }) + )} + + + + {renderSkipControl( + settings.rewindSkipTime, + () => + updateSettings({ + rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5), + }), + () => + updateSettings({ + rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5), + }) + )} + + ); }; diff --git a/components/settings/OptimizedServerForm.tsx b/components/settings/OptimizedServerForm.tsx new file mode 100644 index 00000000..2aa7ebda --- /dev/null +++ b/components/settings/OptimizedServerForm.tsx @@ -0,0 +1,43 @@ +import { TextInput, View, Linking } from "react-native"; +import { Text } from "../common/Text"; + +interface Props { + value: string; + onChangeValue: (value: string) => void; +} + +export const OptimizedServerForm: React.FC = ({ + value, + onChangeValue, +}) => { + const handleOpenLink = () => { + Linking.openURL("https://github.com/streamyfin/optimized-versions-server"); + }; + + return ( + + + + URL + onChangeValue(text)} + /> + + + + Enter the URL for the optimize server. The URL should include http or + https and optionally the port.{" "} + + Read more about the optimize server. + + + + ); +}; diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx new file mode 100644 index 00000000..5ec5d0df --- /dev/null +++ b/components/settings/OtherSettings.tsx @@ -0,0 +1,401 @@ +import { Stepper } from "@/components/inputs/Stepper"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + ScreenOrientationEnum, + Settings, + useSettings, +} from "@/utils/atoms/settings"; +import { + BACKGROUND_FETCH_TASK, + registerBackgroundFetchAsync, + unregisterBackgroundFetchAsync, +} from "@/utils/background-tasks"; +import { Ionicons } from "@expo/vector-icons"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import * as BackgroundFetch from "expo-background-fetch"; +import { useRouter } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; +import * as TaskManager from "expo-task-manager"; +import { useAtom } from "jotai"; +import React, { useEffect, useState } from "react"; +import { + Linking, + Switch, + TouchableOpacity, + View, + ViewProps, +} from "react-native"; +import { toast } from "sonner-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import { Button } from "../Button"; +import { Input } from "../common/Input"; +import { Text } from "../common/Text"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; +import { Loader } from "../Loader"; + +interface Props extends ViewProps {} + +export const OtherSettings: React.FC = () => { + const [settings, updateSettings] = useSettings(); + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const [marlinUrl, setMarlinUrl] = useState(""); + + /******************** + * Background task + *******************/ + const checkStatusAsync = async () => { + await BackgroundFetch.getStatusAsync(); + return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); + }; + + useEffect(() => { + (async () => { + const registered = await checkStatusAsync(); + + if (settings?.autoDownload === true && !registered) { + registerBackgroundFetchAsync(); + toast.success("Background downloads enabled"); + } else if (settings?.autoDownload === false && registered) { + unregisterBackgroundFetchAsync(); + toast.info("Background downloads disabled"); + } else if (settings?.autoDownload === true && registered) { + // Don't to anything + } else if (settings?.autoDownload === false && !registered) { + // Don't to anything + } else { + updateSettings({ autoDownload: false }); + } + })(); + }, [settings?.autoDownload]); + /********************** + *********************/ + + const { + data: mediaListCollections, + isLoading: isLoadingMediaListCollections, + } = useQuery({ + queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + tags: ["sf_promoted"], + recursive: true, + fields: ["Tags"], + includeItemTypes: ["BoxSet"], + }); + + return response.data.Items ?? []; + }, + enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, + staleTime: 0, + }); + + if (!settings) return null; + + return ( + + + updateSettings({ autoRotate: value })} + /> + + + + + + + + {ScreenOrientationEnum[settings.defaultVideoOrientation]} + + + + + + Orientation + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.DEFAULT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.DEFAULT + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.PORTRAIT_UP, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.PORTRAIT_UP + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT + ] + } + + + + + + + + + updateSettings({ safeAreaInControlsEnabled: value }) + } + /> + + + + Linking.openURL( + "https://github.com/lostb1t/jellyfin-plugin-media-lists" + ) + } + > + updateSettings({ usePopularPlugin: value })} + /> + + + {settings.usePopularPlugin && ( + + {mediaListCollections?.map((mlc) => ( + + { + if (!settings.mediaListCollectionIds) { + updateSettings({ + mediaListCollectionIds: [mlc.Id!], + }); + return; + } + + updateSettings({ + mediaListCollectionIds: + settings.mediaListCollectionIds.includes(mlc.Id!) + ? settings.mediaListCollectionIds.filter( + (id) => id !== mlc.Id + ) + : [...settings.mediaListCollectionIds, mlc.Id!], + }); + }} + /> + + ))} + {isLoadingMediaListCollections && } + {mediaListCollections?.length === 0 && ( + + No collections found. Add some in Jellyfin. + + )} + + )} + + + + + + + {settings.searchEngine} + + + + + + Orientation + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.DEFAULT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.DEFAULT + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.PORTRAIT_UP, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.PORTRAIT_UP + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT + ] + } + + + { + updateSettings({ + defaultVideoOrientation: + ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, + }); + }} + > + + { + ScreenOrientationEnum[ + ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT + ] + } + + + + + + + {settings.searchEngine === "Marlin" && ( + + + setMarlinUrl(text)} + /> + + + + )} + + + Linking.openURL( + "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links" + ) + } + > + + updateSettings({ showCustomMenuLinks: value }) + } + /> + + + ); +}; diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx new file mode 100644 index 00000000..8067187f --- /dev/null +++ b/components/settings/QuickConnect.tsx @@ -0,0 +1,59 @@ +import { Alert, View, ViewProps } from "react-native"; +import { Text } from "../common/Text"; +import { ListItem } from "../list/ListItem"; +import { Button } from "../Button"; +import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; +import { useAtom } from "jotai"; +import Constants from "expo-constants"; +import Application from "expo-application"; +import { ListGroup } from "../list/ListGroup"; +import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; +import * as Haptics from "expo-haptics"; + +interface Props extends ViewProps {} + +export const QuickConnect: React.FC = ({ ...props }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const openQuickConnectAuthCodeInput = () => { + Alert.prompt( + "Quick connect", + "Enter the quick connect code", + async (text) => { + if (text) { + try { + const res = await getQuickConnectApi(api!).authorizeQuickConnect({ + code: text, + userId: user?.Id, + }); + if (res.status === 200) { + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success + ); + Alert.alert("Success", "Quick connect authorized"); + } else { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert("Error", "Invalid code"); + } + } catch (e) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert("Error", "Invalid code"); + } + } + } + ); + }; + + return ( + + + + + + ); +}; diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx deleted file mode 100644 index a83f95f8..00000000 --- a/components/settings/SettingToggles.tsx +++ /dev/null @@ -1,643 +0,0 @@ -import { useDownload } from "@/providers/DownloadProvider"; -import { - apiAtom, - getOrSetDeviceId, - userAtom, -} from "@/providers/JellyfinProvider"; -import { - ScreenOrientationEnum, - Settings, - useSettings, -} from "@/utils/atoms/settings"; -import { - BACKGROUND_FETCH_TASK, - registerBackgroundFetchAsync, - unregisterBackgroundFetchAsync, -} from "@/utils/background-tasks"; -import { getStatistics } from "@/utils/optimize-server"; -import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import * as BackgroundFetch from "expo-background-fetch"; -import * as ScreenOrientation from "expo-screen-orientation"; -import * as TaskManager from "expo-task-manager"; -import { useAtom } from "jotai"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { - Linking, - Switch, - TouchableOpacity, - View, - ViewProps, -} from "react-native"; -import { toast } from "sonner-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; -import { Button } from "../Button"; -import { Input } from "../common/Input"; -import { Text } from "../common/Text"; -import { Loader } from "../Loader"; -import { MediaToggles } from "./MediaToggles"; -import { Stepper } from "@/components/inputs/Stepper"; -import { MediaProvider } from "./MediaContext"; -import { SubtitleToggles } from "./SubtitleToggles"; -import { AudioToggles } from "./AudioToggles"; -import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; -import { ListItem } from "@/components/ListItem"; -import { JellyseerrSettings } from "./Jellyseerr"; - -interface Props extends ViewProps {} - -export const SettingToggles: React.FC = ({ ...props }) => { - const [settings, updateSettings] = useSettings(); - const { setProcesses } = useDownload(); - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const [marlinUrl, setMarlinUrl] = useState(""); - const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = - useState(settings?.optimizedVersionsServerUrl || ""); - - const queryClient = useQueryClient(); - - /******************** - * Background task - *******************/ - const checkStatusAsync = async () => { - await BackgroundFetch.getStatusAsync(); - return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK); - }; - - useEffect(() => { - (async () => { - const registered = await checkStatusAsync(); - - if (settings?.autoDownload === true && !registered) { - registerBackgroundFetchAsync(); - toast.success("Background downloads enabled"); - } else if (settings?.autoDownload === false && registered) { - unregisterBackgroundFetchAsync(); - toast.info("Background downloads disabled"); - } else if (settings?.autoDownload === true && registered) { - // Don't to anything - } else if (settings?.autoDownload === false && !registered) { - // Don't to anything - } else { - updateSettings({ autoDownload: false }); - } - })(); - }, [settings?.autoDownload]); - /********************** - *********************/ - - const { - data: mediaListCollections, - isLoading: isLoadingMediaListCollections, - } = useQuery({ - queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin], - queryFn: async () => { - if (!api || !user?.Id) return []; - - const response = await getItemsApi(api).getItems({ - userId: user.Id, - tags: ["sf_promoted"], - recursive: true, - fields: ["Tags"], - includeItemTypes: ["BoxSet"], - }); - - return response.data.Items ?? []; - }, - enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, - staleTime: 0, - }); - - if (!settings) return null; - - return ( - - {/* - Look and feel - - - - Coming soon - - Options for changing the look and feel of the app. - - - - - - */} - - - - - - - - - Other - - - - - Auto rotate - - Important on android since the video player orientation is - locked to the app orientation. - - - updateSettings({ autoRotate: value })} - /> - - - - - Video orientation - - Set the full screen video player orientation. - - - - - - - {ScreenOrientationEnum[settings.defaultVideoOrientation]} - - - - - Orientation - { - updateSettings({ - defaultVideoOrientation: - ScreenOrientation.OrientationLock.DEFAULT, - }); - }} - > - - { - ScreenOrientationEnum[ - ScreenOrientation.OrientationLock.DEFAULT - ] - } - - - { - updateSettings({ - defaultVideoOrientation: - ScreenOrientation.OrientationLock.PORTRAIT_UP, - }); - }} - > - - { - ScreenOrientationEnum[ - ScreenOrientation.OrientationLock.PORTRAIT_UP - ] - } - - - { - updateSettings({ - defaultVideoOrientation: - ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, - }); - }} - > - - { - ScreenOrientationEnum[ - ScreenOrientation.OrientationLock.LANDSCAPE_LEFT - ] - } - - - { - updateSettings({ - defaultVideoOrientation: - ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT, - }); - }} - > - - { - ScreenOrientationEnum[ - ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT - ] - } - - - - - - - - - Safe area in controls - - Enable safe area in video player controls - - - - updateSettings({ safeAreaInControlsEnabled: value }) - } - /> - - - - - - Use popular lists plugin - Made by: lostb1t - { - Linking.openURL( - "https://github.com/lostb1t/jellyfin-plugin-media-lists" - ); - }} - > - More info - - - - updateSettings({ usePopularPlugin: value }) - } - /> - - {settings.usePopularPlugin && ( - - {mediaListCollections?.map((mlc) => ( - - - {mlc.Name} - - { - if (!settings.mediaListCollectionIds) { - updateSettings({ - mediaListCollectionIds: [mlc.Id!], - }); - return; - } - - updateSettings({ - mediaListCollectionIds: - settings.mediaListCollectionIds.includes(mlc.Id!) - ? settings.mediaListCollectionIds.filter( - (id) => id !== mlc.Id - ) - : [...settings.mediaListCollectionIds, mlc.Id!], - }); - }} - /> - - ))} - {isLoadingMediaListCollections && ( - - - - )} - {mediaListCollections?.length === 0 && ( - - - No collections found. Add some in Jellyfin. - - - )} - - )} - - - - - - Search engine - - Choose the search engine you want to use. - - - - - - {settings.searchEngine} - - - - Profiles - { - updateSettings({ searchEngine: "Jellyfin" }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - Jellyfin - - { - updateSettings({ searchEngine: "Marlin" }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - Marlin - - - - - {settings.searchEngine === "Marlin" && ( - - - - setMarlinUrl(text)} - /> - - - - - {settings.marlinServerUrl && ( - - Current: {settings.marlinServerUrl} - - )} - - )} - - - - - Show Custom Menu Links - - Show custom menu links defined inside your Jellyfin web - config.json file - - - Linking.openURL( - "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links" - ) - } - > - More info - - - - updateSettings({ showCustomMenuLinks: value }) - } - /> - - - - - - Downloads - - - - Download method - - Choose the download method to use. Optimized requires the - optimized server. - - - - - - - {settings.downloadMethod === "remux" - ? "Default" - : "Optimized"} - - - - - Methods - { - updateSettings({ downloadMethod: "remux" }); - setProcesses([]); - }} - > - Default - - { - updateSettings({ downloadMethod: "optimized" }); - setProcesses([]); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} - > - Optimized - - - - - - - Remux max download - - This is the total media you want to be able to download at the - same time. - - - - updateSettings({ - remuxConcurrentLimit: - value as Settings["remuxConcurrentLimit"], - }) - } - /> - - - - Auto download - - This will automatically download the media file when it's - finished optimizing on the server. - - - updateSettings({ autoDownload: value })} - /> - - - - - - - Optimized versions server - - - - Set the URL for the optimized versions server for downloads. - - - - - setOptimizedVersionsServerUrl(text)} - /> - - - - - - - - - - ); -}; diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx new file mode 100644 index 00000000..5b693acd --- /dev/null +++ b/components/settings/StorageSettings.tsx @@ -0,0 +1,109 @@ +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { useDownload } from "@/providers/DownloadProvider"; +import { clearLogs } from "@/utils/log"; +import { useQuery } from "@tanstack/react-query"; +import * as FileSystem from "expo-file-system"; +import * as Haptics from "expo-haptics"; +import { View } from "react-native"; +import * as Progress from "react-native-progress"; +import { toast } from "sonner-native"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; + +export const StorageSettings = () => { + const { deleteAllFiles, appSizeUsage } = useDownload(); + + const { data: size, isLoading: appSizeLoading } = useQuery({ + queryKey: ["appSize", appSizeUsage], + queryFn: async () => { + const app = await appSizeUsage; + + const remaining = await FileSystem.getFreeDiskStorageAsync(); + const total = await FileSystem.getTotalDiskCapacityAsync(); + + return { app, remaining, total, used: (total - remaining) / total }; + }, + }); + + const onDeleteClicked = async () => { + try { + await deleteAllFiles(); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (e) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + toast.error("Error deleting files"); + } + }; + + const calculatePercentage = (value: number, total: number) => { + return ((value / total) * 100).toFixed(2); + }; + + return ( + + + + Storage + {size && ( + + {Number(size.total - size.remaining).bytesToReadable()} of{" "} + {size.total?.bytesToReadable()} used + + )} + + + {size && ( + <> + + + + )} + + + {size && ( + <> + + + + App {calculatePercentage(size.app, size.total)}% + + + + + + Phone{" "} + {calculatePercentage( + size.total - size.remaining - size.app, + size.total + )} + % + + + + )} + + + + + + + ); +}; diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 93745df2..66c514b1 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -3,6 +3,9 @@ import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "../common/Text"; import { useMedia } from "./MediaContext"; import { Switch } from "react-native-gesture-handler"; +import { ListGroup } from "../list/ListGroup"; +import { ListItem } from "../list/ListItem"; +import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; interface Props extends ViewProps {} @@ -11,6 +14,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const media = useMedia(); const { settings, updateSettings } = media; const cultures = media.cultures; + if (!settings) return null; const subtitleModes = [ @@ -22,26 +26,27 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { ]; return ( - - Subtitle - - - - Subtitle language - - Choose a default subtitle language. - - + + + Configure subtitle preferences. + + } + > + - - + + {settings?.defaultSubtitleLanguage?.DisplayName || "None"} + = ({ ...props }) => { ))} - + - - - Subtitle Mode - - Subtitles are loaded based on the default and forced flags in the - embedded metadata. Language preferences are considered when - multiple options are available. - - + - - {settings?.subtitleMode || "Loading"} + + + {settings?.subtitleMode || "Loading"} + + = ({ ...props }) => { ))} - + - - - - - Set Subtitle Track From Previous Item - - - Try to set the subtitle track to the closest match to the last - video. - - - - updateSettings({ rememberSubtitleSelections: value }) - } - /> - - + + + updateSettings({ rememberSubtitleSelections: value }) + } + /> + - - - Subtitle Size - - Choose a default subtitle size for direct play (only works for - some subtitle formats). - - + @@ -170,7 +148,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { > - - + {settings.subtitleSize} = ({ ...props }) => { + - - + + ); }; diff --git a/components/settings/UserInfo.tsx b/components/settings/UserInfo.tsx new file mode 100644 index 00000000..5d80a60d --- /dev/null +++ b/components/settings/UserInfo.tsx @@ -0,0 +1,29 @@ +import { View, ViewProps } from "react-native"; +import { Text } from "../common/Text"; +import { ListItem } from "../list/ListItem"; +import { Button } from "../Button"; +import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; +import { useAtom } from "jotai"; +import Constants from "expo-constants"; +import Application from "expo-application"; +import { ListGroup } from "../list/ListGroup"; + +interface Props extends ViewProps {} + +export const UserInfo: React.FC = ({ ...props }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const version = Application?.nativeApplicationVersion || "N/A"; + + return ( + + + + + + + + + ); +};