diff --git a/README.md b/README.md
index a0843126..342daa48 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
-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
@@ -108,7 +108,7 @@ Key points of the MPL-2.0:
## 🌐 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:
@@ -117,7 +117,7 @@ If you have questions or need support, feel free to reach out:
## 📝 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
@@ -130,4 +130,4 @@ I'd like to thank the following people and projects for their contributions to S
## Star History
-[](https://star-history.com/#fredrikburmester/streamyfin&Date)
+[](https://star-history.com/#streamyfin/streamyfin&Date)
diff --git a/app/(auth)/(tabs)/(custom-links)/index.tsx b/app/(auth)/(tabs)/(custom-links)/index.tsx
index 76b10fb8..bf6dc46b 100644
--- a/app/(auth)/(tabs)/(custom-links)/index.tsx
+++ b/app/(auth)/(tabs)/(custom-links)/index.tsx
@@ -1,27 +1,29 @@
-import {FlatList, TouchableOpacity, View} from "react-native";
-import {useSafeAreaInsets} from "react-native-safe-area-context";
-import React, {useCallback, useEffect, useState} from "react";
-import {useAtom} from "jotai/index";
-import {apiAtom} from "@/providers/JellyfinProvider";
-import {ListItem} from "@/components/ListItem";
-import * as WebBrowser from 'expo-web-browser';
-import Ionicons from '@expo/vector-icons/Ionicons';
-import {Text} from "@/components/common/Text";
+import { FlatList, TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import React, { useCallback, useEffect, useState } from "react";
+import { useAtom } from "jotai/index";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { ListItem } from "@/components/list/ListItem";
+import * as WebBrowser from "expo-web-browser";
+import Ionicons from "@expo/vector-icons/Ionicons";
+import { Text } from "@/components/common/Text";
export interface MenuLink {
- name: string,
- url: string,
- icon: string
+ name: string;
+ url: string;
+ icon: string;
}
export default function menuLinks() {
const [api] = useAtom(apiAtom);
- const insets = useSafeAreaInsets()
- const [menuLinks, setMenuLinks] = useState([])
+ const insets = useSafeAreaInsets();
+ const [menuLinks, setMenuLinks] = useState([]);
const getMenuLinks = useCallback(async () => {
try {
- const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
+ const response = await api?.axiosInstance.get(
+ api?.basePath + "/web/config.json"
+ );
const config = response?.data;
if (!config && !config.hasOwnProperty("menuLinks")) {
@@ -29,15 +31,15 @@ export default function menuLinks() {
return;
}
- setMenuLinks(config?.menuLinks as MenuLink[])
- } catch (error) {
- console.error("Failed to retrieve config:", error);
- }
- },
- [api]
- )
+ setMenuLinks(config?.menuLinks as MenuLink[]);
+ } catch (error) {
+ console.error("Failed to retrieve config:", error);
+ }
+ }, [api]);
- useEffect(() => { getMenuLinks() }, []);
+ useEffect(() => {
+ getMenuLinks();
+ }, []);
return (
(
- WebBrowser.openBrowserAsync(item.url) }>
+ renderItem={({ item }) => (
+ WebBrowser.openBrowserAsync(item.url)}>
}
+ title={item.name}
+ iconAfter={}
/>
- )
- }
+ )}
ItemSeparatorComponent={() => (
- )}
+ }}
+ />
+ )}
ListEmptyComponent={
-
- No links
-
+
+ No links
+
}
- />
+ />
);
-}
\ No newline at end of file
+}
diff --git a/app/(auth)/(tabs)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx
index cd0a619e..106f477f 100644
--- a/app/(auth)/(tabs)/(favorites)/_layout.tsx
+++ b/app/(auth)/(tabs)/(favorites)/_layout.tsx
@@ -13,6 +13,9 @@ export default function SearchLayout() {
headerShown: true,
headerLargeTitle: true,
headerTitle: t("favorites.favorites_title"),
+ headerLargeStyle: {
+ backgroundColor: "black",
+ },
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index c36a3347..a98ffbda 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -1,6 +1,7 @@
import { Chromecast } from "@/components/Chromecast";
+import { Text } from "@/components/common/Text";
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 { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -17,6 +18,9 @@ export default function IndexLayout() {
headerLargeTitle: true,
headerTitle: t("home.home"),
headerBlurEffect: "prominent",
+ headerLargeStyle: {
+ backgroundColor: "black",
+ },
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
@@ -51,6 +55,30 @@ export default function IndexLayout() {
title: t("home.settings.settings_title"),
}}
/>
+
+
+
+
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
))}
diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index b89e7cf6..c3d709f3 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -6,18 +6,23 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
-import {useNavigation, useRouter} from "expo-router";
+import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
-import React, {useEffect, useMemo, useRef} from "react";
-import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
+import React, { useEffect, useMemo, useRef } from "react";
+import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
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 { 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() {
const navigation = useNavigation();
@@ -59,28 +64,29 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
-
- f.item) || []}/>
+
+ f.item) || []} />
- )
- })
+ ),
+ });
}, [downloadedFiles]);
- const deleteMovies = () => deleteFileByType("Movie")
- .then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
- .catch((reason) => {
- 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")))
- .catch((reason) => {
- writeToLog("ERROR", reason);
- toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
- });
- const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
+ const deleteMovies = () =>
+ deleteFileByType("Movie")
+ .then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
+ .catch((reason) => {
+ 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")))
+ .catch((reason) => {
+ writeToLog("ERROR", reason);
+ toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
+ });
+ const deleteAllMedia = async () =>
+ await Promise.all([deleteMovies(), deleteShows()]);
return (
<>
@@ -110,7 +116,9 @@ export default function page() {
>
{q.item.Name}
- {q.item.Type}
+
+ {q.item.Type}
+
{
@@ -121,7 +129,7 @@ export default function page() {
});
}}
>
-
+
))}
@@ -133,7 +141,7 @@ export default function page() {
)}
-
+
{movies.length > 0 && (
@@ -148,7 +156,7 @@ export default function page() {
{movies?.map((item) => (
-
+
))}
@@ -160,13 +168,18 @@ export default function page() {
{t("home.downloads.tvseries")}
- {groupedBySeries?.length}
+
+ {groupedBySeries?.length}
+
{groupedBySeries?.map((items) => (
-
+
i.item)}
key={items[0].item.SeriesId}
@@ -203,9 +216,15 @@ export default function page() {
>
-
-
-
+
+
+
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 6c936c3e..7889ec53 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -1,179 +1,88 @@
-import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
-import { ListItem } from "@/components/ListItem";
-import { SettingToggles } from "@/components/settings/SettingToggles";
-import {useDownload} from "@/providers/DownloadProvider";
-import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
-import { clearLogs, useLog } from "@/utils/log";
-import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import * as FileSystem from "expo-file-system";
+import { ListGroup } from "@/components/list/ListGroup";
+import { ListItem } from "@/components/list/ListItem";
+import { AudioToggles } from "@/components/settings/AudioToggles";
+import { DownloadSettings } from "@/components/settings/DownloadSettings";
+import { MediaProvider } from "@/components/settings/MediaContext";
+import { MediaToggles } from "@/components/settings/MediaToggles";
+import { OtherSettings } from "@/components/settings/OtherSettings";
+import { 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 { useAtom } from "jotai";
-import { Alert, ScrollView, View } from "react-native";
-import * as Progress from "react-native-progress";
+import { useNavigation, useRouter } from "expo-router";
+import { t } from "i18next";
+import { useEffect } from "react";
+import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { toast } from "sonner-native";
-import { useTranslation } from "react-i18next";
export default function settings() {
- const { logout } = useJellyfin();
- const { deleteAllFiles, appSizeUsage } = useDownload();
- const { logs } = useLog();
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
+ const router = useRouter();
const insets = useSafeAreaInsets();
-
- const { 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 { logout } = useJellyfin();
const onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
+ const navigation = useNavigation();
+ useEffect(() => {
+ navigation.setOptions({
+ headerRight: () => (
+ {
+ logout();
+ }}
+ >
+ {t("home.settings.log_out_button")}
+
+ ),
+ });
+ }, []);
+
return (
- {/* */}
-
- {t("home.settings.user_info.user_info_title")}
+
+
-
-
-
-
-
-
-
+
+
+
+
+
-
- {t("home.settings.quick_connect.quick_connect_title")}
-
-
+
+
-
+
-
- {t("home.settings.storage.storage_title")}
-
- {size && {t("home.settings.storage.app_usage", {usedSpace: size.app.bytesToReadable()})}}
-
+
+ router.push("/settings/logs/page")}
+ showArrow
+ title={t("home.settings.logs.logs_title")}
/>
- {size && (
-
- {t("home.settings.storage.available_total", {availableSpace: size.remaining?.bytesToReadable(), totalSpace: size.total?.bytesToReadable()})}
- {}
-
- )}
-
-
-
-
-
- {t("home.settings.logs.logs_title")}
-
- {logs?.map((log, index) => (
-
-
- {log.level}
-
-
- {log.message}
-
-
- ))}
- {logs?.length === 0 && (
- {t("home.settings.logs.no_logs_available")}
- )}
-
+
+
+
+
);
diff --git a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
new file mode 100644
index 00000000..af4247d5
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
@@ -0,0 +1,78 @@
+import { Text } from "@/components/common/Text";
+import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
+import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getOrSetDeviceId } from "@/utils/device";
+import { getStatistics } from "@/utils/optimize-server";
+import { useMutation } from "@tanstack/react-query";
+import { useNavigation } from "expo-router";
+import { useAtom } from "jotai";
+import { useEffect, useState } from "react";
+import { ActivityIndicator, TouchableOpacity, View } from "react-native";
+import { toast } from "sonner-native";
+
+export default function page() {
+ const navigation = useNavigation();
+
+ const [api] = useAtom(apiAtom);
+ const [settings, updateSettings] = useSettings();
+
+ const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
+ useState(settings?.optimizedVersionsServerUrl || "");
+
+ const saveMutation = useMutation({
+ mutationFn: async (newVal: string) => {
+ if (newVal.length === 0 || !newVal.startsWith("http")) {
+ toast.error("Invalid URL");
+ return;
+ }
+
+ const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
+
+ updateSettings({
+ optimizedVersionsServerUrl: updatedUrl,
+ });
+
+ return await getStatistics({
+ url: settings?.optimizedVersionsServerUrl,
+ authHeader: api?.accessToken,
+ deviceId: getOrSetDeviceId(),
+ });
+ },
+ onSuccess: (data) => {
+ if (data) {
+ toast.success("Connected");
+ } else {
+ toast.error("Could not connect");
+ }
+ },
+ onError: () => {
+ toast.error("Could not connect");
+ },
+ });
+
+ const onSave = (newVal: string) => {
+ saveMutation.mutate(newVal);
+ };
+
+ // useEffect(() => {
+ // navigation.setOptions({
+ // title: "Optimized Server",
+ // headerRight: () =>
+ // saveMutation.isPending ? (
+ //
+ // ) : (
+ // onSave(optimizedVersionsServerUrl)}>
+ // Save
+ //
+ // ),
+ // });
+ // }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
+
+ return (
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/logs/page.tsx b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx
new file mode 100644
index 00000000..1c59ba15
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/logs/page.tsx
@@ -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 (
+
+
+ {logs?.map((log, index) => (
+
+
+ {log.level}
+
+
+ {log.message}
+
+
+ ))}
+ {logs?.length === 0 && (
+ {t("home.settings.logs.no_logs_available")}
+ )}
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
new file mode 100644
index 00000000..b263d857
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
@@ -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(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: () => (
+ onSave(value)}>
+ {t("home.settings.plugins.marlin_search.save_button")}
+
+ ),
+ });
+ }, [navigation, value]);
+
+ if (!settings) return null;
+
+ return (
+
+
+ {
+ updateSettings({ searchEngine: "Jellyfin" });
+ queryClient.invalidateQueries({ queryKey: ["search"] });
+ }}
+ >
+ {
+ updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
+ queryClient.invalidateQueries({ queryKey: ["search"] });
+ }}
+ />
+
+
+
+
+
+
+ {t("home.settings.plugins.marlin_search.url")}
+ setValue(text)}
+ />
+
+
+
+ {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
+
+ {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
new file mode 100644
index 00000000..b47d565f
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
@@ -0,0 +1,80 @@
+import { Text } from "@/components/common/Text";
+import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getOrSetDeviceId } from "@/utils/device";
+import { getStatistics } from "@/utils/optimize-server";
+import { useMutation } from "@tanstack/react-query";
+import { useNavigation } from "expo-router";
+import { useAtom } from "jotai";
+import { useEffect, useState } from "react";
+import { ActivityIndicator, TouchableOpacity, View } from "react-native";
+import { toast } from "sonner-native";
+
+export default function page() {
+ const navigation = useNavigation();
+
+ const [api] = useAtom(apiAtom);
+ const [settings, updateSettings] = useSettings();
+
+ const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
+ useState(settings?.optimizedVersionsServerUrl || "");
+
+ const saveMutation = useMutation({
+ mutationFn: async (newVal: string) => {
+ if (newVal.length === 0 || !newVal.startsWith("http")) {
+ toast.error("Invalid URL");
+ return;
+ }
+
+ const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
+
+ updateSettings({
+ optimizedVersionsServerUrl: updatedUrl,
+ });
+
+ return await getStatistics({
+ url: settings?.optimizedVersionsServerUrl,
+ authHeader: api?.accessToken,
+ deviceId: getOrSetDeviceId(),
+ });
+ },
+ onSuccess: (data) => {
+ if (data) {
+ toast.success("Connected");
+ } else {
+ toast.error("Could not connect");
+ }
+ },
+ onError: () => {
+ toast.error("Could not connect");
+ },
+ });
+
+ const onSave = (newVal: string) => {
+ saveMutation.mutate(newVal);
+ };
+
+ useEffect(() => {
+ navigation.setOptions({
+ title: "Optimized Server",
+ headerRight: () =>
+ saveMutation.isPending ? (
+
+ ) : (
+ onSave(optimizedVersionsServerUrl)}>
+ Save
+
+ ),
+ });
+ }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
+
+ return (
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
new file mode 100644
index 00000000..ef05ecbc
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
@@ -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 (
+
+
+ {
+ updateSettings({ usePopularPlugin: true });
+ queryClient.invalidateQueries({ queryKey: ["search"] });
+ }}
+ >
+ {
+ updateSettings({ usePopularPlugin: value });
+ }}
+ />
+
+
+
+ {t("home.settings.plugins.popular_lists.enable_popular_hint")}{" "}
+
+ {t("home.settings.plugins.popular_lists.read_more_about_popular_lists")}
+
+
+
+ {settings.usePopularPlugin && (
+ <>
+ {!isLoadingMediaListCollections ? (
+ <>
+ {mediaListCollections?.length === 0 ? (
+
+ {t("home.settings.plugins.popular_lists.no_collections_found")}
+
+ ) : (
+ <>
+
+ {mediaListCollections?.map((mlc) => (
+
+ {
+ if (!settings.mediaListCollectionIds) {
+ updateSettings({
+ mediaListCollectionIds: [mlc.Id!],
+ });
+ return;
+ }
+
+ updateSettings({
+ mediaListCollectionIds:
+ settings.mediaListCollectionIds.includes(
+ mlc.Id!
+ )
+ ? settings.mediaListCollectionIds.filter(
+ (id) => id !== mlc.Id
+ )
+ : [
+ ...settings.mediaListCollectionIds,
+ mlc.Id!,
+ ],
+ });
+ }}
+ />
+
+ ))}
+
+
+ {t("home.settings.plugins.popular_lists.select_the_lists_you_want_to_display")}
+
+ >
+ )}
+ >
+ ) : (
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
index 0be85e92..42edcb59 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
@@ -55,6 +55,7 @@ const Page: React.FC = () => {
data: details,
isFetching,
isLoading,
+ refetch
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
@@ -63,6 +64,7 @@ const Page: React.FC = () => {
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
+ refetchInterval: 0,
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
@@ -94,15 +96,18 @@ const Page: React.FC = () => {
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const request = useCallback(
- () =>
+ async () => {
requestMedia(mediaTitle, {
- mediaId: Number(result.id!!),
- mediaType: result.mediaType!!,
- tvdbId: details?.externalIds?.tvdbId,
- seasons: (details as TvDetails)?.seasons
- ?.filter?.((s) => s.seasonNumber !== 0)
- ?.map?.((s) => s.seasonNumber),
- }),
+ mediaId: Number(result.id!!),
+ mediaType: result.mediaType!!,
+ tvdbId: details?.externalIds?.tvdbId,
+ seasons: (details as TvDetails)?.seasons
+ ?.filter?.((s) => s.seasonNumber !== 0)
+ ?.map?.((s) => s.seasonNumber),
+ },
+ refetch
+ )
+ },
[details, result, requestMedia]
);
@@ -205,6 +210,7 @@ const Page: React.FC = () => {
isLoading={isLoading || isFetching}
result={result as TvResult}
details={details as TvDetails}
+ refetch={refetch}
/>
)}
diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 31142eef..45a61a6c 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -41,7 +41,6 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
import { useTranslation } from "react-i18next";
const Page = () => {
@@ -154,6 +153,8 @@ const Page = () => {
itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
+ } else if (library.CollectionType === "music") {
+ itemType = "MusicAlbum";
}
const response = await getItemsApi(api).getItems({
diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx
index a60b33bc..cdc06f42 100644
--- a/app/(auth)/(tabs)/(libraries)/_layout.tsx
+++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx
@@ -22,6 +22,9 @@ export default function IndexLayout() {
headerLargeTitle: true,
headerTitle: t("library.library_title"),
headerBlurEffect: "prominent",
+ headerLargeStyle: {
+ backgroundColor: "black",
+ },
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx
index f8a2f168..5cd25df8 100644
--- a/app/(auth)/(tabs)/(search)/_layout.tsx
+++ b/app/(auth)/(tabs)/(search)/_layout.tsx
@@ -1,4 +1,7 @@
-import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
+import {
+ commonScreenOptions,
+ nestedTabPageScreenOptions,
+} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
@@ -13,6 +16,9 @@ export default function SearchLayout() {
headerShown: true,
headerLargeTitle: true,
headerTitle: t("search.search_title"),
+ headerLargeStyle: {
+ backgroundColor: "black",
+ },
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
@@ -31,10 +37,7 @@ export default function SearchLayout() {
headerShadowVisible: false,
}}
/>
-
+
);
}
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 4c90c264..3c448e26 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -98,6 +98,7 @@ export default function search() {
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
} else {
if (!settings?.marlinServerUrl) return [];
+
const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
@@ -105,6 +106,7 @@ export default function search() {
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
+
const ids = response1.data.ids;
if (!ids || !ids.length) return [];
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index a20f3d61..d720408c 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -106,7 +106,6 @@ export default function page() {
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
- console.log("Offline:", offline);
if (offline) {
const item = await getDownloadedItem(itemId);
if (item) return item.item;
@@ -130,7 +129,6 @@ export default function page() {
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
- console.log("Offline:", offline);
if (offline) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
@@ -197,8 +195,6 @@ export default function page() {
playSessionId: stream.sessionId,
});
}
-
- console.log("Actually marked as paused");
} else {
videoRef.current?.play();
if (!offline && stream) {
@@ -341,7 +337,6 @@ export default function page() {
React.useCallback(() => {
return async () => {
stop();
- console.log("Unmounted");
};
}, [])
);
@@ -351,10 +346,8 @@ export default function page() {
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
- console.log("App has come to the foreground!");
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
- console.log("App has gone to the background!");
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx
index 13aa4ecc..eca16b4c 100644
--- a/app/(auth)/player/music-player.tsx
+++ b/app/(auth)/player/music-player.tsx
@@ -169,18 +169,15 @@ export default function page() {
);
const play = useCallback(() => {
- console.log("play");
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
- console.log("play");
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
- console.log("stop");
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx
index f382411b..8a8b4a9f 100644
--- a/app/(auth)/player/transcoding-player.tsx
+++ b/app/(auth)/player/transcoding-player.tsx
@@ -260,13 +260,6 @@ const Player = () => {
progress.value = ticks;
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: since playable duration is always 0 then.
setIsBuffering(data.playableDuration === 0);
@@ -339,11 +332,7 @@ const Player = () => {
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
- console.log(
- "Setting selected text track",
- subtitleIndex,
- embeddedTrackIndex
- );
+
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: embeddedTrackIndex,
@@ -439,7 +428,6 @@ const Player = () => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
- console.log("onAudioTracks: ", e.audioTracks);
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
@@ -493,7 +481,6 @@ const Player = () => {
}}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
- console.log("setAudioTrack ~", i);
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,
diff --git a/app/(auth)/trailer/page.tsx b/app/(auth)/trailer/page.tsx
index 9120fa37..d56a6099 100644
--- a/app/(auth)/trailer/page.tsx
+++ b/app/(auth)/trailer/page.tsx
@@ -7,8 +7,6 @@ import { useTranslation } from "react-i18next";
export default function page() {
const searchParams = useGlobalSearchParams();
const { t } = useTranslation();
- console.log(searchParams);
-
const { url } = searchParams as { url: string };
const videoId = useMemo(() => {
diff --git a/app/login.tsx b/app/login.tsx
index dd9efe93..0ecd5fb5 100644
--- a/app/login.tsx
+++ b/app/login.tsx
@@ -1,6 +1,7 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
+import { PreviousServersList } from "@/components/PreviousServersList";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
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 { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
-import React, { useEffect, useState } from "react";
+import React, { useCallback, useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
@@ -119,7 +120,7 @@ const CredentialsSchema = z.object({
* - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console.
*/
- async function checkUrl(url: string) {
+ const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
try {
@@ -129,6 +130,7 @@ const CredentialsSchema = z.object({
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
+
setServerName(data.ServerName || "");
return url;
}
@@ -137,7 +139,7 @@ const CredentialsSchema = z.object({
} finally {
setLoadingServerCheck(false);
}
- }
+ }, []);
/**
* 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.
*
*/
- const handleConnect = async (url: string) => {
+ const handleConnect = useCallback(async (url: string) => {
url = url.trim();
const result = await checkUrl(url);
@@ -169,7 +171,7 @@ const CredentialsSchema = z.object({
}
setServer({ address: url });
- };
+ }, []);
const handleQuickConnect = async () => {
try {
@@ -206,7 +208,7 @@ const CredentialsSchema = z.object({
) : t("login.login_title")}
>
- {serverURL}
+ {api.basePath}
@@ -292,9 +294,14 @@ const CredentialsSchema = z.object({
textContentType="URL"
maxLength={500}
/>
-
+
{t("server.server_url_hint")}
+ {
+ handleConnect(s.address);
+ }}
+ />
{
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
- {t("home.settings.jellyseerr.password")}
+ {t("home.settings.plugins.jellyseerr.password")}
{
className="h-12 mt-2"
onPress={() => loginToJellyseerrMutation.mutate()}
>
- {t("home.settings.jellyseerr.login_button")}
+ {t("home.settings.plugins.jellyseerr.login_button")}
diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx
index 6ef4cae4..e7989aa0 100644
--- a/components/settings/MediaToggles.tsx
+++ b/components/settings/MediaToggles.tsx
@@ -1,5 +1,8 @@
-import { useSettings } from "@/utils/atoms/settings";
+import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
+import { useSettings } from "@/utils/atoms/settings";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
import { Text } from "../common/Text";
import { useTranslation } from "react-i18next";
@@ -11,86 +14,61 @@ export const MediaToggles: React.FC = ({ ...props }) => {
if (!settings) return null;
- return (
-
- {t("home.settings.media.media_title")}
-
-
-
- {t("home.settings.media.forward_skip_length")}
-
- {t("home.settings.media.forward_skip_length_hint")}
-
-
-
-
- updateSettings({
- forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
- })
- }
- className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
- >
- -
-
-
- {settings.forwardSkipTime}s
-
-
- updateSettings({
- forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
- })
- }
- >
- +
-
-
-
+ const renderSkipControl = (
+ value: number,
+ onDecrease: () => void,
+ onIncrease: () => void
+ ) => (
+
+
+ -
+
+
+ {value}s
+
+
+ +
+
+
+ );
-
-
- {t("home.settings.media.rewind_length")}
-
- {t("home.settings.media.rewind_length_hint")}
-
-
-
-
- updateSettings({
- rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
- })
- }
- className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
- >
- -
-
-
- {settings.rewindSkipTime}s
-
-
- updateSettings({
- rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
- })
- }
- >
- +
-
-
-
-
+ return (
+
+
+
+ {renderSkipControl(
+ settings.forwardSkipTime,
+ () =>
+ updateSettings({
+ forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
+ }),
+ () =>
+ updateSettings({
+ forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
+ })
+ )}
+
+
+
+ {renderSkipControl(
+ settings.rewindSkipTime,
+ () =>
+ updateSettings({
+ rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
+ }),
+ () =>
+ updateSettings({
+ rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
+ })
+ )}
+
+
);
};
diff --git a/components/settings/OptimizedServerForm.tsx b/components/settings/OptimizedServerForm.tsx
new file mode 100644
index 00000000..2aa7ebda
--- /dev/null
+++ b/components/settings/OptimizedServerForm.tsx
@@ -0,0 +1,43 @@
+import { TextInput, View, Linking } from "react-native";
+import { Text } from "../common/Text";
+
+interface Props {
+ value: string;
+ onChangeValue: (value: string) => void;
+}
+
+export const OptimizedServerForm: React.FC = ({
+ value,
+ onChangeValue,
+}) => {
+ const handleOpenLink = () => {
+ Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
+ };
+
+ return (
+
+
+
+ URL
+ onChangeValue(text)}
+ />
+
+
+
+ Enter the URL for the optimize server. The URL should include http or
+ https and optionally the port.{" "}
+
+ Read more about the optimize server.
+
+
+
+ );
+};
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
new file mode 100644
index 00000000..fdd27206
--- /dev/null
+++ b/components/settings/OtherSettings.tsx
@@ -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 (
+
+
+ updateSettings({ autoRotate: value })}
+ />
+
+
+
+
+
+
+
+ {ScreenOrientationEnum[settings.defaultVideoOrientation]}
+
+
+
+
+
+ Orientation
+ {
+ updateSettings({
+ defaultVideoOrientation:
+ ScreenOrientation.OrientationLock.DEFAULT,
+ });
+ }}
+ >
+
+ {
+ ScreenOrientationEnum[
+ ScreenOrientation.OrientationLock.DEFAULT
+ ]
+ }
+
+
+ {
+ updateSettings({
+ defaultVideoOrientation:
+ ScreenOrientation.OrientationLock.PORTRAIT_UP,
+ });
+ }}
+ >
+
+ {
+ ScreenOrientationEnum[
+ ScreenOrientation.OrientationLock.PORTRAIT_UP
+ ]
+ }
+
+
+ {
+ updateSettings({
+ defaultVideoOrientation:
+ ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
+ });
+ }}
+ >
+
+ {
+ ScreenOrientationEnum[
+ ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
+ ]
+ }
+
+
+ {
+ updateSettings({
+ defaultVideoOrientation:
+ ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
+ });
+ }}
+ >
+
+ {
+ ScreenOrientationEnum[
+ ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
+ ]
+ }
+
+
+
+
+
+
+
+
+ updateSettings({ safeAreaInControlsEnabled: value })
+ }
+ />
+
+
+
+ Linking.openURL(
+ "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
+ )
+ }
+ >
+
+ updateSettings({ showCustomMenuLinks: value })
+ }
+ />
+
+
+ );
+};
diff --git a/components/settings/PluginSettings.tsx b/components/settings/PluginSettings.tsx
new file mode 100644
index 00000000..2f1265b7
--- /dev/null
+++ b/components/settings/PluginSettings.tsx
@@ -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 (
+
+
+ router.push("/settings/jellyseerr/page")}
+ title={"Jellyseerr"}
+ showArrow
+ />
+ router.push("/settings/marlin-search/page")}
+ title="Marlin Search"
+ showArrow
+ />
+ router.push("/settings/popular-lists/page")}
+ title="Popular Lists"
+ showArrow
+ />
+
+
+ );
+};
diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx
new file mode 100644
index 00000000..fe7c7df1
--- /dev/null
+++ b/components/settings/QuickConnect.tsx
@@ -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 }) => {
+ 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 (
+
+
+
+
+
+ );
+};
diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx
deleted file mode 100644
index 02e2e799..00000000
--- a/components/settings/SettingToggles.tsx
+++ /dev/null
@@ -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 }) => {
- const [settings, updateSettings] = useSettings();
- const { setProcesses } = useDownload();
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const [marlinUrl, setMarlinUrl] = useState("");
- const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
- useState(settings?.optimizedVersionsServerUrl || "");
-
- const queryClient = useQueryClient();
-
- 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 (
-
- {/*
- Look and feel
-
-
-
- Coming soon
-
- Options for changing the look and feel of the app.
-
-
-
-
-
- */}
-
-
-
-
-
-
-
-
-
-
-
- {t("home.settings.other.other_title")}
-
-
-
-
-
-
- {t("home.settings.other.auto_rotate")}
-
-
- {t("home.settings.other.auto_rotate_hint")}
-
-
- updateSettings({ autoRotate: value })}
- />
-
-
-
-
-
- {t("home.settings.other.video_orientation")}
-
-
- {t("home.settings.other.video_orientation_hint")}
-
-
-
-
-
-
- {ScreenOrientationEnum[settings.defaultVideoOrientation]}
-
-
-
-
- Orientation
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.DEFAULT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.DEFAULT
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.PORTRAIT_UP,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.PORTRAIT_UP
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
- ]
- }
-
-
-
-
-
-
-
-
-
- {t("home.settings.other.safe_area_in_controls")}
-
-
- {t("home.settings.other.safe_area_in_controls_hint")}
-
-
-
- updateSettings({ safeAreaInControlsEnabled: value })
- }
- />
-
-
-
-
-
-
- {t("home.settings.other.use_popular_lists_plugin")}
-
-
- {t("home.settings.other.use_popular_lists_plugin_hint")}
-
- {
- Linking.openURL(
- "https://github.com/lostb1t/jellyfin-plugin-media-lists"
- );
- }}
- >
-
- {t("home.settings.other.more_info")}
-
-
-
-
- updateSettings({ usePopularPlugin: value })
- }
- />
-
- {settings.usePopularPlugin && (
-
- {mediaListCollections?.map((mlc) => (
-
-
- {mlc.Name}
-
- {
- if (!settings.mediaListCollectionIds) {
- updateSettings({
- mediaListCollectionIds: [mlc.Id!],
- });
- return;
- }
-
- updateSettings({
- mediaListCollectionIds:
- settings.mediaListCollectionIds.includes(mlc.Id!)
- ? settings.mediaListCollectionIds.filter(
- (id) => id !== mlc.Id
- )
- : [...settings.mediaListCollectionIds, mlc.Id!],
- });
- }}
- />
-
- ))}
- {isLoadingMediaListCollections && (
-
-
-
- )}
- {mediaListCollections?.length === 0 && (
-
-
- No collections found. Add some in Jellyfin.
-
-
- )}
-
- )}
-
-
-
-
-
-
- {t("home.settings.other.search_engine")}
-
-
- {t("home.settings.other.search_engine_hint")}
-
-
-
-
-
- {settings.searchEngine}
-
-
-
- Profiles
- {
- updateSettings({ searchEngine: "Jellyfin" });
- queryClient.invalidateQueries({ queryKey: ["search"] });
- }}
- >
- Jellyfin
-
- {
- updateSettings({ searchEngine: "Marlin" });
- queryClient.invalidateQueries({ queryKey: ["search"] });
- }}
- >
- Marlin
-
-
-
-
- {settings.searchEngine === "Marlin" && (
-
-
-
- setMarlinUrl(text)}
- />
-
- {
- updateSettings({
- marlinServerUrl: marlinUrl.endsWith("/")
- ? marlinUrl
- : marlinUrl + "/",
- });
- }}
- >
- {t("home.settings.other.save_button")}
-
-
-
- {settings.marlinServerUrl && (
-
- Current: {settings.marlinServerUrl}
-
- )}
-
- )}
-
-
-
-
-
- {t("home.settings.other.show_custom_menu_links")}
-
-
- {t("home.settings.other.show_custom_menu_links_hint")}
-
-
- Linking.openURL(
- "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
- )
- }
- >
-
- {t("home.settings.other.more_info")}
-
-
-
-
- updateSettings({ showCustomMenuLinks: value })
- }
- />
-
-
-
-
-
-
- {t("home.settings.downloads.downloads_title")}
-
-
-
-
-
- {t("home.settings.downloads.download_method")}
-
-
- {t("home.settings.downloads.download_method_hint")}
-
-
-
-
-
-
- {settings.downloadMethod === "remux"
- ? "Default"
- : "Optimized"}
-
-
-
-
- Methods
- {
- updateSettings({ downloadMethod: "remux" });
- setProcesses([]);
- }}
- >
- Default
-
- {
- updateSettings({ downloadMethod: "optimized" });
- setProcesses([]);
- queryClient.invalidateQueries({ queryKey: ["search"] });
- }}
- >
- Optimized
-
-
-
-
-
-
-
- {t("home.settings.downloads.remux_max_download")}
-
-
- {t("home.settings.downloads.remux_max_download_hint")}
-
-
-
- updateSettings({
- remuxConcurrentLimit:
- value as Settings["remuxConcurrentLimit"],
- })
- }
- />
-
-
-
-
- {t("home.settings.downloads.auto_download")}
-
-
- {t("home.settings.downloads.auto_download_hint")}
-
-
- updateSettings({ autoDownload: value })}
- />
-
-
-
-
-
-
- {t("home.settings.downloads.optimized_versions_server")}
-
-
-
- {t("home.settings.downloads.optimized_versions_server_hint")}
-
-
-
-
- setOptimizedVersionsServerUrl(text)}
- />
- {
- 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")}
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx
new file mode 100644
index 00000000..77ced95d
--- /dev/null
+++ b/components/settings/StorageSettings.tsx
@@ -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 (
+
+
+
+ {t("home.settings.storage.storage_title")}
+ {size && (
+
+ {t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})}
+
+ )}
+
+
+ {size && (
+ <>
+
+
+ >
+ )}
+
+
+ {size && (
+ <>
+
+
+
+ {t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})}
+
+
+
+
+
+ {t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})}
+
+
+ >
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx
index e5fcc54f..97d46965 100644
--- a/components/settings/SubtitleToggles.tsx
+++ b/components/settings/SubtitleToggles.tsx
@@ -3,6 +3,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { useMedia } from "./MediaContext";
import { Switch } from "react-native-gesture-handler";
+import { ListGroup } from "../list/ListGroup";
+import { ListItem } from "../list/ListItem";
+import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useTranslation } from "react-i18next";
@@ -25,26 +28,27 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
];
return (
-
- {t("home.settings.subtitles.subtitle_title")}
-
-
-
- {t("home.settings.subtitles.subtitle_language")}
-
- {t("home.settings.subtitles.subtitle_language_hint")}
-
-
+
+
+ {t("home.settings.subtitles.subtitle_hint")}
+
+ }
+ >
+
-
-
+
+
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
+
= ({ ...props }) => {
))}
-
+
-
-
- {t("home.settings.subtitles.subtitle_mode")}
-
- {t("home.settings.subtitles.subtitle_mode_hint")}
-
-
+
-
- {settings?.subtitleMode || "Loading"}
+
+
+ {settings?.subtitleMode || "Loading"}
+
+
= ({ ...props }) => {
))}
-
+
-
-
-
-
- {t("home.settings.subtitles.set_subtitle_track")}
-
-
- {t("home.settings.subtitles.set_subtitle_track_hint")}
-
-
-
- updateSettings({ rememberSubtitleSelections: value })
- }
- />
-
-
+
+
+ updateSettings({ rememberSubtitleSelections: value })
+ }
+ />
+
-
-
- {t("home.settings.subtitles.subtitle_size")}
-
- {t("home.settings.subtitles.subtitle_size_hint")}
-
-
+
@@ -169,7 +150,7 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
>
-
-
+
{settings.subtitleSize}
= ({ ...props }) => {
+
-
-
+
+
);
};
diff --git a/components/settings/UserInfo.tsx b/components/settings/UserInfo.tsx
new file mode 100644
index 00000000..fdd3db44
--- /dev/null
+++ b/components/settings/UserInfo.tsx
@@ -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 }) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const { t } = useTranslation();
+
+ const version =
+ Application?.nativeApplicationVersion ||
+ Application?.nativeBuildVersion ||
+ "N/A";
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts
index 95b00b12..5b183183 100644
--- a/hooks/useJellyseerr.ts
+++ b/hooks/useJellyseerr.ts
@@ -338,12 +338,13 @@ export const useJellyseerr = () => {
}, []);
const requestMedia = useCallback(
- (title: string, request: MediaRequestBody) => {
+ (title: string, request: MediaRequestBody, onSuccess?: () => void) => {
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
switch (mediaRequest.status) {
case MediaRequestStatus.PENDING:
case MediaRequestStatus.APPROVED:
toast.success(t("jellyseerr.toasts.requested_item", {item: title}));
+ onSuccess?.()
break;
case MediaRequestStatus.DECLINED:
toast.error(t("jellyseerr.toasts.you_dont_have_permission_to_request"));
diff --git a/package.json b/package.json
index ba949384..cef45157 100644
--- a/package.json
+++ b/package.json
@@ -90,7 +90,7 @@
"react-native-pager-view": "6.3.0",
"react-native-progress": "^5.0.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-screens": "3.31.1",
"react-native-svg": "15.2.0",
@@ -101,7 +101,7 @@
"react-native-video": "^6.7.0",
"react-native-volume-manager": "^1.10.0",
"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",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 497d17dd..5145c4b2 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -19,6 +19,7 @@ import React, {
import { Platform } from "react-native";
import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info";
+import { toast } from "sonner-native";
interface Server {
address: string;
@@ -179,6 +180,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setApi(apiInstance);
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) => {
console.error("Failed to set server:", error);
},
diff --git a/translations/en.json b/translations/en.json
index 7526a592..92623026 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -19,7 +19,9 @@
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
"server_url_placeholder": "Server URL",
"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",
@@ -35,99 +37,97 @@
"suggested_episodes": "Suggested Episodes",
"settings": {
"settings_title": "Settings",
+ "log_out_button": "Log out",
"user_info": {
"user_info_title": "User Info",
"user": "User",
"server": "Server",
- "log_out_button": "Log out",
- "token": "Token"
+ "token": "Token",
+ "app_version": "App Version"
},
"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",
"success": "Success",
"quick_connect_autorized": "Quick Connect authorized",
"error": "Error",
"invalid_code": "Invalid code"
},
- "media": {
- "media_title": "Media",
+ "media_controls": {
+ "media_controls_title": "Media Controls",
"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_hint": "Choose length in seconds when skipping in video playback."
+ "rewind_length": "Rewind length"
},
"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_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": {
- "subtitle_title": "Subtitle",
+ "subtitle_title": "Subtitles",
"subtitle_language": "Subtitle language",
- "subtitle_language_hint": "Choose a default subtitle language.",
"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_hint": "Try to set the subtitle track to the closest match to the last\nvideo.",
"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_title": "Other",
"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_hint": "Set the full screen video player orientation",
"safe_area_in_controls": "Safe area in controls",
- "safe_area_in_controls_hint": "Enable safe area in video player controls",
- "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"
+ "show_custom_menu_links": "Show Custom Menu Links"
},
"downloads": {
"downloads_title": "Downloads",
"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_hint": "This is the total media you want to be able to download at the same time.",
"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_hint": "Set the URL for the optimized versions server for downloads.",
- "save_button": "Save"
+ "optimized_versions_server": "Optimized versions server"
},
- "jellyseerr": {
- "jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
- "server_url": "Server URL",
- "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
- "server_url_placeholder": "Jellyseerr URL...",
- "password": "Password",
- "password_placeholder": "Enter password for Jellyfin user {{username}}",
- "save_button": "Save",
- "clear_button": "Clear",
- "login_button": "Login"
+ "plugins": {
+ "plugins_title": "Plugins",
+ "jellyseerr": {
+ "jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
+ "server_url": "Server URL",
+ "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
+ "server_url_placeholder": "Jellyseerr URL...",
+ "password": "Password",
+ "password_placeholder": "Enter password for Jellyfin user {{username}}",
+ "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_title": "Storage",
- "app_usage": "App usage: {{usedSpace}}",
- "available_total": "Available: {{availableSpace}}, Total: {{totalSpace}}",
- "delete_all_downloaded_files": "Delete all downloaded files",
- "delete_all_logs": "Delete all logs"
+ "app_usage": "App {{usedSpace}}%",
+ "phone_usage": "Phone {{availableSpace}}%",
+ "size_used": "{{used}} of {{total}} used",
+ "delete_all_downloaded_files": "Delete All Downloaded Files"
},
"logs": {
"logs_title": "Logs",
- "no_logs_available": "No logs available"
+ "no_logs_available": "No logs available",
+ "delete_all_logs": "Delete all logs"
},
"languages": {
"title": "Languages",