diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index c8f30a95..6d36f734 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -44,8 +44,13 @@ body:
description: What version of Streamyfin are you running?
options:
- 0.25.0
+ - 0.24.0
+ - 0.23.0
+<<<<<<< Updated upstream
+=======
- 0.22.0
- 0.21.0
+>>>>>>> Stashed changes
- older
validations:
required: true
diff --git a/.gitignore b/.gitignore
index 33ed8e6d..1878db42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,6 @@ credentials.json
*.ipa
.continuerc.json
-.vscode/
\ No newline at end of file
+.vscode/
+.idea/
+.ruby-lsp
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d33521..00000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
deleted file mode 100644
index b81700b5..00000000
--- a/.idea/caches/deviceStreaming.xml
+++ /dev/null
@@ -1,329 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 639900d1..00000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index ba6d5c31..00000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/streamyfin.iml b/.idea/streamyfin.iml
deleted file mode 100644
index d6ebd480..00000000
--- a/.idea/streamyfin.iml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddf..00000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index 87a6420c..c1abe08e 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
-
+
## 🌟 Features
@@ -70,7 +70,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
### Beta testing
-To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
+To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
@@ -90,7 +90,7 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed.
-4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
+4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app.
## 📄 License
diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index c3d709f3..51991f1b 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -4,7 +4,7 @@ import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
-import { useSettings } from "@/utils/atoms/settings";
+import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -99,7 +99,7 @@ export default function page() {
>
- {settings?.downloadMethod === "remux" && (
+ {settings?.downloadMethod === DownloadMethod.Remux && (
{t("home.downloads.queue")}
diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx
index 59a315b0..cd77ce75 100644
--- a/app/(auth)/(tabs)/(home)/intro/page.tsx
+++ b/app/(auth)/(tabs)/(home)/intro/page.tsx
@@ -5,8 +5,8 @@ import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
-import { TouchableOpacity, View } from "react-native";
import {useTranslation } from "react-i18next";
+import { Linking, TouchableOpacity, View } from "react-native";
export default function page() {
const router = useRouter();
@@ -19,7 +19,7 @@ export default function page() {
);
return (
-
+
{t("home.intro.welcome_to_streamyfin")}
@@ -83,25 +83,55 @@ export default function page() {
+
+
+
+
+
+ Centralised Settings Plugin
+
+ Configure settings from a centralised location on your Jellyfin
+ server. All client settings for all users will be synced
+ automatically.{" "}
+ {
+ Linking.openURL(
+ "https://github.com/streamyfin/jellyfin-plugin-streamyfin"
+ );
+ }}
+ >
+ Read more
+
+
+
+
+
+
+
+ {
+ router.back();
+ router.push("/settings");
+ }}
+ className="mt-4"
+ >
+ {t("home.intro.go_to_settings_button")}
+
-
-
- {
- router.back();
- router.push("/settings");
- }}
- className="mt-4"
- >
- {t("home.intro.go_to_settings_button")}
-
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index bd1c9260..bc17faf0 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -17,7 +17,7 @@ import { clearLogs } from "@/utils/log";
import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
-import { useEffect } from "react";
+import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
diff --git a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
index 6919441e..35200bc1 100644
--- a/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
@@ -8,9 +8,10 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Switch, View } from "react-native";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -35,7 +36,10 @@ export default function page() {
);
return (
-
+
{data?.map((view) => (
{}}>
@@ -56,6 +60,6 @@ export default function page() {
Select the libraries you want to hide from the Library tab and home page
sections.
-
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
index eaa5e8ae..5da08ff1 100644
--- a/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
@@ -1,81 +1,16 @@
-import { Text } from "@/components/common/Text";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
-import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
-import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { getOrSetDeviceId } from "@/utils/device";
-import { getStatistics } from "@/utils/optimize-server";
-import { useMutation } from "@tanstack/react-query";
-import { useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
-import { ActivityIndicator, TouchableOpacity, View } from "react-native";
-import { toast } from "sonner-native";
-import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
- const navigation = useNavigation();
-
- const { t } = useTranslation();
-
- 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(t("home.settings.toasts.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(t("home.settings.toasts.connected"));
- } else {
- toast.error(t("home.settings.toasts.could_not_connect"));
- }
- },
- onError: () => {
- toast.error(t("home.settings.toasts.could_not_connect"));
- },
- });
-
- const onSave = (newVal: string) => {
- saveMutation.mutate(newVal);
- };
-
- // useEffect(() => {
- // navigation.setOptions({
- // title: "Optimized Server",
- // headerRight: () =>
- // saveMutation.isPending ? (
- //
- // ) : (
- // onSave(optimizedVersionsServerUrl)}>
- // Save
- //
- // ),
- // });
- // }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
+ const [settings, updateSettings, pluginSettings] = useSettings();
return (
-
+
-
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
index 4a907dd3..b67f6ea0 100644
--- a/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
@@ -1,13 +1,12 @@
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 React, {useEffect, useMemo, useState} from "react";
import {
Linking,
Switch,
@@ -16,12 +15,14 @@ import {
View,
} from "react-native";
import { toast } from "sonner-native";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
+
const { t } = useTranslation();
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState(settings?.marlinServerUrl || "");
@@ -37,68 +38,80 @@ export default function page() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
+ const disabled = useMemo(() => {
+ return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
+ }, [pluginSettings]);
+
useEffect(() => {
- navigation.setOptions({
- headerRight: () => (
- onSave(value)}>
- {t("home.settings.plugins.marlin_search.save_button")}
-
- ),
- });
+ if (!pluginSettings?.marlinServerUrl?.locked) {
+ 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" });
+ {
+ 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.url")}
+ setValue(text)}
+ />
-
- {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
-
- {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
-
+
+
+ {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
index 425efc66..988651f0 100644
--- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
@@ -18,7 +19,7 @@ export default function page() {
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState(settings?.optimizedVersionsServerUrl || "");
@@ -59,25 +60,30 @@ export default function page() {
};
useEffect(() => {
- navigation.setOptions({
- title: t("home.settings.downloads.optimized_server"),
- headerRight: () =>
- saveMutation.isPending ? (
-
- ) : (
- onSave(optimizedVersionsServerUrl)}>
- {t("home.settings.downloads.save_button")}
-
- ),
- });
+ if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
+ navigation.setOptions({
+ title: t("home.settings.downloads.optimized_server"),
+ headerRight: () =>
+ saveMutation.isPending ? (
+
+ ) : (
+ onSave(optimizedVersionsServerUrl)}>
+ {t("home.settings.downloads.save_button")}
+
+ ),
+ });
+ }
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
-
+
-
+
);
}
diff --git a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
index ef05ecbc..c44146db 100644
--- a/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
@@ -2,23 +2,23 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader";
+import DisabledSetting from "@/components/settings/DisabledSetting";
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 { useMemo } from "react";
import { useTranslation } from "react-i18next";
+import { Linking, Switch } from "react-native";
export default function page() {
- const navigation = useNavigation();
- const { t } = useTranslation();
-
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
- const [settings, updateSettings] = useSettings();
+ const { t } = useTranslation();
+
+ const [settings, updateSettings, pluginSettings] = useSettings();
const handleOpenLink = () => {
Linking.openURL(
@@ -50,13 +50,21 @@ export default function page() {
staleTime: 0,
});
+ const disabled = useMemo(
+ () =>
+ pluginSettings?.usePopularPlugin?.locked === true &&
+ pluginSettings?.mediaListCollectionIds?.locked === true,
+ [pluginSettings]
+ );
+
if (!settings) return null;
return (
-
+
{
updateSettings({ usePopularPlugin: true });
queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -64,9 +72,10 @@ export default function page() {
>
{
- updateSettings({ usePopularPlugin: value });
- }}
+ disabled={pluginSettings?.usePopularPlugin?.locked}
+ onValueChange={(usePopularPlugin) =>
+ updateSettings({ usePopularPlugin })
+ }
/>
@@ -89,8 +98,17 @@ export default function page() {
<>
{mediaListCollections?.map((mlc) => (
-
+
)}
-
+
);
}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx
deleted file mode 100644
index d2489332..00000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-import { Chromecast } from "@/components/Chromecast";
-import { ItemImage } from "@/components/common/ItemImage";
-import { Text } from "@/components/common/Text";
-import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
-import { SongsList } from "@/components/music/SongsList";
-import { ParallaxScrollView } from "@/components/ParallaxPage";
-import ArtistPoster from "@/components/posters/ArtistPoster";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { router, useLocalSearchParams, useNavigation } from "expo-router";
-import { t } from "i18next";
-import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
-import { ScrollView, TouchableOpacity, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-
-export default function page() {
- const searchParams = useLocalSearchParams();
- const { collectionId, artistId, albumId } = searchParams as {
- collectionId: string;
- artistId: string;
- albumId: string;
- };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const navigation = useNavigation();
-
- useEffect(() => {
- navigation.setOptions({
- headerRight: () => (
-
-
-
- ),
- });
- });
-
- const { data: album } = useQuery({
- queryKey: ["album", albumId, artistId],
- queryFn: async () => {
- if (!api) return null;
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- ids: [albumId],
- });
- const data = response.data.Items?.[0];
- return data;
- },
- enabled: !!api && !!user?.Id && !!albumId,
- staleTime: 0,
- });
-
- const {
- data: songs,
- isLoading,
- isError,
- } = useQuery<{
- Items: BaseItemDto[];
- TotalRecordCount: number;
- }>({
- queryKey: ["songs", artistId, albumId],
- queryFn: async () => {
- if (!api)
- return {
- Items: [],
- TotalRecordCount: 0,
- };
-
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- parentId: albumId,
- fields: [
- "ItemCounts",
- "PrimaryImageAspectRatio",
- "CanDelete",
- "MediaSourceCount",
- ],
- sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
- });
-
- const data = response.data.Items;
-
- return {
- Items: data || [],
- TotalRecordCount: response.data.TotalRecordCount || 0,
- };
- },
- enabled: !!api && !!user?.Id,
- });
-
- const insets = useSafeAreaInsets();
-
- if (!album) return null;
-
- return (
-
- }
- >
-
- {album?.Name}
-
- {t("item_card.x_songs", { count: songs?.TotalRecordCount })}
-
-
-
-
-
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx
deleted file mode 100644
index aea5af16..00000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import ArtistPoster from "@/components/posters/ArtistPoster";
-import { Text } from "@/components/common/Text";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { router, useLocalSearchParams, useNavigation } from "expo-router";
-import { t } from "i18next";
-import { useAtom } from "jotai";
-import { useEffect, useState } from "react";
-import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { ItemImage } from "@/components/common/ItemImage";
-import { ParallaxScrollView } from "@/components/ParallaxPage";
-import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
-
-export default function page() {
- const searchParams = useLocalSearchParams();
- const { artistId } = searchParams as {
- artistId: string;
- };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const navigation = useNavigation();
-
- const [startIndex, setStartIndex] = useState(0);
-
- const { data: artist } = useQuery({
- queryKey: ["album", artistId],
- queryFn: async () => {
- if (!api) return null;
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- ids: [artistId],
- });
- const data = response.data.Items?.[0];
- return data;
- },
- enabled: !!api && !!user?.Id && !!artistId,
- staleTime: 0,
- });
-
- const {
- data: albums,
- isLoading,
- isError,
- } = useQuery<{
- Items: BaseItemDto[];
- TotalRecordCount: number;
- }>({
- queryKey: ["albums", artistId, startIndex],
- queryFn: async () => {
- if (!api)
- return {
- Items: [],
- TotalRecordCount: 0,
- };
-
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- parentId: artistId,
- sortOrder: ["Descending", "Descending", "Ascending"],
- includeItemTypes: ["MusicAlbum"],
- recursive: true,
- fields: [
- "ParentId",
- "PrimaryImageAspectRatio",
- "ParentId",
- "PrimaryImageAspectRatio",
- ],
- collapseBoxSetItems: false,
- albumArtistIds: [artistId],
- startIndex,
- limit: 100,
- sortBy: ["PremiereDate", "ProductionYear", "SortName"],
- });
-
- const data = response.data.Items;
-
- return {
- Items: data || [],
- TotalRecordCount: response.data.TotalRecordCount || 0,
- };
- },
- enabled: !!api && !!user?.Id,
- });
-
- const insets = useSafeAreaInsets();
-
- if (!artist || !albums) return null;
-
- return (
-
- }
- >
-
- {artist?.Name}
-
- {t("item_card.x_albums", { count: albums.TotalRecordCount })}
-
-
-
- {albums.Items.map((item, idx) => (
-
-
-
- {item.Name}
- {item.ProductionYear}
-
-
- ))}
-
-
- );
-}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx
deleted file mode 100644
index 55471ed5..00000000
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
-import ArtistPoster from "@/components/posters/ArtistPoster";
-import MoviePoster from "@/components/posters/MoviePoster";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { router, useLocalSearchParams } from "expo-router";
-import { t } from "i18next";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
-import { FlatList, TouchableOpacity, View } from "react-native";
-
-export default function page() {
- const searchParams = useLocalSearchParams();
- const { collectionId } = searchParams as { collectionId: string };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const { data: collection } = useQuery({
- queryKey: ["collection", collectionId],
- queryFn: async () => {
- if (!api) return null;
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- ids: [collectionId],
- });
- const data = response.data.Items?.[0];
- return data;
- },
- enabled: !!api && !!user?.Id && !!collectionId,
- staleTime: 0,
- });
-
- const [startIndex, setStartIndex] = useState(0);
-
- const { data, isLoading, isError } = useQuery<{
- Items: BaseItemDto[];
- TotalRecordCount: number;
- }>({
- queryKey: ["collection-items", collection?.Id, startIndex],
- queryFn: async () => {
- if (!api || !collectionId)
- return {
- Items: [],
- TotalRecordCount: 0,
- };
-
- const response = await getArtistsApi(api).getArtists({
- sortBy: ["SortName"],
- sortOrder: ["Ascending"],
- fields: ["PrimaryImageAspectRatio", "SortName"],
- imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
- parentId: collectionId,
- userId: user?.Id,
- });
-
- const data = response.data.Items;
-
- return {
- Items: data || [],
- TotalRecordCount: response.data.TotalRecordCount || 0,
- };
- },
- enabled: !!collection?.Id && !!api && !!user?.Id,
- });
-
- const totalItems = useMemo(() => {
- return data?.TotalRecordCount;
- }, [data]);
-
- if (!data) return null;
-
- return (
-
- {t("item_card.artists")}
-
- }
- nestedScrollEnabled
- data={data.Items}
- numColumns={3}
- columnWrapperStyle={{
- justifyContent: "space-between",
- }}
- renderItem={({ item, index }) => (
-
-
- {collection?.CollectionType === "movies" && (
-
- )}
- {collection?.CollectionType === "music" && (
-
- )}
- {item.Name}
- {item.ProductionYear}
-
-
- )}
- keyExtractor={(item) => item.Id || ""}
- />
- );
-}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
index 87f316e6..49896946 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx
@@ -112,7 +112,7 @@ const page: React.FC = () => {
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
- includeItemTypes: ["Movie", "Series", "MusicAlbum"],
+ includeItemTypes: ["Movie", "Series"],
});
return response.data || null;
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 83fbf665..3cf03a9a 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
@@ -29,10 +29,13 @@ import {
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
-import React, { useCallback, useEffect, useRef, useState } from "react";
+import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as DropdownMenu from "zeego/dropdown-menu";
+import RequestModal from "@/components/jellyseerr/RequestModal";
+import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants";
+import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
@@ -52,6 +55,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState();
const [issueMessage, setIssueMessage] = useState();
+ const advancedReqModalRef = useRef(null);
const bottomSheetModalRef = useRef(null);
const {
@@ -75,7 +79,7 @@ const Page: React.FC = () => {
},
});
- const canRequest = useJellyseerrCanRequest(details);
+ const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -101,19 +105,27 @@ 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),
- },
- refetch
- );
- }, [details, result, requestMedia]);
+ const body: MediaRequestBody = {
+ 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),
+ }
+
+ if (hasAdvancedRequestPermission) {
+ advancedReqModalRef?.current?.present?.(body)
+ return
+ }
+
+ requestMedia(mediaTitle, body, refetch);
+ }, [details, result, requestMedia, hasAdvancedRequestPermission]);
+
+ const isAnime = useMemo(
+ () => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV,
+ [details]
+ )
useEffect(() => {
if (details) {
@@ -232,6 +244,10 @@ const Page: React.FC = () => {
result={result as TvResult}
details={details as TvDetails}
refetch={refetch}
+ hasAdvancedRequest={hasAdvancedRequestPermission}
+ onAdvancedRequest={(data) =>
+ advancedReqModalRef?.current?.present(data)
+ }
/>
)}
{
+ {
+ advancedReqModalRef?.current?.close()
+ refetch()
+ }}
+ />
{
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 a3d12c0b..5cce9784 100644
--- a/app/(auth)/(tabs)/(libraries)/_layout.tsx
+++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx
@@ -7,7 +7,7 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { useTranslation } from "react-i18next";
export default function IndexLayout() {
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const { t } = useTranslation();
@@ -28,6 +28,7 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerRight: () => (
+ !pluginSettings?.libraryOptions?.locked &&
data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
+ () =>
+ data
+ ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
+ .filter((l) => l.CollectionType !== "music")
+ .filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries]
);
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 4b2ea40f..ccb90cb6 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -5,7 +5,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
-import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
@@ -187,52 +186,19 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
- const { data: artists, isFetching: l4 } = useQuery({
- queryKey: ["search", "artists", debouncedSearch],
- queryFn: () =>
- searchFn({
- query: debouncedSearch,
- types: ["MusicArtist"],
- }),
- enabled: searchType === "Library" && debouncedSearch.length > 0,
- });
-
- const { data: albums, isFetching: l5 } = useQuery({
- queryKey: ["search", "albums", debouncedSearch],
- queryFn: () =>
- searchFn({
- query: debouncedSearch,
- types: ["MusicAlbum"],
- }),
- enabled: searchType === "Library" && debouncedSearch.length > 0,
- });
-
- const { data: songs, isFetching: l6 } = useQuery({
- queryKey: ["search", "songs", debouncedSearch],
- queryFn: () =>
- searchFn({
- query: debouncedSearch,
- types: ["Audio"],
- }),
- enabled: searchType === "Library" && debouncedSearch.length > 0,
- });
-
const noResults = useMemo(() => {
return !(
- artists?.length ||
- albums?.length ||
- songs?.length ||
movies?.length ||
episodes?.length ||
series?.length ||
collections?.length ||
actors?.length
);
- }, [artists, episodes, albums, songs, movies, series, collections, actors]);
+ }, [episodes, movies, series, collections, actors]);
const loading = useMemo(() => {
- return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
- }, [l1, l2, l3, l4, l5, l6, l7, l8]);
+ return l1 || l2 || l3 || l7 || l8;
+ }, [l1, l2, l3, l7, l8]);
return (
<>
@@ -368,48 +334,6 @@ export default function search() {
)}
/>
- m.Id!)}
- header={t("search.artists")}
- renderItem={(item: BaseItemDto) => (
-
-
-
-
- )}
- />
- m.Id!)}
- header={t("search.albums")}
- renderItem={(item: BaseItemDto) => (
-
-
-
-
- )}
- />
- m.Id!)}
- header={t("search.songs")}
- renderItem={(item: BaseItemDto) => (
-
-
-
-
- )}
- />
) : (
diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx
index 96d08058..4b0ae1c1 100644
--- a/app/(auth)/player/_layout.tsx
+++ b/app/(auth)/player/_layout.tsx
@@ -25,15 +25,6 @@ export default function Layout() {
animation: "fade",
}}
/>
-
>
);
diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx
deleted file mode 100644
index 76d70ce4..00000000
--- a/app/(auth)/player/music-player.tsx
+++ /dev/null
@@ -1,421 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { Loader } from "@/components/Loader";
-import { Controls } from "@/components/video-player/controls/Controls";
-import { useOrientation } from "@/hooks/useOrientation";
-import { useOrientationSettings } from "@/hooks/useOrientationSettings";
-import { useWebSocket } from "@/hooks/useWebsockets";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { secondsToTicks } from "@/utils/secondsToTicks";
-import { Api } from "@jellyfin/sdk";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import {
- getPlaystateApi,
- getUserLibraryApi,
-} from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { useHaptic } from "@/hooks/useHaptic";
-import { Image } from "expo-image";
-import { useFocusEffect, useLocalSearchParams } from "expo-router";
-import { useAtomValue } from "jotai";
-import React, { useCallback, useMemo, useRef, useState } from "react";
-import { Pressable, useWindowDimensions, View } from "react-native";
-import { useSharedValue } from "react-native-reanimated";
-import Video, { OnProgressData, VideoRef } from "react-native-video";
-import { useTranslation } from "react-i18next";
-
-export default function page() {
- const api = useAtomValue(apiAtom);
- const user = useAtomValue(userAtom);
- const [settings] = useSettings();
- const videoRef = useRef(null);
- const windowDimensions = useWindowDimensions();
- const { t } = useTranslation();
-
- const firstTime = useRef(true);
-
- const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
- const [showControls, setShowControls] = useState(true);
- const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
- const [isPlaying, setIsPlaying] = useState(false);
- const [isBuffering, setIsBuffering] = useState(true);
-
- const progress = useSharedValue(0);
- const isSeeking = useSharedValue(false);
- const cacheProgress = useSharedValue(0);
-
- const lightHapticFeedback = useHaptic("light");
-
- const {
- itemId,
- audioIndex: audioIndexStr,
- subtitleIndex: subtitleIndexStr,
- mediaSourceId,
- bitrateValue: bitrateValueStr,
- } = useLocalSearchParams<{
- itemId: string;
- audioIndex: string;
- subtitleIndex: string;
- mediaSourceId: string;
- bitrateValue: string;
- }>();
-
- const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
- const subtitleIndex = subtitleIndexStr
- ? parseInt(subtitleIndexStr, 10)
- : undefined;
- const bitrateValue = bitrateValueStr
- ? parseInt(bitrateValueStr, 10)
- : undefined;
-
- const {
- data: item,
- isLoading: isLoadingItem,
- isError: isErrorItem,
- } = useQuery({
- queryKey: ["item", itemId],
- queryFn: async () => {
- if (!api) return;
- const res = await getUserLibraryApi(api).getItem({
- itemId,
- userId: user?.Id,
- });
-
- return res.data;
- },
- enabled: !!itemId && !!api,
- staleTime: 0,
- });
-
- const {
- data: stream,
- isLoading: isLoadingStreamUrl,
- isError: isErrorStreamUrl,
- } = useQuery({
- queryKey: ["stream-url"],
- queryFn: async () => {
- if (!api) return;
- const res = await getStreamUrl({
- api,
- item,
- startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
- userId: user?.Id,
- audioStreamIndex: audioIndex,
- maxStreamingBitrate: bitrateValue,
- mediaSourceId: mediaSourceId,
- subtitleStreamIndex: subtitleIndex,
- });
-
- if (!res) return null;
-
- const { mediaSource, sessionId, url } = res;
-
- if (!sessionId || !mediaSource || !url) return null;
-
- return {
- mediaSource,
- sessionId,
- url,
- };
- },
- });
-
- const poster = usePoster(item, api);
- const videoSource = useVideoSource(item, api, poster, stream?.url);
-
- const togglePlay = useCallback(
- async (ticks: number) => {
- lightHapticFeedback();
- if (isPlaying) {
- videoRef.current?.pause();
- await getPlaystateApi(api!).onPlaybackProgress({
- itemId: item?.Id!,
- audioStreamIndex: audioIndex ? audioIndex : undefined,
- subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
- mediaSourceId: mediaSourceId,
- positionTicks: Math.floor(ticks),
- isPaused: true,
- playMethod: stream?.url.includes("m3u8")
- ? "Transcode"
- : "DirectStream",
- playSessionId: stream?.sessionId,
- });
- } else {
- videoRef.current?.resume();
- await getPlaystateApi(api!).onPlaybackProgress({
- itemId: item?.Id!,
- audioStreamIndex: audioIndex ? audioIndex : undefined,
- subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
- mediaSourceId: mediaSourceId,
- positionTicks: Math.floor(ticks),
- isPaused: false,
- playMethod: stream?.url.includes("m3u8")
- ? "Transcode"
- : "DirectStream",
- playSessionId: stream?.sessionId,
- });
- }
- },
- [
- isPlaying,
- api,
- item,
- videoRef,
- settings,
- audioIndex,
- subtitleIndex,
- mediaSourceId,
- stream,
- ]
- );
-
- const play = useCallback(() => {
- videoRef.current?.resume();
- reportPlaybackStart();
- }, [videoRef]);
-
- const pause = useCallback(() => {
- videoRef.current?.pause();
- }, [videoRef]);
-
- const stop = useCallback(() => {
- setIsPlaybackStopped(true);
- videoRef.current?.pause();
- reportPlaybackStopped();
- }, [videoRef]);
-
- const seek = useCallback(
- (seconds: number) => {
- videoRef.current?.seek(seconds);
- },
- [videoRef]
- );
-
- const reportPlaybackStopped = async () => {
- if (!item?.Id) return;
- await getPlaystateApi(api!).onPlaybackStopped({
- itemId: item.Id,
- mediaSourceId: mediaSourceId,
- positionTicks: Math.floor(progress.value),
- playSessionId: stream?.sessionId,
- });
- };
-
- const reportPlaybackStart = async () => {
- if (!item?.Id) return;
- await getPlaystateApi(api!).onPlaybackStart({
- itemId: item?.Id,
- audioStreamIndex: audioIndex ? audioIndex : undefined,
- subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
- mediaSourceId: mediaSourceId,
- playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
- playSessionId: stream?.sessionId,
- });
- };
-
- const onProgress = useCallback(
- async (data: OnProgressData) => {
- if (isSeeking.value === true) return;
- if (isPlaybackStopped === true) return;
-
- const ticks = data.currentTime * 10000000;
-
- progress.value = secondsToTicks(data.currentTime);
- cacheProgress.value = secondsToTicks(data.playableDuration);
- setIsBuffering(data.playableDuration === 0);
-
- if (!item?.Id || data.currentTime === 0) return;
-
- await getPlaystateApi(api!).onPlaybackProgress({
- itemId: item.Id!,
- audioStreamIndex: audioIndex ? audioIndex : undefined,
- subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
- mediaSourceId: mediaSourceId,
- positionTicks: Math.round(ticks),
- isPaused: !isPlaying,
- playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
- playSessionId: stream?.sessionId,
- });
- },
- [
- item,
- isPlaying,
- api,
- isPlaybackStopped,
- audioIndex,
- subtitleIndex,
- mediaSourceId,
- stream,
- ]
- );
-
- useFocusEffect(
- useCallback(() => {
- play();
-
- return () => {
- stop();
- };
- }, [play, stop])
- );
-
- useOrientation();
- useOrientationSettings();
-
- useWebSocket({
- isPlaying: isPlaying,
- pauseVideo: pause,
- playVideo: play,
- stopPlayback: stop,
- });
-
- if (isLoadingItem || isLoadingStreamUrl)
- return (
-
-
-
- );
-
- if (isErrorItem || isErrorStreamUrl)
- return (
-
- {t("player.error")}
-
- );
-
- if (!item || !stream)
- return (
-
- {t("player.error")}
-
- );
-
- return (
-
-
-
-
-
- {
- setShowControls(!showControls);
- }}
- className="absolute z-0 h-full w-full opacity-0"
- >
- {videoSource && (
-
-
-
-
- );
-}
-
-export function usePoster(
- item: BaseItemDto | null | undefined,
- api: Api | null
-): string | undefined {
- const poster = useMemo(() => {
- if (!item || !api) return undefined;
- return item.Type === "Audio"
- ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
- : getBackdropUrl({
- api,
- item: item,
- quality: 70,
- width: 200,
- });
- }, [item, api]);
-
- return poster ?? undefined;
-}
-
-export function useVideoSource(
- item: BaseItemDto | null | undefined,
- api: Api | null,
- poster: string | undefined,
- url?: string | null
-) {
- const videoSource = useMemo(() => {
- if (!item || !api || !url) {
- return null;
- }
-
- const startPosition = item?.UserData?.PlaybackPositionTicks
- ? Math.round(item.UserData.PlaybackPositionTicks / 10000)
- : 0;
-
- return {
- uri: url,
- isNetwork: true,
- startPosition,
- headers: getAuthHeaders(api),
- metadata: {
- artist: item?.AlbumArtist ?? undefined,
- title: item?.Name || "Unknown",
- description: item?.Overview ?? undefined,
- imageUri: poster,
- subtitle: item?.Album ?? undefined,
- },
- };
- }, [item, api, poster]);
-
- return videoSource;
-}
diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx
index e02fdef3..38a2b2e5 100644
--- a/app/(auth)/player/transcoding-player.tsx
+++ b/app/(auth)/player/transcoding-player.tsx
@@ -416,7 +416,6 @@ const Player = () => {
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
- pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
@@ -534,7 +533,6 @@ export function useVideoSource(
startPosition,
headers: getAuthHeaders(api),
metadata: {
- artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
diff --git a/app/+not-found.tsx b/app/+not-found.tsx
index 41968287..5a8c1964 100644
--- a/app/+not-found.tsx
+++ b/app/+not-found.tsx
@@ -1,13 +1,10 @@
-import { Link, Stack, usePathname } from "expo-router";
+import { Link, Stack } from "expo-router";
import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
-import { useEffect } from "react";
export default function NotFoundScreen() {
- const pathname = usePathname();
-
return (
<>
diff --git a/app/login.tsx b/app/login.tsx
index 08c24075..870f97b1 100644
--- a/app/login.tsx
+++ b/app/login.tsx
@@ -1,16 +1,12 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
+import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
-import {
- Ionicons,
- MaterialCommunityIcons,
- MaterialIcons,
-} from "@expo/vector-icons";
+import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
-import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
@@ -310,6 +306,15 @@ const CredentialsSchema = z.object({
>
{t("server.connect_button")}
+ {
+ setServerURL(server.address);
+ if (server.serverName) {
+ setServerName(server.serverName);
+ }
+ handleConnect(server.address);
+ }}
+ />
{
handleConnect(s.address);
diff --git a/augmentations/api.ts b/augmentations/api.ts
new file mode 100644
index 00000000..da5c02a9
--- /dev/null
+++ b/augmentations/api.ts
@@ -0,0 +1,46 @@
+import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
+import { AxiosRequestConfig, AxiosResponse } from "axios";
+import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
+
+declare module "@jellyfin/sdk" {
+ interface Api {
+ get(
+ url: string,
+ config?: AxiosRequestConfig
+ ): Promise>;
+ post(
+ url: string,
+ data: D,
+ config?: AxiosRequestConfig
+ ): Promise>;
+ getStreamyfinPluginConfig(): Promise>;
+ }
+}
+
+Api.prototype.get = function (
+ url: string,
+ config: AxiosRequestConfig = {}
+): Promise> {
+ return this.axiosInstance.get(`${this.basePath}${url}`, {
+ ...(config ?? {}),
+ headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
+ });
+};
+
+Api.prototype.post = function (
+ url: string,
+ data: D,
+ config: AxiosRequestConfig
+): Promise> {
+ return this.axiosInstance.post(`${this.basePath}${url}`, {
+ ...(config || {}),
+ data,
+ headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
+ });
+};
+
+Api.prototype.getStreamyfinPluginConfig = function (): Promise<
+ AxiosResponse
+> {
+ return this.get("/Streamyfin/config");
+};
diff --git a/augmentations/index.ts b/augmentations/index.ts
index 22ca2cb0..abec02c9 100644
--- a/augmentations/index.ts
+++ b/augmentations/index.ts
@@ -1,3 +1,4 @@
+export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";
diff --git a/augmentations/mmkv.ts b/augmentations/mmkv.ts
index 80fbeede..5667502f 100644
--- a/augmentations/mmkv.ts
+++ b/augmentations/mmkv.ts
@@ -13,5 +13,10 @@ MMKV.prototype.get = function (key: string): T | undefined {
}
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
- this.set(key, JSON.stringify(value));
+ if (value === undefined) {
+ this.delete(key)
+ }
+ else {
+ this.set(key, JSON.stringify(value));
+ }
}
\ No newline at end of file
diff --git a/augmentations/number.ts b/augmentations/number.ts
index c0f53075..15b70507 100644
--- a/augmentations/number.ts
+++ b/augmentations/number.ts
@@ -1,25 +1,23 @@
declare global {
interface Number {
- bytesToReadable(): string;
+ bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
-Number.prototype.bytesToReadable = function () {
+Number.prototype.bytesToReadable = function (decimals: number = 2) {
const bytes = this.valueOf();
- const gb = bytes / 1e9;
+ if (bytes === 0) return '0 Bytes';
- if (gb >= 1) return `${gb.toFixed(0)} GB`;
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
- const mb = bytes / 1024.0 / 1024.0;
- if (mb >= 1) return `${mb.toFixed(0)} MB`;
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
- const kb = bytes / 1024.0;
- if (kb >= 1) return `${kb.toFixed(0)} KB`;
-
- return `${bytes.toFixed(2)} B`;
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
Number.prototype.secondsToMilliseconds = function () {
diff --git a/bun.lockb b/bun.lockb
index bd6c5da1..f242a822 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx
index 1f1c8bc0..be00cc9e 100644
--- a/components/BitrateSelector.tsx
+++ b/components/BitrateSelector.tsx
@@ -28,6 +28,10 @@ export const BITRATES: Bitrate[] = [
key: "2 Mb/s",
value: 2000000,
},
+ {
+ key: "1 Mb/s",
+ value: 1000000,
+ },
{
key: "500 Kb/s",
value: 500000,
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 4df4601a..eede13f5 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
-import { useSettings } from "@/utils/atoms/settings";
+import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -76,7 +76,7 @@ export const DownloadItems: React.FC = ({
[user]
);
const usingOptimizedServer = useMemo(
- () => settings?.downloadMethod === "optimized",
+ () => settings?.downloadMethod === DownloadMethod.Optimized,
[settings]
);
diff --git a/components/JellyfinServerDiscovery.tsx b/components/JellyfinServerDiscovery.tsx
new file mode 100644
index 00000000..5c310d64
--- /dev/null
+++ b/components/JellyfinServerDiscovery.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import { View, Text, TouchableOpacity } from "react-native";
+import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
+import { Button } from "./Button";
+import { ListGroup } from "./list/ListGroup";
+import { ListItem } from "./list/ListItem";
+
+interface Props {
+ onServerSelect?: (server: { address: string; serverName?: string }) => void;
+}
+
+const JellyfinServerDiscovery: React.FC = ({ onServerSelect }) => {
+ const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
+
+ return (
+
+
+
+ {servers.length ? (
+
+ {servers.map((server) => (
+
+ onServerSelect?.({
+ address: server.address,
+ serverName: server.serverName,
+ })
+ }
+ title={server.address}
+ showArrow
+ />
+ ))}
+
+ ) : null}
+
+ );
+};
+
+export default JellyfinServerDiscovery;
diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx
index ec757f23..1a187e62 100644
--- a/components/MediaSourceSelector.tsx
+++ b/components/MediaSourceSelector.tsx
@@ -1,13 +1,11 @@
-import { tc } from "@/utils/textTools";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
-import { useEffect, useMemo } from "react";
+import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
-import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps {
@@ -32,6 +30,27 @@ export const MediaSourceSelector: React.FC = ({
const { t } = useTranslation();
+ const commonPrefix = useMemo(() => {
+ const mediaSources = item.MediaSources || [];
+ if (!mediaSources.length) return "";
+
+ let commonPrefix = "";
+ for (let i = 0; i < mediaSources[0].Name!.length; i++) {
+ const char = mediaSources[0].Name![i];
+ if (mediaSources.every((source) => source.Name![i] === char)) {
+ commonPrefix += char;
+ } else {
+ commonPrefix = commonPrefix.slice(0, -1);
+ break;
+ }
+ }
+ return commonPrefix;
+ }, [item.MediaSources]);
+
+ const name = (name?: string | null) => {
+ return name?.replace(commonPrefix, "").toLowerCase();
+ };
+
return (
= ({
}}
>
- {`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
- source.Size
- )}`}
+ {`${name(source.Name)}`}
))}
@@ -77,9 +94,3 @@ export const MediaSourceSelector: React.FC = ({
);
};
-
-const name = (name?: string | null) => {
- if (name && name.length > 40)
- return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
- return name;
-};
diff --git a/components/common/Dropdown.tsx b/components/common/Dropdown.tsx
new file mode 100644
index 00000000..fec36d2f
--- /dev/null
+++ b/components/common/Dropdown.tsx
@@ -0,0 +1,108 @@
+import * as DropdownMenu from "zeego/dropdown-menu";
+import {TouchableOpacity, View, ViewProps} from "react-native";
+import {Text} from "@/components/common/Text";
+import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react";
+import DisabledSetting from "@/components/settings/DisabledSetting";
+
+interface Props {
+ data: T[]
+ disabled?: boolean
+ placeholderText?: string,
+ keyExtractor: (item: T) => string
+ titleExtractor: (item: T) => string | undefined
+ title: string | ReactNode,
+ label: string,
+ onSelected: (...item: T[]) => void
+ multi?: boolean
+}
+
+const Dropdown = ({
+ data,
+ disabled,
+ placeholderText,
+ keyExtractor,
+ titleExtractor,
+ title,
+ label,
+ onSelected,
+ multi = false,
+ ...props
+}: PropsWithChildren & ViewProps>) => {
+ const [selected, setSelected] = useState();
+
+ useEffect(() => {
+ if (selected !== undefined) {
+ onSelected(...selected)
+ }
+ }, [selected]);
+
+ return (
+
+
+
+ {typeof title === 'string' ? (
+
+
+ {title}
+
+
+
+ {selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
+
+
+
+ ) : (
+ <>
+ {title}
+ >
+ )}
+
+
+ {label}
+ {data.map((item, idx) => (
+ multi ? (
+ keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'}
+ key={keyExtractor(item)}
+ onValueChange={(next, previous) =>
+ setSelected((p) => {
+ const prev = p || []
+ if (next == 'on') {
+ return [...prev, item]
+ }
+ return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))]
+ })
+ }
+ >
+ {titleExtractor(item)}
+
+ )
+ : (
+ setSelected([item])}
+ >
+ {titleExtractor(item)}
+
+ )
+ ))}
+
+
+
+ )
+};
+
+export default Dropdown;
\ No newline at end of file
diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx
index 04d36dc7..9ca97bee 100644
--- a/components/common/TouchableItemRouter.tsx
+++ b/components/common/TouchableItemRouter.tsx
@@ -26,18 +26,6 @@ export const itemRouter = (
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
- if (item.Type === "MusicAlbum") {
- return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
- }
-
- if (item.Type === "Audio") {
- return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
- }
-
- if (item.Type === "MusicArtist") {
- return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
- }
-
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}
diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx
index 3008c5ff..47c79f5d 100644
--- a/components/downloads/ActiveDownloads.tsx
+++ b/components/downloads/ActiveDownloads.tsx
@@ -1,7 +1,6 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
+import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
@@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
-import { useAtom } from "jotai";
import {
ActivityIndicator,
TouchableOpacity,
@@ -63,7 +61,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
- if (settings?.downloadMethod === "optimized") {
+ if (settings?.downloadMethod === DownloadMethod.Optimized) {
try {
const tasks = await checkForExistingDownloads();
for (const task of tasks) {
diff --git a/components/filters/FilterButton.tsx b/components/filters/FilterButton.tsx
index 6d976b26..de8caa2e 100644
--- a/components/filters/FilterButton.tsx
+++ b/components/filters/FilterButton.tsx
@@ -1,7 +1,7 @@
import { Text } from "@/components/common/Text";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx
index 908c3b3c..c4ab373e 100644
--- a/components/home/Favorites.tsx
+++ b/components/home/Favorites.tsx
@@ -55,14 +55,6 @@ export const Favorites = () => {
() => fetchFavoritesByType("Playlist"),
[fetchFavoritesByType]
);
- const fetchFavoriteMusicAlbum = useCallback(
- () => fetchFavoritesByType("MusicAlbum"),
- [fetchFavoritesByType]
- );
- const fetchFavoriteAudio = useCallback(
- () => fetchFavoritesByType("Audio"),
- [fetchFavoritesByType]
- );
return (
@@ -103,18 +95,6 @@ export const Favorites = () => {
title={t("favorites.playlists")}
hideIfEmpty
/>
-
-
);
};
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index 00767621..5b228901 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -1,3 +1,4 @@
+import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -6,9 +7,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
+import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react";
-import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
+import { Dimensions, View, ViewProps } from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
runOnJS,
useSharedValue,
@@ -18,11 +21,7 @@ import Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
-import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
-import { Loader } from "../Loader";
-import { Gesture, GestureDetector } from "react-native-gesture-handler";
-import { useRouter, useSegments } from "expo-router";
-import { useHaptic } from "@/hooks/useHaptic";
+import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {}
@@ -162,7 +161,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const tap = Gesture.Tap()
.maxDuration(2000)
.onBegin(() => {
- opacity.value = withTiming(0.5, { duration: 100 });
+ opacity.value = withTiming(0.8, { duration: 100 });
})
.onEnd(() => {
runOnJS(handleRoute)();
diff --git a/components/inputs/Stepper.tsx b/components/inputs/Stepper.tsx
index eb5032cf..86a4ffa9 100644
--- a/components/inputs/Stepper.tsx
+++ b/components/inputs/Stepper.tsx
@@ -1,8 +1,10 @@
import {TouchableOpacity, View} from "react-native";
import {Text} from "@/components/common/Text";
+import DisabledSetting from "@/components/settings/DisabledSetting";
interface StepperProps {
value: number,
+ disabled?: boolean,
step: number,
min: number,
max: number,
@@ -12,6 +14,7 @@ interface StepperProps {
export const Stepper: React.FC = ({
value,
+ disabled,
step,
min,
max,
@@ -19,7 +22,11 @@ export const Stepper: React.FC = ({
appendValue
}) => {
return (
-
+
onUpdate(Math.max(min, value - step))}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
@@ -39,6 +46,6 @@ export const Stepper: React.FC = ({
>
+
-
+
)
}
\ No newline at end of file
diff --git a/components/jellyseerr/Cast.tsx b/components/jellyseerr/Cast.tsx
index f5474caf..fd6fc753 100644
--- a/components/jellyseerr/Cast.tsx
+++ b/components/jellyseerr/Cast.tsx
@@ -10,7 +10,7 @@ const CastSlide: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, ...props }) => {
return (
- details?.credits?.cast?.length &&
+ details?.credits?.cast &&
details?.credits?.cast?.length > 0 && (
Cast
diff --git a/components/jellyseerr/DetailFacts.tsx b/components/jellyseerr/DetailFacts.tsx
index 782ede8b..d7d65419 100644
--- a/components/jellyseerr/DetailFacts.tsx
+++ b/components/jellyseerr/DetailFacts.tsx
@@ -29,8 +29,8 @@ const Facts: React.FC<
> = ({ title, facts, ...props }) =>
facts &&
facts?.length > 0 && (
-
- {title}
+
+ {title}
{facts.map((f, idx) =>
diff --git a/components/jellyseerr/RequestModal.tsx b/components/jellyseerr/RequestModal.tsx
new file mode 100644
index 00000000..2f735bd7
--- /dev/null
+++ b/components/jellyseerr/RequestModal.tsx
@@ -0,0 +1,233 @@
+import React, {forwardRef, useCallback, useMemo, useState} from "react";
+import {View, ViewProps} from "react-native";
+import {useJellyseerr} from "@/hooks/useJellyseerr";
+import {useQuery} from "@tanstack/react-query";
+import {MediaType} from "@/utils/jellyseerr/server/constants/media";
+import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
+import Dropdown from "@/components/common/Dropdown";
+import {QualityProfile, RootFolder, Tag} from "@/utils/jellyseerr/server/api/servarr/base";
+import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
+import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types";
+import {Button} from "@/components/Button";
+import {Text} from "@/components/common/Text";
+
+interface Props {
+ id: number;
+ title: string,
+ type: MediaType;
+ isAnime?: boolean;
+ is4k?: boolean;
+ onRequested?: () => void;
+}
+
+const RequestModal = forwardRef>(({
+ id,
+ title,
+ type,
+ isAnime = false,
+ onRequested,
+ ...props
+}, ref) => {
+ const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
+ const [requestOverrides, setRequestOverrides] =
+ useState({
+ mediaId: Number(id),
+ mediaType: type,
+ userId: jellyseerrUser?.id
+ });
+
+ const [modalRequestProps, setModalRequestProps] = useState();
+
+ const {data: serviceSettings} = useQuery({
+ queryKey: ["jellyseerr", "request", type, 'service'],
+ queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
+ enabled: !!jellyseerrApi && !!jellyseerrUser,
+ refetchOnMount: 'always'
+ });
+
+ const {data: users} = useQuery({
+ queryKey: ["jellyseerr", "users"],
+ queryFn: async () => jellyseerrApi?.user({take: 1000, sort: 'displayname'}),
+ enabled: !!jellyseerrApi && !!jellyseerrUser,
+ refetchOnMount: 'always'
+ });
+
+ const defaultService = useMemo(
+ () => serviceSettings?.find?.(v => v.isDefault),
+ [serviceSettings]
+ );
+
+ const {data: defaultServiceDetails} = useQuery({
+ queryKey: ["jellyseerr", "request", type, 'service', 'details', defaultService?.id],
+ queryFn: async () => {
+ setRequestOverrides((prev) => ({
+ ...prev,
+ serverId: defaultService?.id
+ }))
+ return jellyseerrApi?.serviceDetails(type === 'movie' ? 'radarr' : 'sonarr', defaultService!!.id)
+ },
+ enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
+ refetchOnMount: 'always',
+ });
+
+ const defaultProfile: QualityProfile = useMemo(
+ () => defaultServiceDetails?.profiles
+ .find(p =>
+ p.id === (isAnime ? defaultServiceDetails.server?.activeAnimeProfileId : defaultServiceDetails.server?.activeProfileId)
+ ),
+ [defaultServiceDetails]
+ );
+
+ const defaultFolder: RootFolder = useMemo(
+ () => defaultServiceDetails?.rootFolders
+ .find(f =>
+ f.path === (isAnime ? defaultServiceDetails?.server.activeAnimeDirectory : defaultServiceDetails.server?.activeDirectory)
+ ),
+ [defaultServiceDetails]
+ );
+
+ const defaultTags: Tag[] = useMemo(
+ () => {
+ const tags = defaultServiceDetails?.tags
+ .filter(t =>
+ (isAnime
+ ? defaultServiceDetails?.server.activeAnimeTags
+ : defaultServiceDetails?.server.activeTags
+ )?.includes(t.id)
+ ) ?? []
+
+ console.log(tags)
+ return tags
+ },
+ [defaultServiceDetails]
+ );
+
+ const seasonTitle = useMemo(
+ () => modalRequestProps?.seasons?.length ? `Season (${modalRequestProps?.seasons})` : undefined,
+ [modalRequestProps?.seasons]
+ );
+
+ const request = useCallback(() => {requestMedia(
+ seasonTitle ? `${title}, ${seasonTitle}` : title,
+ {
+ is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
+ profileId: defaultProfile.id,
+ rootFolder: defaultFolder.path,
+ tags: defaultTags.map(t => t.id),
+ ...modalRequestProps,
+ ...requestOverrides
+ },
+ onRequested
+ )
+ }, [requestOverrides, defaultProfile, defaultFolder, defaultTags]);
+
+ const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
+
+ return (
+ setModalRequestProps(undefined)}
+ handleIndicatorStyle={{
+ backgroundColor: "white",
+ }}
+ backgroundStyle={{
+ backgroundColor: "#171717",
+ }}
+ backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
+
+ }
+ >
+ {(data) => {
+ setModalRequestProps(data?.data as MediaRequestBody)
+ return
+
+
+ Advanced
+ {seasonTitle &&
+ {seasonTitle}
+ }
+
+
+ {(defaultService && defaultServiceDetails && users) && (
+ <>
+ item.name}
+ placeholderText={defaultProfile.name}
+ keyExtractor={(item) => item.id.toString()}
+ label={"Quality Profile"}
+ onSelected={(item) =>
+ item && setRequestOverrides((prev) => ({
+ ...prev,
+ profileId: item?.id
+ }))
+ }
+ title={"Quality Profile"}
+ />
+ item.id.toString()}
+ label={"Root Folder"}
+ onSelected={(item) =>
+ item && setRequestOverrides((prev) => ({
+ ...prev,
+ rootFolder: item.path
+ }))}
+ title={"Root Folder"}
+ />
+ item.label}
+ placeholderText={defaultTags.map(t => t.label).join(",")}
+ keyExtractor={(item) => item.id.toString()}
+ label={"Tags"}
+ onSelected={(...item) =>
+ item && setRequestOverrides((prev) => ({
+ ...prev,
+ tags: item.map(i => i.id)
+ }))
+ }
+ title={"Tags"}
+ />
+ item.displayName}
+ placeholderText={jellyseerrUser!!.displayName}
+ keyExtractor={(item) => item.id.toString() || ""}
+ label={"Request As"}
+ onSelected={(item) =>
+ item && setRequestOverrides((prev) => ({
+ ...prev,
+ userId: item?.id
+ }))
+ }
+ title={"Request As"}
+ />
+ >
+ )
+ }
+
+
+
+
+ }}
+
+ );
+});
+
+export default RequestModal;
\ No newline at end of file
diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx
index b30df3d7..abee4a9d 100644
--- a/components/jellyseerr/discover/CompanySlide.tsx
+++ b/components/jellyseerr/discover/CompanySlide.tsx
@@ -1,23 +1,30 @@
-import React, {useCallback} from "react";
-import {
- useJellyseerr,
-} from "@/hooks/useJellyseerr";
-import {TouchableOpacity, ViewProps} from "react-native";
-import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
-import {COMPANY_LOGO_IMAGE_FILTER, Network} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
-import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
-import {router, useSegments} from "expo-router";
+import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import {
+ COMPANY_LOGO_IMAGE_FILTER,
+ Network,
+} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
+import { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
+import { router, useSegments } from "expo-router";
+import React, { useCallback } from "react";
+import { TouchableOpacity, ViewProps } from "react-native";
-const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewProps> = ({ slide, data, ...props }) => {
+const CompanySlide: React.FC<
+ { data: Network[] | Studio[] } & SlideProps & ViewProps
+> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = segments[2];
- const navigate = useCallback(({id, image, name}: Network | Studio) => router.push({
- pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
- params: {id, image, name, type: slide.type }
- }), [slide]);
+ const navigate = useCallback(
+ ({ id, image, name }: Network | Studio) =>
+ router.push({
+ pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
+ params: { id, image, name, type: slide.type },
+ }),
+ [slide]
+ );
return (
)}
diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx
index 551ee2de..36623e20 100644
--- a/components/jellyseerr/discover/GenreSlide.tsx
+++ b/components/jellyseerr/discover/GenreSlide.tsx
@@ -1,55 +1,66 @@
-import React, {useCallback} from "react";
-import {Endpoints, useJellyseerr,} from "@/hooks/useJellyseerr";
-import {TouchableOpacity, ViewProps} from "react-native";
-import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
-import {router, useSegments} from "expo-router";
-import {useQuery} from "@tanstack/react-query";
-import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
-import {genreColorMap} from "@/utils/jellyseerr/src/components/Discover/constants";
-import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
+import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
+import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
+import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
+import { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
+import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
+import { useQuery } from "@tanstack/react-query";
+import { router, useSegments } from "expo-router";
+import React, { useCallback } from "react";
+import { TouchableOpacity, ViewProps } from "react-native";
const GenreSlide: React.FC = ({ slide, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = segments[2];
- const navigate = useCallback((genre: GenreSliderItem) => router.push({
- pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
- params: {type: slide.type, name: genre.name}
- }), [slide]);
+ const navigate = useCallback(
+ (genre: GenreSliderItem) =>
+ router.push({
+ pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
+ params: { type: slide.type, name: genre.name },
+ }),
+ [slide]
+ );
- const {data, isFetching, isLoading } = useQuery({
- queryKey: ['jellyseerr', 'discover', slide.type, slide.id],
+ const { data, isFetching, isLoading } = useQuery({
+ queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => {
return jellyseerrApi?.getGenreSliders(
slide.type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE
: Endpoints.TV
- )
+ );
},
- enabled: !!jellyseerrApi
- })
+ enabled: !!jellyseerrApi,
+ });
return (
- data && item.id.toString()}
- renderItem={(item, index) => (
- navigate(item)}>
-
-
- )}
- />
+ data && (
+ item.id.toString()}
+ renderItem={(item, index) => (
+ navigate(item)}>
+
+
+ )}
+ />
+ )
);
};
diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx
index 17595db6..7a370405 100644
--- a/components/library/LibraryItemCard.tsx
+++ b/components/library/LibraryItemCard.tsx
@@ -60,8 +60,6 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
_itemType = "Series";
} else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet";
- } else if (library.CollectionType === "music") {
- _itemType = "MusicAlbum";
}
return _itemType;
@@ -76,8 +74,6 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => {
nameStr = "series";
} else if (library.CollectionType === "boxsets") {
nameStr = "box sets";
- } else if (library.CollectionType === "music") {
- nameStr = "albums";
} else {
nameStr = "items";
}
diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx
index 46856b00..403b33dc 100644
--- a/components/list/ListItem.tsx
+++ b/components/list/ListItem.tsx
@@ -1,3 +1,4 @@
+import { Ionicons } from "@expo/vector-icons";
import { PropsWithChildren, ReactNode } from "react";
import {
TouchableOpacity,
@@ -6,7 +7,6 @@ import {
ViewProps,
} from "react-native";
import { Text } from "../common/Text";
-import { Ionicons } from "@expo/vector-icons";
interface Props extends TouchableOpacityProps, ViewProps {
title?: string | null | undefined;
diff --git a/components/music/SongsList.tsx b/components/music/SongsList.tsx
deleted file mode 100644
index 4d576f3c..00000000
--- a/components/music/SongsList.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useRouter } from "expo-router";
-import { View, ViewProps } from "react-native";
-import { SongsListItem } from "./SongsListItem";
-
-interface Props extends ViewProps {
- songs?: BaseItemDto[] | null;
- collectionId: string;
- artistId: string;
- albumId: string;
-}
-
-export const SongsList: React.FC = ({
- collectionId,
- artistId,
- albumId,
- songs = [],
- ...props
-}) => {
- const router = useRouter();
- return (
-
- {songs?.map((item: BaseItemDto, index: number) => (
-
- ))}
-
- );
-};
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
deleted file mode 100644
index 552baa69..00000000
--- a/components/music/SongsListItem.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
-import { runtimeTicksToSeconds } from "@/utils/time";
-import { useActionSheet } from "@expo/react-native-action-sheet";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useRouter } from "expo-router";
-import { useAtom } from "jotai";
-import { useCallback } from "react";
-import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
-import CastContext, {
- PlayServicesState,
- useCastDevice,
- useRemoteMediaClient,
-} from "react-native-google-cast";
-
-interface Props extends TouchableOpacityProps {
- collectionId: string;
- artistId: string;
- albumId: string;
- item: BaseItemDto;
- index: number;
-}
-
-export const SongsListItem: React.FC = ({
- collectionId,
- artistId,
- albumId,
- item,
- index,
- ...props
-}) => {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
- const castDevice = useCastDevice();
- const router = useRouter();
- const client = useRemoteMediaClient();
- const { showActionSheetWithOptions } = useActionSheet();
-
- const { setPlaySettings } = usePlaySettings();
-
- const openSelect = () => {
- if (!castDevice?.deviceId) {
- play("device");
- return;
- }
-
- const options = ["Chromecast", "Device", "Cancel"];
- const cancelButtonIndex = 2;
-
- showActionSheetWithOptions(
- {
- options,
- cancelButtonIndex,
- },
- (selectedIndex: number | undefined) => {
- switch (selectedIndex) {
- case 0:
- play("cast");
- break;
- case 1:
- play("device");
- break;
- case cancelButtonIndex:
- break;
- }
- }
- );
- };
-
- const play = useCallback(async (type: "device" | "cast") => {
- if (!user?.Id || !api || !item.Id) {
- console.warn("No user, api or item", user, api, item.Id);
- return;
- }
-
- const data = await setPlaySettings({
- item,
- });
-
- if (!data?.url) {
- throw new Error("play-music ~ No stream url");
- }
-
- if (type === "cast" && client) {
- await CastContext.getPlayServicesState().then((state) => {
- if (state && state !== PlayServicesState.SUCCESS)
- CastContext.showPlayServicesErrorDialog(state);
- else {
- client.loadMedia({
- mediaInfo: {
- contentUrl: data.url!,
- contentType: "video/mp4",
- metadata: {
- type: item.Type === "Episode" ? "tvShow" : "movie",
- title: item.Name || "",
- subtitle: item.Overview || "",
- },
- },
- startTime: 0,
- });
- }
- });
- } else {
- console.log("Playing on device", data.url, item.Id);
- router.push("/music-player");
- }
- }, []);
-
- return (
- {
- openSelect();
- }}
- {...props}
- >
-
- {index + 1}
-
- {item.Name}
-
- {runtimeTicksToSeconds(item.RunTimeTicks)}
-
-
-
-
- );
-};
diff --git a/components/posters/AlbumCover.tsx b/components/posters/AlbumCover.tsx
deleted file mode 100644
index 870dce6a..00000000
--- a/components/posters/AlbumCover.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-
-type ArtistPosterProps = {
- item?: BaseItemDto | null;
- id?: string | null;
- showProgress?: boolean;
-};
-
-const AlbumCover: React.FC = ({ item, id }) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(() => {
- const u = getPrimaryImageUrl({
- api,
- item,
- });
- return u;
- }, [item]);
-
- const url2 = useMemo(() => {
- const u = getPrimaryImageUrlById({
- api,
- id,
- quality: 85,
- width: 300,
- });
- return u;
- }, [item]);
-
- if (!item && id)
- return (
-
-
-
- );
-
- if (item)
- return (
-
-
-
- );
-};
-
-export default AlbumCover;
diff --git a/components/posters/ArtistPoster.tsx b/components/posters/ArtistPoster.tsx
deleted file mode 100644
index d64818b6..00000000
--- a/components/posters/ArtistPoster.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-
-type ArtistPosterProps = {
- item: BaseItemDto;
- showProgress?: boolean;
-};
-
-const ArtistPoster: React.FC = ({
- item,
- showProgress = false,
-}) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item,
- }),
- [item]
- );
-
- if (!url)
- return (
-
- );
-
- return (
-
-
-
- );
-};
-
-export default ArtistPoster;
diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx
index 11fb4941..1c3ce45b 100644
--- a/components/posters/JellyseerrPoster.tsx
+++ b/components/posters/JellyseerrPoster.tsx
@@ -57,7 +57,7 @@ const JellyseerrPoster: React.FC = ({ item, ...props }) => {
[item]
);
- const canRequest = useJellyseerrCanRequest(item);
+ const [canRequest] = useJellyseerrCanRequest(item);
return (
void;
refetch: (options?: (RefetchOptions | undefined)) => Promise>;
-}> = ({ isLoading, result, details, refetch }) => {
+}> = ({
+ isLoading,
+ result,
+ details,
+ refetch,
+ hasAdvancedRequest,
+ onAdvancedRequest,
+}) => {
if (!details) return null;
const { jellyseerrApi, requestMedia } = useJellyseerr();
@@ -143,7 +153,7 @@ const JellyseerrSeasons: React.FC<{
const requestAll = useCallback(() => {
if (details && jellyseerrApi) {
- requestMedia(result?.name!!, {
+ const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
@@ -152,9 +162,15 @@ const JellyseerrSeasons: React.FC<{
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
)
.map((s) => s.seasonNumber),
- });
+ }
+
+ if (hasAdvancedRequest) {
+ return onAdvancedRequest?.(body)
+ }
+
+ requestMedia(result?.name!!, body, refetch);
}
- }, [jellyseerrApi, seasons, details]);
+ }, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
const promptRequestAll = useCallback(
() =>
@@ -173,18 +189,20 @@ const JellyseerrSeasons: React.FC<{
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
if (canRequest) {
- requestMedia(
- `${result?.name!!}, Season ${seasonNumber}`,
- {
- mediaId: details.id,
- mediaType: MediaType.TV,
- tvdbId: details.externalIds?.tvdbId,
- seasons: [seasonNumber],
- },
- refetch
- )
+ const body: MediaRequestBody = {
+ mediaId: details.id,
+ mediaType: MediaType.TV,
+ tvdbId: details.externalIds?.tvdbId,
+ seasons: [seasonNumber],
+ }
+
+ if (hasAdvancedRequest) {
+ return onAdvancedRequest?.(body)
+ }
+
+ requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
}
- }, [requestMedia]);
+ }, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);
if (isLoading)
return (
diff --git a/components/settings/AudioToggles.tsx b/components/settings/AudioToggles.tsx
index a2bdec3f..c1f82d6c 100644
--- a/components/settings/AudioToggles.tsx
+++ b/components/settings/AudioToggles.tsx
@@ -7,11 +7,13 @@ import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
+import {useSettings} from "@/utils/atoms/settings";
interface Props extends ViewProps {}
export const AudioToggles: React.FC = ({ ...props }) => {
const media = useMedia();
+ const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();
@@ -28,9 +30,13 @@ export const AudioToggles: React.FC = ({ ...props }) => {
}
>
-
+
updateSettings({ rememberAudioSelections: value })
}
diff --git a/components/settings/DisabledSetting.tsx b/components/settings/DisabledSetting.tsx
new file mode 100644
index 00000000..b340fb96
--- /dev/null
+++ b/components/settings/DisabledSetting.tsx
@@ -0,0 +1,26 @@
+import {View, ViewProps} from "react-native";
+import {Text} from "@/components/common/Text";
+
+const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({
+ disabled = false,
+ showText = true,
+ text,
+ children,
+ ...props
+}) => (
+
+
+ {disabled && showText &&
+ {text ?? "Currently disabled by admin."}
+ }
+ {children}
+
+
+)
+
+export default DisabledSetting;
\ No newline at end of file
diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx
index 131ec9a6..3199e049 100644
--- a/components/settings/DownloadSettings.tsx
+++ b/components/settings/DownloadSettings.tsx
@@ -1,35 +1,47 @@
import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider";
-import { Settings, useSettings } from "@/utils/atoms/settings";
+import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
-import React from "react";
-import { Switch, TouchableOpacity, View } from "react-native";
+import React, { useMemo } from "react";
+import { Switch, TouchableOpacity } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => {
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslation();
+ const allDisabled = useMemo(
+ () =>
+ pluginSettings?.downloadMethod?.locked === true &&
+ pluginSettings?.remuxConcurrentLimit?.locked === true &&
+ pluginSettings?.autoDownload.locked === true,
+ [pluginSettings]
+ );
+
if (!settings) return null;
return (
-
+
-
+
- {settings.downloadMethod === "remux"
+ {settings.downloadMethod === DownloadMethod.Remux
? t("home.settings.downloads.default")
: t("home.settings.downloads.optimized")}
@@ -53,7 +65,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
{
- updateSettings({ downloadMethod: "remux" });
+ updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]);
}}
>
@@ -62,7 +74,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
{
- updateSettings({ downloadMethod: "optimized" });
+ updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
@@ -75,7 +87,10 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
{
updateSettings({ autoDownload: value })}
/>
router.push("/settings/optimized-server/page")}
showArrow
title={t("home.settings.downloads.optimized_versions_server")}
>
-
+
);
};
diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx
index 5c3f6afc..5f0afdc5 100644
--- a/components/settings/Jellyseerr.tsx
+++ b/components/settings/Jellyseerr.tsx
@@ -24,7 +24,7 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation();
const [user] = useAtom(userAtom);
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState(false);
diff --git a/components/settings/MediaToggles.tsx b/components/settings/MediaToggles.tsx
index e7989aa0..a187f6e0 100644
--- a/components/settings/MediaToggles.tsx
+++ b/components/settings/MediaToggles.tsx
@@ -1,74 +1,64 @@
-import React from "react";
-import { TouchableOpacity, View, ViewProps } from "react-native";
+import React, {useMemo} from "react";
+import { ViewProps } from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
-import { Text } from "../common/Text";
import { useTranslation } from "react-i18next";
+import DisabledSetting from "@/components/settings/DisabledSetting";
+import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {}
export const MediaToggles: React.FC = ({ ...props }) => {
- const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
+ const [settings, updateSettings, pluginSettings] = useSettings();
+
if (!settings) return null;
- const renderSkipControl = (
- value: number,
- onDecrease: () => void,
- onIncrease: () => void
- ) => (
-
-
- -
-
-
- {value}s
-
-
- +
-
-
- );
+ const disabled = useMemo(() => (
+ pluginSettings?.forwardSkipTime?.locked === true &&
+ pluginSettings?.rewindSkipTime?.locked === true
+ ),
+ [pluginSettings]
+ )
return (
-
+
-
- {renderSkipControl(
- settings.forwardSkipTime,
- () =>
- updateSettings({
- forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
- }),
- () =>
- updateSettings({
- forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
- })
- )}
+
+ updateSettings({forwardSkipTime})}
+ />
-
- {renderSkipControl(
- settings.rewindSkipTime,
- () =>
- updateSettings({
- rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
- }),
- () =>
- updateSettings({
- rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
- })
- )}
+
+ updateSettings({rewindSkipTime})}
+ />
-
+
);
};
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index 78887a89..ce772bbe 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -9,20 +9,19 @@ import * as BackgroundFetch from "expo-background-fetch";
import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
-import React, { useEffect } from "react";
-import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native";
+import React, {useEffect, useMemo} from "react";
+import { Linking, Switch, TouchableOpacity } from "react-native";
import { toast } from "sonner-native";
-import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
-
-interface Props extends ViewProps {}
+import DisabledSetting from "@/components/settings/DisabledSetting";
+import Dropdown from "@/components/common/Dropdown";
export const OtherSettings: React.FC = () => {
const router = useRouter();
- const [settings, updateSettings] = useSettings();
+ const [settings, updateSettings, pluginSettings] = useSettings();
const { t } = useTranslation();
@@ -56,146 +55,114 @@ export const OtherSettings: React.FC = () => {
/**********************
*********************/
+ const disabled = useMemo(() => (
+ pluginSettings?.autoRotate?.locked === true &&
+ pluginSettings?.defaultVideoOrientation?.locked === true &&
+ pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
+ pluginSettings?.showCustomMenuLinks?.locked === true &&
+ pluginSettings?.hiddenLibraries?.locked === true &&
+ pluginSettings?.disableHapticFeedback?.locked === true
+ ), [pluginSettings]);
+
+ const orientations = [
+ ScreenOrientation.OrientationLock.DEFAULT,
+ ScreenOrientation.OrientationLock.PORTRAIT_UP,
+ ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
+ ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
+ ]
+
if (!settings) return null;
return (
-
-
- updateSettings({ autoRotate: value })}
- />
-
+
+
+
+ updateSettings({autoRotate: value})}
+ />
+
-
-
-
-
-
- {ScreenOrientationEnum[settings.defaultVideoOrientation]}
-
-
-
-
-
- Orientation
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.DEFAULT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.DEFAULT
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.PORTRAIT_UP,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.PORTRAIT_UP
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
- ]
- }
-
-
- {
- updateSettings({
- defaultVideoOrientation:
- ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
- });
- }}
- >
-
- {
- ScreenOrientationEnum[
- ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
- ]
- }
-
-
-
-
-
+
+
+ ScreenOrientationEnum[item]
+ }
+ title={
+
+
+ {ScreenOrientationEnum[settings.defaultVideoOrientation]}
+
+
+
+ }
+ label="Orientation"
+ onSelected={(defaultVideoOrientation) =>
+ updateSettings({defaultVideoOrientation})
+ }
+ />
+
-
-
- updateSettings({ safeAreaInControlsEnabled: value })
- }
- />
-
+
+
+ updateSettings({safeAreaInControlsEnabled: value})
+ }
+ />
+
-
- Linking.openURL(
- "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
- )
- }
- >
-
- updateSettings({ showCustomMenuLinks: value })
+
+ Linking.openURL(
+ "https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
+ )
}
+ >
+
+ updateSettings({showCustomMenuLinks: value})
+ }
+ />
+
+ router.push("/settings/hide-libraries/page")}
+ title="Hide Libraries"
+ showArrow
/>
-
- router.push("/settings/hide-libraries/page")}
- title="Hide Libraries"
- showArrow
- />
-
-
- updateSettings({ disableHapticFeedback: value })
- }
- />
-
-
+
+
+ updateSettings({disableHapticFeedback})
+ }
+ />
+
+
+
);
};
diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx
index cd7a3df0..6b9c8444 100644
--- a/components/settings/StorageSettings.tsx
+++ b/components/settings/StorageSettings.tsx
@@ -1,12 +1,9 @@
-import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
+import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider";
-import { clearLogs } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
-import { useHaptic } from "@/hooks/useHaptic";
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";
diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx
index d4cb5326..c84e058e 100644
--- a/components/settings/SubtitleToggles.tsx
+++ b/components/settings/SubtitleToggles.tsx
@@ -8,11 +8,15 @@ import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useTranslation } from "react-i18next";
+import {useSettings} from "@/utils/atoms/settings";
+import {Stepper} from "@/components/inputs/Stepper";
+import Dropdown from "@/components/common/Dropdown";
interface Props extends ViewProps {}
export const SubtitleToggles: React.FC = ({ ...props }) => {
const media = useMedia();
+ const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();
@@ -38,8 +42,11 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
}
>
-
-
+ item?.ThreeLetterISOLanguageName ?? "unknown"}
+ titleExtractor={(item) => item?.DisplayName}
+ title={
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
@@ -50,48 +57,28 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
color="#5A5960"
/>
-
-
- Languages
- {
- updateSettings({
- defaultSubtitleLanguage: null,
- });
- }}
- >
- {t("home.settings.subtitles.none")}
-
- {cultures?.map((l) => (
- {
- updateSettings({
- defaultSubtitleLanguage: l,
- });
- }}
- >
-
- {l.DisplayName}
-
-
- ))}
-
-
+ }
+ label="Languages"
+ onSelected={(defaultSubtitleLanguage) =>
+ updateSettings({
+ defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
+ ? null
+ : defaultSubtitleLanguage
+ })
+ }
+ />
-
-
-
+
+
{settings?.subtitleMode || "Loading"}
@@ -102,68 +89,39 @@ export const SubtitleToggles: React.FC = ({ ...props }) => {
color="#5A5960"
/>
-
-
- Subtitle Mode
- {subtitleModes?.map((l) => (
- {
- updateSettings({
- subtitleMode: l,
- });
- }}
- >
- {l}
-
- ))}
-
-
+ }
+ label="Subtitle Mode"
+ onSelected={(subtitleMode) =>
+ updateSettings({subtitleMode})
+ }
+ />
-
+
updateSettings({ rememberSubtitleSelections: value })
}
/>
-
-
-
- updateSettings({
- subtitleSize: Math.max(0, settings.subtitleSize - 5),
- })
- }
- className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
- >
- -
-
-
- {settings.subtitleSize}
-
-
- updateSettings({
- subtitleSize: Math.min(120, settings.subtitleSize + 5),
- })
- }
- >
- +
-
-
+
+ updateSettings({subtitleSize})}
+ />
diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx
index 024b1272..2cfeed1d 100644
--- a/components/stacks/NestedTabPageStack.tsx
+++ b/components/stacks/NestedTabPageStack.tsx
@@ -17,14 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => ,
};
-const routes = [
- "actors/[actorId]",
- "albums/[albumId]",
- "artists/index",
- "artists/[artistId]",
- "items/page",
- "series/[id]",
-];
+const routes = ["actors/[actorId]", "items/page", "series/[id]"];
export const nestedTabPageScreenOptions: Record =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index 1f436f7f..f209a411 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -35,12 +35,7 @@ import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react";
-import {
- Pressable,
- TouchableOpacity,
- useWindowDimensions,
- View,
-} from "react-native";
+import { TouchableOpacity, useWindowDimensions, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
@@ -59,6 +54,8 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
+import { useControlsTimeout } from "./useControlsTimeout";
+import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props {
item: BaseItemDto;
@@ -89,6 +86,8 @@ interface Props {
isVlc?: boolean;
}
+const CONTROLS_TIMEOUT = 4000;
+
export const Controls: React.FC = ({
item,
seek,
@@ -121,6 +120,12 @@ export const Controls: React.FC = ({
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
+ const [episodeView, setEpisodeView] = useState(false);
+ const [isSliding, setIsSliding] = useState(false);
+
+ // Used when user changes audio through audio button on device.
+ const [showAudioSlider, setShowAudioSlider] = useState(false);
+
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = useAdjacentItems({ item });
const {
@@ -139,6 +144,23 @@ export const Controls: React.FC = ({
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef(0);
+ const lightHapticFeedback = useHaptic("light");
+
+ useEffect(() => {
+ prefetchAllTrickplayImages();
+ }, []);
+
+ useEffect(() => {
+ if (item) {
+ progress.value = isVlc
+ ? ticksToMs(item?.UserData?.PlaybackPositionTicks)
+ : item?.UserData?.PlaybackPositionTicks || 0;
+ max.value = isVlc
+ ? ticksToMs(item.RunTimeTicks || 0)
+ : item.RunTimeTicks || 0;
+ }
+ }, [item, isVlc]);
+
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
bitrateValue: string;
audioIndex: string;
@@ -161,8 +183,6 @@ export const Controls: React.FC = ({
isVlc
);
- const lightHapticFeedback = useHaptic("light");
-
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
@@ -266,20 +286,19 @@ export const Controls: React.FC = ({
[updateTimes]
);
- useEffect(() => {
- if (item) {
- progress.value = isVlc
- ? ticksToMs(item?.UserData?.PlaybackPositionTicks)
- : item?.UserData?.PlaybackPositionTicks || 0;
- max.value = isVlc
- ? ticksToMs(item.RunTimeTicks || 0)
- : item.RunTimeTicks || 0;
- }
- }, [item, isVlc]);
-
- useEffect(() => {
- prefetchAllTrickplayImages();
+ const hideControls = useCallback(() => {
+ setShowControls(false);
+ setShowAudioSlider(false);
}, []);
+
+ const { handleControlsInteraction } = useControlsTimeout({
+ showControls,
+ isSliding,
+ episodeView,
+ onHideControls: hideControls,
+ timeout: CONTROLS_TIMEOUT,
+ });
+
const toggleControls = () => {
if (showControls) {
setShowAudioSlider(false);
@@ -300,16 +319,13 @@ export const Controls: React.FC = ({
isSeeking.value = true;
}, [showControls, isPlaying]);
- const [isSliding, setIsSliding] = useState(false);
const handleSliderComplete = useCallback(
async (value: number) => {
isSeeking.value = false;
progress.value = value;
setIsSliding(false);
- await seek(
- Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
- );
+ seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
if (wasPlayingRef.current === true) play();
},
[isVlc]
@@ -339,7 +355,7 @@ export const Controls: React.FC = ({
const newTime = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
- await seek(newTime);
+ seek(newTime);
if (wasPlayingRef.current === true) play();
}
} catch (error) {
@@ -357,7 +373,7 @@ export const Controls: React.FC = ({
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime;
- await seek(Math.max(0, newTime));
+ seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
@@ -365,81 +381,6 @@ export const Controls: React.FC = ({
}
}, [settings, isPlaying, isVlc]);
- const toggleIgnoreSafeAreas = useCallback(() => {
- setIgnoreSafeAreas((prev) => !prev);
- lightHapticFeedback();
- }, []);
-
- const memoizedRenderBubble = useCallback(() => {
- if (!trickPlayUrl || !trickplayInfo) {
- return null;
- }
- const { x, y, url } = trickPlayUrl;
- const tileWidth = 150;
- const tileHeight = 150 / trickplayInfo.aspectRatio!;
-
- return (
-
-
-
-
-
- {`${time.hours > 0 ? `${time.hours}:` : ""}${
- time.minutes < 10 ? `0${time.minutes}` : time.minutes
- }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
-
-
- );
- }, [trickPlayUrl, trickplayInfo, time]);
-
- const [EpisodeView, setEpisodeView] = useState(false);
-
- const switchOnEpisodeMode = () => {
- setEpisodeView(true);
- if (isPlaying) togglePlay();
- };
-
const goToItem = useCallback(
async (itemId: string) => {
try {
@@ -486,8 +427,77 @@ export const Controls: React.FC = ({
[settings, subtitleIndex, audioIndex]
);
- // Used when user changes audio through audio button on device.
- const [showAudioSlider, setShowAudioSlider] = useState(false);
+ const toggleIgnoreSafeAreas = useCallback(() => {
+ setIgnoreSafeAreas((prev) => !prev);
+ lightHapticFeedback();
+ }, []);
+
+ const switchOnEpisodeMode = useCallback(() => {
+ setEpisodeView(true);
+ if (isPlaying) togglePlay();
+ }, [isPlaying, togglePlay]);
+
+ const memoizedRenderBubble = useCallback(() => {
+ if (!trickPlayUrl || !trickplayInfo) {
+ return null;
+ }
+ const { x, y, url } = trickPlayUrl;
+ const tileWidth = 150;
+ const tileHeight = 150 / trickplayInfo.aspectRatio!;
+
+ return (
+
+
+
+
+
+ {`${time.hours > 0 ? `${time.hours}:` : ""}${
+ time.minutes < 10 ? `0${time.minutes}` : time.minutes
+ }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
+
+
+ );
+ }, [trickPlayUrl, trickplayInfo, time]);
return (
= ({
mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded}
>
- {EpisodeView ? (
+ {episodeView ? (
setEpisodeView(false)}
@@ -503,23 +513,12 @@ export const Controls: React.FC = ({
/>
) : (
<>
- {
- toggleControls();
- }}
- style={{
- position: "absolute",
- width: screenWidth,
- height: screenHeight,
- backgroundColor: "black",
- left: 0,
- right: 0,
- top: 0,
- bottom: 0,
- opacity: showControls ? 0.5 : 0,
- }}
- >
-
+
= ({
},
]}
pointerEvents={showControls ? "auto" : "none"}
- className={`flex flex-row w-full p-4 `}
+ className={`flex flex-row w-full pt-2`}
>
= ({
onPress={() => {
switchOnEpisodeMode();
}}
- className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
+ className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
@@ -565,7 +564,7 @@ export const Controls: React.FC = ({
{previousItem && !offline && (
@@ -574,7 +573,7 @@ export const Controls: React.FC = ({
{nextItem && !offline && (
@@ -583,7 +582,7 @@ export const Controls: React.FC = ({
{/* {mediaSource?.TranscodingUrl && ( */}
= ({
);
router.back();
}}
- className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
+ className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
>
@@ -730,10 +729,11 @@ export const Controls: React.FC = ({
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
},
]}
- className={`flex flex-col p-4`}
+ className={`flex flex-col px-2`}
+ onTouchStart={handleControlsInteraction}
>
= ({
}}
pointerEvents={showControls ? "box-none" : "none"}
>
- {item?.Name}
{item?.Type === "Episode" && (
- {item.SeriesName}
+
+ {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
+
)}
+ {item?.Name}
{item?.Type === "Movie" && (
{item?.ProductionYear}
@@ -785,7 +787,7 @@ export const Controls: React.FC = ({
= ({
bubbleTextColor: "#666",
heartbeatColor: "#999",
}}
- renderThumb={() => (
-
- )}
+ renderThumb={() => null}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
@@ -828,7 +818,7 @@ export const Controls: React.FC = ({
minimumValue={min}
maximumValue={max}
/>
-
+
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx
index db7c5c71..422a2fc3 100644
--- a/components/video-player/controls/EpisodeList.tsx
+++ b/components/video-player/controls/EpisodeList.tsx
@@ -1,26 +1,26 @@
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { runtimeTicksToSeconds } from "@/utils/time";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { atom, useAtom } from "jotai";
-import { useEffect, useMemo, useState, useRef } from "react";
-import { View, TouchableOpacity } from "react-native";
-import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
-import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
-import { Ionicons } from "@expo/vector-icons";
-import { Loader } from "@/components/Loader";
-import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
-import { Text } from "@/components/common/Text";
-import { DownloadSingleItem } from "@/components/DownloadItem";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
HorizontalScroll,
HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll";
+import { Text } from "@/components/common/Text";
+import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
+import { DownloadSingleItem } from "@/components/DownloadItem";
+import { Loader } from "@/components/Loader";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { runtimeTicksToSeconds } from "@/utils/time";
+import { Ionicons } from "@expo/vector-icons";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { atom, useAtom } from "jotai";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
type Props = {
item: BaseItemDto;
diff --git a/components/video-player/controls/VideoTouchOverlay.tsx b/components/video-player/controls/VideoTouchOverlay.tsx
new file mode 100644
index 00000000..85385acf
--- /dev/null
+++ b/components/video-player/controls/VideoTouchOverlay.tsx
@@ -0,0 +1,38 @@
+import { Pressable } from "react-native";
+import { useTapDetection } from "./useTapDetection";
+
+interface Props {
+ screenWidth: number;
+ screenHeight: number;
+ showControls: boolean;
+ onToggleControls: () => void;
+}
+
+export const VideoTouchOverlay = ({
+ screenWidth,
+ screenHeight,
+ showControls,
+ onToggleControls,
+}: Props) => {
+ const { handleTouchStart, handleTouchEnd } = useTapDetection({
+ onValidTap: onToggleControls,
+ });
+
+ return (
+
+ );
+};
diff --git a/components/video-player/controls/dropdown/DropdownViewDirect.tsx b/components/video-player/controls/dropdown/DropdownViewDirect.tsx
index 28b55fa0..e2ba25fd 100644
--- a/components/video-player/controls/dropdown/DropdownViewDirect.tsx
+++ b/components/video-player/controls/dropdown/DropdownViewDirect.tsx
@@ -74,7 +74,7 @@ const DropdownViewDirect: React.FC = ({
return (
-
+
diff --git a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx
index d57cc126..5a05dd17 100644
--- a/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx
+++ b/components/video-player/controls/dropdown/DropdownViewTranscoding.tsx
@@ -121,7 +121,7 @@ const DropdownView: React.FC = ({ showControls }) => {
-
+
diff --git a/components/video-player/controls/useControlsTimeout.ts b/components/video-player/controls/useControlsTimeout.ts
new file mode 100644
index 00000000..ac10fff3
--- /dev/null
+++ b/components/video-player/controls/useControlsTimeout.ts
@@ -0,0 +1,56 @@
+import { useEffect, useRef } from "react";
+
+interface UseControlsTimeoutProps {
+ showControls: boolean;
+ isSliding: boolean;
+ episodeView: boolean;
+ onHideControls: () => void;
+ timeout?: number;
+}
+
+export const useControlsTimeout = ({
+ showControls,
+ isSliding,
+ episodeView,
+ onHideControls,
+ timeout = 4000,
+}: UseControlsTimeoutProps) => {
+ const controlsTimeoutRef = useRef();
+
+ useEffect(() => {
+ const resetControlsTimeout = () => {
+ if (controlsTimeoutRef.current) {
+ clearTimeout(controlsTimeoutRef.current);
+ }
+
+ if (showControls && !isSliding && !episodeView) {
+ controlsTimeoutRef.current = setTimeout(() => {
+ onHideControls();
+ }, timeout);
+ }
+ };
+
+ resetControlsTimeout();
+
+ return () => {
+ if (controlsTimeoutRef.current) {
+ clearTimeout(controlsTimeoutRef.current);
+ }
+ };
+ }, [showControls, isSliding, episodeView, timeout, onHideControls]);
+
+ const handleControlsInteraction = () => {
+ if (showControls) {
+ if (controlsTimeoutRef.current) {
+ clearTimeout(controlsTimeoutRef.current);
+ }
+ controlsTimeoutRef.current = setTimeout(() => {
+ onHideControls();
+ }, timeout);
+ }
+ };
+
+ return {
+ handleControlsInteraction,
+ };
+};
diff --git a/components/video-player/controls/useTapDetection.tsx b/components/video-player/controls/useTapDetection.tsx
new file mode 100644
index 00000000..041e6d39
--- /dev/null
+++ b/components/video-player/controls/useTapDetection.tsx
@@ -0,0 +1,48 @@
+import { useRef } from "react";
+import { GestureResponderEvent } from "react-native";
+
+interface TapDetectionOptions {
+ maxDuration?: number;
+ maxDistance?: number;
+ onValidTap?: () => void;
+}
+
+export const useTapDetection = ({
+ maxDuration = 200,
+ maxDistance = 10,
+ onValidTap,
+}: TapDetectionOptions = {}) => {
+ const touchStartTime = useRef(0);
+ const touchStartPosition = useRef({ x: 0, y: 0 });
+
+ const handleTouchStart = (event: GestureResponderEvent) => {
+ touchStartTime.current = Date.now();
+ touchStartPosition.current = {
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ };
+ };
+
+ const handleTouchEnd = (event: GestureResponderEvent) => {
+ const touchEndTime = Date.now();
+ const touchEndPosition = {
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ };
+
+ const touchDuration = touchEndTime - touchStartTime.current;
+ const touchDistance = Math.sqrt(
+ Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) +
+ Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2)
+ );
+
+ if (touchDuration < maxDuration && touchDistance < maxDistance) {
+ onValidTap?.();
+ }
+ };
+
+ return {
+ handleTouchStart,
+ handleTouchEnd,
+ };
+};
diff --git a/hooks/useJellyfinDiscovery.tsx b/hooks/useJellyfinDiscovery.tsx
new file mode 100644
index 00000000..963dfe81
--- /dev/null
+++ b/hooks/useJellyfinDiscovery.tsx
@@ -0,0 +1,106 @@
+import { useState, useCallback } from "react";
+import dgram from "react-native-udp";
+
+const JELLYFIN_DISCOVERY_PORT = 7359;
+const DISCOVERY_MESSAGE = "Who is JellyfinServer?";
+
+interface ServerInfo {
+ address: string;
+ port: number;
+ serverId?: string;
+ serverName?: string;
+}
+
+export const useJellyfinDiscovery = () => {
+ const [servers, setServers] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+
+ const startDiscovery = useCallback(() => {
+ setIsSearching(true);
+ setServers([]);
+
+ const discoveredServers = new Set();
+ let discoveryTimeout: NodeJS.Timeout;
+
+ const socket = dgram.createSocket({
+ type: "udp4",
+ reusePort: true,
+ debug: __DEV__,
+ });
+
+ socket.on("error", (err) => {
+ console.error("Socket error:", err);
+ socket.close();
+ setIsSearching(false);
+ });
+
+ socket.bind(0, () => {
+ console.log("UDP socket bound successfully");
+
+ try {
+ socket.setBroadcast(true);
+ const messageBuffer = new TextEncoder().encode(DISCOVERY_MESSAGE);
+
+ socket.send(
+ messageBuffer,
+ 0,
+ messageBuffer.length,
+ JELLYFIN_DISCOVERY_PORT,
+ "255.255.255.255",
+ (err) => {
+ if (err) {
+ console.error("Failed to send discovery message:", err);
+ return;
+ }
+ console.log("Discovery message sent successfully");
+ }
+ );
+
+ discoveryTimeout = setTimeout(() => {
+ setIsSearching(false);
+ socket.close();
+ }, 5000);
+ } catch (error) {
+ console.error("Error during discovery:", error);
+ setIsSearching(false);
+ }
+ });
+
+ socket.on("message", (msg, rinfo: any) => {
+ if (discoveredServers.has(rinfo.address)) {
+ return;
+ }
+
+ try {
+ const response = new TextDecoder().decode(msg);
+ const serverInfo = JSON.parse(response);
+ discoveredServers.add(rinfo.address);
+
+ const newServer: ServerInfo = {
+ address: `http://${rinfo.address}:${serverInfo.Port || 8096}`,
+ port: serverInfo.Port || 8096,
+ serverId: serverInfo.Id,
+ serverName: serverInfo.Name,
+ };
+
+ setServers((prev) => [...prev, newServer]);
+ } catch (error) {
+ console.error("Error parsing server response:", error);
+ }
+ });
+
+ return () => {
+ clearTimeout(discoveryTimeout);
+ if (isSearching) {
+ setIsSearching(false);
+ }
+ socket.close();
+ };
+ }, []);
+
+ return {
+ servers,
+ isSearching,
+ startDiscovery,
+ };
+};
diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts
index 2a708115..e56ab277 100644
--- a/hooks/useJellyseerr.ts
+++ b/hooks/useJellyseerr.ts
@@ -35,6 +35,11 @@ import {
} from "@/utils/jellyseerr/server/models/Person";
import { useQueryClient } from "@tanstack/react-query";
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
+import {UserResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/userInterfaces";
+import {
+ ServiceCommonServer,
+ ServiceCommonServerWithDetails
+} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces";
interface SearchParams {
query: string;
@@ -67,6 +72,8 @@ export enum Endpoints {
MOVIE = "/movie",
RATINGS = "/ratings",
ISSUE = "/issue",
+ USER = "/user",
+ SERVICE = "/service",
TV = "/tv",
SETTINGS = "/settings",
NETWORK = "/network",
@@ -283,6 +290,12 @@ export class JellyseerrApi {
});
}
+ async user(params: any) {
+ return this.axios
+ ?.get(`${Endpoints.API_V1}${Endpoints.USER}`, { params })
+ .then(({data}) => data.results)
+ }
+
imageProxy(
path?: string,
filter: string = "original",
@@ -316,6 +329,18 @@ export class JellyseerrApi {
});
}
+ async service(type: 'radarr' | 'sonarr') {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}`)
+ .then(({data}) => data);
+ }
+
+ async serviceDetails(type: 'radarr' | 'sonarr', id: number) {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}` + `/${id}`)
+ .then(({data}) => data);
+ }
+
private setInterceptors() {
this.axios.interceptors.response.use(
async (response) => {
diff --git a/package.json b/package.json
index 5afe2df9..53d74e67 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"version": "1.0.0",
"scripts": {
"submodule-reload": "git submodule update --init --remote --recursive",
+ "clean": "echo y | expo prebuild --clean",
"start": "bun run submodule-reload && expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "bun run submodule-reload && expo run:android",
@@ -17,14 +18,15 @@
"preset": "jest-expo"
},
"dependencies": {
- "@bottom-tabs/react-navigation": "^0.7.1",
+ "@bottom-tabs/react-navigation": "0.7.8",
+ "react-native-bottom-tabs": "0.7.8",
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4",
"@futurejj/react-native-visibility-sensor": "^1.3.5",
"@gorhom/bottom-sheet": "^4.6.4",
"@jellyfin/sdk": "^0.11.0",
- "@kesha-antonov/react-native-background-downloader": "3.1.2",
+ "@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.6",
@@ -75,7 +77,6 @@
"react-i18next": "^15.4.0",
"react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6",
- "react-native-bottom-tabs": "0.7.8",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.9.0",
"react-native-country-flag": "^2.0.2",
@@ -96,6 +97,7 @@
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-tab-view": "^3.5.2",
+ "react-native-udp": "^4.1.7",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index 1c912e2e..149cdd96 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -1,4 +1,4 @@
-import { useSettings } from "@/utils/atoms/settings";
+import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { useLog, writeToLog } from "@/utils/log";
import {
@@ -108,7 +108,7 @@ function useDownloadProvider() {
const url = settings?.optimizedVersionsServerUrl;
if (
- settings?.downloadMethod !== "optimized" ||
+ settings?.downloadMethod !== DownloadMethod.Optimized ||
!url ||
!deviceId ||
!authHeader
@@ -168,7 +168,7 @@ function useDownloadProvider() {
},
staleTime: 0,
refetchInterval: 2000,
- enabled: settings?.downloadMethod === "optimized",
+ enabled: settings?.downloadMethod === DownloadMethod.Optimized,
});
useEffect(() => {
diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx
index 198d6792..30bea359 100644
--- a/providers/JellyfinProvider.tsx
+++ b/providers/JellyfinProvider.tsx
@@ -1,3 +1,4 @@
+import "@/augmentations";
import { useInterval } from "@/hooks/useInterval";
import { storage } from "@/utils/mmkv";
import { Api, Jellyfin } from "@jellyfin/sdk";
@@ -19,8 +20,9 @@ import React, {
import { Platform } from "react-native";
import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info";
-import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
+import { useSettings } from "@/utils/atoms/settings";
+import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
interface Server {
address: string;
@@ -73,6 +75,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState(false);
const [secret, setSecret] = useState(null);
+ const [
+ settings,
+ updateSettings,
+ pluginSettings,
+ setPluginSettings,
+ refreshStreamyfinPluginSettings,
+ ] = useSettings();
+ const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
useQuery({
queryKey: ["user", api],
@@ -229,6 +239,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken);
+
+ const recentPluginSettings = await refreshStreamyfinPluginSettings();
+ if (recentPluginSettings?.jellyseerrServerUrl?.value) {
+ const jellyseerrApi = new JellyseerrApi(
+ recentPluginSettings.jellyseerrServerUrl.value
+ );
+ await jellyseerrApi.test().then((result) => {
+ if (result.isValid && result.requiresPass) {
+ jellyseerrApi.login(username, password).then(setJellyseerrUser);
+ }
+ });
+ }
}
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -265,6 +287,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async () => {
storage.delete("token");
setUser(null);
+ setPluginSettings(undefined);
+ await clearAllJellyseerData();
},
onError: (error) => {
console.error("Logout failed:", error);
diff --git a/providers/PlaySettingsProvider.tsx b/providers/PlaySettingsProvider.tsx
index 50f780cd..ff80bb9e 100644
--- a/providers/PlaySettingsProvider.tsx
+++ b/providers/PlaySettingsProvider.tsx
@@ -38,7 +38,6 @@ type PlaySettingsContextType = {
setPlayUrl: React.Dispatch>;
playSessionId?: string | null;
setOfflineSettings: (data: PlaybackType) => void;
- setMusicPlaySettings: (item: BaseItemDto, url: string) => void;
};
const PlaySettingsContext = createContext(
@@ -61,13 +60,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
_setPlaySettings(data);
}, []);
- const setMusicPlaySettings = (item: BaseItemDto, url: string) => {
- setPlaySettings({
- item: item,
- });
- setPlayUrl(url);
- };
-
const setPlaySettings = useCallback(
async (
dataOrUpdater:
@@ -147,7 +139,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
setPlaySettings,
playUrl,
setPlayUrl,
- setMusicPlaySettings,
setOfflineSettings,
playSessionId,
mediaSource,
diff --git a/translations/en.json b/translations/en.json
index df44273d..3e12745d 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -242,9 +242,6 @@
"episodes": "Episodes",
"collections": "Collections",
"actors": "Actors",
- "artists": "Artists",
- "albums": "Albums",
- "songs": "Songs",
"request_movies": "Request Movies",
"request_series": "Request Series",
"recently_added": "Recently Added",
@@ -297,9 +294,7 @@
"episodes": "Episodes",
"videos": "Videos",
"boxsets": "Boxsets",
- "playlists": "Playlists",
- "music_albums": "Music Albums",
- "audio": "Audio"
+ "playlists": "Playlists"
},
"custom_links": {
"no_links": "No links"
@@ -341,9 +336,6 @@
"show_more": "Show more",
"show_less": "Show less",
"appeared_in": "Appeared in",
- "x_songs": "{{count}} songs",
- "x_albums": "{{count}} albums",
- "artists": "Artists",
"could_not_load_item": "Could not load item",
"none": "None",
"download": {
diff --git a/translations/fr.json b/translations/fr.json
index a7b72412..777f87ee 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -242,9 +242,6 @@
"episodes": "Épisodes",
"collections": "Collections",
"actors": "Acteurs",
- "artists": "Artistes",
- "albums": "Albums",
- "songs": "Chansons",
"request_movies": "Demander un film",
"request_series": "Demander une série",
"recently_added": "Ajoutés récemment",
@@ -297,9 +294,7 @@
"episodes": "Épisodes",
"videos": "Vidéos",
"boxsets": "Coffrets",
- "playlists": "Listes de lecture",
- "music_albums": "Albums de musique",
- "audio": "Audio"
+ "playlists": "Listes de lecture"
},
"custom_links": {
"no_links": "Aucun lien"
@@ -341,9 +336,6 @@
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"appeared_in": "Apparu dans",
- "x_songs": "{{count}} chansons",
- "x_albums": "{{count}} albums",
- "artists": "Artistes",
"could_not_load_item": "Impossible de charger l'item",
"none": "Aucun",
"download": {
diff --git a/utils/_jellyseerr/useJellyseerrCanRequest.ts b/utils/_jellyseerr/useJellyseerrCanRequest.ts
index ba692df3..c58a8928 100644
--- a/utils/_jellyseerr/useJellyseerrCanRequest.ts
+++ b/utils/_jellyseerr/useJellyseerrCanRequest.ts
@@ -48,5 +48,20 @@ export const useJellyseerrCanRequest = (
return userHasPermission && !canNotRequest;
}, [item, jellyseerrUser]);
- return canRequest;
+ const hasAdvancedRequestPermission = useMemo(() => {
+ if (!jellyseerrUser) return false;
+
+ return hasPermission(
+ [
+ Permission.REQUEST_ADVANCED,
+ Permission.MANAGE_REQUESTS
+ ],
+ jellyseerrUser.permissions,
+ {type: 'or'}
+ )
+ },
+ [jellyseerrUser]
+ );
+
+ return [canRequest, hasAdvancedRequestPermission];
};
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index e60b0f1a..7f9bba1e 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -1,13 +1,19 @@
import { atom, useAtom } from "jotai";
-import { useEffect } from "react";
-import { getLocales } from "expo-localization";
+import {useCallback, useEffect, useMemo} from "react";
import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv";
import { Platform } from "react-native";
import {
CultureDto,
+ PluginStatus,
SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client";
+import {apiAtom} from "@/providers/JellyfinProvider";
+import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api";
+import {writeErrorLog} from "@/utils/log";
+
+const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"
+const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"
export type DownloadQuality = "original" | "high" | "low";
@@ -60,6 +66,11 @@ export type DefaultLanguageOption = {
label: string;
};
+export enum DownloadMethod {
+ Remux = "remux",
+ Optimized = "optimized"
+}
+
export type Settings = {
autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean;
@@ -83,7 +94,7 @@ export type Settings = {
forwardSkipTime: number;
rewindSkipTime: number;
optimizedVersionsServerUrl?: string | null;
- downloadMethod: "optimized" | "remux";
+ downloadMethod: DownloadMethod;
autoDownload: boolean;
showCustomMenuLinks: boolean;
disableHapticFeedback: boolean;
@@ -94,6 +105,16 @@ export type Settings = {
hiddenLibraries?: string[];
};
+export interface Lockable {
+ locked: boolean;
+ value: T
+}
+
+export type PluginLockableSettings = { [K in keyof Settings]: Lockable };
+export type StreamyfinPluginConfig = {
+ settings: PluginLockableSettings
+}
+
const loadSettings = (): Settings => {
const defaultValues: Settings = {
autoRotate: true,
@@ -124,7 +145,7 @@ const loadSettings = (): Settings => {
forwardSkipTime: 30,
rewindSkipTime: 10,
optimizedVersionsServerUrl: null,
- downloadMethod: "remux",
+ downloadMethod: DownloadMethod.Remux,
autoDownload: false,
showCustomMenuLinks: false,
disableHapticFeedback: false,
@@ -153,16 +174,76 @@ const saveSettings = (settings: Settings) => {
};
export const settingsAtom = atom(null);
+export const pluginSettingsAtom = atom(storage.get(STREAMYFIN_PLUGIN_SETTINGS));
export const useSettings = () => {
- const [settings, setSettings] = useAtom(settingsAtom);
+ const [api] = useAtom(apiAtom);
+ const [_settings, setSettings] = useAtom(settingsAtom);
+ const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
useEffect(() => {
- if (settings === null) {
+ if (_settings === null) {
const loadedSettings = loadSettings();
setSettings(loadedSettings);
}
- }, [settings, setSettings]);
+ }, [_settings, setSettings]);
+
+ const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => {
+ storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings)
+ _setPluginSettings(settings)
+ },
+ [_setPluginSettings]
+ )
+
+ const refreshStreamyfinPluginSettings = useCallback(
+ async () => {
+ if (!api)
+ return
+
+ const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data);
+
+ if (plugins && plugins.length > 0) {
+ const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID);
+
+ if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) {
+ writeErrorLog(
+ "Streamyfin plugin is currently not active.\n" +
+ `Current status is: ${streamyfinPlugin?.Status}`
+ );
+ setPluginSettings(undefined);
+ return;
+ }
+
+ const settings = await api.getStreamyfinPluginConfig()
+ .then(({data}) => data.settings)
+
+ setPluginSettings(settings);
+ return settings;
+ }
+ },
+ [api]
+ )
+
+ // We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
+ // If admin sets locked to false but provides a value,
+ // use user settings first and fallback on admin setting if required.
+ const settings: Settings = useMemo(() => {
+ const overrideSettings = Object.entries(pluginSettings || {})
+ .reduce((acc, [key, setting]) => {
+ if (setting) {
+ const {value, locked} = setting
+ acc = Object.assign(acc, {
+ [key]: locked ? value : _settings?.[key as keyof Settings] ?? value
+ })
+ }
+ return acc
+ }, {} as Settings)
+
+ return {
+ ..._settings,
+ ...overrideSettings
+ }
+ }, [_settings, setSettings, pluginSettings, _setPluginSettings, setPluginSettings])
const updateSettings = (update: Partial) => {
if (settings) {
@@ -173,5 +254,5 @@ export const useSettings = () => {
}
};
- return [settings, updateSettings] as const;
+ return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const;
};
diff --git a/utils/collectionTypeToItemType.ts b/utils/collectionTypeToItemType.ts
index 64c23ff0..f37fe5f4 100644
--- a/utils/collectionTypeToItemType.ts
+++ b/utils/collectionTypeToItemType.ts
@@ -10,8 +10,6 @@ import {
* readonly Unknown: "unknown";
readonly Movies: "movies";
readonly Tvshows: "tvshows";
- readonly Music: "music";
- readonly Musicvideos: "musicvideos";
readonly Trailers: "trailers";
readonly Homevideos: "homevideos";
readonly Boxsets: "boxsets";
@@ -33,8 +31,6 @@ export const colletionTypeToItemType = (
return BaseItemKind.Series;
case CollectionType.Homevideos:
return BaseItemKind.Video;
- case CollectionType.Musicvideos:
- return BaseItemKind.MusicVideo;
case CollectionType.Books:
return BaseItemKind.Book;
case CollectionType.Playlists: