mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 05:28:37 +01:00
Merge branch 'master' into feat/i18n
This commit is contained in:
@@ -66,7 +66,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
|||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ Key points of the MPL-2.0:
|
|||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
|
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
If you have questions or need support, feel free to reach out:
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ If you have questions or need support, feel free to reach out:
|
|||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
@@ -130,4 +130,4 @@ I'd like to thank the following people and projects for their contributions to S
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import {FlatList, TouchableOpacity, View} from "react-native";
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import React, {useCallback, useEffect, useState} from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {useAtom} from "jotai/index";
|
import { useAtom } from "jotai/index";
|
||||||
import {apiAtom} from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import {ListItem} from "@/components/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from "expo-web-browser";
|
||||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {Text} from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
export interface MenuLink {
|
export interface MenuLink {
|
||||||
name: string,
|
name: string;
|
||||||
url: string,
|
url: string;
|
||||||
icon: string
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function menuLinks() {
|
export default function menuLinks() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets();
|
||||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||||
|
|
||||||
const getMenuLinks = useCallback(async () => {
|
const getMenuLinks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
const response = await api?.axiosInstance.get(
|
||||||
|
api?.basePath + "/web/config.json"
|
||||||
|
);
|
||||||
const config = response?.data;
|
const config = response?.data;
|
||||||
|
|
||||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||||
@@ -29,15 +31,15 @@ export default function menuLinks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMenuLinks(config?.menuLinks as MenuLink[])
|
setMenuLinks(config?.menuLinks as MenuLink[]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to retrieve config:", error);
|
console.error("Failed to retrieve config:", error);
|
||||||
}
|
}
|
||||||
},
|
}, [api]);
|
||||||
[api]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => { getMenuLinks() }, []);
|
useEffect(() => {
|
||||||
|
getMenuLinks();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
@@ -47,27 +49,27 @@ export default function menuLinks() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={menuLinks}
|
data={menuLinks}
|
||||||
renderItem={({item}) => (
|
renderItem={({ item }) => (
|
||||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={item.name}
|
title={item.name}
|
||||||
iconAfter={<Ionicons name="link" size={24} color="white"/>}
|
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
ItemSeparatorComponent={() => (
|
ItemSeparatorComponent={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}/>
|
}}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,9 @@ export default function SearchLayout() {
|
|||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("favorites.favorites_title"),
|
headerTitle: t("favorites.favorites_title"),
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,6 +18,9 @@ export default function IndexLayout() {
|
|||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("home.home"),
|
headerTitle: t("home.home"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@@ -51,6 +55,30 @@ export default function IndexLayout() {
|
|||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/optimized-server/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/marlin-search/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/jellyseerr/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/popular-lists/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,18 +6,23 @@ 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 { 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";
|
||||||
import React, {useEffect, useMemo, useRef} from "react";
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
|
||||||
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
|
||||||
import {toast} from "sonner-native";
|
|
||||||
import {writeToLog} from "@/utils/log";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -59,28 +64,29 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||||
onPress={bottomSheetModalRef.current?.present}
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
>
|
|
||||||
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
),
|
||||||
})
|
});
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const deleteMovies = () => deleteFileByType("Movie")
|
const deleteMovies = () =>
|
||||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
deleteFileByType("Movie")
|
||||||
.catch((reason) => {
|
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
||||||
writeToLog("ERROR", reason);
|
.catch((reason) => {
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
writeToLog("ERROR", reason);
|
||||||
});
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
const deleteShows = () => deleteFileByType("Episode")
|
});
|
||||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
const deleteShows = () =>
|
||||||
.catch((reason) => {
|
deleteFileByType("Episode")
|
||||||
writeToLog("ERROR", reason);
|
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
.catch((reason) => {
|
||||||
});
|
writeToLog("ERROR", reason);
|
||||||
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
|
});
|
||||||
|
const deleteAllMedia = async () =>
|
||||||
|
await Promise.all([deleteMovies(), deleteShows()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -110,7 +116,9 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
<Text className="font-semibold">{q.item.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
<Text className="text-xs opacity-50">
|
||||||
|
{q.item.Type}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -121,7 +129,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="red"/>
|
<Ionicons name="close" size={24} color="red" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
@@ -133,7 +141,7 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ActiveDownloads/>
|
<ActiveDownloads />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{movies.length > 0 && (
|
{movies.length > 0 && (
|
||||||
@@ -148,7 +156,7 @@ export default function page() {
|
|||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{movies?.map((item) => (
|
{movies?.map((item) => (
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||||
<MovieCard item={item.item}/>
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -160,13 +168,18 @@ export default function page() {
|
|||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
|
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
<Text className="text-xs font-bold">
|
||||||
|
{groupedBySeries?.length}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{groupedBySeries?.map((items) => (
|
{groupedBySeries?.map((items) => (
|
||||||
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
<View
|
||||||
|
className="mb-2 last:mb-0"
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
>
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
items={items.map((i) => i.item)}
|
items={items.map((i) => i.item)}
|
||||||
key={items[0].item.SeriesId}
|
key={items[0].item.SeriesId}
|
||||||
@@ -203,9 +216,15 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="p-4 space-y-4 mb-4">
|
<View className="p-4 space-y-4 mb-4">
|
||||||
<Button color="purple" onPress={deleteMovies}>{t("home.downloads.delete_all_movies_button")}</Button>
|
<Button color="purple" onPress={deleteMovies}>
|
||||||
<Button color="purple" onPress={deleteShows}>{t("home.downloads.delete_all_tvseries_button")}</Button>
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
<Button color="red" onPress={deleteAllMedia}>{t("home.downloads.delete_all_button")}</Button>
|
</Button>
|
||||||
|
<Button color="purple" onPress={deleteShows}>
|
||||||
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onPress={deleteAllMedia}>
|
||||||
|
{t("home.downloads.delete_all_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
|||||||
@@ -1,179 +1,88 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import {useDownload} from "@/providers/DownloadProvider";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { DownloadSettings } from "@/components/settings/DownloadSettings";
|
||||||
import { clearLogs, useLog } from "@/utils/log";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
import * as FileSystem from "expo-file-system";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import { clearLogs } from "@/utils/log";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { t } from "i18next";
|
||||||
import * as Progress from "react-native-progress";
|
import { useEffect } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const router = useRouter();
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
|
||||||
const { logs } = useLog();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { logout } = useJellyfin();
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
|
||||||
queryKey: ["appSize", appSizeUsage],
|
|
||||||
queryFn: async () => {
|
|
||||||
const app = await appSizeUsage;
|
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
|
||||||
|
|
||||||
return { app, remaining, total, used: (total - remaining) / total };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
|
||||||
Alert.prompt(
|
|
||||||
t("home.settings.quick_connect.quick_connect_title"),
|
|
||||||
t("home.settings.quick_connect.enter_the_quick_connect_code"),
|
|
||||||
async (text) => {
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
|
||||||
code: text,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized"));
|
|
||||||
} else {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
const onClearLogsClicked = async () => {
|
||||||
clearLogs();
|
clearLogs();
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-red-600">{t("home.settings.log_out_button")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
{/* <Button
|
<UserInfo />
|
||||||
onPress={() => {
|
<QuickConnect className="mb-4" />
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
registerBackgroundFetchAsync
|
|
||||||
</Button> */}
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">{t("home.settings.user_info.user_info_title")}</Text>
|
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<MediaProvider>
|
||||||
<ListItem title={t("home.settings.user_info.user")} subTitle={user?.Name} />
|
<MediaToggles className="mb-4" />
|
||||||
<ListItem title={t("home.settings.user_info.server")} subTitle={api?.basePath} />
|
<AudioToggles className="mb-4" />
|
||||||
<ListItem title={t("home.settings.user_info.token")} subTitle={api?.accessToken} />
|
<SubtitleToggles className="mb-4" />
|
||||||
</View>
|
</MediaProvider>
|
||||||
<Button className="my-2.5" color="black" onPress={logout}>
|
|
||||||
{t("home.settings.user_info.log_out_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
<OtherSettings />
|
||||||
<Text className="font-bold text-lg mb-2">{t("home.settings.quick_connect.quick_connect_title")}</Text>
|
<DownloadSettings />
|
||||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
|
||||||
{t("home.settings.quick_connect.authorize_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<SettingToggles />
|
<PluginSettings />
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="mb-4">
|
||||||
<Text className="font-bold text-lg mb-2">{t("home.settings.storage.storage_title")}</Text>
|
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||||
<View className="mb-4 space-y-2">
|
<ListItem
|
||||||
{size && <Text>{t("home.settings.storage.app_usage", {usedSpace: size.app.bytesToReadable()})}</Text>}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
<Progress.Bar
|
showArrow
|
||||||
className="bg-gray-100/10"
|
title={t("home.settings.logs.logs_title")}
|
||||||
indeterminate={appSizeLoading}
|
|
||||||
color="#9333ea"
|
|
||||||
width={null}
|
|
||||||
height={10}
|
|
||||||
borderRadius={6}
|
|
||||||
borderWidth={0}
|
|
||||||
progress={size?.used}
|
|
||||||
/>
|
/>
|
||||||
{size && (
|
<ListItem
|
||||||
<Text>
|
textColor="red"
|
||||||
{t("home.settings.storage.available_total", {availableSpace: size.remaining?.bytesToReadable(), totalSpace: size.total?.bytesToReadable()})}
|
onPress={onClearLogsClicked}
|
||||||
{}
|
title={t("home.settings.logs.delete_all_logs")}
|
||||||
</Text>
|
/>
|
||||||
)}
|
</ListGroup>
|
||||||
</View>
|
|
||||||
<Button color="red" onPress={onDeleteClicked}>
|
|
||||||
{t("home.settings.storage.delete_all_downloaded_files")}
|
|
||||||
</Button>
|
|
||||||
<Button color="red" onPress={onClearLogsClicked}>
|
|
||||||
{t("home.settings.storage.delete_all_logs")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">{t("home.settings.logs.logs_title")}</Text>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
{logs?.map((log, index) => (
|
|
||||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
|
||||||
<Text
|
|
||||||
className={`
|
|
||||||
mb-1
|
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{log.level}
|
|
||||||
</Text>
|
|
||||||
<Text uiTextView selectable className="text-xs">
|
|
||||||
{log.message}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{logs?.length === 0 && (
|
|
||||||
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<StorageSettings />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
78
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
78
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||||
|
useState<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 (
|
||||||
|
<View className="p-4">
|
||||||
|
<JellyseerrSettings />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
35
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useLog } from "@/utils/log";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { logs } = useLog();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView className="p-4">
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{logs?.map((log, index) => (
|
||||||
|
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
|
mb-1
|
||||||
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{log.level}
|
||||||
|
</Text>
|
||||||
|
<Text uiTextView selectable className="text-xs">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{logs?.length === 0 && (
|
||||||
|
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
104
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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 { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const onSave = (val: string) => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
|
});
|
||||||
|
toast.success("Saved");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
|
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, value]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4">
|
||||||
|
<ListGroup>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`mt-2 ${
|
||||||
|
settings.searchEngine === "Marlin" ? "" : "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
||||||
|
<View
|
||||||
|
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
||||||
|
>
|
||||||
|
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
||||||
|
<TextInput
|
||||||
|
editable={settings.searchEngine === "Marlin"}
|
||||||
|
className="text-white"
|
||||||
|
placeholder={t("home.settings.plugins.marlin_search.url")}
|
||||||
|
value={value}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={(text) => setValue(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
80
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||||
|
useState<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 (
|
||||||
|
<View className="p-4">
|
||||||
|
<OptimizedServerForm
|
||||||
|
value={optimizedVersionsServerUrl}
|
||||||
|
onChangeValue={setOptimizedVersionsServerUrl}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
Normal file
136
app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { Linking, Switch, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL(
|
||||||
|
"https://github.com/lostb1t/jellyfin-plugin-collection-import"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mediaListCollections,
|
||||||
|
isLoading: isLoadingMediaListCollections,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
tags: ["sf_promoted"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["Tags"],
|
||||||
|
includeItemTypes: ["BoxSet"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4 pt-4">
|
||||||
|
<ListGroup title={t("home.settings.plugins.popular_lists.enable_plugin")} className="">
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.popular_lists.enable_popular_lists")}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ usePopularPlugin: true });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.usePopularPlugin}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ usePopularPlugin: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
{t("home.settings.plugins.popular_lists.enable_popular_hint")}{" "}
|
||||||
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
|
{t("home.settings.plugins.popular_lists.read_more_about_popular_lists")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{settings.usePopularPlugin && (
|
||||||
|
<>
|
||||||
|
{!isLoadingMediaListCollections ? (
|
||||||
|
<>
|
||||||
|
{mediaListCollections?.length === 0 ? (
|
||||||
|
<Text className="text-xs opacity-50 p-4">
|
||||||
|
{t("home.settings.plugins.popular_lists.no_collections_found")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ListGroup title="Media List Collections" className="mt-4">
|
||||||
|
{mediaListCollections?.map((mlc) => (
|
||||||
|
<ListItem key={mlc.Id} title={mlc.Name}>
|
||||||
|
<Switch
|
||||||
|
value={settings.mediaListCollectionIds?.includes(
|
||||||
|
mlc.Id!
|
||||||
|
)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!settings.mediaListCollectionIds) {
|
||||||
|
updateSettings({
|
||||||
|
mediaListCollectionIds: [mlc.Id!],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings({
|
||||||
|
mediaListCollectionIds:
|
||||||
|
settings.mediaListCollectionIds.includes(
|
||||||
|
mlc.Id!
|
||||||
|
)
|
||||||
|
? settings.mediaListCollectionIds.filter(
|
||||||
|
(id) => id !== mlc.Id
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
...settings.mediaListCollectionIds,
|
||||||
|
mlc.Id!,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
{t("home.settings.plugins.popular_lists.select_the_lists_you_want_to_display")}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ const Page: React.FC = () => {
|
|||||||
data: details,
|
data: details,
|
||||||
isFetching,
|
isFetching,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
refetch
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||||
@@ -63,6 +64,7 @@ const Page: React.FC = () => {
|
|||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
|
refetchInterval: 0,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return result.mediaType === MediaType.MOVIE
|
return result.mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(result.id!!)
|
? jellyseerrApi?.movieDetails(result.id!!)
|
||||||
@@ -94,15 +96,18 @@ const Page: React.FC = () => {
|
|||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const request = useCallback(
|
const request = useCallback(
|
||||||
() =>
|
async () => {
|
||||||
requestMedia(mediaTitle, {
|
requestMedia(mediaTitle, {
|
||||||
mediaId: Number(result.id!!),
|
mediaId: Number(result.id!!),
|
||||||
mediaType: result.mediaType!!,
|
mediaType: result.mediaType!!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
?.map?.((s) => s.seasonNumber),
|
?.map?.((s) => s.seasonNumber),
|
||||||
}),
|
},
|
||||||
|
refetch
|
||||||
|
)
|
||||||
|
},
|
||||||
[details, result, requestMedia]
|
[details, result, requestMedia]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -205,6 +210,7 @@ const Page: React.FC = () => {
|
|||||||
isLoading={isLoading || isFetching}
|
isLoading={isLoading || isFetching}
|
||||||
result={result as TvResult}
|
result={result as TvResult}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
@@ -154,6 +153,8 @@ const Page = () => {
|
|||||||
itemType = "Series";
|
itemType = "Series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
itemType = "BoxSet";
|
itemType = "BoxSet";
|
||||||
|
} else if (library.CollectionType === "music") {
|
||||||
|
itemType = "MusicAlbum";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export default function IndexLayout() {
|
|||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("library.library_title"),
|
headerTitle: t("library.library_title"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
import {
|
||||||
|
commonScreenOptions,
|
||||||
|
nestedTabPageScreenOptions,
|
||||||
|
} from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -13,6 +16,9 @@ export default function SearchLayout() {
|
|||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("search.search_title"),
|
headerTitle: t("search.search_title"),
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -31,10 +37,7 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
name="jellyseerr/page"
|
|
||||||
options={commonScreenOptions}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export default function search() {
|
|||||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||||
} else {
|
} else {
|
||||||
if (!settings?.marlinServerUrl) return [];
|
if (!settings?.marlinServerUrl) return [];
|
||||||
|
|
||||||
const url = `${
|
const url = `${
|
||||||
settings.marlinServerUrl
|
settings.marlinServerUrl
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
@@ -105,6 +106,7 @@ export default function search() {
|
|||||||
.join("&includeItemTypes=")}`;
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
const response1 = await axios.get(url);
|
||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids || !ids.length) return [];
|
if (!ids || !ids.length) return [];
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
if (offline) {
|
||||||
const item = await getDownloadedItem(itemId);
|
const item = await getDownloadedItem(itemId);
|
||||||
if (item) return item.item;
|
if (item) return item.item;
|
||||||
@@ -130,7 +129,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
if (offline) {
|
||||||
const data = await getDownloadedItem(itemId);
|
const data = await getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return null;
|
if (!data?.mediaSource) return null;
|
||||||
@@ -197,8 +195,6 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Actually marked as paused");
|
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
if (!offline && stream) {
|
if (!offline && stream) {
|
||||||
@@ -341,7 +337,6 @@ export default function page() {
|
|||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
return async () => {
|
return async () => {
|
||||||
stop();
|
stop();
|
||||||
console.log("Unmounted");
|
|
||||||
};
|
};
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
@@ -351,10 +346,8 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||||
console.log("App has come to the foreground!");
|
|
||||||
// Handle app coming to the foreground
|
// Handle app coming to the foreground
|
||||||
} else if (nextAppState.match(/inactive|background/)) {
|
} else if (nextAppState.match(/inactive|background/)) {
|
||||||
console.log("App has gone to the background!");
|
|
||||||
// Handle app going to the background
|
// Handle app going to the background
|
||||||
if (videoRef.current && videoRef.current.pause) {
|
if (videoRef.current && videoRef.current.pause) {
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
|
|||||||
@@ -169,18 +169,15 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.resume();
|
videoRef.current?.resume();
|
||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
console.log("stop");
|
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
|
|||||||
@@ -260,13 +260,6 @@ const Player = () => {
|
|||||||
progress.value = ticks;
|
progress.value = ticks;
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
|
||||||
console.log(
|
|
||||||
"onProgress ~",
|
|
||||||
ticks,
|
|
||||||
isPlaying,
|
|
||||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||||
// TODO: since playable duration is always 0 then.
|
// TODO: since playable duration is always 0 then.
|
||||||
setIsBuffering(data.playableDuration === 0);
|
setIsBuffering(data.playableDuration === 0);
|
||||||
@@ -339,11 +332,7 @@ const Player = () => {
|
|||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
// Most likely the subtitle is burned in.
|
||||||
if (embeddedTrackIndex === -1) return;
|
if (embeddedTrackIndex === -1) return;
|
||||||
console.log(
|
|
||||||
"Setting selected text track",
|
|
||||||
subtitleIndex,
|
|
||||||
embeddedTrackIndex
|
|
||||||
);
|
|
||||||
setSelectedTextTrack({
|
setSelectedTextTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: embeddedTrackIndex,
|
value: embeddedTrackIndex,
|
||||||
@@ -439,7 +428,6 @@ const Player = () => {
|
|||||||
setIsBuffering(e.isBuffering);
|
setIsBuffering(e.isBuffering);
|
||||||
}}
|
}}
|
||||||
onAudioTracks={(e) => {
|
onAudioTracks={(e) => {
|
||||||
console.log("onAudioTracks: ", e.audioTracks);
|
|
||||||
setAudioTracks(
|
setAudioTracks(
|
||||||
e.audioTracks.map((t) => ({
|
e.audioTracks.map((t) => ({
|
||||||
index: t.index,
|
index: t.index,
|
||||||
@@ -493,7 +481,6 @@ const Player = () => {
|
|||||||
}}
|
}}
|
||||||
getAudioTracks={getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
setAudioTrack={(i) => {
|
setAudioTrack={(i) => {
|
||||||
console.log("setAudioTrack ~", i);
|
|
||||||
setSelectedAudioTrack({
|
setSelectedAudioTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: i,
|
value: i,
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useGlobalSearchParams();
|
const searchParams = useGlobalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
console.log(searchParams);
|
|
||||||
|
|
||||||
const { url } = searchParams as { url: string };
|
const { url } = searchParams as { url: string };
|
||||||
|
|
||||||
const videoId = useMemo(() => {
|
const videoId = useMemo(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -8,7 +9,7 @@ import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -119,7 +120,7 @@ const CredentialsSchema = z.object({
|
|||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
||||||
* - Logs errors and timeout information to the console.
|
* - Logs errors and timeout information to the console.
|
||||||
*/
|
*/
|
||||||
async function checkUrl(url: string) {
|
const checkUrl = useCallback(async (url: string) => {
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +130,7 @@ const CredentialsSchema = z.object({
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
|
|
||||||
setServerName(data.ServerName || "");
|
setServerName(data.ServerName || "");
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -137,7 +139,7 @@ const CredentialsSchema = z.object({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
* Handles the connection attempt to a Jellyfin server.
|
||||||
@@ -155,7 +157,7 @@ const CredentialsSchema = z.object({
|
|||||||
* - Sets the server address using `setServer` if the connection is successful.
|
* - Sets the server address using `setServer` if the connection is successful.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const handleConnect = async (url: string) => {
|
const handleConnect = useCallback(async (url: string) => {
|
||||||
url = url.trim();
|
url = url.trim();
|
||||||
|
|
||||||
const result = await checkUrl(url);
|
const result = await checkUrl(url);
|
||||||
@@ -169,7 +171,7 @@ const CredentialsSchema = z.object({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: url });
|
setServer({ address: url });
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -206,7 +208,7 @@ const CredentialsSchema = z.object({
|
|||||||
) : t("login.login_title")}
|
) : t("login.login_title")}
|
||||||
</>
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
<Text className="text-xs text-neutral-400">{api.basePath}</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -292,9 +294,14 @@ const CredentialsSchema = z.object({
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Text className="text-xs text-neutral-500">
|
<Text className="text-xs text-neutral-500 ml-4">
|
||||||
{t("server.server_url_hint")}
|
{t("server.server_url_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={(s) => {
|
||||||
|
handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Number {
|
interface Number {
|
||||||
bytesToReadable(): string;
|
bytesToReadable(): string;
|
||||||
secondsToMilliseconds(): number
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,27 +11,27 @@ Number.prototype.bytesToReadable = function () {
|
|||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
const gb = bytes / 1e9;
|
const gb = bytes / 1e9;
|
||||||
|
|
||||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
if (gb >= 1) return `${gb.toFixed(0)} GB`;
|
||||||
|
|
||||||
const mb = bytes / 1024.0 / 1024.0;
|
const mb = bytes / 1024.0 / 1024.0;
|
||||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
if (mb >= 1) return `${mb.toFixed(0)} MB`;
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
const kb = bytes / 1024.0;
|
||||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
if (kb >= 1) return `${kb.toFixed(0)} KB`;
|
||||||
|
|
||||||
return `${bytes.toFixed(2)} B`;
|
return `${bytes.toFixed(2)} B`;
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
return this.valueOf() * 1000
|
return this.valueOf() * 1000;
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.minutesToMilliseconds = function () {
|
Number.prototype.minutesToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).secondsToMilliseconds()
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).minutesToMilliseconds()
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
}
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -34,6 +34,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!discoveryManager) {
|
if (!discoveryManager) {
|
||||||
|
console.warn("DiscoveryManager is not initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
@@ -77,6 +79,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-x
|
|||||||
return (
|
return (
|
||||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<View>
|
<View key={idx}>
|
||||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
) as MediaStream;
|
) as MediaStream;
|
||||||
}, [source.MediaStreams]);
|
}, [source.MediaStreams]);
|
||||||
|
|
||||||
|
if (!videoStream) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
title?: string | null | undefined;
|
|
||||||
subTitle?: string | null | undefined;
|
|
||||||
children?: ReactNode;
|
|
||||||
iconAfter?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|
||||||
title,
|
|
||||||
subTitle,
|
|
||||||
iconAfter,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col overflow-visible">
|
|
||||||
<Text className="font-bold ">{title}</Text>
|
|
||||||
{subTitle && (
|
|
||||||
<Text uiTextView selectable className="text-xs text-neutral-400">
|
|
||||||
{subTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{iconAfter}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
51
components/PreviousServersList.tsx
Normal file
51
components/PreviousServersList.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
|
import { ListGroup } from "./list/ListGroup";
|
||||||
|
import { ListItem } from "./list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviousServersListProps {
|
||||||
|
onServerSelect: (server: Server) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||||
|
onServerSelect,
|
||||||
|
}) => {
|
||||||
|
const [_previousServers, setPreviousServers] =
|
||||||
|
useMMKVString("previousServers");
|
||||||
|
|
||||||
|
const previousServers = useMemo(() => {
|
||||||
|
return JSON.parse(_previousServers || "[]") as Server[];
|
||||||
|
}, [_previousServers]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("server.previous_servers")} className="mt-4">
|
||||||
|
{previousServers.map((s) => (
|
||||||
|
<ListItem
|
||||||
|
key={s.address}
|
||||||
|
onPress={() => onServerSelect(s)}
|
||||||
|
title={s.address}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
setPreviousServers("[]");
|
||||||
|
}}
|
||||||
|
title={t("server.clear_button")}
|
||||||
|
textColor="red"
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
import * as Haptics from "expo-haptics";
|
BaseItemDto,
|
||||||
|
BaseItemPerson,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
@@ -10,8 +12,13 @@ interface Props extends TouchableOpacityProps {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
export const itemRouter = (
|
||||||
if (item.CollectionType === "livetv") {
|
item: BaseItemDto | BaseItemPerson,
|
||||||
|
from: string
|
||||||
|
) => {
|
||||||
|
console.log(item.Type, item?.CollectionType);
|
||||||
|
|
||||||
|
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||||
return `/(auth)/(tabs)/${from}/livetv`;
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +38,7 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
|||||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "Person") {
|
if (item.Type === "Person" || item.Type === "Actor") {
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,21 +84,27 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const width = Dimensions.get("screen").width;
|
const width = Dimensions.get("screen").width;
|
||||||
|
|
||||||
|
if (settings?.usePopularPlugin === false) return null;
|
||||||
if (l1 || l2) return null;
|
if (l1 || l2) return null;
|
||||||
if (!popularItems) return null;
|
if (!popularItems) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center" {...props}>
|
<View className="flex flex-col items-center mt-2" {...props}>
|
||||||
<Carousel
|
<Carousel
|
||||||
autoPlay={true}
|
|
||||||
autoPlayInterval={3000}
|
|
||||||
loop={true}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
autoPlay={false}
|
||||||
|
loop={true}
|
||||||
|
snapEnabled={true}
|
||||||
|
mode="parallax"
|
||||||
|
modeConfig={{
|
||||||
|
parallaxScrollingScale: 0.86,
|
||||||
|
parallaxScrollingOffset: 100,
|
||||||
|
}}
|
||||||
width={width}
|
width={width}
|
||||||
height={204}
|
height={204}
|
||||||
data={popularItems}
|
data={popularItems}
|
||||||
onProgressChange={progress}
|
onProgressChange={progress}
|
||||||
renderItem={({ item, index }) => <RenderItem item={item} />}
|
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
||||||
/>
|
/>
|
||||||
<Pagination.Basic
|
<Pagination.Basic
|
||||||
progress={progress}
|
progress={progress}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
CollectionType,
|
CollectionType,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -50,18 +51,52 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
[library]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const itemType = useMemo(() => {
|
||||||
|
let _itemType: BaseItemKind | undefined;
|
||||||
|
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
_itemType = "Movie";
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
_itemType = "Series";
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
_itemType = "BoxSet";
|
||||||
|
} else if (library.CollectionType === "music") {
|
||||||
|
_itemType = "MusicAlbum";
|
||||||
|
}
|
||||||
|
|
||||||
|
return _itemType;
|
||||||
|
}, [library.CollectionType]);
|
||||||
|
|
||||||
|
const itemTypeName = useMemo(() => {
|
||||||
|
let nameStr: string;
|
||||||
|
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
nameStr = "movies";
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
nameStr = "series";
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
nameStr = "box sets";
|
||||||
|
} else if (library.CollectionType === "music") {
|
||||||
|
nameStr = "albums";
|
||||||
|
} else {
|
||||||
|
nameStr = "items";
|
||||||
|
}
|
||||||
|
|
||||||
|
return nameStr;
|
||||||
|
}, [library.CollectionType]);
|
||||||
|
|
||||||
const { data: itemsCount } = useQuery({
|
const { data: itemsCount } = useQuery({
|
||||||
queryKey: ["library-count", library.Id],
|
queryKey: ["library-count", library.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
const response = await getItemsApi(api!).getItems({
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: library.Id,
|
parentId: library.Id,
|
||||||
|
recursive: true,
|
||||||
limit: 0,
|
limit: 0,
|
||||||
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
});
|
});
|
||||||
return response.data.TotalRecordCount;
|
return response.data.TotalRecordCount;
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
@@ -80,7 +115,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
||||||
{itemsCount} items
|
{itemsCount} {itemTypeName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -109,6 +144,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -128,7 +164,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
)}
|
)}
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-start px-4">
|
<Text className="font-bold text-xs text-start px-4">
|
||||||
{itemsCount} items
|
{itemsCount} {itemTypeName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -145,7 +181,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
||||||
{itemsCount} items
|
{itemsCount} {itemTypeName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
58
components/list/ListGroup.tsx
Normal file
58
components/list/ListGroup.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
PropsWithChildren,
|
||||||
|
Children,
|
||||||
|
isValidElement,
|
||||||
|
cloneElement,
|
||||||
|
ReactElement,
|
||||||
|
} from "react";
|
||||||
|
import { StyleSheet, View, ViewProps, ViewStyle } from "react-native";
|
||||||
|
import { ListItem } from "./ListItem";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
description?: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const childrenArray = Children.toArray(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="ml-4 mb-1 uppercase text-[#8E8D91] text-xs">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[]}
|
||||||
|
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900"
|
||||||
|
>
|
||||||
|
{Children.map(childrenArray, (child, index) => {
|
||||||
|
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||||
|
return cloneElement(child as any, {
|
||||||
|
style: StyleSheet.compose(
|
||||||
|
child.props.style,
|
||||||
|
index < childrenArray.length - 1
|
||||||
|
? styles.borderBottom
|
||||||
|
: undefined
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
{description && <View className="pl-4 mt-1">{description}</View>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
borderBottom: {
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: "#3D3C40",
|
||||||
|
},
|
||||||
|
});
|
||||||
124
components/list/ListItem.tsx
Normal file
124
components/list/ListItem.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps, ViewProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
value?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
showArrow?: boolean;
|
||||||
|
textColor?: "default" | "blue" | "red";
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
iconAfter,
|
||||||
|
children,
|
||||||
|
showArrow = false,
|
||||||
|
icon,
|
||||||
|
textColor = "default",
|
||||||
|
onPress,
|
||||||
|
disabled = false,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (onPress)
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
||||||
|
disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ListItemContent
|
||||||
|
title={title}
|
||||||
|
value={value}
|
||||||
|
icon={icon}
|
||||||
|
textColor={textColor}
|
||||||
|
showArrow={showArrow}
|
||||||
|
iconAfter={iconAfter}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ListItemContent>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
||||||
|
disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ListItemContent
|
||||||
|
title={title}
|
||||||
|
value={value}
|
||||||
|
icon={icon}
|
||||||
|
textColor={textColor}
|
||||||
|
showArrow={showArrow}
|
||||||
|
iconAfter={iconAfter}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ListItemContent>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListItemContent = ({
|
||||||
|
title,
|
||||||
|
textColor,
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
showArrow,
|
||||||
|
iconAfter,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row items-center w-full">
|
||||||
|
{icon && (
|
||||||
|
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
|
||||||
|
<Ionicons name="person-circle-outline" size={18} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
textColor === "blue"
|
||||||
|
? "text-[#0584FE]"
|
||||||
|
: textColor === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{value && (
|
||||||
|
<View className="ml-auto items-end">
|
||||||
|
<Text selectable className=" text-[#9899A1]" numberOfLines={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{children && <View className="ml-auto">{children}</View>}
|
||||||
|
{showArrow && (
|
||||||
|
<View className={children ? "ml-1" : "ml-auto"}>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#5A5960" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{iconAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -61,7 +61,7 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{collection.Name}
|
{collection.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<InfiniteHorizontalScroll
|
<InfiniteHorizontalScroll
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemPerson,
|
BaseItemPerson,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router } from "expo-router";
|
import { router, useSegments } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../posters/Poster";
|
import Poster from "../posters/Poster";
|
||||||
|
import { itemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -19,6 +20,8 @@ interface Props extends ViewProps {
|
|||||||
|
|
||||||
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = segments[2];
|
||||||
|
|
||||||
const destinctPeople = useMemo(() => {
|
const destinctPeople = useMemo(() => {
|
||||||
const people: BaseItemPerson[] = [];
|
const people: BaseItemPerson[] = [];
|
||||||
@@ -33,6 +36,8 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
return people;
|
return people;
|
||||||
}, [item?.People]);
|
}, [item?.People]);
|
||||||
|
|
||||||
|
if (!from) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="flex flex-col">
|
<View {...props} className="flex flex-col">
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
@@ -44,7 +49,9 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/actors/${i.Id}`);
|
const url = itemRouter(i, from);
|
||||||
|
// @ts-ignore
|
||||||
|
router.push(url);
|
||||||
}}
|
}}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { RoundButton } from "@/components/RoundButton";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import {QueryObserverResult, RefetchOptions, useQuery} from "@tanstack/react-query";
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
|
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
|
||||||
const JellyseerrSeasonEpisodes: React.FC<{
|
const JellyseerrSeasonEpisodes: React.FC<{
|
||||||
details: TvDetails;
|
details: TvDetails;
|
||||||
@@ -101,7 +102,8 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
result?: TvResult;
|
result?: TvResult;
|
||||||
details?: TvDetails;
|
details?: TvDetails;
|
||||||
}> = ({ isLoading, result, details }) => {
|
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
|
||||||
|
}> = ({ isLoading, result, details, refetch }) => {
|
||||||
if (!details) return null;
|
if (!details) return null;
|
||||||
|
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
@@ -169,6 +171,21 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
[requestAll]
|
[requestAll]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
|
||||||
|
if (canRequest) {
|
||||||
|
requestMedia(
|
||||||
|
`${result?.name!!}, Season ${seasonNumber}`,
|
||||||
|
{
|
||||||
|
mediaId: details.id,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
|
seasons: [seasonNumber],
|
||||||
|
},
|
||||||
|
refetch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [requestMedia]);
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -232,22 +249,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<JellyseerrIconStatus
|
<JellyseerrIconStatus
|
||||||
key={0}
|
key={0}
|
||||||
onPress={
|
onPress={() => requestSeason(canRequest, season.seasonNumber)}
|
||||||
canRequest
|
|
||||||
? () =>
|
|
||||||
requestMedia(
|
|
||||||
`${result?.name!!}, Season ${
|
|
||||||
season.seasonNumber
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
mediaId: details.id,
|
|
||||||
mediaType: MediaType.TV,
|
|
||||||
tvdbId: details.externalIds?.tvdbId,
|
|
||||||
seasons: [season.seasonNumber],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||||
mediaStatus={
|
mediaStatus={
|
||||||
seasons?.find(
|
seasons?.find(
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { Text } from "../common/Text";
|
|||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -16,26 +19,35 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props}>
|
||||||
<Text className="text-lg font-bold mb-2">{t("home.settings.audio.audio_title")}</Text>
|
<ListGroup
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
title={t("home.settings.audio.audio_title")}
|
||||||
<View
|
description={
|
||||||
className={`
|
<Text className="text-[#8E8D91] text-xs">
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
{t("home.settings.audio.audio_hint")}
|
||||||
`}
|
</Text>
|
||||||
>
|
}
|
||||||
<View className="flex flex-col shrink">
|
>
|
||||||
<Text className="font-semibold">{t("home.settings.audio.audio_language")}</Text>
|
<ListItem title={t("home.settings.audio.set_audio_track")}>
|
||||||
<Text className="text-xs opacity-50">
|
<Switch
|
||||||
{t("home.settings.audio.audio_language_hint")}
|
value={settings.rememberAudioSelections}
|
||||||
</Text>
|
onValueChange={(value) =>
|
||||||
</View>
|
updateSettings({ rememberAudioSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem title={t("home.settings.audio.audio_language")}>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
|
||||||
<Text>
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -74,42 +86,8 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</ListItem>
|
||||||
<View className="flex flex-col">
|
</ListGroup>
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">{t("home.settings.audio.use_default_audio")}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.audio.use_default_audio_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.playDefaultAudioTrack}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ playDefaultAudioTrack: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.audio.set_audio_track")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50 min max-w-[85%]">
|
|
||||||
{t("home.settings.audio.set_audio_track_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.rememberAudioSelections}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ rememberAudioSelections: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
113
components/settings/DownloadSettings.tsx
Normal file
113
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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 * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const DownloadSettings: React.FC = ({ ...props }) => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const { setProcesses } = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props} className="mb-4">
|
||||||
|
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
||||||
|
<ListItem title={t("home.settings.downloads.download_method")}>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
|
{settings.downloadMethod === "remux"
|
||||||
|
? "Default"
|
||||||
|
: "Optimized"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Methods</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ downloadMethod: "remux" });
|
||||||
|
setProcesses([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ downloadMethod: "optimized" });
|
||||||
|
setProcesses([]);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.downloads.remux_max_download")}
|
||||||
|
disabled={settings.downloadMethod !== "remux"}
|
||||||
|
>
|
||||||
|
<Stepper
|
||||||
|
value={settings.remuxConcurrentLimit}
|
||||||
|
step={1}
|
||||||
|
min={1}
|
||||||
|
max={4}
|
||||||
|
onUpdate={(value) =>
|
||||||
|
updateSettings({
|
||||||
|
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.downloads.auto_download")}
|
||||||
|
disabled={settings.downloadMethod !== "optimized"}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
disabled={settings.downloadMethod !== "optimized"}
|
||||||
|
value={settings.autoDownload}
|
||||||
|
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
disabled={settings.downloadMethod !== "optimized"}
|
||||||
|
onPress={() => router.push("/settings/optimized-server/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.downloads.optimized_versions_server")}
|
||||||
|
></ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import { View } from "react-native";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
import { ListItem } from "../ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
@@ -12,6 +12,7 @@ import { useAtom } from "jotai";
|
|||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
|
||||||
export const JellyseerrSettings = () => {
|
export const JellyseerrSettings = () => {
|
||||||
const {
|
const {
|
||||||
@@ -86,54 +87,56 @@ export const JellyseerrSettings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mt-4">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
|
|
||||||
<View>
|
<View>
|
||||||
{jellyseerrUser ? (
|
{jellyseerrUser ? (
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
|
<>
|
||||||
<ListItem
|
<ListGroup title={"Jellyseerr"}>
|
||||||
title="Total media requests"
|
<ListItem
|
||||||
subTitle={jellyseerrUser?.requestCount?.toString()}
|
title="Total media requests"
|
||||||
/>
|
value={jellyseerrUser?.requestCount?.toString()}
|
||||||
<ListItem
|
/>
|
||||||
title="Movie quota limit"
|
<ListItem
|
||||||
subTitle={
|
title="Movie quota limit"
|
||||||
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
value={
|
||||||
}
|
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
||||||
/>
|
}
|
||||||
<ListItem
|
/>
|
||||||
title="Movie quota days"
|
<ListItem
|
||||||
subTitle={
|
title="Movie quota days"
|
||||||
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
value={
|
||||||
}
|
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
||||||
/>
|
}
|
||||||
<ListItem
|
/>
|
||||||
title="TV quota limit"
|
<ListItem
|
||||||
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
title="TV quota limit"
|
||||||
/>
|
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
||||||
<ListItem
|
/>
|
||||||
title="TV quota days"
|
<ListItem
|
||||||
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
title="TV quota days"
|
||||||
/>
|
value={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<Button color="red" onPress={clearData}>
|
<Button color="red" onPress={clearData}>
|
||||||
Reset Jellyseerr config
|
Reset Jellyseerr config
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
||||||
<Text className="text-xs text-red-600 mb-2">
|
<Text className="text-xs text-red-600 mb-2">
|
||||||
{t("home.settings.jellyseerr.jellyseerr_warning")}
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="font-bold mb-1">{t("home.settings.jellyseerr.server_url")}</Text>
|
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
|
||||||
<View className="flex flex-col shrink mb-2">
|
<View className="flex flex-col shrink mb-2">
|
||||||
<Text className="text-xs text-gray-600">
|
<Text className="text-xs text-gray-600">
|
||||||
{t("home.settings.jellyseerr.server_url_hint")}
|
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("home.settings.jellyseerr.server_url_placeholder")}
|
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
|
||||||
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
@@ -163,7 +166,7 @@ export const JellyseerrSettings = () => {
|
|||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{promptForJellyseerrPass ? t("home.settings.jellyseerr.clear_button") : t("home.settings.jellyseerr.save_button")}
|
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
@@ -172,11 +175,11 @@ export const JellyseerrSettings = () => {
|
|||||||
opacity: promptForJellyseerrPass ? 1 : 0.5,
|
opacity: promptForJellyseerrPass ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="font-bold mb-2">{t("home.settings.jellyseerr.password")}</Text>
|
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
|
||||||
<Input
|
<Input
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
focusable={true}
|
focusable={true}
|
||||||
placeholder={t("home.settings.jellyseerr.password_placeholder", {username: user?.Name})}
|
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
|
||||||
value={jellyseerrPassword}
|
value={jellyseerrPassword}
|
||||||
keyboardType="default"
|
keyboardType="default"
|
||||||
secureTextEntry={true}
|
secureTextEntry={true}
|
||||||
@@ -196,7 +199,7 @@ export const JellyseerrSettings = () => {
|
|||||||
className="h-12 mt-2"
|
className="h-12 mt-2"
|
||||||
onPress={() => loginToJellyseerrMutation.mutate()}
|
onPress={() => loginToJellyseerrMutation.mutate()}
|
||||||
>
|
>
|
||||||
{t("home.settings.jellyseerr.login_button")}
|
{t("home.settings.plugins.jellyseerr.login_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import React from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -11,86 +14,61 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
const renderSkipControl = (
|
||||||
<View>
|
value: number,
|
||||||
<Text className="text-lg font-bold mb-2">{t("home.settings.media.media_title")}</Text>
|
onDecrease: () => void,
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
onIncrease: () => void
|
||||||
<View
|
) => (
|
||||||
className={`
|
<View className="flex flex-row items-center">
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
<TouchableOpacity
|
||||||
`}
|
onPress={onDecrease}
|
||||||
>
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
<View className="flex flex-col shrink">
|
>
|
||||||
<Text className="font-semibold">{t("home.settings.media.forward_skip_length")}</Text>
|
<Text>-</Text>
|
||||||
<Text className="text-xs opacity-50">
|
</TouchableOpacity>
|
||||||
{t("home.settings.media.forward_skip_length_hint")}
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
</Text>
|
{value}s
|
||||||
</View>
|
</Text>
|
||||||
<View className="flex flex-row items-center">
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
onPress={() =>
|
onPress={onIncrease}
|
||||||
updateSettings({
|
>
|
||||||
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
<Text>+</Text>
|
||||||
})
|
</TouchableOpacity>
|
||||||
}
|
</View>
|
||||||
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">
|
|
||||||
{settings.forwardSkipTime}s
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
|
||||||
onPress={() =>
|
|
||||||
updateSettings({
|
|
||||||
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
return (
|
||||||
className={`
|
<View {...props}>
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
||||||
`}
|
<ListItem title={t("home.settings.media_controls.forward_skip_length")}>
|
||||||
>
|
{renderSkipControl(
|
||||||
<View className="flex flex-col shrink">
|
settings.forwardSkipTime,
|
||||||
<Text className="font-semibold">{t("home.settings.media.rewind_length")}</Text>
|
() =>
|
||||||
<Text className="text-xs opacity-50">
|
updateSettings({
|
||||||
{t("home.settings.media.rewind_length_hint")}
|
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
||||||
</Text>
|
}),
|
||||||
</View>
|
() =>
|
||||||
<View className="flex flex-row items-center">
|
updateSettings({
|
||||||
<TouchableOpacity
|
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
||||||
onPress={() =>
|
})
|
||||||
updateSettings({
|
)}
|
||||||
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
</ListItem>
|
||||||
})
|
|
||||||
}
|
<ListItem title={t("home.settings.media_controls.rewind_length")}>
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
{renderSkipControl(
|
||||||
>
|
settings.rewindSkipTime,
|
||||||
<Text>-</Text>
|
() =>
|
||||||
</TouchableOpacity>
|
updateSettings({
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
||||||
{settings.rewindSkipTime}s
|
}),
|
||||||
</Text>
|
() =>
|
||||||
<TouchableOpacity
|
updateSettings({
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
||||||
onPress={() =>
|
})
|
||||||
updateSettings({
|
)}
|
||||||
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
</ListItem>
|
||||||
})
|
</ListGroup>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
43
components/settings/OptimizedServerForm.tsx
Normal file
43
components/settings/OptimizedServerForm.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { TextInput, View, Linking } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChangeValue: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OptimizedServerForm: React.FC<Props> = ({
|
||||||
|
value,
|
||||||
|
onChangeValue,
|
||||||
|
}) => {
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
||||||
|
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
|
||||||
|
<Text className="mr-4">URL</Text>
|
||||||
|
<TextInput
|
||||||
|
className="text-white"
|
||||||
|
placeholder="http(s)://domain.org:port"
|
||||||
|
value={value}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={(text) => onChangeValue(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
Enter the URL for the optimize server. The URL should include http or
|
||||||
|
https and optionally the port.{" "}
|
||||||
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
|
Read more about the optimize server.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
186
components/settings/OtherSettings.tsx
Normal file
186
components/settings/OtherSettings.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
BACKGROUND_FETCH_TASK,
|
||||||
|
registerBackgroundFetchAsync,
|
||||||
|
unregisterBackgroundFetchAsync,
|
||||||
|
} from "@/utils/background-tasks";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
|
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 { 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";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const OtherSettings: React.FC = () => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
/********************
|
||||||
|
* Background task
|
||||||
|
*******************/
|
||||||
|
const checkStatusAsync = async () => {
|
||||||
|
await BackgroundFetch.getStatusAsync();
|
||||||
|
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const registered = await checkStatusAsync();
|
||||||
|
|
||||||
|
if (settings?.autoDownload === true && !registered) {
|
||||||
|
registerBackgroundFetchAsync();
|
||||||
|
toast.success("Background downloads enabled");
|
||||||
|
} else if (settings?.autoDownload === false && registered) {
|
||||||
|
unregisterBackgroundFetchAsync();
|
||||||
|
toast.info("Background downloads disabled");
|
||||||
|
} else if (settings?.autoDownload === true && registered) {
|
||||||
|
// Don't to anything
|
||||||
|
} else if (settings?.autoDownload === false && !registered) {
|
||||||
|
// Don't to anything
|
||||||
|
} else {
|
||||||
|
updateSettings({ autoDownload: false });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [settings?.autoDownload]);
|
||||||
|
/**********************
|
||||||
|
*********************/
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListGroup title={t("home.settings.other.other_title")} className="mb-4">
|
||||||
|
<ListItem title={t("home.settings.other.auto_rotate")}>
|
||||||
|
<Switch
|
||||||
|
value={settings.autoRotate}
|
||||||
|
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem title={t("home.settings.other.video_orientation")} disabled={settings.autoRotate}>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
|
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
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={t("home.settings.other.safe_area_in_controls")}>
|
||||||
|
<Switch
|
||||||
|
value={settings.safeAreaInControlsEnabled}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.show_custom_menu_links")}
|
||||||
|
onPress={() =>
|
||||||
|
Linking.openURL(
|
||||||
|
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.showCustomMenuLinks}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ showCustomMenuLinks: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
components/settings/PluginSettings.tsx
Normal file
38
components/settings/PluginSettings.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const PluginSettings = () => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.plugins.plugins_title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/jellyseerr/page")}
|
||||||
|
title={"Jellyseerr"}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/marlin-search/page")}
|
||||||
|
title="Marlin Search"
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/popular-lists/page")}
|
||||||
|
title="Popular Lists"
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
components/settings/QuickConnect.tsx
Normal file
61
components/settings/QuickConnect.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Alert, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
import Application from "expo-application";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
|
Alert.prompt(
|
||||||
|
t("home.settings.quick_connect.quick_connect_title"),
|
||||||
|
t("home.settings.quick_connect.enter_the_quick_connect_code"),
|
||||||
|
async (text) => {
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
|
code: text,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
if (res.status === 200) {
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized"));
|
||||||
|
} else {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<ListGroup title={"Quick Connect"}>
|
||||||
|
<ListItem
|
||||||
|
onPress={openQuickConnectAuthCodeInput}
|
||||||
|
title={t("home.settings.quick_connect.authorize_button")}
|
||||||
|
textColor="blue"
|
||||||
|
></ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,672 +0,0 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import {
|
|
||||||
apiAtom,
|
|
||||||
getOrSetDeviceId,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
ScreenOrientationEnum,
|
|
||||||
Settings,
|
|
||||||
useSettings,
|
|
||||||
} from "@/utils/atoms/settings";
|
|
||||||
import {
|
|
||||||
BACKGROUND_FETCH_TASK,
|
|
||||||
registerBackgroundFetchAsync,
|
|
||||||
unregisterBackgroundFetchAsync,
|
|
||||||
} from "@/utils/background-tasks";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Linking,
|
|
||||||
Switch,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Input } from "../common/Input";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { Loader } from "../Loader";
|
|
||||||
import { MediaToggles } from "./MediaToggles";
|
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import { MediaProvider } from "./MediaContext";
|
|
||||||
import { SubtitleToggles } from "./SubtitleToggles";
|
|
||||||
import { AudioToggles } from "./AudioToggles";
|
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { ListItem } from "@/components/ListItem";
|
|
||||||
import { JellyseerrSettings } from "./Jellyseerr";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { AppLanguageSelector } from "./AppLanguageSelector";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const { setProcesses } = useDownload();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
/********************
|
|
||||||
* Background task
|
|
||||||
*******************/
|
|
||||||
const checkStatusAsync = async () => {
|
|
||||||
await BackgroundFetch.getStatusAsync();
|
|
||||||
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const registered = await checkStatusAsync();
|
|
||||||
|
|
||||||
if (settings?.autoDownload === true && !registered) {
|
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
toast.success(t("home.settings.toasts.background_downloads_enabled"));
|
|
||||||
} else if (settings?.autoDownload === false && registered) {
|
|
||||||
unregisterBackgroundFetchAsync();
|
|
||||||
toast.info(t("home.settings.toasts.background_downloads_disabled"));
|
|
||||||
} else if (settings?.autoDownload === true && registered) {
|
|
||||||
// Don't to anything
|
|
||||||
} else if (settings?.autoDownload === false && !registered) {
|
|
||||||
// Don't to anything
|
|
||||||
} else {
|
|
||||||
updateSettings({ autoDownload: false });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [settings?.autoDownload]);
|
|
||||||
/**********************
|
|
||||||
*********************/
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: mediaListCollections,
|
|
||||||
isLoading: isLoadingMediaListCollections,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
tags: ["sf_promoted"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["Tags"],
|
|
||||||
includeItemTypes: ["BoxSet"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
{/* <View>
|
|
||||||
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="shrink">
|
|
||||||
<Text className="font-semibold">Coming soon</Text>
|
|
||||||
<Text className="text-xs opacity-50 max-w-[90%]">
|
|
||||||
Options for changing the look and feel of the app.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch disabled />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View> */}
|
|
||||||
|
|
||||||
<AppLanguageSelector />
|
|
||||||
|
|
||||||
<MediaProvider>
|
|
||||||
<MediaToggles />
|
|
||||||
<AudioToggles />
|
|
||||||
<SubtitleToggles />
|
|
||||||
</MediaProvider>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-lg font-bold mb-2">
|
|
||||||
{t("home.settings.other.other_title")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="shrink">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.other.auto_rotate")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.other.auto_rotate_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.autoRotate}
|
|
||||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
pointerEvents={settings.autoRotate ? "none" : "auto"}
|
|
||||||
className={`
|
|
||||||
${
|
|
||||||
settings.autoRotate
|
|
||||||
? "opacity-50 pointer-events-none"
|
|
||||||
: "opacity-100"
|
|
||||||
}
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.other.video_orientation")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.other.video_orientation_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>
|
|
||||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
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>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="shrink">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.other.safe_area_in_controls")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.other.safe_area_in_controls_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.safeAreaInControlsEnabled}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.other.use_popular_lists_plugin")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.other.use_popular_lists_plugin_hint")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
Linking.openURL(
|
|
||||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-xs text-purple-600">
|
|
||||||
{t("home.settings.other.more_info")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.usePopularPlugin}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ usePopularPlugin: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{settings.usePopularPlugin && (
|
|
||||||
<View className="flex flex-col py-2 bg-neutral-900">
|
|
||||||
{mediaListCollections?.map((mlc) => (
|
|
||||||
<View
|
|
||||||
key={mlc.Id}
|
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
|
|
||||||
>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">{mlc.Name}</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (!settings.mediaListCollectionIds) {
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds: [mlc.Id!],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds:
|
|
||||||
settings.mediaListCollectionIds.includes(mlc.Id!)
|
|
||||||
? settings.mediaListCollectionIds.filter(
|
|
||||||
(id) => id !== mlc.Id
|
|
||||||
)
|
|
||||||
: [...settings.mediaListCollectionIds, mlc.Id!],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{isLoadingMediaListCollections && (
|
|
||||||
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{mediaListCollections?.length === 0 && (
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
No collections found. Add some in Jellyfin.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.other.search_engine")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.other.search_engine_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>{settings.searchEngine}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ searchEngine: "Marlin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
{settings.searchEngine === "Marlin" && (
|
|
||||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
|
||||||
<View className="flex flex-row items-center space-x-2">
|
|
||||||
<View className="grow">
|
|
||||||
<Input
|
|
||||||
placeholder="Marlin Server URL..."
|
|
||||||
defaultValue={settings.marlinServerUrl}
|
|
||||||
value={marlinUrl}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
onChangeText={(text) => setMarlinUrl(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
className="shrink w-16 h-12"
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({
|
|
||||||
marlinServerUrl: marlinUrl.endsWith("/")
|
|
||||||
? marlinUrl
|
|
||||||
: marlinUrl + "/",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("home.settings.other.save_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{settings.marlinServerUrl && (
|
|
||||||
<Text className="text-neutral-500 mt-2">
|
|
||||||
Current: {settings.marlinServerUrl}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="shrink">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.other.show_custom_menu_links")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.other.show_custom_menu_links_hint")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
Linking.openURL(
|
|
||||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text className="text-xs text-purple-600">
|
|
||||||
{t("home.settings.other.more_info")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.showCustomMenuLinks}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ showCustomMenuLinks: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="mt-4">
|
|
||||||
<Text className="text-lg font-bold mb-2">
|
|
||||||
{t("home.settings.downloads.downloads_title")}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.downloads.download_method")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.downloads.download_method_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>
|
|
||||||
{settings.downloadMethod === "remux"
|
|
||||||
? "Default"
|
|
||||||
: "Optimized"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Methods</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: "remux" });
|
|
||||||
setProcesses([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: "optimized" });
|
|
||||||
setProcesses([]);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
pointerEvents={
|
|
||||||
settings.downloadMethod === "remux" ? "auto" : "none"
|
|
||||||
}
|
|
||||||
className={`
|
|
||||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
|
||||||
${
|
|
||||||
settings.downloadMethod === "remux"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.downloads.remux_max_download")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50 shrink">
|
|
||||||
{t("home.settings.downloads.remux_max_download_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Stepper
|
|
||||||
value={settings.remuxConcurrentLimit}
|
|
||||||
step={1}
|
|
||||||
min={1}
|
|
||||||
max={4}
|
|
||||||
onUpdate={(value) =>
|
|
||||||
updateSettings({
|
|
||||||
remuxConcurrentLimit:
|
|
||||||
value as Settings["remuxConcurrentLimit"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
pointerEvents={
|
|
||||||
settings.downloadMethod === "optimized" ? "auto" : "none"
|
|
||||||
}
|
|
||||||
className={`
|
|
||||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
|
||||||
${
|
|
||||||
settings.downloadMethod === "optimized"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.downloads.auto_download")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50 shrink">
|
|
||||||
{t("home.settings.downloads.auto_download_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.autoDownload}
|
|
||||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
pointerEvents={
|
|
||||||
settings.downloadMethod === "optimized" ? "auto" : "none"
|
|
||||||
}
|
|
||||||
className={`
|
|
||||||
${
|
|
||||||
settings.downloadMethod === "optimized"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col bg-neutral-900 px-4 py-4">
|
|
||||||
<View className="flex flex-col shrink mb-2">
|
|
||||||
<View className="flex flex-row justify-between items-center">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
{t("home.settings.downloads.optimized_versions_server")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.downloads.optimized_versions_server_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View></View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Input
|
|
||||||
placeholder="Optimized versions server URL..."
|
|
||||||
value={optimizedVersionsServerUrl}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
className="h-12 mt-2"
|
|
||||||
onPress={async () => {
|
|
||||||
updateSettings({
|
|
||||||
optimizedVersionsServerUrl:
|
|
||||||
optimizedVersionsServerUrl.length === 0
|
|
||||||
? null
|
|
||||||
: optimizedVersionsServerUrl.endsWith("/")
|
|
||||||
? optimizedVersionsServerUrl
|
|
||||||
: optimizedVersionsServerUrl + "/",
|
|
||||||
});
|
|
||||||
const res = await getStatistics({
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: await getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
if (res) {
|
|
||||||
toast.success(t("home.settings.toasts.connected"));
|
|
||||||
} else toast.error(t("home.settings.toasts.could_not_connect"));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("home.settings.downloads.save_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<JellyseerrSettings />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
105
components/settings/StorageSettings.tsx
Normal file
105
components/settings/StorageSettings.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { clearLogs } from "@/utils/log";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import * as Progress from "react-native-progress";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const StorageSettings = () => {
|
||||||
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data: size, isLoading: appSizeLoading } = useQuery({
|
||||||
|
queryKey: ["appSize", appSizeUsage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const app = await appSizeUsage;
|
||||||
|
|
||||||
|
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
||||||
|
const total = await FileSystem.getTotalDiskCapacityAsync();
|
||||||
|
|
||||||
|
return { app, remaining, total, used: (total - remaining) / total };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDeleteClicked = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAllFiles();
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
} catch (e) {
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
return ((value / total) * 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className="flex flex-col gap-y-1">
|
||||||
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
<Text className="">{t("home.settings.storage.storage_title")}</Text>
|
||||||
|
{size && (
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
{t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="h-3 w-full bg-gray-100/10 rounded-md overflow-hidden flex flex-row">
|
||||||
|
{size && (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${(size.app / size.total) * 100}%`,
|
||||||
|
backgroundColor: "rgb(147 51 234)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
((size.total - size.remaining - size.app) / size.total) *
|
||||||
|
100
|
||||||
|
}%`,
|
||||||
|
backgroundColor: "rgb(192 132 252)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row gap-x-2">
|
||||||
|
{size && (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
|
||||||
|
<Text className="text-white text-xs">
|
||||||
|
{t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
|
||||||
|
<Text className="text-white text-xs">
|
||||||
|
{t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ListGroup>
|
||||||
|
<ListItem
|
||||||
|
textColor="red"
|
||||||
|
onPress={onDeleteClicked}
|
||||||
|
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -25,26 +28,27 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props}>
|
||||||
<Text className="text-lg font-bold mb-2">{t("home.settings.subtitles.subtitle_title")}</Text>
|
<ListGroup
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
<View
|
description={
|
||||||
className={`
|
<Text className="text-[#8E8D91] text-xs">
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
{t("home.settings.subtitles.subtitle_hint")}
|
||||||
`}
|
</Text>
|
||||||
>
|
}
|
||||||
<View className="flex flex-col shrink">
|
>
|
||||||
<Text className="font-semibold">{t("home.settings.subtitles.subtitle_language")}</Text>
|
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.subtitles.subtitle_language_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text>
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -83,23 +87,20 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</ListItem>
|
||||||
|
|
||||||
<View
|
<ListItem title={t("home.settings.subtitles.subtitle_mode")}>
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">{t("home.settings.subtitles.subtitle_mode")}</Text>
|
|
||||||
<Text className="text-xs opacity-50 mr-2">
|
|
||||||
{t("home.settings.subtitles.subtitle_mode_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
|
{settings?.subtitleMode || "Loading"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -126,38 +127,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</ListItem>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<ListItem title={t("home.settings.subtitles.set_subtitle_track")}>
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<Switch
|
||||||
<View className="flex flex-col">
|
value={settings.rememberSubtitleSelections}
|
||||||
<Text className="font-semibold">
|
onValueChange={(value) =>
|
||||||
{t("home.settings.subtitles.set_subtitle_track")}
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
</Text>
|
}
|
||||||
<Text className="text-xs opacity-50 min max-w-[85%]">
|
/>
|
||||||
{t("home.settings.subtitles.set_subtitle_track_hint")}
|
</ListItem>
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.rememberSubtitleSelections}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ rememberSubtitleSelections: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
<ListItem title={t("home.settings.subtitles.subtitle_size")}>
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">{t("home.settings.subtitles.subtitle_size")}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{t("home.settings.subtitles.subtitle_size_hint")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center">
|
<View className="flex flex-row items-center">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
@@ -169,7 +150,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
>
|
>
|
||||||
<Text>-</Text>
|
<Text>-</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
|
||||||
{settings.subtitleSize}
|
{settings.subtitleSize}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -183,8 +164,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<Text>+</Text>
|
<Text>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ListItem>
|
||||||
</View>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
34
components/settings/UserInfo.tsx
Normal file
34
components/settings/UserInfo.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
import Application from "expo-application";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const version =
|
||||||
|
Application?.nativeApplicationVersion ||
|
||||||
|
Application?.nativeBuildVersion ||
|
||||||
|
"N/A";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<ListGroup title={t("home.settings.user_info.user_info_title")}>
|
||||||
|
<ListItem title={t("home.settings.user_info.user")} value={user?.Name} />
|
||||||
|
<ListItem title={t("home.settings.user_info.server")} value={api?.basePath} />
|
||||||
|
<ListItem title={t("home.settings.user_info.token")} value={api?.accessToken} />
|
||||||
|
<ListItem title={t("home.settings.user_info.app_version")} value={version} />
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -338,12 +338,13 @@ export const useJellyseerr = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestMedia = useCallback(
|
const requestMedia = useCallback(
|
||||||
(title: string, request: MediaRequestBody) => {
|
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||||
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
|
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
|
||||||
switch (mediaRequest.status) {
|
switch (mediaRequest.status) {
|
||||||
case MediaRequestStatus.PENDING:
|
case MediaRequestStatus.PENDING:
|
||||||
case MediaRequestStatus.APPROVED:
|
case MediaRequestStatus.APPROVED:
|
||||||
toast.success(t("jellyseerr.toasts.requested_item", {item: title}));
|
toast.success(t("jellyseerr.toasts.requested_item", {item: title}));
|
||||||
|
onSuccess?.()
|
||||||
break;
|
break;
|
||||||
case MediaRequestStatus.DECLINED:
|
case MediaRequestStatus.DECLINED:
|
||||||
toast.error(t("jellyseerr.toasts.you_dont_have_permission_to_request"));
|
toast.error(t("jellyseerr.toasts.you_dont_have_permission_to_request"));
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
"react-native-pager-view": "6.3.0",
|
"react-native-pager-view": "6.3.0",
|
||||||
"react-native-progress": "^5.0.1",
|
"react-native-progress": "^5.0.1",
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.10.1",
|
||||||
"react-native-reanimated-carousel": "4.0.0-canary.15",
|
"react-native-reanimated-carousel": "4.0.0-canary.22",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "4.10.5",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.2.0",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"react-native-video": "^6.7.0",
|
"react-native-video": "^6.7.0",
|
||||||
"react-native-volume-manager": "^1.10.0",
|
"react-native-volume-manager": "^1.10.0",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-webview": "^13.12.5",
|
"react-native-webview": "13.8.6",
|
||||||
"react-native-youtube-iframe": "^2.3.0",
|
"react-native-youtube-iframe": "^2.3.0",
|
||||||
"sonner-native": "^0.14.2",
|
"sonner-native": "^0.14.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -179,6 +180,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
storage.set("serverUrl", server.address);
|
storage.set("serverUrl", server.address);
|
||||||
},
|
},
|
||||||
|
onSuccess: (_, server) => {
|
||||||
|
const previousServers = JSON.parse(
|
||||||
|
storage.getString("previousServers") || "[]"
|
||||||
|
);
|
||||||
|
const updatedServers = [
|
||||||
|
server,
|
||||||
|
...previousServers.filter((s: Server) => s.address !== server.address),
|
||||||
|
];
|
||||||
|
storage.set(
|
||||||
|
"previousServers",
|
||||||
|
JSON.stringify(updatedServers.slice(0, 5))
|
||||||
|
);
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Failed to set server:", error);
|
console.error("Failed to set server:", error);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
"server_url_placeholder": "Server URL",
|
"server_url_placeholder": "Server URL",
|
||||||
"server_url_hint": "Make sure to include http or https",
|
"server_url_hint": "Make sure to include http or https",
|
||||||
"connect_button": "Connect"
|
"connect_button": "Connect",
|
||||||
|
"previous_servers": "previous servers",
|
||||||
|
"clear_button": "Clear"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -35,99 +37,97 @@
|
|||||||
"suggested_episodes": "Suggested Episodes",
|
"suggested_episodes": "Suggested Episodes",
|
||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
|
"log_out_button": "Log out",
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"user_info_title": "User Info",
|
"user_info_title": "User Info",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
"log_out_button": "Log out",
|
"token": "Token",
|
||||||
"token": "Token"
|
"app_version": "App Version"
|
||||||
},
|
},
|
||||||
"quick_connect": {
|
"quick_connect": {
|
||||||
"quick_connect_title": "Quick connect",
|
"quick_connect_title": "Quick connect",
|
||||||
"authorize_button": "Authorize",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the Quick Connect code",
|
"enter_the_quick_connect_code": "Enter the Quick Connect code",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_autorized": "Quick Connect authorized",
|
"quick_connect_autorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code"
|
"invalid_code": "Invalid code"
|
||||||
},
|
},
|
||||||
"media": {
|
"media_controls": {
|
||||||
"media_title": "Media",
|
"media_controls_title": "Media Controls",
|
||||||
"forward_skip_length": "Forward skip length",
|
"forward_skip_length": "Forward skip length",
|
||||||
"forward_skip_length_hint": "Choose length in seconds when skipping in video playback.",
|
"rewind_length": "Rewind length"
|
||||||
"rewind_length": "Rewind length",
|
|
||||||
"rewind_length_hint": "Choose length in seconds when skipping in video playback."
|
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "Audio",
|
"audio_title": "Audio",
|
||||||
"audio_language": "Audio language",
|
|
||||||
"audio_language_hint": "Choose a default audio language.",
|
|
||||||
"use_default_audio": "Use Default Audio",
|
|
||||||
"use_default_audio_hint": "Play default audio track regardless of language.",
|
|
||||||
"set_audio_track": "Set Audio Track From Previous Item",
|
"set_audio_track": "Set Audio Track From Previous Item",
|
||||||
"set_audio_track_hint": "Try to set the audio track to the closest match to the last\nvideo."
|
"audio_language": "Audio language",
|
||||||
|
"audio_hint": "Choose a default audio language."
|
||||||
},
|
},
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"subtitle_title": "Subtitle",
|
"subtitle_title": "Subtitles",
|
||||||
"subtitle_language": "Subtitle language",
|
"subtitle_language": "Subtitle language",
|
||||||
"subtitle_language_hint": "Choose a default subtitle language.",
|
|
||||||
"subtitle_mode": "Subtitle Mode",
|
"subtitle_mode": "Subtitle Mode",
|
||||||
"subtitle_mode_hint": "Subtitles are loaded based on the default and forced flags in the\nembedded metadata. Language preferences are considered when\nmultiple options are available.",
|
|
||||||
"set_subtitle_track": "Set Subtitle Track From Previous Item",
|
"set_subtitle_track": "Set Subtitle Track From Previous Item",
|
||||||
"set_subtitle_track_hint": "Try to set the subtitle track to the closest match to the last\nvideo.",
|
|
||||||
"subtitle_size": "Subtitle Size",
|
"subtitle_size": "Subtitle Size",
|
||||||
"subtitle_size_hint": "Choose a default subtitle size for direct play (only works for\nsome subtitle formats)."
|
"subtitle_hint": "Configure subtitle preference."
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Other",
|
"other_title": "Other",
|
||||||
"auto_rotate": "Auto rotate",
|
"auto_rotate": "Auto rotate",
|
||||||
"auto_rotate_hint": "Important on android since the video player orientation is locked to the app orientation.",
|
|
||||||
"video_orientation": "Video orientation",
|
"video_orientation": "Video orientation",
|
||||||
"video_orientation_hint": "Set the full screen video player orientation",
|
|
||||||
"safe_area_in_controls": "Safe area in controls",
|
"safe_area_in_controls": "Safe area in controls",
|
||||||
"safe_area_in_controls_hint": "Enable safe area in video player controls",
|
"show_custom_menu_links": "Show Custom Menu Links"
|
||||||
"use_popular_lists_plugin": "Use popular lists plugin",
|
|
||||||
"use_popular_lists_plugin_hint": "Made by: lostb1t",
|
|
||||||
"more_info": "More info",
|
|
||||||
"search_engine": "Search engine",
|
|
||||||
"search_engine_hint": "Choose the search engine you want to use.",
|
|
||||||
"show_custom_menu_links": "Show Custom Menu Links",
|
|
||||||
"show_custom_menu_links_hint": "Show custom menu links defined inside your Jellyfin web config.json file",
|
|
||||||
"save_button": "Save"
|
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"download_method": "Download method",
|
"download_method": "Download method",
|
||||||
"download_method_hint": "Choose the download method to use. Optimized requires the optimized server.",
|
|
||||||
"remux_max_download": "Remux max download",
|
"remux_max_download": "Remux max download",
|
||||||
"remux_max_download_hint": "This is the total media you want to be able to download at the same time.",
|
|
||||||
"auto_download": "Auto download",
|
"auto_download": "Auto download",
|
||||||
"auto_download_hint": "This will automatically download the media file when it's finished optimizing on the server.",
|
"optimized_versions_server": "Optimized versions server"
|
||||||
"optimized_versions_server": "Optimized versions server",
|
|
||||||
"optimized_versions_server_hint": "Set the URL for the optimized versions server for downloads.",
|
|
||||||
"save_button": "Save"
|
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"plugins": {
|
||||||
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
"plugins_title": "Plugins",
|
||||||
"server_url": "Server URL",
|
"jellyseerr": {
|
||||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
"server_url": "Server URL",
|
||||||
"password": "Password",
|
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||||
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
"save_button": "Save",
|
"password": "Password",
|
||||||
"clear_button": "Clear",
|
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
||||||
"login_button": "Login"
|
"save_button": "Save",
|
||||||
|
"clear_button": "Clear",
|
||||||
|
"login_button": "Login"
|
||||||
|
},
|
||||||
|
"marlin_search": {
|
||||||
|
"enable_marlin_search": "Enable Marlin Search ",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port",
|
||||||
|
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
|
||||||
|
"read_more_about_marlin": "Read more about Marlin.",
|
||||||
|
"save_button": "Save"
|
||||||
|
},
|
||||||
|
"popular_lists": {
|
||||||
|
"enable_plugin": "Enable plugin",
|
||||||
|
"enable_popular_lists": "Enable Popular Lists",
|
||||||
|
"enable_popular_hint": "Popular Lists is a plugin that enables you to show custom Jellyfin lists on the Streamyfin home page.",
|
||||||
|
"read_more_about_popular_lists": "Read more about Popular Lists.",
|
||||||
|
"no_collections_found": "No collections found. Add some in Jellyfin.",
|
||||||
|
"select_the_lists_you_want_to_display": "Select the lists you want displayed on the home screen."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"storage_title": "Storage",
|
"storage_title": "Storage",
|
||||||
"app_usage": "App usage: {{usedSpace}}",
|
"app_usage": "App {{usedSpace}}%",
|
||||||
"available_total": "Available: {{availableSpace}}, Total: {{totalSpace}}",
|
"phone_usage": "Phone {{availableSpace}}%",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_logs": "Delete all logs"
|
"delete_all_downloaded_files": "Delete All Downloaded Files"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"logs_title": "Logs",
|
"logs_title": "Logs",
|
||||||
"no_logs_available": "No logs available"
|
"no_logs_available": "No logs available",
|
||||||
|
"delete_all_logs": "Delete all logs"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Languages",
|
"title": "Languages",
|
||||||
|
|||||||
Reference in New Issue
Block a user