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 Get the beta on Google Play -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 -[![Star History Chart](https://api.star-history.com/svg?repos=fredrikburmester/streamyfin&type=Date)](https://star-history.com/#fredrikburmester/streamyfin&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=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); + }} + /> - + ) : ( - {t("home.settings.jellyseerr.jellyseerr_warning")} + {t("home.settings.plugins.jellyseerr.jellyseerr_warning")} - {t("home.settings.jellyseerr.server_url")} + {t("home.settings.plugins.jellyseerr.server_url")} - {t("home.settings.jellyseerr.server_url_hint")} + {t("home.settings.plugins.jellyseerr.server_url_hint")} { 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")} { 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)} - /> - - - - - {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)} - /> - - - - - - - - - - ); -}; 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",