diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 56f21af3..2e78e84f 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -4,7 +4,7 @@ import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; import { queueAtom } from "@/utils/atoms/queue"; -import { useSettings } from "@/utils/atoms/settings"; +import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { Ionicons } from "@expo/vector-icons"; import { useNavigation, useRouter } from "expo-router"; import { useAtom } from "jotai"; @@ -96,7 +96,7 @@ export default function page() { > - {settings?.downloadMethod === "remux" && ( + {settings?.downloadMethod === DownloadMethod.Remux && ( Queue diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 38a0d34a..b96802a9 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -15,7 +15,7 @@ import { useJellyfin } from "@/providers/JellyfinProvider"; import { clearLogs } from "@/utils/log"; import { useHaptic } from "@/hooks/useHaptic"; import { useNavigation, useRouter } from "expo-router"; -import { useEffect } from "react"; +import React, { useEffect } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { storage } from "@/utils/mmkv"; diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx index 6919441e..35200bc1 100644 --- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx @@ -8,9 +8,10 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { Switch, View } from "react-native"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); @@ -35,7 +36,10 @@ export default function page() { ); return ( - + {data?.map((view) => ( {}}> @@ -56,6 +60,6 @@ export default function page() { Select the libraries you want to hide from the Library tab and home page sections. - + ); } diff --git a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx index af4247d5..5da08ff1 100644 --- a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx @@ -1,78 +1,16 @@ -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"; +import DisabledSetting from "@/components/settings/DisabledSetting"; 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]); + const [settings, updateSettings, pluginSettings] = useSettings(); return ( - + - + ); } diff --git a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx index b8255c6e..dab489cb 100644 --- a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx @@ -1,12 +1,10 @@ import { Text } from "@/components/common/Text"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; -import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; +import React, {useEffect, useMemo, useState} from "react"; import { Linking, Switch, @@ -15,11 +13,12 @@ import { View, } from "react-native"; import { toast } from "sonner-native"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { const navigation = useNavigation(); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const queryClient = useQueryClient(); const [value, setValue] = useState(settings?.marlinServerUrl || ""); @@ -35,69 +34,81 @@ export default function page() { Linking.openURL("https://github.com/fredrikburmester/marlin-search"); }; + const disabled = useMemo(() => { + return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true + }, [pluginSettings]); + useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - onSave(value)}> - Save - - ), - }); + if (!pluginSettings?.marlinServerUrl?.locked) { + navigation.setOptions({ + headerRight: () => ( + onSave(value)}> + Save + + ), + }); + } }, [navigation, value]); if (!settings) return null; return ( - + - { - updateSettings({ searchEngine: "Jellyfin" }); - queryClient.invalidateQueries({ queryKey: ["search"] }); - }} + - { - updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" }); + { + updateSettings({ searchEngine: "Jellyfin" }); queryClient.invalidateQueries({ queryKey: ["search"] }); }} - /> - + > + { + updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" }); + queryClient.invalidateQueries({ queryKey: ["search"] }); + }} + /> + + - - - - URL - setValue(text)} - /> - + + URL + setValue(text)} + /> - - Enter the URL for the Marlin server. The URL should include http or - https and optionally the port.{" "} - - Read more about Marlin. - + + + Enter the URL for the Marlin server. The URL should include http or + https and optionally the port.{" "} + + Read more about Marlin. - - + + ); } diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx index b47d565f..11930607 100644 --- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx @@ -10,12 +10,13 @@ import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { toast } from "sonner-native"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { const navigation = useNavigation(); const [api] = useAtom(apiAtom); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = useState(settings?.optimizedVersionsServerUrl || ""); @@ -56,25 +57,30 @@ export default function page() { }; useEffect(() => { - navigation.setOptions({ - title: "Optimized Server", - headerRight: () => - saveMutation.isPending ? ( - - ) : ( - onSave(optimizedVersionsServerUrl)}> - Save - - ), - }); + if (!pluginSettings?.optimizedVersionsServerUrl?.locked) { + navigation.setOptions({ + title: "Optimized Server", + headerRight: () => + saveMutation.isPending ? ( + + ) : ( + onSave(optimizedVersionsServerUrl)}> + Save + + ), + }); + } }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); return ( - + - + ); } diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx index 43cf76c4..c7d66e75 100644 --- a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx @@ -9,6 +9,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { Linking, Switch, View } from "react-native"; +import {useMemo} from "react"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export default function page() { const navigation = useNavigation(); @@ -16,7 +18,7 @@ export default function page() { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const handleOpenLink = () => { Linking.openURL( @@ -48,13 +50,22 @@ export default function page() { staleTime: 0, }); + const disabled = useMemo(() => ( + pluginSettings?.usePopularPlugin?.locked === true && + pluginSettings?.mediaListCollectionIds?.locked === true + ), [pluginSettings]); + if (!settings) return null; return ( - + { updateSettings({ usePopularPlugin: true }); queryClient.invalidateQueries({ queryKey: ["search"] }); @@ -62,9 +73,10 @@ export default function page() { > { - updateSettings({ usePopularPlugin: value }); - }} + disabled={pluginSettings?.usePopularPlugin?.locked} + onValueChange={(usePopularPlugin) => + updateSettings({ usePopularPlugin }) + } /> @@ -88,11 +100,14 @@ export default function page() { <> {mediaListCollections?.map((mlc) => ( - + { if (!settings.mediaListCollectionIds) { updateSettings({ @@ -130,6 +145,6 @@ export default function page() { )} )} - + ); } diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index 17813ed1..439e41df 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -6,7 +6,7 @@ import { Platform } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; export default function IndexLayout() { - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); if (!settings?.libraryOptions) return null; @@ -25,6 +25,7 @@ export default function IndexLayout() { headerTransparent: Platform.OS === "ios" ? true : false, headerShadowVisible: false, headerRight: () => ( + !pluginSettings?.libraryOptions?.locked && ( + url: string, + config?: AxiosRequestConfig + ): Promise>; + post( + url: string, + data: D, + config?: AxiosRequestConfig + ): Promise>; + getStreamyfinPluginConfig(): Promise>; + } +} + +Api.prototype.get = function ( + url: string, + config: AxiosRequestConfig = {} +): Promise> { + return this.axiosInstance.get(`${this.basePath}${url}`, { + ...(config ?? {}), + headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader }, + }); +}; + +Api.prototype.post = function ( + url: string, + data: D, + config: AxiosRequestConfig +): Promise> { + return this.axiosInstance.post(`${this.basePath}${url}`, { + ...(config || {}), + data, + headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader }, + }); +}; + +Api.prototype.getStreamyfinPluginConfig = function (): Promise< + AxiosResponse +> { + return this.get("/Streamyfin/config"); +}; diff --git a/augmentations/index.ts b/augmentations/index.ts index 22ca2cb0..abec02c9 100644 --- a/augmentations/index.ts +++ b/augmentations/index.ts @@ -1,3 +1,4 @@ +export * from "./api"; export * from "./mmkv"; export * from "./number"; export * from "./string"; diff --git a/augmentations/mmkv.ts b/augmentations/mmkv.ts index 80fbeede..5667502f 100644 --- a/augmentations/mmkv.ts +++ b/augmentations/mmkv.ts @@ -13,5 +13,10 @@ MMKV.prototype.get = function (key: string): T | undefined { } MMKV.prototype.setAny = function (key: string, value: any | undefined): void { - this.set(key, JSON.stringify(value)); + if (value === undefined) { + this.delete(key) + } + else { + this.set(key, JSON.stringify(value)); + } } \ No newline at end of file diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 4618bb4f..35ed063c 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { queueActions, queueAtom } from "@/utils/atoms/queue"; -import { useSettings } from "@/utils/atoms/settings"; +import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; @@ -74,7 +74,7 @@ export const DownloadItems: React.FC = ({ [user] ); const usingOptimizedServer = useMemo( - () => settings?.downloadMethod === "optimized", + () => settings?.downloadMethod === DownloadMethod.Optimized, [settings] ); diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx index d9612d48..fec36d2f 100644 --- a/components/common/Dropdown.tsx +++ b/components/common/Dropdown.tsx @@ -1,14 +1,16 @@ import * as DropdownMenu from "zeego/dropdown-menu"; import {TouchableOpacity, View, ViewProps} from "react-native"; import {Text} from "@/components/common/Text"; -import React, {PropsWithChildren, useEffect, useState} from "react"; +import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react"; +import DisabledSetting from "@/components/settings/DisabledSetting"; interface Props { data: T[] + disabled?: boolean placeholderText?: string, keyExtractor: (item: T) => string - titleExtractor: (item: T) => string - title: string, + titleExtractor: (item: T) => string | undefined + title: string | ReactNode, label: string, onSelected: (...item: T[]) => void multi?: boolean @@ -16,6 +18,7 @@ interface Props { const Dropdown = ({ data, + disabled, placeholderText, keyExtractor, titleExtractor, @@ -34,20 +37,30 @@ const Dropdown = ({ }, [selected]); return ( - + - - - {title} - - - - {selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText} + {typeof title === 'string' ? ( + + + {title} - - + + + {selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText} + + + + ) : ( + <> + {title} + + )} ({ ))} - + ) }; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 556ae8c7..e42027ab 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -1,7 +1,6 @@ import { Text } from "@/components/common/Text"; import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; +import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { JobStatus } from "@/utils/optimize-server"; import { formatTimeString } from "@/utils/time"; import { Ionicons } from "@expo/vector-icons"; @@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; -import { useAtom } from "jotai"; import { ActivityIndicator, TouchableOpacity, @@ -62,7 +60,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { mutationFn: async (id: string) => { if (!process) throw new Error("No active download"); - if (settings?.downloadMethod === "optimized") { + if (settings?.downloadMethod === DownloadMethod.Optimized) { try { const tasks = await checkForExistingDownloads(); for (const task of tasks) { diff --git a/components/inputs/Stepper.tsx b/components/inputs/Stepper.tsx index eb5032cf..86a4ffa9 100644 --- a/components/inputs/Stepper.tsx +++ b/components/inputs/Stepper.tsx @@ -1,8 +1,10 @@ import {TouchableOpacity, View} from "react-native"; import {Text} from "@/components/common/Text"; +import DisabledSetting from "@/components/settings/DisabledSetting"; interface StepperProps { value: number, + disabled?: boolean, step: number, min: number, max: number, @@ -12,6 +14,7 @@ interface StepperProps { export const Stepper: React.FC = ({ value, + disabled, step, min, max, @@ -19,7 +22,11 @@ export const Stepper: React.FC = ({ appendValue }) => { return ( - + onUpdate(Math.max(min, value - step))} className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" @@ -39,6 +46,6 @@ export const Stepper: React.FC = ({ > + - + ) } \ No newline at end of file diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx index 62aea437..6afaedf4 100644 --- a/components/settings/AudioToggles.tsx +++ b/components/settings/AudioToggles.tsx @@ -6,11 +6,13 @@ import { Switch } from "react-native-gesture-handler"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; import { Ionicons } from "@expo/vector-icons"; +import {useSettings} from "@/utils/atoms/settings"; interface Props extends ViewProps {} export const AudioToggles: React.FC = ({ ...props }) => { const media = useMedia(); + const [_, __, pluginSettings] = useSettings(); const { settings, updateSettings } = media; const cultures = media.cultures; @@ -26,9 +28,13 @@ export const AudioToggles: React.FC = ({ ...props }) => { } > - + updateSettings({ rememberAudioSelections: value }) } diff --git a/components/settings/DisabledSetting.tsx b/components/settings/DisabledSetting.tsx new file mode 100644 index 00000000..b340fb96 --- /dev/null +++ b/components/settings/DisabledSetting.tsx @@ -0,0 +1,26 @@ +import {View, ViewProps} from "react-native"; +import {Text} from "@/components/common/Text"; + +const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({ + disabled = false, + showText = true, + text, + children, + ...props +}) => ( + + + {disabled && showText && + {text ?? "Currently disabled by admin."} + } + {children} + + +) + +export default DisabledSetting; \ No newline at end of file diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index f330dc04..7937b8a8 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -1,33 +1,47 @@ -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, View } from "react-native"; +import {Stepper} from "@/components/inputs/Stepper"; +import {useDownload} from "@/providers/DownloadProvider"; +import {DownloadMethod, 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, {useMemo} 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"; +import {Text} from "../common/Text"; +import {ListGroup} from "../list/ListGroup"; +import {ListItem} from "../list/ListItem"; +import DisabledSetting from "@/components/settings/DisabledSetting"; export const DownloadSettings: React.FC = ({ ...props }) => { - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const { setProcesses } = useDownload(); const router = useRouter(); const queryClient = useQueryClient(); + const disabled = useMemo(() => ( + pluginSettings?.downloadMethod?.locked === true && + pluginSettings?.remuxConcurrentLimit?.locked === true && + pluginSettings?.autoDownload.locked === true + ), [pluginSettings]) + if (!settings) return null; return ( - + - + - {settings.downloadMethod === "remux" + {settings.downloadMethod === DownloadMethod.Remux ? "Default" : "Optimized"} @@ -51,7 +65,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { { - updateSettings({ downloadMethod: "remux" }); + updateSettings({ downloadMethod: DownloadMethod.Remux }); setProcesses([]); }} > @@ -60,7 +74,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { { - updateSettings({ downloadMethod: "optimized" }); + updateSettings({ downloadMethod: DownloadMethod.Optimized }); setProcesses([]); queryClient.invalidateQueries({ queryKey: ["search"] }); }} @@ -73,7 +87,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => { { 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 148f1823..d0ebd9df 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -21,7 +21,7 @@ export const JellyseerrSettings = () => { } = useJellyseerr(); const [user] = useAtom(userAtom); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const [promptForJellyseerrPass, setPromptForJellyseerrPass] = useState(false); diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx index 7e4c4346..6283cb03 100644 --- a/components/settings/MediaToggles.tsx +++ b/components/settings/MediaToggles.tsx @@ -1,72 +1,61 @@ -import React from "react"; -import { TouchableOpacity, View, ViewProps } from "react-native"; +import React, {useMemo} from "react"; +import { 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"; +import DisabledSetting from "@/components/settings/DisabledSetting"; +import {Stepper} from "@/components/inputs/Stepper"; interface Props extends ViewProps {} export const MediaToggles: React.FC = ({ ...props }) => { - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); if (!settings) return null; - const renderSkipControl = ( - value: number, - onDecrease: () => void, - onIncrease: () => void - ) => ( - - - - - - - {value}s - - - + - - - ); + const disabled = useMemo(() => ( + pluginSettings?.forwardSkipTime?.locked === true && + pluginSettings?.rewindSkipTime?.locked === true + ), + [pluginSettings] + ) return ( - + - - {renderSkipControl( - settings.forwardSkipTime, - () => - updateSettings({ - forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5), - }), - () => - updateSettings({ - forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5), - }) - )} + + updateSettings({forwardSkipTime})} + /> - - {renderSkipControl( - settings.rewindSkipTime, - () => - updateSettings({ - rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5), - }), - () => - updateSettings({ - rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5), - }) - )} + + updateSettings({rewindSkipTime})} + /> - + ); }; diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx index cbd8fc18..dcea3f26 100644 --- a/components/settings/OtherSettings.tsx +++ b/components/settings/OtherSettings.tsx @@ -9,19 +9,18 @@ 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 React, { useEffect } from "react"; -import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native"; +import React, {useEffect, useMemo} from "react"; +import { Linking, Switch, TouchableOpacity } from "react-native"; import { toast } from "sonner-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "../common/Text"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; - -interface Props extends ViewProps {} +import DisabledSetting from "@/components/settings/DisabledSetting"; +import Dropdown from "@/components/common/Dropdown"; export const OtherSettings: React.FC = () => { const router = useRouter(); - const [settings, updateSettings] = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); /******************** * Background task @@ -53,146 +52,114 @@ export const OtherSettings: React.FC = () => { /********************** *********************/ + const disabled = useMemo(() => ( + pluginSettings?.autoRotate?.locked === true && + pluginSettings?.defaultVideoOrientation?.locked === true && + pluginSettings?.safeAreaInControlsEnabled?.locked === true && + pluginSettings?.showCustomMenuLinks?.locked === true && + pluginSettings?.hiddenLibraries?.locked === true && + pluginSettings?.disableHapticFeedback?.locked === true + ), [pluginSettings]); + + const orientations = [ + ScreenOrientation.OrientationLock.DEFAULT, + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT, + ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT + ] + if (!settings) return null; return ( - - - updateSettings({ autoRotate: value })} - /> - + + + + 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 - ] - } - - - - - + + + ScreenOrientationEnum[item] + } + title={ + + + {ScreenOrientationEnum[settings.defaultVideoOrientation]} + + + + } + label="Orientation" + onSelected={(defaultVideoOrientation) => + updateSettings({defaultVideoOrientation}) + } + /> + - - - updateSettings({ safeAreaInControlsEnabled: value }) - } - /> - + + + updateSettings({safeAreaInControlsEnabled: value}) + } + /> + - - Linking.openURL( - "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links" - ) - } - > - - updateSettings({ showCustomMenuLinks: value }) + + Linking.openURL( + "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links" + ) } + > + + updateSettings({showCustomMenuLinks: value}) + } + /> + + router.push("/settings/hide-libraries/page")} + title="Hide Libraries" + showArrow /> - - router.push("/settings/hide-libraries/page")} - title="Hide Libraries" - showArrow - /> - - - updateSettings({ disableHapticFeedback: value }) - } - /> - - + + + updateSettings({disableHapticFeedback}) + } + /> + + + ); }; diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 66c514b1..6719171d 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -7,11 +7,15 @@ import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; +import {useSettings} from "@/utils/atoms/settings"; +import {Stepper} from "@/components/inputs/Stepper"; +import Dropdown from "@/components/common/Dropdown"; interface Props extends ViewProps {} export const SubtitleToggles: React.FC = ({ ...props }) => { const media = useMedia(); + const [_, __, pluginSettings] = useSettings(); const { settings, updateSettings } = media; const cultures = media.cultures; @@ -36,8 +40,11 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { } > - - + item?.ThreeLetterISOLanguageName ?? "unknown"} + titleExtractor={(item) => item?.DisplayName} + title={ {settings?.defaultSubtitleLanguage?.DisplayName || "None"} @@ -48,48 +55,28 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { color="#5A5960" /> - - - Languages - { - updateSettings({ - defaultSubtitleLanguage: null, - }); - }} - > - None - - {cultures?.map((l) => ( - { - updateSettings({ - defaultSubtitleLanguage: l, - }); - }} - > - - {l.DisplayName} - - - ))} - - + } + label="Languages" + onSelected={(defaultSubtitleLanguage) => + updateSettings({ + defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === "None" + ? null + : defaultSubtitleLanguage + }) + } + /> - - - + + {settings?.subtitleMode || "Loading"} @@ -100,68 +87,39 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { color="#5A5960" /> - - - Subtitle Mode - {subtitleModes?.map((l) => ( - { - updateSettings({ - subtitleMode: l, - }); - }} - > - {l} - - ))} - - + } + label="Subtitle Mode" + onSelected={(subtitleMode) => + updateSettings({subtitleMode}) + } + /> - + updateSettings({ rememberSubtitleSelections: value }) } /> - - - - updateSettings({ - subtitleSize: Math.max(0, settings.subtitleSize - 5), - }) - } - className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" - > - - - - - {settings.subtitleSize} - - - updateSettings({ - subtitleSize: Math.min(120, settings.subtitleSize + 5), - }) - } - > - + - - + + updateSettings({subtitleSize})} + /> diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index fb8b137f..51f1f35c 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,4 +1,4 @@ -import { useSettings } from "@/utils/atoms/settings"; +import {DownloadMethod, useSettings} from "@/utils/atoms/settings"; import { getOrSetDeviceId } from "@/utils/device"; import { useLog, writeToLog } from "@/utils/log"; import { @@ -106,7 +106,7 @@ function useDownloadProvider() { const url = settings?.optimizedVersionsServerUrl; if ( - settings?.downloadMethod !== "optimized" || + settings?.downloadMethod !== DownloadMethod.Optimized || !url || !deviceId || !authHeader @@ -166,7 +166,7 @@ function useDownloadProvider() { }, staleTime: 0, refetchInterval: 2000, - enabled: settings?.downloadMethod === "optimized", + enabled: settings?.downloadMethod === DownloadMethod.Optimized, }); useEffect(() => { diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index f4ccce75..2b602323 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -1,3 +1,4 @@ +import "@/augmentations"; import { useInterval } from "@/hooks/useInterval"; import { storage } from "@/utils/mmkv"; import { Api, Jellyfin } from "@jellyfin/sdk"; @@ -19,7 +20,8 @@ import React, { import { Platform } from "react-native"; import uuid from "react-native-uuid"; import { getDeviceName } from "react-native-device-info"; -import { toast } from "sonner-native"; +import { useSettings } from "@/utils/atoms/settings"; +import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; interface Server { address: string; @@ -70,6 +72,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [user, setUser] = useAtom(userAtom); const [isPolling, setIsPolling] = useState(false); const [secret, setSecret] = useState(null); + const [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] = useSettings(); + const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); useQuery({ queryKey: ["user", api], @@ -226,6 +230,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ storage.set("user", JSON.stringify(auth.data.User)); setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken)); storage.set("token", auth.data?.AccessToken); + + const recentPluginSettings = await refreshStreamyfinPluginSettings(); + if (recentPluginSettings?.jellyseerrServerUrl?.value) { + const jellyseerrApi = new JellyseerrApi(recentPluginSettings.jellyseerrServerUrl.value); + await jellyseerrApi.test().then((result) => { + if (result.isValid && result.requiresPass) { + jellyseerrApi.login(username, password).then(setJellyseerrUser); + } + }) + } } } catch (error) { if (axios.isAxiosError(error)) { @@ -262,6 +276,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ mutationFn: async () => { storage.delete("token"); setUser(null); + setPluginSettings(undefined); + await clearAllJellyseerData(); }, onError: (error) => { console.error("Logout failed:", error); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index b473198d..993c722d 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -1,12 +1,19 @@ import { atom, useAtom } from "jotai"; -import { useEffect } from "react"; +import {useCallback, useEffect, useMemo} from "react"; import * as ScreenOrientation from "expo-screen-orientation"; import { storage } from "../mmkv"; import { Platform } from "react-native"; import { CultureDto, + PluginStatus, SubtitlePlaybackMode, } from "@jellyfin/sdk/lib/generated-client"; +import {apiAtom} from "@/providers/JellyfinProvider"; +import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api"; +import {writeErrorLog} from "@/utils/log"; + +const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004" +const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS" export type DownloadQuality = "original" | "high" | "low"; @@ -59,6 +66,11 @@ export type DefaultLanguageOption = { label: string; }; +export enum DownloadMethod { + Remux = "remux", + Optimized = "optimized" +} + export type Settings = { autoRotate?: boolean; forceLandscapeInVideoPlayer?: boolean; @@ -81,7 +93,7 @@ export type Settings = { forwardSkipTime: number; rewindSkipTime: number; optimizedVersionsServerUrl?: string | null; - downloadMethod: "optimized" | "remux"; + downloadMethod: DownloadMethod; autoDownload: boolean; showCustomMenuLinks: boolean; disableHapticFeedback: boolean; @@ -92,6 +104,16 @@ export type Settings = { hiddenLibraries?: string[]; }; +export interface Lockable { + locked: boolean; + value: T +} + +export type PluginLockableSettings = { [K in keyof Settings]: Lockable }; +export type StreamyfinPluginConfig = { + settings: PluginLockableSettings +} + const loadSettings = (): Settings => { const defaultValues: Settings = { autoRotate: true, @@ -121,7 +143,7 @@ const loadSettings = (): Settings => { forwardSkipTime: 30, rewindSkipTime: 10, optimizedVersionsServerUrl: null, - downloadMethod: "remux", + downloadMethod: DownloadMethod.Remux, autoDownload: false, showCustomMenuLinks: false, disableHapticFeedback: false, @@ -150,16 +172,76 @@ const saveSettings = (settings: Settings) => { }; export const settingsAtom = atom(null); +export const pluginSettingsAtom = atom(storage.get(STREAMYFIN_PLUGIN_SETTINGS)); export const useSettings = () => { - const [settings, setSettings] = useAtom(settingsAtom); + const [api] = useAtom(apiAtom); + const [_settings, setSettings] = useAtom(settingsAtom); + const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom); useEffect(() => { - if (settings === null) { + if (_settings === null) { const loadedSettings = loadSettings(); setSettings(loadedSettings); } - }, [settings, setSettings]); + }, [_settings, setSettings]); + + const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => { + storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings) + _setPluginSettings(settings) + }, + [_setPluginSettings] + ) + + const refreshStreamyfinPluginSettings = useCallback( + async () => { + if (!api) + return + + const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data); + + if (plugins && plugins.length > 0) { + const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID); + + if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) { + writeErrorLog( + "Streamyfin plugin is currently not active.\n" + + `Current status is: ${streamyfinPlugin?.Status}` + ); + setPluginSettings(undefined); + return; + } + + const settings = await api.getStreamyfinPluginConfig() + .then(({data}) => data.settings) + + setPluginSettings(settings); + return settings; + } + }, + [api] + ) + + // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting. + // If admin sets locked to false but provides a value, + // use user settings first and fallback on admin setting if required. + const settings: Settings = useMemo(() => { + const overrideSettings = Object.entries(pluginSettings || {}) + .reduce((acc, [key, setting]) => { + if (setting) { + const {value, locked} = setting + acc = Object.assign(acc, { + [key]: locked ? value : _settings?.[key as keyof Settings] ?? value + }) + } + return acc + }, {} as Settings) + + return { + ..._settings, + ...overrideSettings + } + }, [_settings, setSettings, pluginSettings, _setPluginSettings, setPluginSettings]) const updateSettings = (update: Partial) => { if (settings) { @@ -170,5 +252,5 @@ export const useSettings = () => { } }; - return [settings, updateSettings] as const; + return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const; };