Merge pull request #402 from streamyfin/feat/401

Streamyfin Plugin App Management solution
This commit is contained in:
herrrta
2025-01-11 00:20:35 -05:00
committed by GitHub
25 changed files with 601 additions and 498 deletions

View File

@@ -4,7 +4,7 @@ import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard"; import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue"; 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 { Ionicons } from "@expo/vector-icons";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -96,7 +96,7 @@ export default function page() {
> >
<View className="py-4"> <View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4"> <View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && ( {settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl"> <View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text> <Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600"> <Text className="text-xs opacity-70 text-red-600">

View File

@@ -15,7 +15,7 @@ import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useEffect } from "react"; import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";

View File

@@ -8,9 +8,10 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Switch, View } from "react-native"; import { Switch, View } from "react-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -35,7 +36,10 @@ export default function page() {
); );
return ( return (
<View className="px-4"> <DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className="px-4"
>
<ListGroup> <ListGroup>
{data?.map((view) => ( {data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}> <ListItem key={view.Id} title={view.Name} onPress={() => {}}>
@@ -56,6 +60,6 @@ export default function page() {
Select the libraries you want to hide from the Library tab and home page Select the libraries you want to hide from the Library tab and home page
sections. sections.
</Text> </Text>
</View> </DisabledSetting>
); );
} }

View File

@@ -1,78 +1,16 @@
import { Text } from "@/components/common/Text";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device"; import DisabledSetting from "@/components/settings/DisabledSetting";
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() { export default function page() {
const navigation = useNavigation(); const [settings, updateSettings, pluginSettings] = useSettings();
const [api] = useAtom(apiAtom);
const [settings, updateSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(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 ? (
// <ActivityIndicator size={"small"} color={"white"} />
// ) : (
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
// <Text className="text-blue-500">Save</Text>
// </TouchableOpacity>
// ),
// });
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return ( return (
<View className="p-4"> <DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className="p-4"
>
<JellyseerrSettings /> <JellyseerrSettings />
</View> </DisabledSetting>
); );
} }

View File

@@ -1,12 +1,10 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import React, {useEffect, useMemo, useState} from "react";
import { useEffect, useState } from "react";
import { import {
Linking, Linking,
Switch, Switch,
@@ -15,11 +13,12 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
@@ -35,69 +34,81 @@ export default function page() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search"); Linking.openURL("https://github.com/fredrikburmester/marlin-search");
}; };
const disabled = useMemo(() => {
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
}, [pluginSettings]);
useEffect(() => { useEffect(() => {
navigation.setOptions({ if (!pluginSettings?.marlinServerUrl?.locked) {
headerRight: () => ( navigation.setOptions({
<TouchableOpacity onPress={() => onSave(value)}> headerRight: () => (
<Text className="text-blue-500">Save</Text> <TouchableOpacity onPress={() => onSave(value)}>
</TouchableOpacity> <Text className="text-blue-500">Save</Text>
), </TouchableOpacity>
}); ),
});
}
}, [navigation, value]); }, [navigation, value]);
if (!settings) return null; if (!settings) return null;
return ( return (
<View className="px-4"> <DisabledSetting
disabled={disabled}
className="px-4"
>
<ListGroup> <ListGroup>
<ListItem <DisabledSetting
title={"Enable Marlin Search"} disabled={pluginSettings?.searchEngine?.locked === true}
onPress={() => { showText={!pluginSettings?.marlinServerUrl?.locked}
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
> >
<Switch <ListItem
value={settings.searchEngine === "Marlin"} title={"Enable Marlin Search"}
onValueChange={(value) => { onPress={() => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" }); updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
/> >
</ListItem> <Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup> </ListGroup>
<View <DisabledSetting
className={`mt-2 ${ disabled={pluginSettings?.marlinServerUrl?.locked === true}
settings.searchEngine === "Marlin" ? "" : "opacity-50" showText={!pluginSettings?.searchEngine?.locked}
}`} className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
> >
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"> <View
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`} >
> <Text className="mr-4">URL</Text>
<Text className="mr-4">URL</Text> <TextInput
<TextInput editable={settings.searchEngine === "Marlin"}
editable={settings.searchEngine === "Marlin"} className="text-white"
className="text-white" placeholder="http(s)://domain.org:port"
placeholder="http(s)://domain.org:port" value={value}
value={value} keyboardType="url"
keyboardType="url" returnKeyType="done"
returnKeyType="done" autoCapitalize="none"
autoCapitalize="none" textContentType="URL"
textContentType="URL" onChangeText={(text) => setValue(text)}
onChangeText={(text) => setValue(text)} />
/>
</View>
</View> </View>
<Text className="px-4 text-xs text-neutral-500 mt-1"> </DisabledSetting>
Enter the URL for the Marlin server. The URL should include http or <Text className="px-4 text-xs text-neutral-500 mt-1">
https and optionally the port.{" "} Enter the URL for the Marlin server. The URL should include http or
<Text className="text-blue-500" onPress={handleOpenLink}> https and optionally the port.{" "}
Read more about Marlin. <Text className="text-blue-500" onPress={handleOpenLink}>
</Text> Read more about Marlin.
</Text> </Text>
</View> </Text>
</View> </DisabledSetting>
); );
} }

View File

@@ -10,12 +10,13 @@ import { useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || ""); useState<string>(settings?.optimizedVersionsServerUrl || "");
@@ -56,25 +57,30 @@ export default function page() {
}; };
useEffect(() => { useEffect(() => {
navigation.setOptions({ if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
title: "Optimized Server", navigation.setOptions({
headerRight: () => title: "Optimized Server",
saveMutation.isPending ? ( headerRight: () =>
<ActivityIndicator size={"small"} color={"white"} /> saveMutation.isPending ? (
) : ( <ActivityIndicator size={"small"} color={"white"} />
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}> ) : (
<Text className="text-blue-500">Save</Text> <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
</TouchableOpacity> <Text className="text-blue-500">Save</Text>
), </TouchableOpacity>
}); ),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return ( return (
<View className="p-4"> <DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
>
<OptimizedServerForm <OptimizedServerForm
value={optimizedVersionsServerUrl} value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl} onChangeValue={setOptimizedVersionsServerUrl}
/> />
</View> </DisabledSetting>
); );
} }

View File

@@ -9,6 +9,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Linking, Switch, View } from "react-native"; import { Linking, Switch, View } from "react-native";
import {useMemo} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -16,7 +18,7 @@ export default function page() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const handleOpenLink = () => { const handleOpenLink = () => {
Linking.openURL( Linking.openURL(
@@ -48,13 +50,22 @@ export default function page() {
staleTime: 0, staleTime: 0,
}); });
const disabled = useMemo(() => (
pluginSettings?.usePopularPlugin?.locked === true &&
pluginSettings?.mediaListCollectionIds?.locked === true
), [pluginSettings]);
if (!settings) return null; if (!settings) return null;
return ( return (
<View className="px-4 pt-4"> <DisabledSetting
disabled={disabled}
className="px-4 pt-4"
>
<ListGroup title={"Enable plugin"} className=""> <ListGroup title={"Enable plugin"} className="">
<ListItem <ListItem
title={"Enable Popular Lists"} title={"Enable Popular Lists"}
disabled={pluginSettings?.usePopularPlugin?.locked}
onPress={() => { onPress={() => {
updateSettings({ usePopularPlugin: true }); updateSettings({ usePopularPlugin: true });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -62,9 +73,10 @@ export default function page() {
> >
<Switch <Switch
value={settings.usePopularPlugin} value={settings.usePopularPlugin}
onValueChange={(value) => { disabled={pluginSettings?.usePopularPlugin?.locked}
updateSettings({ usePopularPlugin: value }); onValueChange={(usePopularPlugin) =>
}} updateSettings({ usePopularPlugin })
}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
@@ -88,11 +100,14 @@ export default function page() {
<> <>
<ListGroup title="Media List Collections" className="mt-4"> <ListGroup title="Media List Collections" className="mt-4">
{mediaListCollections?.map((mlc) => ( {mediaListCollections?.map((mlc) => (
<ListItem key={mlc.Id} title={mlc.Name}> <ListItem
key={mlc.Id}
title={mlc.Name}
disabled={pluginSettings?.mediaListCollectionIds?.locked}
>
<Switch <Switch
value={settings.mediaListCollectionIds?.includes( disabled={pluginSettings?.mediaListCollectionIds?.locked}
mlc.Id! value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
)}
onValueChange={(value) => { onValueChange={(value) => {
if (!settings.mediaListCollectionIds) { if (!settings.mediaListCollectionIds) {
updateSettings({ updateSettings({
@@ -130,6 +145,6 @@ export default function page() {
)} )}
</> </>
)} )}
</View> </DisabledSetting>
); );
} }

View File

@@ -6,7 +6,7 @@ import { Platform } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
export default function IndexLayout() { export default function IndexLayout() {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
@@ -25,6 +25,7 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios" ? true : false, headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
!pluginSettings?.libraryOptions?.locked &&
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Ionicons <Ionicons

46
augmentations/api.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
import { AxiosRequestConfig, AxiosResponse } from "axios";
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
declare module "@jellyfin/sdk" {
interface Api {
get<T, D = any>(
url: string,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>>;
post<T, D = any>(
url: string,
data: D,
config?: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>>;
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
}
}
Api.prototype.get = function <T, D = any>(
url: string,
config: AxiosRequestConfig<D> = {}
): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
...(config ?? {}),
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};
Api.prototype.post = function <T, D = any>(
url: string,
data: D,
config: AxiosRequestConfig<D>
): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
...(config || {}),
data,
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
});
};
Api.prototype.getStreamyfinPluginConfig = function (): Promise<
AxiosResponse<StreamyfinPluginConfig>
> {
return this.get<StreamyfinPluginConfig>("/Streamyfin/config");
};

View File

@@ -1,3 +1,4 @@
export * from "./api";
export * from "./mmkv"; export * from "./mmkv";
export * from "./number"; export * from "./number";
export * from "./string"; export * from "./string";

View File

@@ -13,5 +13,10 @@ MMKV.prototype.get = function <T> (key: string): T | undefined {
} }
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { 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));
}
} }

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue"; 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 { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -74,7 +74,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[user] [user]
); );
const usingOptimizedServer = useMemo( const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === "optimized", () => settings?.downloadMethod === DownloadMethod.Optimized,
[settings] [settings]
); );

View File

@@ -1,14 +1,16 @@
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import {TouchableOpacity, View, ViewProps} from "react-native"; import {TouchableOpacity, View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text"; 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<T> { interface Props<T> {
data: T[] data: T[]
disabled?: boolean
placeholderText?: string, placeholderText?: string,
keyExtractor: (item: T) => string keyExtractor: (item: T) => string
titleExtractor: (item: T) => string titleExtractor: (item: T) => string | undefined
title: string, title: string | ReactNode,
label: string, label: string,
onSelected: (...item: T[]) => void onSelected: (...item: T[]) => void
multi?: boolean multi?: boolean
@@ -16,6 +18,7 @@ interface Props<T> {
const Dropdown = <T extends unknown>({ const Dropdown = <T extends unknown>({
data, data,
disabled,
placeholderText, placeholderText,
keyExtractor, keyExtractor,
titleExtractor, titleExtractor,
@@ -34,20 +37,30 @@ const Dropdown = <T extends unknown>({
}, [selected]); }, [selected]);
return ( return (
<View {...props}> <DisabledSetting
disabled={disabled === true}
showText={false}
{...props}
>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col"> {typeof title === 'string' ? (
<Text className="opacity-50 mb-1 text-xs"> <View className="flex flex-col">
{title} <Text className="opacity-50 mb-1 text-xs">
</Text> {title}
<TouchableOpacity
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
</Text> </Text>
</TouchableOpacity> <TouchableOpacity
</View> className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
</Text>
</TouchableOpacity>
</View>
) : (
<>
{title}
</>
)}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={false} loop={false}
@@ -88,7 +101,7 @@ const Dropdown = <T extends unknown>({
))} ))}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</View> </DisabledSetting>
) )
}; };

View File

@@ -1,7 +1,6 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider"; import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server"; import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native"; import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import { import {
ActivityIndicator, ActivityIndicator,
TouchableOpacity, TouchableOpacity,
@@ -62,7 +60,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
if (!process) throw new Error("No active download"); if (!process) throw new Error("No active download");
if (settings?.downloadMethod === "optimized") { if (settings?.downloadMethod === DownloadMethod.Optimized) {
try { try {
const tasks = await checkForExistingDownloads(); const tasks = await checkForExistingDownloads();
for (const task of tasks) { for (const task of tasks) {

View File

@@ -1,8 +1,10 @@
import {TouchableOpacity, View} from "react-native"; import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text"; import {Text} from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface StepperProps { interface StepperProps {
value: number, value: number,
disabled?: boolean,
step: number, step: number,
min: number, min: number,
max: number, max: number,
@@ -12,6 +14,7 @@ interface StepperProps {
export const Stepper: React.FC<StepperProps> = ({ export const Stepper: React.FC<StepperProps> = ({
value, value,
disabled,
step, step,
min, min,
max, max,
@@ -19,7 +22,11 @@ export const Stepper: React.FC<StepperProps> = ({
appendValue appendValue
}) => { }) => {
return ( return (
<View className="flex flex-row items-center"> <DisabledSetting
disabled={disabled === true}
showText={false}
className="flex flex-row items-center"
>
<TouchableOpacity <TouchableOpacity
onPress={() => onUpdate(Math.max(min, value - step))} onPress={() => onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" 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<StepperProps> = ({
> >
<Text>+</Text> <Text>+</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </DisabledSetting>
) )
} }

View File

@@ -6,11 +6,13 @@ import { Switch } from "react-native-gesture-handler";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {useSettings} from "@/utils/atoms/settings";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const AudioToggles: React.FC<Props> = ({ ...props }) => { export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia(); const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
@@ -26,9 +28,13 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
</Text> </Text>
} }
> >
<ListItem title={"Set Audio Track From Previous Item"}> <ListItem
title={"Set Audio Track From Previous Item"}
disabled={pluginSettings?.rememberAudioSelections?.locked}
>
<Switch <Switch
value={settings.rememberAudioSelections} value={settings.rememberAudioSelections}
disabled={pluginSettings?.rememberAudioSelections?.locked}
onValueChange={(value) => onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value }) updateSettings({ rememberAudioSelections: value })
} }

View File

@@ -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
}) => (
<View
pointerEvents={disabled ? "none" : "auto"}
style={{
opacity: disabled ? 0.5 : 1,
}}
>
<View {...props}>
{disabled && showText &&
<Text className="text-center text-red-700 my-4">{text ?? "Currently disabled by admin."}</Text>
}
{children}
</View>
</View>
)
export default DisabledSetting;

View File

@@ -1,33 +1,47 @@
import { Stepper } from "@/components/inputs/Stepper"; import {Stepper} from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider"; import {useDownload} from "@/providers/DownloadProvider";
import { Settings, useSettings } from "@/utils/atoms/settings"; import {DownloadMethod, Settings, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import {Ionicons} from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query"; import {useQueryClient} from "@tanstack/react-query";
import { useRouter } from "expo-router"; import {useRouter} from "expo-router";
import React from "react"; import React, {useMemo} from "react";
import { Switch, TouchableOpacity, View } from "react-native"; import {Switch, TouchableOpacity} from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import {Text} from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import {ListGroup} from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import {ListItem} from "../list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => { export const DownloadSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload(); const { setProcesses } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const disabled = useMemo(() => (
pluginSettings?.downloadMethod?.locked === true &&
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload.locked === true
), [pluginSettings])
if (!settings) return null; if (!settings) return null;
return ( return (
<View {...props} className="mb-4"> <DisabledSetting
disabled={disabled}
{...props}
className="mb-4"
>
<ListGroup title="Downloads"> <ListGroup title="Downloads">
<ListItem title="Download method"> <ListItem
title="Download method"
disabled={pluginSettings?.downloadMethod?.locked}
>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings.downloadMethod === "remux" {settings.downloadMethod === DownloadMethod.Remux
? "Default" ? "Default"
: "Optimized"} : "Optimized"}
</Text> </Text>
@@ -51,7 +65,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<DropdownMenu.Item <DropdownMenu.Item
key="1" key="1"
onSelect={() => { onSelect={() => {
updateSettings({ downloadMethod: "remux" }); updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]); setProcesses([]);
}} }}
> >
@@ -60,7 +74,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<DropdownMenu.Item <DropdownMenu.Item
key="2" key="2"
onSelect={() => { onSelect={() => {
updateSettings({ downloadMethod: "optimized" }); updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]); setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
@@ -73,7 +87,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<ListItem <ListItem
title="Remux max download" title="Remux max download"
disabled={settings.downloadMethod !== "remux"} disabled={pluginSettings?.remuxConcurrentLimit?.locked || settings.downloadMethod !== DownloadMethod.Remux}
> >
<Stepper <Stepper
value={settings.remuxConcurrentLimit} value={settings.remuxConcurrentLimit}
@@ -90,22 +104,22 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<ListItem <ListItem
title="Auto download" title="Auto download"
disabled={settings.downloadMethod !== "optimized"} disabled={pluginSettings?.autoDownload?.locked || settings.downloadMethod !== DownloadMethod.Optimized}
> >
<Switch <Switch
disabled={settings.downloadMethod !== "optimized"} disabled={pluginSettings?.autoDownload?.locked || settings.downloadMethod !== DownloadMethod.Optimized}
value={settings.autoDownload} value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })} onValueChange={(value) => updateSettings({ autoDownload: value })}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
disabled={settings.downloadMethod !== "optimized"} disabled={pluginSettings?.optimizedVersionsServerUrl?.locked || settings.downloadMethod !== DownloadMethod.Optimized}
onPress={() => router.push("/settings/optimized-server/page")} onPress={() => router.push("/settings/optimized-server/page")}
showArrow showArrow
title="Optimized Versions Server" title="Optimized Versions Server"
></ListItem> ></ListItem>
</ListGroup> </ListGroup>
</View> </DisabledSetting>
); );
}; };

View File

@@ -21,7 +21,7 @@ export const JellyseerrSettings = () => {
} = useJellyseerr(); } = useJellyseerr();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] = const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false); useState<boolean>(false);

View File

@@ -1,72 +1,61 @@
import React from "react"; import React, {useMemo} from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { ViewProps } from "react-native";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; 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 {} interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => { export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
if (!settings) return null; if (!settings) return null;
const renderSkipControl = ( const disabled = useMemo(() => (
value: number, pluginSettings?.forwardSkipTime?.locked === true &&
onDecrease: () => void, pluginSettings?.rewindSkipTime?.locked === true
onIncrease: () => void ),
) => ( [pluginSettings]
<View className="flex flex-row items-center"> )
<TouchableOpacity
onPress={onDecrease}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{value}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={onIncrease}
>
<Text>+</Text>
</TouchableOpacity>
</View>
);
return ( return (
<View {...props}> <DisabledSetting
disabled={disabled}
{...props}
>
<ListGroup title="Media Controls"> <ListGroup title="Media Controls">
<ListItem title="Forward Skip Length"> <ListItem
{renderSkipControl( title="Forward Skip Length"
settings.forwardSkipTime, disabled={pluginSettings?.forwardSkipTime?.locked}
() => >
updateSettings({ <Stepper
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5), value={settings.forwardSkipTime}
}), disabled={pluginSettings?.forwardSkipTime?.locked}
() => step={5}
updateSettings({ appendValue="s"
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5), min={0}
}) max={60}
)} onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})}
/>
</ListItem> </ListItem>
<ListItem title="Rewind Length"> <ListItem
{renderSkipControl( title="Rewind Length"
settings.rewindSkipTime, disabled={pluginSettings?.rewindSkipTime?.locked}
() => >
updateSettings({ <Stepper
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5), value={settings.rewindSkipTime}
}), disabled={pluginSettings?.rewindSkipTime?.locked}
() => step={5}
updateSettings({ appendValue="s"
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5), min={0}
}) max={60}
)} onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})}
/>
</ListItem> </ListItem>
</ListGroup> </ListGroup>
</View> </DisabledSetting>
); );
}; };

View File

@@ -9,19 +9,18 @@ import * as BackgroundFetch from "expo-background-fetch";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager"; import * as TaskManager from "expo-task-manager";
import React, { useEffect } from "react"; import React, {useEffect, useMemo} from "react";
import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native"; import { Linking, Switch, TouchableOpacity } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props extends ViewProps {} import Dropdown from "@/components/common/Dropdown";
export const OtherSettings: React.FC = () => { export const OtherSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
/******************** /********************
* Background task * 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; if (!settings) return null;
return ( return (
<ListGroup title="Other" className=""> <DisabledSetting
<ListItem title="Auto rotate"> disabled={disabled}
<Switch >
value={settings.autoRotate} <ListGroup title="Other" className="">
onValueChange={(value) => updateSettings({ autoRotate: value })} <ListItem
/> title="Auto rotate"
</ListItem> disabled={pluginSettings?.autoRotate?.locked}
>
<Switch
value={settings.autoRotate}
disabled={pluginSettings?.autoRotate?.locked}
onValueChange={(value) => updateSettings({autoRotate: value})}
/>
</ListItem>
<ListItem title="Video orientation" disabled={settings.autoRotate}> <ListItem
<DropdownMenu.Root> title="Video orientation"
<DropdownMenu.Trigger> disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> >
<Text className="mr-1 text-[#8E8D91]"> <Dropdown
{ScreenOrientationEnum[settings.defaultVideoOrientation]} data={orientations}
</Text> disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" /> keyExtractor={String}
</TouchableOpacity> titleExtractor={(item) =>
</DropdownMenu.Trigger> ScreenOrientationEnum[item]
<DropdownMenu.Content }
loop={true} title={
side="bottom" <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
align="start" <Text className="mr-1 text-[#8E8D91]">
alignOffset={0} {ScreenOrientationEnum[settings.defaultVideoOrientation]}
avoidCollisions={true} </Text>
collisionPadding={8} <Ionicons name="chevron-expand-sharp" size={18} color="#5A5960"/>
sideOffset={8} </TouchableOpacity>
> }
<DropdownMenu.Label>Orientation</DropdownMenu.Label> label="Orientation"
<DropdownMenu.Item onSelected={(defaultVideoOrientation) =>
key="1" updateSettings({defaultVideoOrientation})
onSelect={() => { }
updateSettings({ />
defaultVideoOrientation: </ListItem>
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
<ListItem title="Safe area in controls"> <ListItem
<Switch title="Safe area in controls"
value={settings.safeAreaInControlsEnabled} disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) => >
updateSettings({ safeAreaInControlsEnabled: value }) <Switch
} value={settings.safeAreaInControlsEnabled}
/> disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
</ListItem> onValueChange={(value) =>
updateSettings({safeAreaInControlsEnabled: value})
}
/>
</ListItem>
<ListItem <ListItem
title="Show Custom Menu Links" title="Show Custom Menu Links"
onPress={() => disabled={pluginSettings?.showCustomMenuLinks?.locked}
Linking.openURL( onPress={() =>
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links" Linking.openURL(
) "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
} )
>
<Switch
value={settings.showCustomMenuLinks}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
} }
>
<Switch
value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) =>
updateSettings({showCustomMenuLinks: value})
}
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title="Hide Libraries"
showArrow
/> />
</ListItem> <ListItem
<ListItem title="Disable Haptic Feedback"
onPress={() => router.push("/settings/hide-libraries/page")} disabled={pluginSettings?.disableHapticFeedback?.locked}
title="Hide Libraries" >
showArrow <Switch
/> value={settings.disableHapticFeedback}
<ListItem title="Disable Haptic Feedback"> disabled={pluginSettings?.disableHapticFeedback?.locked}
<Switch onValueChange={(disableHapticFeedback) =>
value={settings.disableHapticFeedback} updateSettings({disableHapticFeedback})
onValueChange={(value) => }
updateSettings({ disableHapticFeedback: value }) />
} </ListItem>
/> </ListGroup>
</ListItem> </DisabledSetting>
</ListGroup>
); );
}; };

View File

@@ -7,11 +7,15 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; 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 {} interface Props extends ViewProps {}
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => { export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia(); const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
@@ -36,8 +40,11 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
} }
> >
<ListItem title="Subtitle language"> <ListItem title="Subtitle language">
<DropdownMenu.Root> <Dropdown
<DropdownMenu.Trigger> data={[{DisplayName: "None", ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]}
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"}
titleExtractor={(item) => item?.DisplayName}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings?.defaultSubtitleLanguage?.DisplayName || "None"} {settings?.defaultSubtitleLanguage?.DisplayName || "None"}
@@ -48,48 +55,28 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
color="#5A5960" color="#5A5960"
/> />
</TouchableOpacity> </TouchableOpacity>
</DropdownMenu.Trigger> }
<DropdownMenu.Content label="Languages"
loop={true} onSelected={(defaultSubtitleLanguage) =>
side="bottom" updateSettings({
align="start" defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === "None"
alignOffset={0} ? null
avoidCollisions={true} : defaultSubtitleLanguage
collisionPadding={8} })
sideOffset={8} }
> />
<DropdownMenu.Label>Languages</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-subs"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultSubtitleLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem> </ListItem>
<ListItem title="Subtitle Mode"> <ListItem
<DropdownMenu.Root> title="Subtitle Mode"
<DropdownMenu.Trigger> disabled={pluginSettings?.subtitleMode?.locked}
>
<Dropdown
data={subtitleModes}
disabled={pluginSettings?.subtitleMode?.locked}
keyExtractor={String}
titleExtractor={String}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings?.subtitleMode || "Loading"} {settings?.subtitleMode || "Loading"}
@@ -100,68 +87,39 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
color="#5A5960" color="#5A5960"
/> />
</TouchableOpacity> </TouchableOpacity>
</DropdownMenu.Trigger> }
<DropdownMenu.Content label="Subtitle Mode"
loop={true} onSelected={(subtitleMode) =>
side="bottom" updateSettings({subtitleMode})
align="start" }
alignOffset={0} />
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
{subtitleModes?.map((l) => (
<DropdownMenu.Item
key={l}
onSelect={() => {
updateSettings({
subtitleMode: l,
});
}}
>
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem> </ListItem>
<ListItem title="Set Subtitle Track From Previous Item"> <ListItem
title="Set Subtitle Track From Previous Item"
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
>
<Switch <Switch
value={settings.rememberSubtitleSelections} value={settings.rememberSubtitleSelections}
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
onValueChange={(value) => onValueChange={(value) =>
updateSettings({ rememberSubtitleSelections: value }) updateSettings({ rememberSubtitleSelections: value })
} }
/> />
</ListItem> </ListItem>
<ListItem title="Subtitle Size"> <ListItem
<View className="flex flex-row items-center"> title="Subtitle Size"
<TouchableOpacity disabled={pluginSettings?.subtitleSize?.locked}
onPress={() => >
updateSettings({ <Stepper
subtitleSize: Math.max(0, settings.subtitleSize - 5), value={settings.subtitleSize}
}) disabled={pluginSettings?.subtitleSize?.locked}
} step={5}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" min={0}
> max={120}
<Text>-</Text> onUpdate={(subtitleSize) => updateSettings({subtitleSize})}
</TouchableOpacity> />
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
{settings.subtitleSize}
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
subtitleSize: Math.min(120, settings.subtitleSize + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</ListItem> </ListItem>
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -1,4 +1,4 @@
import { useSettings } from "@/utils/atoms/settings"; import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device"; import { getOrSetDeviceId } from "@/utils/device";
import { useLog, writeToLog } from "@/utils/log"; import { useLog, writeToLog } from "@/utils/log";
import { import {
@@ -106,7 +106,7 @@ function useDownloadProvider() {
const url = settings?.optimizedVersionsServerUrl; const url = settings?.optimizedVersionsServerUrl;
if ( if (
settings?.downloadMethod !== "optimized" || settings?.downloadMethod !== DownloadMethod.Optimized ||
!url || !url ||
!deviceId || !deviceId ||
!authHeader !authHeader
@@ -166,7 +166,7 @@ function useDownloadProvider() {
}, },
staleTime: 0, staleTime: 0,
refetchInterval: 2000, refetchInterval: 2000,
enabled: settings?.downloadMethod === "optimized", enabled: settings?.downloadMethod === DownloadMethod.Optimized,
}); });
useEffect(() => { useEffect(() => {

View File

@@ -1,3 +1,4 @@
import "@/augmentations";
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Api, Jellyfin } from "@jellyfin/sdk"; import { Api, Jellyfin } from "@jellyfin/sdk";
@@ -19,7 +20,8 @@ import React, {
import { Platform } from "react-native"; import { Platform } from "react-native";
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info"; 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 { interface Server {
address: string; address: string;
@@ -70,6 +72,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom); const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false); const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null); const [secret, setSecret] = useState<string | null>(null);
const [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
useQuery({ useQuery({
queryKey: ["user", api], queryKey: ["user", api],
@@ -226,6 +230,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("user", JSON.stringify(auth.data.User)); storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken)); setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", 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) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
@@ -262,6 +276,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async () => { mutationFn: async () => {
storage.delete("token"); storage.delete("token");
setUser(null); setUser(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
}, },
onError: (error) => { onError: (error) => {
console.error("Logout failed:", error); console.error("Logout failed:", error);

View File

@@ -1,12 +1,19 @@
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect } from "react"; import {useCallback, useEffect, useMemo} from "react";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv"; import { storage } from "../mmkv";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { import {
CultureDto, CultureDto,
PluginStatus,
SubtitlePlaybackMode, SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client"; } 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"; export type DownloadQuality = "original" | "high" | "low";
@@ -59,6 +66,11 @@ export type DefaultLanguageOption = {
label: string; label: string;
}; };
export enum DownloadMethod {
Remux = "remux",
Optimized = "optimized"
}
export type Settings = { export type Settings = {
autoRotate?: boolean; autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean; forceLandscapeInVideoPlayer?: boolean;
@@ -81,7 +93,7 @@ export type Settings = {
forwardSkipTime: number; forwardSkipTime: number;
rewindSkipTime: number; rewindSkipTime: number;
optimizedVersionsServerUrl?: string | null; optimizedVersionsServerUrl?: string | null;
downloadMethod: "optimized" | "remux"; downloadMethod: DownloadMethod;
autoDownload: boolean; autoDownload: boolean;
showCustomMenuLinks: boolean; showCustomMenuLinks: boolean;
disableHapticFeedback: boolean; disableHapticFeedback: boolean;
@@ -92,6 +104,16 @@ export type Settings = {
hiddenLibraries?: string[]; hiddenLibraries?: string[];
}; };
export interface Lockable<T> {
locked: boolean;
value: T
}
export type PluginLockableSettings = { [K in keyof Settings]: Lockable<Settings[K]> };
export type StreamyfinPluginConfig = {
settings: PluginLockableSettings
}
const loadSettings = (): Settings => { const loadSettings = (): Settings => {
const defaultValues: Settings = { const defaultValues: Settings = {
autoRotate: true, autoRotate: true,
@@ -121,7 +143,7 @@ const loadSettings = (): Settings => {
forwardSkipTime: 30, forwardSkipTime: 30,
rewindSkipTime: 10, rewindSkipTime: 10,
optimizedVersionsServerUrl: null, optimizedVersionsServerUrl: null,
downloadMethod: "remux", downloadMethod: DownloadMethod.Remux,
autoDownload: false, autoDownload: false,
showCustomMenuLinks: false, showCustomMenuLinks: false,
disableHapticFeedback: false, disableHapticFeedback: false,
@@ -150,16 +172,76 @@ const saveSettings = (settings: Settings) => {
}; };
export const settingsAtom = atom<Settings | null>(null); export const settingsAtom = atom<Settings | null>(null);
export const pluginSettingsAtom = atom(storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS));
export const useSettings = () => { export const useSettings = () => {
const [settings, setSettings] = useAtom(settingsAtom); const [api] = useAtom(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
useEffect(() => { useEffect(() => {
if (settings === null) { if (_settings === null) {
const loadedSettings = loadSettings(); const loadedSettings = loadSettings();
setSettings(loadedSettings); 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<Settings>) => { const updateSettings = (update: Partial<Settings>) => {
if (settings) { if (settings) {
@@ -170,5 +252,5 @@ export const useSettings = () => {
} }
}; };
return [settings, updateSettings] as const; return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const;
}; };