Merge branch 'develop' into feat/i18n

This commit is contained in:
Simon Caron
2025-01-12 19:49:58 -05:00
87 changed files with 1703 additions and 2433 deletions

View File

@@ -44,8 +44,13 @@ body:
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running?
options: options:
- 0.25.0 - 0.25.0
- 0.24.0
- 0.23.0
<<<<<<< Updated upstream
=======
- 0.22.0 - 0.22.0
- 0.21.0 - 0.21.0
>>>>>>> Stashed changes
- older - older
validations: validations:
required: true required: true

4
.gitignore vendored
View File

@@ -35,4 +35,6 @@ credentials.json
*.ipa *.ipa
.continuerc.json .continuerc.json
.vscode/ .vscode/
.idea/
.ruby-lsp

3
.idea/.gitignore generated vendored
View File

@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -1,329 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="Lenovo" />
<option name="codename" value="TB370FU" />
<option name="id" value="TB370FU" />
<option name="manufacturer" value="Lenovo" />
<option name="name" value="Tab P12" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1840" />
<option name="screenY" value="2944" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="caiman" />
<option name="id" value="caiman" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro" />
<option name="screenDensity" value="360" />
<option name="screenX" value="960" />
<option name="screenY" value="2142" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="comet" />
<option name="id" value="comet" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e1q" />
<option name="id" value="e1q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="id" value="gts8uwifi" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="komodo" />
<option name="id" value="komodo" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro XL" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q6q" />
<option name="id" value="q6q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1856" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="id" value="r11" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="id" value="tangorpro" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="id" value="tokay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
</list>
</option>
</component>
</project>

6
.idea/misc.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
</modules>
</component>
</project>

9
.idea/streamyfin.iml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
<img width=150 src="./assets/images/screenshots/screenshot1.png" /> <img width=150 src="./assets/images/screenshots/screenshot1.png" />
<img width=150 src="./assets/images/screenshots/screenshot3.png" /> <img width=150 src="./assets/images/screenshots/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" /> <img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=150 src="./assets/images/jellyseerr.PNG"/> <img width=159 src="./assets/images/jellyseerr.PNG"/>
</div> </div>
## 🌟 Features ## 🌟 Features
@@ -70,7 +70,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
### Beta testing ### 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. **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` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. 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 ## 📄 License

View File

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

View File

@@ -5,8 +5,8 @@ import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import {useTranslation } from "react-i18next"; import {useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native";
export default function page() { export default function page() {
const router = useRouter(); const router = useRouter();
@@ -19,7 +19,7 @@ export default function page() {
); );
return ( return (
<View className="bg-neutral-900 h-full py-32 px-4 space-y-4"> <View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
<View> <View>
<Text className="text-3xl font-bold text-center mb-2"> <Text className="text-3xl font-bold text-center mb-2">
{t("home.intro.welcome_to_streamyfin")} {t("home.intro.welcome_to_streamyfin")}
@@ -83,25 +83,55 @@ export default function page() {
</Text> </Text>
</View> </View>
</View> </View>
<View className="flex flex-row items-center mt-4">
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
>
<Feather name="settings" size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Centralised Settings Plugin</Text>
<Text className="shrink text-xs">
Configure settings from a centralised location on your Jellyfin
server. All client settings for all users will be synced
automatically.{" "}
<Text
className="text-purple-600"
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
);
}}
>
Read more
</Text>
</Text>
</View>
</View>
</View>
<View>
<Button
onPress={() => {
router.back();
}}
className="mt-4"
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className="mt-4"
>
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
</TouchableOpacity>
</View> </View>
<Button
onPress={() => {
router.back();
}}
className="mt-4"
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className="mt-4"
>
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
</TouchableOpacity>
</View> </View>
); );
} }

View File

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

View File

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

View File

@@ -1,81 +1,16 @@
import { Text } from "@/components/common/Text";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { getStatistics } from "@/utils/optimize-server";
import { useMutation } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const navigation = useNavigation(); const [settings, updateSettings, pluginSettings] = useSettings();
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [settings, updateSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const saveMutation = useMutation({
mutationFn: async (newVal: string) => {
if (newVal.length === 0 || !newVal.startsWith("http")) {
toast.error(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 ? (
// <ActivityIndicator size={"small"} color={"white"} />
// ) : (
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
// <Text className="text-blue-500">Save</Text>
// </TouchableOpacity>
// ),
// });
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return ( return (
<View className="p-4"> <DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className="p-4"
>
<JellyseerrSettings /> <JellyseerrSettings />
</View> </DisabledSetting>
); );
} }

View File

@@ -1,13 +1,12 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import React, {useEffect, useMemo, useState} from "react";
import { import {
Linking, Linking,
Switch, Switch,
@@ -16,12 +15,14 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
@@ -37,68 +38,80 @@ export default function page() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search"); Linking.openURL("https://github.com/fredrikburmester/marlin-search");
}; };
const disabled = useMemo(() => {
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
}, [pluginSettings]);
useEffect(() => { useEffect(() => {
navigation.setOptions({ if (!pluginSettings?.marlinServerUrl?.locked) {
headerRight: () => ( navigation.setOptions({
<TouchableOpacity onPress={() => onSave(value)}> headerRight: () => (
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text> <TouchableOpacity onPress={() => onSave(value)}>
</TouchableOpacity> <Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
), </TouchableOpacity>
}); ),
});
}
}, [navigation, value]); }, [navigation, value]);
if (!settings) return null; if (!settings) return null;
return ( return (
<View className="px-4"> <DisabledSetting
disabled={disabled}
className="px-4"
>
<ListGroup> <ListGroup>
<ListItem <DisabledSetting
title={t("home.settings.plugins.marlin_search.enable_marlin_search")} disabled={pluginSettings?.searchEngine?.locked === true}
onPress={() => { showText={!pluginSettings?.marlinServerUrl?.locked}
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
> >
<Switch <ListItem
value={settings.searchEngine === "Marlin"} title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
onValueChange={(value) => { onPress={() => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" }); updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
/> >
</ListItem> <Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup> </ListGroup>
<View <DisabledSetting
className={`mt-2 ${ disabled={pluginSettings?.marlinServerUrl?.locked === true}
settings.searchEngine === "Marlin" ? "" : "opacity-50" showText={!pluginSettings?.searchEngine?.locked}
}`} className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
> >
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"> <View
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`} >
> <Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text> <TextInput
<TextInput editable={settings.searchEngine === "Marlin"}
editable={settings.searchEngine === "Marlin"} className="text-white"
className="text-white" placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")} value={value}
value={value} keyboardType="url"
keyboardType="url" returnKeyType="done"
returnKeyType="done" autoCapitalize="none"
autoCapitalize="none" textContentType="URL"
textContentType="URL" onChangeText={(text) => setValue(text)}
onChangeText={(text) => setValue(text)} />
/>
</View>
</View> </View>
<Text className="px-4 text-xs text-neutral-500 mt-1"> </DisabledSetting>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} <Text className="px-4 text-xs text-neutral-500 mt-1">
<Text className="text-blue-500" onPress={handleOpenLink}> {t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
{t("home.settings.plugins.marlin_search.read_more_about_marlin")} <Text className="text-blue-500" onPress={handleOpenLink}>
</Text> {t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text> </Text>
</View> </Text>
</View> </DisabledSetting>
); );
} }

View File

@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -18,7 +19,7 @@ export default function page() {
const { t } = useTranslation(); const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || ""); useState<string>(settings?.optimizedVersionsServerUrl || "");
@@ -59,25 +60,30 @@ export default function page() {
}; };
useEffect(() => { useEffect(() => {
navigation.setOptions({ if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
title: t("home.settings.downloads.optimized_server"), navigation.setOptions({
headerRight: () => title: t("home.settings.downloads.optimized_server"),
saveMutation.isPending ? ( headerRight: () =>
<ActivityIndicator size={"small"} color={"white"} /> saveMutation.isPending ? (
) : ( <ActivityIndicator size={"small"} color={"white"} />
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}> ) : (
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text> <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
</TouchableOpacity> <Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
), </TouchableOpacity>
}); ),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return ( return (
<View className="p-4"> <DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
>
<OptimizedServerForm <OptimizedServerForm
value={optimizedVersionsServerUrl} value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl} onChangeValue={setOptimizedVersionsServerUrl}
/> />
</View> </DisabledSetting>
); );
} }

View File

@@ -2,23 +2,23 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Linking, Switch, View } from "react-native"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Switch } from "react-native";
export default function page() { export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings, updateSettings] = useSettings(); const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings();
const handleOpenLink = () => { const handleOpenLink = () => {
Linking.openURL( Linking.openURL(
@@ -50,13 +50,21 @@ export default function page() {
staleTime: 0, staleTime: 0,
}); });
const disabled = useMemo(
() =>
pluginSettings?.usePopularPlugin?.locked === true &&
pluginSettings?.mediaListCollectionIds?.locked === true,
[pluginSettings]
);
if (!settings) return null; if (!settings) return null;
return ( return (
<View className="px-4 pt-4"> <DisabledSetting disabled={disabled} className="px-4 pt-4">
<ListGroup title={t("home.settings.plugins.popular_lists.enable_plugin")} className=""> <ListGroup title={t("home.settings.plugins.popular_lists.enable_plugin")} className="">
<ListItem <ListItem
title={t("home.settings.plugins.popular_lists.enable_popular_lists")} title={t("home.settings.plugins.popular_lists.enable_popular_lists")}
disabled={pluginSettings?.usePopularPlugin?.locked}
onPress={() => { onPress={() => {
updateSettings({ usePopularPlugin: true }); updateSettings({ usePopularPlugin: true });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -64,9 +72,10 @@ export default function page() {
> >
<Switch <Switch
value={settings.usePopularPlugin} value={settings.usePopularPlugin}
onValueChange={(value) => { disabled={pluginSettings?.usePopularPlugin?.locked}
updateSettings({ usePopularPlugin: value }); onValueChange={(usePopularPlugin) =>
}} updateSettings({ usePopularPlugin })
}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>
@@ -89,8 +98,17 @@ export default function page() {
<> <>
<ListGroup title="Media List Collections" className="mt-4"> <ListGroup title="Media List Collections" className="mt-4">
{mediaListCollections?.map((mlc) => ( {mediaListCollections?.map((mlc) => (
<ListItem key={mlc.Id} title={mlc.Name}> <ListItem
key={mlc.Id}
title={mlc.Name}
disabled={
pluginSettings?.mediaListCollectionIds?.locked
}
>
<Switch <Switch
disabled={
pluginSettings?.mediaListCollectionIds?.locked
}
value={settings.mediaListCollectionIds?.includes( value={settings.mediaListCollectionIds?.includes(
mlc.Id! mlc.Id!
)} )}
@@ -131,6 +149,6 @@ export default function page() {
)} )}
</> </>
)} )}
</View> </DisabledSetting>
); );
} }

View File

@@ -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: () => (
<View className="">
<Chromecast />
</View>
),
});
});
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 (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={album}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
<Text className="text-neutral-500">
{t("item_card.x_songs", { count: songs?.TotalRecordCount })}
</Text>
</View>
<View className="px-4">
<SongsList
albumId={albumId}
songs={songs?.Items}
collectionId={collectionId}
artistId={artistId}
/>
</View>
</ParallaxScrollView>
);
}

View File

@@ -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<number>(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 (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={artist}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
<Text className="text-neutral-500">
{t("item_card.x_albums", { count: albums.TotalRecordCount })}
</Text>
</View>
<View className="flex flex-row flex-wrap justify-between px-4">
{albums.Items.map((item, idx) => (
<TouchableItemRouter
item={item}
style={{ width: "30%", marginBottom: 20 }}
key={idx}
>
<View className="flex flex-col gap-y-2">
<ArtistPoster item={item} />
<Text numberOfLines={2}>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
))}
</View>
</ParallaxScrollView>
);
}

View File

@@ -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<number>(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 (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-4">
<Text className="font-bold text-3xl mb-2">{t("item_card.artists")}</Text>
</View>
}
nestedScrollEnabled
data={data.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableItemRouter
style={{
maxWidth: "30%",
width: "100%",
}}
key={index}
item={item}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -112,7 +112,7 @@ const page: React.FC = () => {
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)), years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: ["Movie", "Series", "MusicAlbum"], includeItemTypes: ["Movie", "Series"],
}); });
return response.data || null; return response.data || null;

View File

@@ -29,10 +29,13 @@ import {
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; 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 { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as DropdownMenu from "zeego/dropdown-menu"; 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 Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -52,6 +55,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { const {
@@ -75,7 +79,7 @@ const Page: React.FC = () => {
}, },
}); });
const canRequest = useJellyseerrCanRequest(details); const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -101,19 +105,27 @@ const Page: React.FC = () => {
}, [jellyseerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const request = useCallback(async () => { const request = useCallback(async () => {
requestMedia( const body: MediaRequestBody = {
mediaTitle, mediaId: Number(result.id!!),
{ mediaType: result.mediaType!!,
mediaId: Number(result.id!!), tvdbId: details?.externalIds?.tvdbId,
mediaType: result.mediaType!!, seasons: (details as TvDetails)?.seasons
tvdbId: details?.externalIds?.tvdbId, ?.filter?.((s) => s.seasonNumber !== 0)
seasons: (details as TvDetails)?.seasons ?.map?.((s) => s.seasonNumber),
?.filter?.((s) => s.seasonNumber !== 0) }
?.map?.((s) => s.seasonNumber),
}, if (hasAdvancedRequestPermission) {
refetch advancedReqModalRef?.current?.present?.(body)
); return
}, [details, result, requestMedia]); }
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(() => { useEffect(() => {
if (details) { if (details) {
@@ -232,6 +244,10 @@ const Page: React.FC = () => {
result={result as TvResult} result={result as TvResult}
details={details as TvDetails} details={details as TvDetails}
refetch={refetch} refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data)
}
/> />
)} )}
<DetailFacts <DetailFacts
@@ -242,6 +258,17 @@ const Page: React.FC = () => {
</View> </View>
</View> </View>
</ParallaxScrollView> </ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
title={mediaTitle}
id={result.id!!}
type={result.mediaType as MediaType}
isAnime={isAnime}
onRequested={() => {
advancedReqModalRef?.current?.close()
refetch()
}}
/>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing

View File

@@ -153,8 +153,6 @@ const Page = () => {
itemType = "Series"; itemType = "Series";
} else if (library.CollectionType === "boxsets") { } else if (library.CollectionType === "boxsets") {
itemType = "BoxSet"; itemType = "BoxSet";
} else if (library.CollectionType === "music") {
itemType = "MusicAlbum";
} }
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({

View File

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

View File

@@ -36,7 +36,11 @@ export default function index() {
}); });
const libraries = useMemo( const libraries = useMemo(
() => 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] [data, settings?.hiddenLibraries]
); );

View File

@@ -5,7 +5,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags"; import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage"; import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
@@ -187,52 +186,19 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0, 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(() => { const noResults = useMemo(() => {
return !( return !(
artists?.length ||
albums?.length ||
songs?.length ||
movies?.length || movies?.length ||
episodes?.length || episodes?.length ||
series?.length || series?.length ||
collections?.length || collections?.length ||
actors?.length actors?.length
); );
}, [artists, episodes, albums, songs, movies, series, collections, actors]); }, [episodes, movies, series, collections, actors]);
const loading = useMemo(() => { const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8; return l1 || l2 || l3 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]); }, [l1, l2, l3, l7, l8]);
return ( return (
<> <>
@@ -368,48 +334,6 @@ export default function search() {
</TouchableItemRouter> </TouchableItemRouter>
)} )}
/> />
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header={t("search.artists")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header={t("search.albums")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header={t("search.songs")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</View> </View>
) : ( ) : (
<JellyserrIndexPage searchQuery={debouncedSearch} /> <JellyserrIndexPage searchQuery={debouncedSearch} />

View File

@@ -25,15 +25,6 @@ export default function Layout() {
animation: "fade", animation: "fade",
}} }}
/> />
<Stack.Screen
name="music-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack> </Stack>
</> </>
); );

View File

@@ -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<VideoRef | null>(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 (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
if (!item || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
</View>
);
return (
<View
style={{
width: windowDimensions.width,
height: windowDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
{videoSource && (
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
)}
</Pressable>
<Controls
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
pause={pause}
play={play}
seek={seek}
isVlc={false}
stop={stop}
/>
</View>
);
}
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;
}

View File

@@ -416,7 +416,6 @@ const Player = () => {
playWhenInactive={true} playWhenInactive={true}
allowsExternalPlayback={true} allowsExternalPlayback={true}
playInBackground={true} playInBackground={true}
pictureInPicture={true}
showNotificationControls={true} showNotificationControls={true}
ignoreSilentSwitch="ignore" ignoreSilentSwitch="ignore"
fullscreen={false} fullscreen={false}
@@ -534,7 +533,6 @@ export function useVideoSource(
startPosition, startPosition,
headers: getAuthHeaders(api), headers: getAuthHeaders(api),
metadata: { metadata: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown", title: item?.Name || "Unknown",
description: item?.Overview ?? undefined, description: item?.Overview ?? undefined,
imageUri: poster, imageUri: poster,

View File

@@ -1,13 +1,10 @@
import { Link, Stack, usePathname } from "expo-router"; import { Link, Stack } from "expo-router";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
import { useEffect } from "react";
export default function NotFoundScreen() { export default function NotFoundScreen() {
const pathname = usePathname();
return ( return (
<> <>
<Stack.Screen options={{ title: "Oops!" }} /> <Stack.Screen options={{ title: "Oops!" }} />

View File

@@ -1,16 +1,12 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
Ionicons,
MaterialCommunityIcons,
MaterialIcons,
} from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -310,6 +306,15 @@ const CredentialsSchema = z.object({
> >
{t("server.connect_button")} {t("server.connect_button")}
</Button> </Button>
<JellyfinServerDiscovery
onServerSelect={(server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
handleConnect(server.address);
}}
/>
<PreviousServersList <PreviousServersList
onServerSelect={(s) => { onServerSelect={(s) => {
handleConnect(s.address); handleConnect(s.address);

46
augmentations/api.ts Normal file
View File

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

View File

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

View File

@@ -13,5 +13,10 @@ MMKV.prototype.get = function <T> (key: string): T | undefined {
} }
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
this.set(key, JSON.stringify(value)); if (value === undefined) {
this.delete(key)
}
else {
this.set(key, JSON.stringify(value));
}
} }

View File

@@ -1,25 +1,23 @@
declare global { declare global {
interface Number { interface Number {
bytesToReadable(): string; bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number; secondsToMilliseconds(): number;
minutesToMilliseconds(): number; minutesToMilliseconds(): number;
hoursToMilliseconds(): number; hoursToMilliseconds(): number;
} }
} }
Number.prototype.bytesToReadable = function () { Number.prototype.bytesToReadable = function (decimals: number = 2) {
const bytes = this.valueOf(); 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; const i = Math.floor(Math.log(bytes) / Math.log(k));
if (mb >= 1) return `${mb.toFixed(0)} MB`;
const kb = bytes / 1024.0; return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
if (kb >= 1) return `${kb.toFixed(0)} KB`;
return `${bytes.toFixed(2)} B`;
}; };
Number.prototype.secondsToMilliseconds = function () { Number.prototype.secondsToMilliseconds = function () {

BIN
bun.lockb

Binary file not shown.

View File

@@ -28,6 +28,10 @@ export const BITRATES: Bitrate[] = [
key: "2 Mb/s", key: "2 Mb/s",
value: 2000000, value: 2000000,
}, },
{
key: "1 Mb/s",
value: 1000000,
},
{ {
key: "500 Kb/s", key: "500 Kb/s",
value: 500000, value: 500000,

View File

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

View File

@@ -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<Props> = ({ onServerSelect }) => {
const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
return (
<View className="mt-2">
<Button onPress={startDiscovery} color="black">
<Text className="text-white text-center">
{isSearching ? "Searching..." : "Search for local servers"}
</Text>
</Button>
{servers.length ? (
<ListGroup title="Servers" className="mt-4">
{servers.map((server) => (
<ListItem
key={server.address}
onPress={() =>
onServerSelect?.({
address: server.address,
serverName: server.serverName,
})
}
title={server.address}
showArrow
/>
))}
</ListGroup>
) : null}
</View>
);
};
export default JellyfinServerDiscovery;

View File

@@ -1,13 +1,11 @@
import { tc } from "@/utils/textTools";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react"; import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
@@ -32,6 +30,27 @@ export const MediaSourceSelector: React.FC<Props> = ({
const { t } = useTranslation(); 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 ( return (
<View <View
className="flex shrink" className="flex shrink"
@@ -66,9 +85,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}} }}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits( {`${name(source.Name)}`}
source.Size
)}`}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
))} ))}
@@ -77,9 +94,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
</View> </View>
); );
}; };
const name = (name?: string | null) => {
if (name && name.length > 40)
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
return name;
};

View File

@@ -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<T> {
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 = <T extends unknown>({
data,
disabled,
placeholderText,
keyExtractor,
titleExtractor,
title,
label,
onSelected,
multi = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const [selected, setSelected] = useState<T[]>();
useEffect(() => {
if (selected !== undefined) {
onSelected(...selected)
}
}, [selected]);
return (
<DisabledSetting
disabled={disabled === true}
showText={false}
{...props}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{typeof title === 'string' ? (
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
{title}
</Text>
<TouchableOpacity
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
</Text>
</TouchableOpacity>
</View>
) : (
<>
{title}
</>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) => (
multi ? (
<DropdownMenu.CheckboxItem
value={selected?.some(s => 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))]
})
}
>
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
: (
<DropdownMenu.Item
key={keyExtractor(item)}
onSelect={() => setSelected([item])}
>
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</DisabledSetting>
)
};
export default Dropdown;

View File

@@ -26,18 +26,6 @@ export const itemRouter = (
return `/(auth)/(tabs)/${from}/series/${item.Id}`; 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") { if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`; return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
} }

View File

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

View File

@@ -1,7 +1,7 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { FontAwesome, Ionicons } from "@expo/vector-icons"; import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet"; import { FilterSheet } from "./FilterSheet";

View File

@@ -55,14 +55,6 @@ export const Favorites = () => {
() => fetchFavoritesByType("Playlist"), () => fetchFavoritesByType("Playlist"),
[fetchFavoritesByType] [fetchFavoritesByType]
); );
const fetchFavoriteMusicAlbum = useCallback(
() => fetchFavoritesByType("MusicAlbum"),
[fetchFavoritesByType]
);
const fetchFavoriteAudio = useCallback(
() => fetchFavoritesByType("Audio"),
[fetchFavoritesByType]
);
return ( return (
<View className="flex flex-co gap-y-4"> <View className="flex flex-co gap-y-4">
@@ -103,18 +95,6 @@ export const Favorites = () => {
title={t("favorites.playlists")} title={t("favorites.playlists")}
hideIfEmpty hideIfEmpty
/> />
<ScrollingCollectionList
queryFn={fetchFavoriteMusicAlbum}
queryKey={["home", "favorites", "musicAlbums"]}
title={t("favorites.music_albums")}
hideIfEmpty
/>
<ScrollingCollectionList
queryFn={fetchFavoriteAudio}
queryKey={["home", "favorites", "audio"]}
title={t("favorites.audio")}
hideIfEmpty
/>
</View> </View>
); );
}; };

View File

@@ -1,3 +1,4 @@
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; 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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo } from "react"; 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, { import Animated, {
runOnJS, runOnJS,
useSharedValue, useSharedValue,
@@ -18,11 +21,7 @@ import Carousel, {
ICarouselInstance, ICarouselInstance,
Pagination, Pagination,
} from "react-native-reanimated-carousel"; } from "react-native-reanimated-carousel";
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter"; import { itemRouter } 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";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -162,7 +161,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const tap = Gesture.Tap() const tap = Gesture.Tap()
.maxDuration(2000) .maxDuration(2000)
.onBegin(() => { .onBegin(() => {
opacity.value = withTiming(0.5, { duration: 100 }); opacity.value = withTiming(0.8, { duration: 100 });
}) })
.onEnd(() => { .onEnd(() => {
runOnJS(handleRoute)(); runOnJS(handleRoute)();

View File

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

View File

@@ -10,7 +10,7 @@ const CastSlide: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps { details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, ...props }) => { > = ({ details, ...props }) => {
return ( return (
details?.credits?.cast?.length && details?.credits?.cast &&
details?.credits?.cast?.length > 0 && ( details?.credits?.cast?.length > 0 && (
<View {...props}> <View {...props}>
<Text className="text-lg font-bold mb-2 px-4">Cast</Text> <Text className="text-lg font-bold mb-2 px-4">Cast</Text>

View File

@@ -29,8 +29,8 @@ const Facts: React.FC<
> = ({ title, facts, ...props }) => > = ({ title, facts, ...props }) =>
facts && facts &&
facts?.length > 0 && ( facts?.length > 0 && (
<View className="flex flex-row justify-between py-2" {...props}> <View className="flex flex-col justify-between py-2" {...props}>
<Text className="font-bold">{title}</Text> <Text className="font-bold text-start">{title}</Text>
<View className="flex flex-col items-end"> <View className="flex flex-col items-end">
{facts.map((f, idx) => {facts.map((f, idx) =>

View File

@@ -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<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
id,
title,
type,
isAnime = false,
onRequested,
...props
}, ref) => {
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
const [requestOverrides, setRequestOverrides] =
useState<MediaRequestBody>({
mediaId: Number(id),
mediaType: type,
userId: jellyseerrUser?.id
});
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
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 (
<BottomSheetModal
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={() => setModalRequestProps(undefined)}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
}
>
{(data) => {
setModalRequestProps(data?.data as MediaRequestBody)
return <BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">Advanced</Text>
{seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text>
}
</View>
<View className="flex flex-col space-y-2">
{(defaultService && defaultServiceDetails && users) && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => 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"}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={"Root Folder"}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={"Root Folder"}
/>
<Dropdown
multi={true}
data={defaultServiceDetails.tags}
titleExtractor={(item) => 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"}
/>
<Dropdown
data={users}
titleExtractor={(item) => 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"}
/>
</>
)
}
</View>
<Button
className="mt-auto"
onPress={request}
color="purple"
>
Request
</Button>
</View>
</BottomSheetView>
}}
</BottomSheetModal>
);
});
export default RequestModal;

View File

@@ -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 GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider"; import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
import {router, useSegments} from "expo-router"; 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 segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const from = segments[2]; const from = segments[2];
const navigate = useCallback(({id, image, name}: Network | Studio) => router.push({ const navigate = useCallback(
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`, ({ id, image, name }: Network | Studio) =>
params: {id, image, name, type: slide.type } router.push({
}), [slide]); pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
params: { id, image, name, type: slide.type },
}),
[slide]
);
return ( return (
<Slide <Slide
@@ -30,7 +37,10 @@ const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewPro
<GenericSlideCard <GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4" className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4"
id={item.id.toString()} id={item.id.toString()}
url={jellyseerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)} url={jellyseerrApi?.imageProxy(
item.image,
COMPANY_LOGO_IMAGE_FILTER
)}
/> />
</TouchableOpacity> </TouchableOpacity>
)} )}

View File

@@ -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 GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import {router, useSegments} from "expo-router"; import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
import {useQuery} from "@tanstack/react-query"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover"; 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 {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<SlideProps & ViewProps> = ({ slide, ...props }) => { const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const from = segments[2]; const from = segments[2];
const navigate = useCallback((genre: GenreSliderItem) => router.push({ const navigate = useCallback(
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`, (genre: GenreSliderItem) =>
params: {type: slide.type, name: genre.name} router.push({
}), [slide]); pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
params: { type: slide.type, name: genre.name },
}),
[slide]
);
const {data, isFetching, isLoading } = useQuery({ const { data, isFetching, isLoading } = useQuery({
queryKey: ['jellyseerr', 'discover', slide.type, slide.id], queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => { queryFn: async () => {
return jellyseerrApi?.getGenreSliders( return jellyseerrApi?.getGenreSliders(
slide.type == DiscoverSliderType.MOVIE_GENRES slide.type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE ? Endpoints.MOVIE
: Endpoints.TV : Endpoints.TV
) );
}, },
enabled: !!jellyseerrApi enabled: !!jellyseerrApi,
}) });
return ( return (
data && <Slide data && (
{...props} <Slide
slide={slide} {...props}
data={data} slide={slide}
keyExtractor={(item) => item.id.toString()} data={data}
renderItem={(item, index) => ( keyExtractor={(item) => item.id.toString()}
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}> renderItem={(item, index) => (
<GenericSlideCard <TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
className="w-28 rounded-lg overflow-hidden border border-neutral-900" <GenericSlideCard
id={item.id.toString()} className="w-28 rounded-lg overflow-hidden border border-neutral-900"
title={item.name} id={item.id.toString()}
colors={[]} title={item.name}
contentFit={"cover"} colors={[]}
url={jellyseerrApi?.imageProxy(item.backdrops?.[0], `w780_filter(duotone,${genreColorMap[item.id] ?? genreColorMap[0]})`)} contentFit={"cover"}
/> url={jellyseerrApi?.imageProxy(
</TouchableOpacity> item.backdrops?.[0],
)} `w780_filter(duotone,${
/> genreColorMap[item.id] ?? genreColorMap[0]
})`
)}
/>
</TouchableOpacity>
)}
/>
)
); );
}; };

View File

@@ -60,8 +60,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
_itemType = "Series"; _itemType = "Series";
} else if (library.CollectionType === "boxsets") { } else if (library.CollectionType === "boxsets") {
_itemType = "BoxSet"; _itemType = "BoxSet";
} else if (library.CollectionType === "music") {
_itemType = "MusicAlbum";
} }
return _itemType; return _itemType;
@@ -76,8 +74,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
nameStr = "series"; nameStr = "series";
} else if (library.CollectionType === "boxsets") { } else if (library.CollectionType === "boxsets") {
nameStr = "box sets"; nameStr = "box sets";
} else if (library.CollectionType === "music") {
nameStr = "albums";
} else { } else {
nameStr = "items"; nameStr = "items";
} }

View File

@@ -1,3 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { PropsWithChildren, ReactNode } from "react"; import { PropsWithChildren, ReactNode } from "react";
import { import {
TouchableOpacity, TouchableOpacity,
@@ -6,7 +7,6 @@ import {
ViewProps, ViewProps,
} from "react-native"; } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { Ionicons } from "@expo/vector-icons";
interface Props extends TouchableOpacityProps, ViewProps { interface Props extends TouchableOpacityProps, ViewProps {
title?: string | null | undefined; title?: string | null | undefined;

View File

@@ -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<Props> = ({
collectionId,
artistId,
albumId,
songs = [],
...props
}) => {
const router = useRouter();
return (
<View className="flex flex-col space-y-2" {...props}>
{songs?.map((item: BaseItemDto, index: number) => (
<SongsListItem
key={item.Id}
item={item}
index={index}
collectionId={collectionId}
artistId={artistId}
albumId={albumId}
/>
))}
</View>
);
};

View File

@@ -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<Props> = ({
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 (
<TouchableOpacity
onPress={() => {
openSelect();
}}
{...props}
>
<View className="flex flex-row items-center space-x-4 bg-neutral-900 border-neutral-800 px-4 py-4 rounded-xl">
<Text className="opacity-50">{index + 1}</Text>
<View>
<Text className="mb-0.5 font-semibold">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -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<ArtistPosterProps> = ({ 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 (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}
source={
url2
? {
uri: url2,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
if (item)
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default AlbumCover;

View File

@@ -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<ArtistPosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item,
}),
[item]
);
if (!url)
return (
<View
className="rounded-lg overflow-hidden border border-neutral-900"
style={{
aspectRatio: "1/1",
}}
></View>
);
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default ArtistPoster;

View File

@@ -57,7 +57,7 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
[item] [item]
); );
const canRequest = useJellyseerrCanRequest(item); const [canRequest] = useJellyseerrCanRequest(item);
return ( return (
<TouchableJellyseerrRouter <TouchableJellyseerrRouter

View File

@@ -22,6 +22,7 @@ import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { t } from "i18next"; import { t } from "i18next";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
const JellyseerrSeasonEpisodes: React.FC<{ const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails; details: TvDetails;
@@ -102,8 +103,17 @@ const JellyseerrSeasons: React.FC<{
isLoading: boolean; isLoading: boolean;
result?: TvResult; result?: TvResult;
details?: TvDetails; details?: TvDetails;
hasAdvancedRequest?: boolean,
onAdvancedRequest?: (data: MediaRequestBody) => void;
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>; refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
}> = ({ isLoading, result, details, refetch }) => { }> = ({
isLoading,
result,
details,
refetch,
hasAdvancedRequest,
onAdvancedRequest,
}) => {
if (!details) return null; if (!details) return null;
const { jellyseerrApi, requestMedia } = useJellyseerr(); const { jellyseerrApi, requestMedia } = useJellyseerr();
@@ -143,7 +153,7 @@ const JellyseerrSeasons: React.FC<{
const requestAll = useCallback(() => { const requestAll = useCallback(() => {
if (details && jellyseerrApi) { if (details && jellyseerrApi) {
requestMedia(result?.name!!, { const body: MediaRequestBody = {
mediaId: details.id, mediaId: details.id,
mediaType: MediaType.TV, mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId, tvdbId: details.externalIds?.tvdbId,
@@ -152,9 +162,15 @@ const JellyseerrSeasons: React.FC<{
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0 (s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
) )
.map((s) => s.seasonNumber), .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( const promptRequestAll = useCallback(
() => () =>
@@ -173,18 +189,20 @@ const JellyseerrSeasons: React.FC<{
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => { const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
if (canRequest) { if (canRequest) {
requestMedia( const body: MediaRequestBody = {
`${result?.name!!}, Season ${seasonNumber}`, mediaId: details.id,
{ mediaType: MediaType.TV,
mediaId: details.id, tvdbId: details.externalIds?.tvdbId,
mediaType: MediaType.TV, seasons: [seasonNumber],
tvdbId: details.externalIds?.tvdbId, }
seasons: [seasonNumber],
}, if (hasAdvancedRequest) {
refetch return onAdvancedRequest?.(body)
) }
requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
} }
}, [requestMedia]); }, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);
if (isLoading) if (isLoading)
return ( return (

View File

@@ -7,11 +7,13 @@ import { useTranslation } from "react-i18next";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {useSettings} from "@/utils/atoms/settings";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const AudioToggles: React.FC<Props> = ({ ...props }) => { export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const media = useMedia(); const media = useMedia();
const [_, __, pluginSettings] = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -28,9 +30,13 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
</Text> </Text>
} }
> >
<ListItem title={t("home.settings.audio.set_audio_track")}> <ListItem
title={t("home.settings.audio.set_audio_track")}
disabled={pluginSettings?.rememberAudioSelections?.locked}
>
<Switch <Switch
value={settings.rememberAudioSelections} value={settings.rememberAudioSelections}
disabled={pluginSettings?.rememberAudioSelections?.locked}
onValueChange={(value) => onValueChange={(value) =>
updateSettings({ rememberAudioSelections: value }) updateSettings({ rememberAudioSelections: value })
} }

View File

@@ -0,0 +1,26 @@
import {View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({
disabled = false,
showText = true,
text,
children,
...props
}) => (
<View
pointerEvents={disabled ? "none" : "auto"}
style={{
opacity: disabled ? 0.5 : 1,
}}
>
<View {...props}>
{disabled && showText &&
<Text className="text-center text-red-700 my-4">{text ?? "Currently disabled by admin."}</Text>
}
{children}
</View>
</View>
)
export default DisabledSetting;

View File

@@ -1,35 +1,47 @@
import { Stepper } from "@/components/inputs/Stepper"; import { Stepper } from "@/components/inputs/Stepper";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { Settings, useSettings } from "@/utils/atoms/settings"; import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React from "react"; import React, { useMemo } from "react";
import { Switch, TouchableOpacity, View } from "react-native"; import { Switch, TouchableOpacity } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export const DownloadSettings: React.FC = ({ ...props }) => { export const DownloadSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
const { setProcesses } = useDownload(); const { setProcesses } = useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const allDisabled = useMemo(
() =>
pluginSettings?.downloadMethod?.locked === true &&
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload.locked === true,
[pluginSettings]
);
if (!settings) return null; if (!settings) return null;
return ( return (
<View {...props} className="mb-4"> <DisabledSetting disabled={allDisabled} {...props} className="mb-4">
<ListGroup title={t("home.settings.downloads.downloads_title")}> <ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem title={t("home.settings.downloads.download_method")}> <ListItem
title={t("home.settings.downloads.download_method")}
disabled={pluginSettings?.downloadMethod?.locked}
>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3"> <TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]"> <Text className="mr-1 text-[#8E8D91]">
{settings.downloadMethod === "remux" {settings.downloadMethod === DownloadMethod.Remux
? t("home.settings.downloads.default") ? t("home.settings.downloads.default")
: t("home.settings.downloads.optimized")} : t("home.settings.downloads.optimized")}
</Text> </Text>
@@ -53,7 +65,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<DropdownMenu.Item <DropdownMenu.Item
key="1" key="1"
onSelect={() => { onSelect={() => {
updateSettings({ downloadMethod: "remux" }); updateSettings({ downloadMethod: DownloadMethod.Remux });
setProcesses([]); setProcesses([]);
}} }}
> >
@@ -62,7 +74,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<DropdownMenu.Item <DropdownMenu.Item
key="2" key="2"
onSelect={() => { onSelect={() => {
updateSettings({ downloadMethod: "optimized" }); updateSettings({ downloadMethod: DownloadMethod.Optimized });
setProcesses([]); setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
@@ -75,7 +87,10 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<ListItem <ListItem
title={t("home.settings.downloads.remux_max_download")} title={t("home.settings.downloads.remux_max_download")}
disabled={settings.downloadMethod !== "remux"} disabled={
pluginSettings?.remuxConcurrentLimit?.locked ||
settings.downloadMethod !== DownloadMethod.Remux
}
> >
<Stepper <Stepper
value={settings.remuxConcurrentLimit} value={settings.remuxConcurrentLimit}
@@ -92,22 +107,31 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
<ListItem <ListItem
title={t("home.settings.downloads.auto_download")} title={t("home.settings.downloads.auto_download")}
disabled={settings.downloadMethod !== "optimized"} disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
> >
<Switch <Switch
disabled={settings.downloadMethod !== "optimized"} disabled={
pluginSettings?.autoDownload?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
value={settings.autoDownload} value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })} onValueChange={(value) => updateSettings({ autoDownload: value })}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
disabled={settings.downloadMethod !== "optimized"} disabled={
pluginSettings?.optimizedVersionsServerUrl?.locked ||
settings.downloadMethod !== DownloadMethod.Optimized
}
onPress={() => router.push("/settings/optimized-server/page")} onPress={() => router.push("/settings/optimized-server/page")}
showArrow showArrow
title={t("home.settings.downloads.optimized_versions_server")} title={t("home.settings.downloads.optimized_versions_server")}
></ListItem> ></ListItem>
</ListGroup> </ListGroup>
</View> </DisabledSetting>
); );
}; };

View File

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

View File

@@ -1,74 +1,64 @@
import React from "react"; import React, {useMemo} from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { ViewProps } from "react-native";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { Text } from "../common/Text";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import {Stepper} from "@/components/inputs/Stepper";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => { export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings();
if (!settings) return null; if (!settings) return null;
const renderSkipControl = ( const disabled = useMemo(() => (
value: number, pluginSettings?.forwardSkipTime?.locked === true &&
onDecrease: () => void, pluginSettings?.rewindSkipTime?.locked === true
onIncrease: () => void ),
) => ( [pluginSettings]
<View className="flex flex-row items-center"> )
<TouchableOpacity
onPress={onDecrease}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{value}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={onIncrease}
>
<Text>+</Text>
</TouchableOpacity>
</View>
);
return ( return (
<View {...props}> <DisabledSetting
disabled={disabled}
{...props}
>
<ListGroup title={t("home.settings.media_controls.media_controls_title")}> <ListGroup title={t("home.settings.media_controls.media_controls_title")}>
<ListItem title={t("home.settings.media_controls.forward_skip_length")}> <ListItem
{renderSkipControl( title={t("home.settings.media_controls.forward_skip_length")}
settings.forwardSkipTime, disabled={pluginSettings?.forwardSkipTime?.locked}
() => >
updateSettings({ <Stepper
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5), value={settings.forwardSkipTime}
}), disabled={pluginSettings?.forwardSkipTime?.locked}
() => step={5}
updateSettings({ appendValue="s"
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5), min={0}
}) max={60}
)} onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})}
/>
</ListItem> </ListItem>
<ListItem title={t("home.settings.media_controls.rewind_length")}> <ListItem
{renderSkipControl( title={t("home.settings.media_controls.rewind_length")}
settings.rewindSkipTime, disabled={pluginSettings?.rewindSkipTime?.locked}
() => >
updateSettings({ <Stepper
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5), value={settings.rewindSkipTime}
}), disabled={pluginSettings?.rewindSkipTime?.locked}
() => step={5}
updateSettings({ appendValue="s"
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5), min={0}
}) max={60}
)} onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})}
/>
</ListItem> </ListItem>
</ListGroup> </ListGroup>
</View> </DisabledSetting>
); );
}; };

View File

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

View File

@@ -1,12 +1,9 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { clearLogs } from "@/utils/log";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useHaptic } from "@/hooks/useHaptic";
import { View } from "react-native"; import { View } from "react-native";
import * as Progress from "react-native-progress";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";

View File

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

View File

@@ -17,14 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => <HeaderBackButton />, headerLeft: () => <HeaderBackButton />,
}; };
const routes = [ const routes = ["actors/[actorId]", "items/page", "series/[id]"];
"actors/[actorId]",
"albums/[albumId]",
"artists/index",
"artists/[artistId]",
"items/page",
"series/[id]",
];
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> = export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions])); Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

View File

@@ -35,12 +35,7 @@ import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { debounce } from "lodash"; import { debounce } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { import { TouchableOpacity, useWindowDimensions, View } from "react-native";
Pressable,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider"; import { Slider } from "react-native-awesome-slider";
import { import {
runOnJS, runOnJS,
@@ -59,6 +54,8 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
import { EpisodeList } from "./EpisodeList"; import { EpisodeList } from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
@@ -89,6 +86,8 @@ interface Props {
isVlc?: boolean; isVlc?: boolean;
} }
const CONTROLS_TIMEOUT = 4000;
export const Controls: React.FC<Props> = ({ export const Controls: React.FC<Props> = ({
item, item,
seek, seek,
@@ -121,6 +120,12 @@ export const Controls: React.FC<Props> = ({
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom); 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 { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = useAdjacentItems({ item }); const { previousItem, nextItem } = useAdjacentItems({ item });
const { const {
@@ -139,6 +144,23 @@ export const Controls: React.FC<Props> = ({
const wasPlayingRef = useRef(false); const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0); const lastProgressRef = useRef<number>(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<{ const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
bitrateValue: string; bitrateValue: string;
audioIndex: string; audioIndex: string;
@@ -161,8 +183,6 @@ export const Controls: React.FC<Props> = ({
isVlc isVlc
); );
const lightHapticFeedback = useHaptic("light");
const goToPreviousItem = useCallback(() => { const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return; if (!previousItem || !settings) return;
@@ -266,20 +286,19 @@ export const Controls: React.FC<Props> = ({
[updateTimes] [updateTimes]
); );
useEffect(() => { const hideControls = useCallback(() => {
if (item) { setShowControls(false);
progress.value = isVlc setShowAudioSlider(false);
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
: item?.UserData?.PlaybackPositionTicks || 0;
max.value = isVlc
? ticksToMs(item.RunTimeTicks || 0)
: item.RunTimeTicks || 0;
}
}, [item, isVlc]);
useEffect(() => {
prefetchAllTrickplayImages();
}, []); }, []);
const { handleControlsInteraction } = useControlsTimeout({
showControls,
isSliding,
episodeView,
onHideControls: hideControls,
timeout: CONTROLS_TIMEOUT,
});
const toggleControls = () => { const toggleControls = () => {
if (showControls) { if (showControls) {
setShowAudioSlider(false); setShowAudioSlider(false);
@@ -300,16 +319,13 @@ export const Controls: React.FC<Props> = ({
isSeeking.value = true; isSeeking.value = true;
}, [showControls, isPlaying]); }, [showControls, isPlaying]);
const [isSliding, setIsSliding] = useState(false);
const handleSliderComplete = useCallback( const handleSliderComplete = useCallback(
async (value: number) => { async (value: number) => {
isSeeking.value = false; isSeeking.value = false;
progress.value = value; progress.value = value;
setIsSliding(false); setIsSliding(false);
await seek( seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
);
if (wasPlayingRef.current === true) play(); if (wasPlayingRef.current === true) play();
}, },
[isVlc] [isVlc]
@@ -339,7 +355,7 @@ export const Controls: React.FC<Props> = ({
const newTime = isVlc const newTime = isVlc
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime)) ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime); : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
await seek(newTime); seek(newTime);
if (wasPlayingRef.current === true) play(); if (wasPlayingRef.current === true) play();
} }
} catch (error) { } catch (error) {
@@ -357,7 +373,7 @@ export const Controls: React.FC<Props> = ({
const newTime = isVlc const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime) ? curr + secondsToMs(settings.forwardSkipTime)
: ticksToSeconds(curr) + settings.forwardSkipTime; : ticksToSeconds(curr) + settings.forwardSkipTime;
await seek(Math.max(0, newTime)); seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play(); if (wasPlayingRef.current === true) play();
} }
} catch (error) { } catch (error) {
@@ -365,81 +381,6 @@ export const Controls: React.FC<Props> = ({
} }
}, [settings, isPlaying, isVlc]); }, [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 (
<View
style={{
position: "absolute",
left: -57,
bottom: 15,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className="bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
const [EpisodeView, setEpisodeView] = useState(false);
const switchOnEpisodeMode = () => {
setEpisodeView(true);
if (isPlaying) togglePlay();
};
const goToItem = useCallback( const goToItem = useCallback(
async (itemId: string) => { async (itemId: string) => {
try { try {
@@ -486,8 +427,77 @@ export const Controls: React.FC<Props> = ({
[settings, subtitleIndex, audioIndex] [settings, subtitleIndex, audioIndex]
); );
// Used when user changes audio through audio button on device. const toggleIgnoreSafeAreas = useCallback(() => {
const [showAudioSlider, setShowAudioSlider] = useState(false); 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 (
<View
style={{
position: "absolute",
left: -62,
bottom: 0,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
justifyContent: "center",
alignItems: "center",
}}
>
<View
style={{
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
borderRadius: 5,
}}
className="bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
resizeMode: "cover",
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
}, [trickPlayUrl, trickplayInfo, time]);
return ( return (
<ControlProvider <ControlProvider
@@ -495,7 +505,7 @@ export const Controls: React.FC<Props> = ({
mediaSource={mediaSource} mediaSource={mediaSource}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
> >
{EpisodeView ? ( {episodeView ? (
<EpisodeList <EpisodeList
item={item} item={item}
close={() => setEpisodeView(false)} close={() => setEpisodeView(false)}
@@ -503,23 +513,12 @@ export const Controls: React.FC<Props> = ({
/> />
) : ( ) : (
<> <>
<Pressable <VideoTouchOverlay
onPressIn={() => { screenWidth={screenWidth}
toggleControls(); screenHeight={screenHeight}
}} showControls={showControls}
style={{ onToggleControls={toggleControls}
position: "absolute", />
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: showControls ? 0.5 : 0,
}}
></Pressable>
<View <View
style={[ style={[
{ {
@@ -533,7 +532,7 @@ export const Controls: React.FC<Props> = ({
}, },
]} ]}
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row w-full p-4 `} className={`flex flex-row w-full pt-2`}
> >
<View className="mr-auto"> <View className="mr-auto">
<VideoProvider <VideoProvider
@@ -557,7 +556,7 @@ export const Controls: React.FC<Props> = ({
onPress={() => { onPress={() => {
switchOnEpisodeMode(); 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"
> >
<Ionicons name="list" size={24} color="white" /> <Ionicons name="list" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
@@ -565,7 +564,7 @@ export const Controls: React.FC<Props> = ({
{previousItem && !offline && ( {previousItem && !offline && (
<TouchableOpacity <TouchableOpacity
onPress={goToPreviousItem} onPress={goToPreviousItem}
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"
> >
<Ionicons name="play-skip-back" size={24} color="white" /> <Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
@@ -574,7 +573,7 @@ export const Controls: React.FC<Props> = ({
{nextItem && !offline && ( {nextItem && !offline && (
<TouchableOpacity <TouchableOpacity
onPress={goToNextItem} onPress={goToNextItem}
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"
> >
<Ionicons name="play-skip-forward" size={24} color="white" /> <Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
@@ -583,7 +582,7 @@ export const Controls: React.FC<Props> = ({
{/* {mediaSource?.TranscodingUrl && ( */} {/* {mediaSource?.TranscodingUrl && ( */}
<TouchableOpacity <TouchableOpacity
onPress={toggleIgnoreSafeAreas} onPress={toggleIgnoreSafeAreas}
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"
> >
<Ionicons <Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"} name={ignoreSafeAreas ? "contract-outline" : "expand"}
@@ -600,7 +599,7 @@ export const Controls: React.FC<Props> = ({
); );
router.back(); 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"
> >
<Ionicons name="close" size={24} color="white" /> <Ionicons name="close" size={24} color="white" />
</TouchableOpacity> </TouchableOpacity>
@@ -730,10 +729,11 @@ export const Controls: React.FC<Props> = ({
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0, bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
}, },
]} ]}
className={`flex flex-col p-4`} className={`flex flex-col px-2`}
onTouchStart={handleControlsInteraction}
> >
<View <View
className="shrink flex flex-col justify-center h-full mb-2" className="shrink flex flex-col justify-center h-full"
style={{ style={{
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
@@ -747,10 +747,12 @@ export const Controls: React.FC<Props> = ({
}} }}
pointerEvents={showControls ? "box-none" : "none"} pointerEvents={showControls ? "box-none" : "none"}
> >
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && ( {item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text> <Text className="opacity-50">
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
</Text>
)} )}
<Text className="font-bold text-xl">{item?.Name}</Text>
{item?.Type === "Movie" && ( {item?.Type === "Movie" && (
<Text className="text-xs opacity-50"> <Text className="text-xs opacity-50">
{item?.ProductionYear} {item?.ProductionYear}
@@ -785,7 +787,7 @@ export const Controls: React.FC<Props> = ({
</View> </View>
</View> </View>
<View <View
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`} className={`flex flex-col-reverse rounded-lg items-center my-2`}
style={{ style={{
opacity: showControls ? 1 : 0, opacity: showControls ? 1 : 0,
}} }}
@@ -801,19 +803,7 @@ export const Controls: React.FC<Props> = ({
bubbleTextColor: "#666", bubbleTextColor: "#666",
heartbeatColor: "#999", heartbeatColor: "#999",
}} }}
renderThumb={() => ( renderThumb={() => null}
<View
style={{
width: 18,
height: 18,
left: -2,
borderRadius: 10,
backgroundColor: "#fff",
justifyContent: "center",
alignItems: "center",
}}
/>
)}
cache={cacheProgress} cache={cacheProgress}
onSlidingStart={handleSliderStart} onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete} onSlidingComplete={handleSliderComplete}
@@ -828,7 +818,7 @@ export const Controls: React.FC<Props> = ({
minimumValue={min} minimumValue={min}
maximumValue={max} maximumValue={max}
/> />
<View className="flex flex-row items-center justify-between mt-0.5"> <View className="flex flex-row items-center justify-between mt-2">
<Text className="text-[12px] text-neutral-400"> <Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime, isVlc ? "ms" : "s")} {formatTimeString(currentTime, isVlc ? "ms" : "s")}
</Text> </Text>

View File

@@ -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 { import {
HorizontalScroll, HorizontalScroll,
HorizontalScrollRef, HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll"; } 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 { import {
SeasonDropdown, SeasonDropdown,
SeasonIndexState, SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } 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 = { type Props = {
item: BaseItemDto; item: BaseItemDto;

View File

@@ -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 (
<Pressable
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{
position: "absolute",
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: showControls ? 0.75 : 0,
}}
/>
);
};

View File

@@ -74,7 +74,7 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
return ( return (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"> <TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} /> <Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity> </TouchableOpacity>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>

View File

@@ -121,7 +121,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
<View> <View>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"> <TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} /> <Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity> </TouchableOpacity>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>

View File

@@ -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<NodeJS.Timeout>();
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,
};
};

View File

@@ -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,
};
};

View File

@@ -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<ServerInfo[]>([]);
const [isSearching, setIsSearching] = useState(false);
const startDiscovery = useCallback(() => {
setIsSearching(true);
setServers([]);
const discoveredServers = new Set<string>();
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,
};
};

View File

@@ -35,6 +35,11 @@ import {
} from "@/utils/jellyseerr/server/models/Person"; } from "@/utils/jellyseerr/server/models/Person";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; 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 { interface SearchParams {
query: string; query: string;
@@ -67,6 +72,8 @@ export enum Endpoints {
MOVIE = "/movie", MOVIE = "/movie",
RATINGS = "/ratings", RATINGS = "/ratings",
ISSUE = "/issue", ISSUE = "/issue",
USER = "/user",
SERVICE = "/service",
TV = "/tv", TV = "/tv",
SETTINGS = "/settings", SETTINGS = "/settings",
NETWORK = "/network", NETWORK = "/network",
@@ -283,6 +290,12 @@ export class JellyseerrApi {
}); });
} }
async user(params: any) {
return this.axios
?.get<UserResultsResponse>(`${Endpoints.API_V1}${Endpoints.USER}`, { params })
.then(({data}) => data.results)
}
imageProxy( imageProxy(
path?: string, path?: string,
filter: string = "original", filter: string = "original",
@@ -316,6 +329,18 @@ export class JellyseerrApi {
}); });
} }
async service(type: 'radarr' | 'sonarr') {
return this.axios
?.get<ServiceCommonServer[]>(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}`)
.then(({data}) => data);
}
async serviceDetails(type: 'radarr' | 'sonarr', id: number) {
return this.axios
?.get<ServiceCommonServerWithDetails>(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}` + `/${id}`)
.then(({data}) => data);
}
private setInterceptors() { private setInterceptors() {
this.axios.interceptors.response.use( this.axios.interceptors.response.use(
async (response) => { async (response) => {

View File

@@ -4,6 +4,7 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"submodule-reload": "git submodule update --init --remote --recursive", "submodule-reload": "git submodule update --init --remote --recursive",
"clean": "echo y | expo prebuild --clean",
"start": "bun run submodule-reload && expo start", "start": "bun run submodule-reload && expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "bun run submodule-reload && expo run:android", "android": "bun run submodule-reload && expo run:android",
@@ -17,14 +18,15 @@
"preset": "jest-expo" "preset": "jest-expo"
}, },
"dependencies": { "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", "@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0", "@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4", "@expo/vector-icons": "^14.0.4",
"@futurejj/react-native-visibility-sensor": "^1.3.5", "@futurejj/react-native-visibility-sensor": "^1.3.5",
"@gorhom/bottom-sheet": "^4.6.4", "@gorhom/bottom-sheet": "^4.6.4",
"@jellyfin/sdk": "^0.11.0", "@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-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1", "@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.6", "@react-native-menu/menu": "^1.1.6",
@@ -75,7 +77,6 @@
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6", "react-native-awesome-slider": "^2.5.6",
"react-native-bottom-tabs": "0.7.8",
"react-native-circular-progress": "^1.4.1", "react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.9.0", "react-native-compressor": "^1.9.0",
"react-native-country-flag": "^2.0.2", "react-native-country-flag": "^2.0.2",
@@ -96,6 +97,7 @@
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-svg": "15.2.0", "react-native-svg": "15.2.0",
"react-native-tab-view": "^3.5.2", "react-native-tab-view": "^3.5.2",
"react-native-udp": "^4.1.7",
"react-native-uitextview": "^1.4.0", "react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2", "react-native-uuid": "^2.0.2",

View File

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

View File

@@ -1,3 +1,4 @@
import "@/augmentations";
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Api, Jellyfin } from "@jellyfin/sdk"; import { Api, Jellyfin } from "@jellyfin/sdk";
@@ -19,8 +20,9 @@ import React, {
import { Platform } from "react-native"; import { Platform } from "react-native";
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info"; import { getDeviceName } from "react-native-device-info";
import { toast } from "sonner-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSettings } from "@/utils/atoms/settings";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
interface Server { interface Server {
address: string; address: string;
@@ -73,6 +75,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom); const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false); const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null); const [secret, setSecret] = useState<string | null>(null);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
useQuery({ useQuery({
queryKey: ["user", api], queryKey: ["user", api],
@@ -229,6 +239,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("user", JSON.stringify(auth.data.User)); storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken)); setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken); storage.set("token", auth.data?.AccessToken);
const recentPluginSettings = await refreshStreamyfinPluginSettings();
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
const jellyseerrApi = new JellyseerrApi(
recentPluginSettings.jellyseerrServerUrl.value
);
await jellyseerrApi.test().then((result) => {
if (result.isValid && result.requiresPass) {
jellyseerrApi.login(username, password).then(setJellyseerrUser);
}
});
}
} }
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
@@ -265,6 +287,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async () => { mutationFn: async () => {
storage.delete("token"); storage.delete("token");
setUser(null); setUser(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
}, },
onError: (error) => { onError: (error) => {
console.error("Logout failed:", error); console.error("Logout failed:", error);

View File

@@ -38,7 +38,6 @@ type PlaySettingsContextType = {
setPlayUrl: React.Dispatch<React.SetStateAction<string | null>>; setPlayUrl: React.Dispatch<React.SetStateAction<string | null>>;
playSessionId?: string | null; playSessionId?: string | null;
setOfflineSettings: (data: PlaybackType) => void; setOfflineSettings: (data: PlaybackType) => void;
setMusicPlaySettings: (item: BaseItemDto, url: string) => void;
}; };
const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>( const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
@@ -61,13 +60,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
_setPlaySettings(data); _setPlaySettings(data);
}, []); }, []);
const setMusicPlaySettings = (item: BaseItemDto, url: string) => {
setPlaySettings({
item: item,
});
setPlayUrl(url);
};
const setPlaySettings = useCallback( const setPlaySettings = useCallback(
async ( async (
dataOrUpdater: dataOrUpdater:
@@ -147,7 +139,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
setPlaySettings, setPlaySettings,
playUrl, playUrl,
setPlayUrl, setPlayUrl,
setMusicPlaySettings,
setOfflineSettings, setOfflineSettings,
playSessionId, playSessionId,
mediaSource, mediaSource,

View File

@@ -242,9 +242,6 @@
"episodes": "Episodes", "episodes": "Episodes",
"collections": "Collections", "collections": "Collections",
"actors": "Actors", "actors": "Actors",
"artists": "Artists",
"albums": "Albums",
"songs": "Songs",
"request_movies": "Request Movies", "request_movies": "Request Movies",
"request_series": "Request Series", "request_series": "Request Series",
"recently_added": "Recently Added", "recently_added": "Recently Added",
@@ -297,9 +294,7 @@
"episodes": "Episodes", "episodes": "Episodes",
"videos": "Videos", "videos": "Videos",
"boxsets": "Boxsets", "boxsets": "Boxsets",
"playlists": "Playlists", "playlists": "Playlists"
"music_albums": "Music Albums",
"audio": "Audio"
}, },
"custom_links": { "custom_links": {
"no_links": "No links" "no_links": "No links"
@@ -341,9 +336,6 @@
"show_more": "Show more", "show_more": "Show more",
"show_less": "Show less", "show_less": "Show less",
"appeared_in": "Appeared in", "appeared_in": "Appeared in",
"x_songs": "{{count}} songs",
"x_albums": "{{count}} albums",
"artists": "Artists",
"could_not_load_item": "Could not load item", "could_not_load_item": "Could not load item",
"none": "None", "none": "None",
"download": { "download": {

View File

@@ -242,9 +242,6 @@
"episodes": "Épisodes", "episodes": "Épisodes",
"collections": "Collections", "collections": "Collections",
"actors": "Acteurs", "actors": "Acteurs",
"artists": "Artistes",
"albums": "Albums",
"songs": "Chansons",
"request_movies": "Demander un film", "request_movies": "Demander un film",
"request_series": "Demander une série", "request_series": "Demander une série",
"recently_added": "Ajoutés récemment", "recently_added": "Ajoutés récemment",
@@ -297,9 +294,7 @@
"episodes": "Épisodes", "episodes": "Épisodes",
"videos": "Vidéos", "videos": "Vidéos",
"boxsets": "Coffrets", "boxsets": "Coffrets",
"playlists": "Listes de lecture", "playlists": "Listes de lecture"
"music_albums": "Albums de musique",
"audio": "Audio"
}, },
"custom_links": { "custom_links": {
"no_links": "Aucun lien" "no_links": "Aucun lien"
@@ -341,9 +336,6 @@
"show_more": "Afficher plus", "show_more": "Afficher plus",
"show_less": "Afficher moins", "show_less": "Afficher moins",
"appeared_in": "Apparu dans", "appeared_in": "Apparu dans",
"x_songs": "{{count}} chansons",
"x_albums": "{{count}} albums",
"artists": "Artistes",
"could_not_load_item": "Impossible de charger l'item", "could_not_load_item": "Impossible de charger l'item",
"none": "Aucun", "none": "Aucun",
"download": { "download": {

View File

@@ -48,5 +48,20 @@ export const useJellyseerrCanRequest = (
return userHasPermission && !canNotRequest; return userHasPermission && !canNotRequest;
}, [item, jellyseerrUser]); }, [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];
}; };

View File

@@ -1,13 +1,19 @@
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect } from "react"; import {useCallback, useEffect, useMemo} from "react";
import { getLocales } from "expo-localization";
import * as ScreenOrientation from "expo-screen-orientation"; import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv"; import { storage } from "../mmkv";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { import {
CultureDto, CultureDto,
PluginStatus,
SubtitlePlaybackMode, SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import {apiAtom} from "@/providers/JellyfinProvider";
import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api";
import {writeErrorLog} from "@/utils/log";
const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"
export type DownloadQuality = "original" | "high" | "low"; export type DownloadQuality = "original" | "high" | "low";
@@ -60,6 +66,11 @@ export type DefaultLanguageOption = {
label: string; label: string;
}; };
export enum DownloadMethod {
Remux = "remux",
Optimized = "optimized"
}
export type Settings = { export type Settings = {
autoRotate?: boolean; autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean; forceLandscapeInVideoPlayer?: boolean;
@@ -83,7 +94,7 @@ export type Settings = {
forwardSkipTime: number; forwardSkipTime: number;
rewindSkipTime: number; rewindSkipTime: number;
optimizedVersionsServerUrl?: string | null; optimizedVersionsServerUrl?: string | null;
downloadMethod: "optimized" | "remux"; downloadMethod: DownloadMethod;
autoDownload: boolean; autoDownload: boolean;
showCustomMenuLinks: boolean; showCustomMenuLinks: boolean;
disableHapticFeedback: boolean; disableHapticFeedback: boolean;
@@ -94,6 +105,16 @@ export type Settings = {
hiddenLibraries?: string[]; hiddenLibraries?: string[];
}; };
export interface Lockable<T> {
locked: boolean;
value: T
}
export type PluginLockableSettings = { [K in keyof Settings]: Lockable<Settings[K]> };
export type StreamyfinPluginConfig = {
settings: PluginLockableSettings
}
const loadSettings = (): Settings => { const loadSettings = (): Settings => {
const defaultValues: Settings = { const defaultValues: Settings = {
autoRotate: true, autoRotate: true,
@@ -124,7 +145,7 @@ const loadSettings = (): Settings => {
forwardSkipTime: 30, forwardSkipTime: 30,
rewindSkipTime: 10, rewindSkipTime: 10,
optimizedVersionsServerUrl: null, optimizedVersionsServerUrl: null,
downloadMethod: "remux", downloadMethod: DownloadMethod.Remux,
autoDownload: false, autoDownload: false,
showCustomMenuLinks: false, showCustomMenuLinks: false,
disableHapticFeedback: false, disableHapticFeedback: false,
@@ -153,16 +174,76 @@ const saveSettings = (settings: Settings) => {
}; };
export const settingsAtom = atom<Settings | null>(null); export const settingsAtom = atom<Settings | null>(null);
export const pluginSettingsAtom = atom(storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS));
export const useSettings = () => { export const useSettings = () => {
const [settings, setSettings] = useAtom(settingsAtom); const [api] = useAtom(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
useEffect(() => { useEffect(() => {
if (settings === null) { if (_settings === null) {
const loadedSettings = loadSettings(); const loadedSettings = loadSettings();
setSettings(loadedSettings); setSettings(loadedSettings);
} }
}, [settings, setSettings]); }, [_settings, setSettings]);
const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => {
storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings)
_setPluginSettings(settings)
},
[_setPluginSettings]
)
const refreshStreamyfinPluginSettings = useCallback(
async () => {
if (!api)
return
const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data);
if (plugins && plugins.length > 0) {
const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID);
if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) {
writeErrorLog(
"Streamyfin plugin is currently not active.\n" +
`Current status is: ${streamyfinPlugin?.Status}`
);
setPluginSettings(undefined);
return;
}
const settings = await api.getStreamyfinPluginConfig()
.then(({data}) => data.settings)
setPluginSettings(settings);
return settings;
}
},
[api]
)
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
// If admin sets locked to false but provides a value,
// use user settings first and fallback on admin setting if required.
const settings: Settings = useMemo(() => {
const overrideSettings = Object.entries(pluginSettings || {})
.reduce((acc, [key, setting]) => {
if (setting) {
const {value, locked} = setting
acc = Object.assign(acc, {
[key]: locked ? value : _settings?.[key as keyof Settings] ?? value
})
}
return acc
}, {} as Settings)
return {
..._settings,
...overrideSettings
}
}, [_settings, setSettings, pluginSettings, _setPluginSettings, setPluginSettings])
const updateSettings = (update: Partial<Settings>) => { const updateSettings = (update: Partial<Settings>) => {
if (settings) { if (settings) {
@@ -173,5 +254,5 @@ export const useSettings = () => {
} }
}; };
return [settings, updateSettings] as const; return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const;
}; };

View File

@@ -10,8 +10,6 @@ import {
* readonly Unknown: "unknown"; * readonly Unknown: "unknown";
readonly Movies: "movies"; readonly Movies: "movies";
readonly Tvshows: "tvshows"; readonly Tvshows: "tvshows";
readonly Music: "music";
readonly Musicvideos: "musicvideos";
readonly Trailers: "trailers"; readonly Trailers: "trailers";
readonly Homevideos: "homevideos"; readonly Homevideos: "homevideos";
readonly Boxsets: "boxsets"; readonly Boxsets: "boxsets";
@@ -33,8 +31,6 @@ export const colletionTypeToItemType = (
return BaseItemKind.Series; return BaseItemKind.Series;
case CollectionType.Homevideos: case CollectionType.Homevideos:
return BaseItemKind.Video; return BaseItemKind.Video;
case CollectionType.Musicvideos:
return BaseItemKind.MusicVideo;
case CollectionType.Books: case CollectionType.Books:
return BaseItemKind.Book; return BaseItemKind.Book;
case CollectionType.Playlists: case CollectionType.Playlists: