forked from Ninjalama/streamyfin_mirror
Compare commits
46 Commits
feat/conte
...
feat/favor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece3bc001f | ||
|
|
27609e7789 | ||
|
|
a994868be4 | ||
|
|
ee6d43e3e8 | ||
|
|
f8d22bb7d6 | ||
|
|
a0391b484d | ||
|
|
681aadb121 | ||
|
|
479a1f037e | ||
|
|
ae5b88ab56 | ||
|
|
9091b9b66a | ||
|
|
cccb26c9cc | ||
|
|
28568cbb9c | ||
|
|
8344d4025b | ||
|
|
0f69448081 | ||
|
|
a936916da4 | ||
|
|
c753e33f38 | ||
|
|
48422fa93e | ||
|
|
5adf943fd9 | ||
|
|
9174a8104d | ||
|
|
56f1bd489c | ||
|
|
5e79b5a581 | ||
|
|
36a689f59d | ||
|
|
47211ba009 | ||
|
|
e86a2af9a9 | ||
|
|
c46b4cc34d | ||
|
|
ec0d9d7788 | ||
|
|
d2eda1365c | ||
|
|
b58fa86a6b | ||
|
|
400dfe3679 | ||
|
|
cf58a5e749 | ||
|
|
001eba02b4 | ||
|
|
67e767f298 | ||
|
|
5f1c5f7b34 | ||
|
|
e54cac1e09 | ||
|
|
cbce83e109 | ||
|
|
c6b58c5c28 | ||
|
|
0468756317 | ||
|
|
9f12ee027f | ||
|
|
78b7425c6b | ||
|
|
c38c1d06ad | ||
|
|
5af735065a | ||
|
|
600276cb69 | ||
|
|
ba3104f87e | ||
|
|
3aef9458e3 | ||
|
|
5bce394836 | ||
|
|
dd09f3d4d9 |
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,13 +1,13 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: Create a report to help us improve
|
description: Create a report to help us improve
|
||||||
title: '[Bug]: '
|
title: "[Bug]: "
|
||||||
labels:
|
labels:
|
||||||
- ['❌ bug']
|
- ["❌ bug"]
|
||||||
projects:
|
projects:
|
||||||
- ['fredrikburmester/5']
|
- ["fredrikburmester/5"]
|
||||||
assignees:
|
assignees:
|
||||||
- fredrikburmester
|
- fredrikburmester
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
@@ -43,8 +43,9 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.23.0
|
||||||
- 0.22.0
|
- 0.22.0
|
||||||
- 0.21.0
|
- 0.21.0
|
||||||
- older
|
- older
|
||||||
@@ -54,6 +55,5 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: screenshots
|
id: screenshots
|
||||||
attributes:
|
attributes:
|
||||||
label:
|
label: If applicable, please add screenshots to help explain your problem.
|
||||||
If applicable, please add screenshots to help explain your problem.
|
|
||||||
You can drag and drop images here or paste them directly into the comment box.
|
You can drag and drop images here or paste them directly into the comment box.
|
||||||
|
|||||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "utils/jellyseerr"]
|
||||||
|
path = utils/jellyseerr
|
||||||
|
url = https://github.com/herrrta/jellyseerr
|
||||||
|
branch = models
|
||||||
43
README.md
43
README.md
@@ -15,10 +15,10 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
|
|
||||||
- 🚀 **Skp intro / credits support**
|
- 🚀 **Skp intro / credits support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
|
||||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
@@ -70,11 +70,9 @@ Or download the APKs [here on GitHub](https://github.com/fredrikburmester/stream
|
|||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
Get the latest updates by using the TestFlight version of the app.
|
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.
|
||||||
|
|
||||||
<a href="https://testflight.apple.com/join/CWBaAAK2">
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -89,36 +87,10 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
|
|
||||||
### Development info
|
### Development info
|
||||||
|
|
||||||
1. Use node `20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
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.
|
||||||
## Extended chromecast controls
|
|
||||||
|
|
||||||
Add this to AppDelegate.mm:
|
|
||||||
|
|
||||||
```
|
|
||||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
|
||||||
{
|
|
||||||
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
|
|
||||||
#if __has_include(<GoogleCast/GoogleCast.h>)
|
|
||||||
...
|
|
||||||
|
|
||||||
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;`
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
Add this to Info.plist:
|
|
||||||
|
|
||||||
```
|
|
||||||
<key>NSBonjourServices</key>
|
|
||||||
<array>
|
|
||||||
<string>_googlecast._tcp</string>
|
|
||||||
<string>_CC1AD845._googlecast._tcp</string>
|
|
||||||
</array>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -153,6 +125,7 @@ I'd like to thank the following people and projects for their contributions to S
|
|||||||
|
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||||
|
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
- The Jellyfin devs for always being helpful in the Discord.
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.22.0",
|
"version": "0.23.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 47,
|
"versionCode": 49,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
|
|||||||
24
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
24
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function SearchLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: "Favorites",
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
34
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function favorites() {
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Favorites />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
|||||||
@@ -107,9 +107,9 @@ export default function index() {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanCacheDirectory()
|
cleanCacheDirectory().catch((e) =>
|
||||||
.then(r => console.log("Cache directory cleaned"))
|
console.error("Something went wrong cleaning cache directory")
|
||||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
);
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import { Button } from "@/components/Button";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import {bytesToReadable, useDownload} from "@/providers/DownloadProvider";
|
import {useDownload} from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {clearLogs, useLog} from "@/utils/log";
|
import { clearLogs, useLog } from "@/utils/log";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {Alert, ScrollView, View} from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
|
import * as Progress from "react-native-progress";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import * as Progress from 'react-native-progress';
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
@@ -25,17 +25,17 @@ export default function settings() {
|
|||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const {data: size , isLoading: appSizeLoading } = useQuery({
|
const { data: size, isLoading: appSizeLoading } = useQuery({
|
||||||
queryKey: ["appSize", appSizeUsage],
|
queryKey: ["appSize", appSizeUsage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const app = await appSizeUsage
|
const app = await appSizeUsage;
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync()
|
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync()
|
const total = await FileSystem.getTotalDiskCapacityAsync();
|
||||||
|
|
||||||
return {app, remaining, total, used: (total - remaining) / total}
|
return { app, remaining, total, used: (total - remaining) / total };
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
Alert.prompt(
|
Alert.prompt(
|
||||||
@@ -69,22 +69,16 @@ export default function settings() {
|
|||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
Haptics.notificationAsync(
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Haptics.notificationAsync(
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
Haptics.NotificationFeedbackType.Error
|
|
||||||
);
|
|
||||||
toast.error("Error deleting files");
|
toast.error("Error deleting files");
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
const onClearLogsClicked = async () => {
|
||||||
clearLogs();
|
clearLogs();
|
||||||
Haptics.notificationAsync(
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,7 +122,7 @@ export default function settings() {
|
|||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="font-bold text-lg mb-2">Storage</Text>
|
<Text className="font-bold text-lg mb-2">Storage</Text>
|
||||||
<View className="mb-4 space-y-2">
|
<View className="mb-4 space-y-2">
|
||||||
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
|
{size && <Text>App usage: {size.app.bytesToReadable()}</Text>}
|
||||||
<Progress.Bar
|
<Progress.Bar
|
||||||
className="bg-gray-100/10"
|
className="bg-gray-100/10"
|
||||||
indeterminate={appSizeLoading}
|
indeterminate={appSizeLoading}
|
||||||
@@ -140,19 +134,16 @@ export default function settings() {
|
|||||||
progress={size?.used}
|
progress={size?.used}
|
||||||
/>
|
/>
|
||||||
{size && (
|
{size && (
|
||||||
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
|
<Text>
|
||||||
|
Available: {size.remaining?.bytesToReadable()}, Total:{" "}
|
||||||
|
{size.total?.bytesToReadable()}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button color="red" onPress={onDeleteClicked}>
|
||||||
color="red"
|
|
||||||
onPress={onDeleteClicked}
|
|
||||||
>
|
|
||||||
Delete all downloaded files
|
Delete all downloaded files
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button color="red" onPress={onClearLogsClicked}>
|
||||||
color="red"
|
|
||||||
onPress={onClearLogsClicked}
|
|
||||||
>
|
|
||||||
Delete all logs
|
Delete all logs
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -104,9 +104,12 @@ const page: React.FC = () => {
|
|||||||
"CanDelete",
|
"CanDelete",
|
||||||
"MediaSourceCount",
|
"MediaSourceCount",
|
||||||
],
|
],
|
||||||
|
// true is needed for merged versions
|
||||||
|
recursive: true,
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
|
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import {
|
||||||
|
IssueType,
|
||||||
|
IssueTypeName,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
|
||||||
|
const Page: React.FC = () => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const {
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest: canRequestString,
|
||||||
|
posterSrc,
|
||||||
|
...result
|
||||||
|
} = params as unknown as {
|
||||||
|
mediaTitle: string;
|
||||||
|
releaseYear: number;
|
||||||
|
canRequest: string;
|
||||||
|
posterSrc: string;
|
||||||
|
} & Partial<MovieResult | TvResult>;
|
||||||
|
|
||||||
|
const canRequest = canRequestString === "true";
|
||||||
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: details,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
} = useQuery({
|
||||||
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
|
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retryOnMount: true,
|
||||||
|
queryFn: async () => {
|
||||||
|
return result.mediaType === MediaType.MOVIE
|
||||||
|
? jellyseerrApi?.movieDetails(result.id!!)
|
||||||
|
: jellyseerrApi?.tvDetails(result.id!!);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitIssue = useCallback(() => {
|
||||||
|
if (result.id && issueType && issueMessage && details) {
|
||||||
|
jellyseerrApi
|
||||||
|
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||||
|
.then(() => {
|
||||||
|
setIssueType(undefined);
|
||||||
|
setIssueMessage(undefined);
|
||||||
|
bottomSheetModalRef?.current?.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
|
const request = useCallback(
|
||||||
|
() =>
|
||||||
|
requestMedia(mediaTitle, {
|
||||||
|
mediaId: Number(result.id!!),
|
||||||
|
mediaType: result.mediaType!!,
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
seasons: (details as TvDetails)?.seasons
|
||||||
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
}),
|
||||||
|
[details, result, requestMedia]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 relative"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParallaxScrollView
|
||||||
|
className="flex-1 opacity-100"
|
||||||
|
headerHeight={300}
|
||||||
|
headerImage={
|
||||||
|
<View>
|
||||||
|
{result.backdropPath ? (
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
transition={300}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
source={{
|
||||||
|
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="image-outline"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="space-y-4">
|
||||||
|
<View className="px-4">
|
||||||
|
<View className="flex flex-row justify-between w-full">
|
||||||
|
<View className="flex flex-col w-56">
|
||||||
|
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||||
|
<Text
|
||||||
|
uiTextView
|
||||||
|
selectable
|
||||||
|
className="font-bold text-2xl mb-1"
|
||||||
|
>
|
||||||
|
{mediaTitle}
|
||||||
|
</Text>
|
||||||
|
<Text className="opacity-50">{releaseYear}</Text>
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
transition={300}
|
||||||
|
source={{
|
||||||
|
uri: posterSrc,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="mb-4">
|
||||||
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
|
</View>
|
||||||
|
{canRequest ? (
|
||||||
|
<Button color="purple" onPress={request}>
|
||||||
|
Request
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
||||||
|
color="transparent"
|
||||||
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="warning-outline" size={24} color="white" />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Report issue
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<OverviewText text={result.overview} className="mt-4" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{result.mediaType === MediaType.TV && (
|
||||||
|
<JellyseerrSeasons
|
||||||
|
isLoading={isLoading || isFetching}
|
||||||
|
result={result as TvResult}
|
||||||
|
details={details as TvDetails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
Whats wrong?
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-col space-y-2 items-start">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
Issue Type
|
||||||
|
</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}>
|
||||||
|
{issueType
|
||||||
|
? IssueTypeName[issueType]
|
||||||
|
: "Select an issue"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={false}
|
||||||
|
side="bottom"
|
||||||
|
align="center"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
sideOffset={0}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Types</DropdownMenu.Label>
|
||||||
|
{Object.entries(IssueTypeName)
|
||||||
|
.reverse()
|
||||||
|
.map(([key, value], idx) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={value}
|
||||||
|
onSelect={() =>
|
||||||
|
setIssueType(key as unknown as IssueType)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{value}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
placeholder="(optional) Describe the issue..."
|
||||||
|
value={issueMessage}
|
||||||
|
keyboardType="default"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="none"
|
||||||
|
maxLength={254}
|
||||||
|
onChangeText={setIssueMessage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { Ratings } from "@/components/Ratings";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
@@ -70,6 +73,7 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
return res?.data.Items || [];
|
return res?.data.Items || [];
|
||||||
},
|
},
|
||||||
|
staleTime: 60,
|
||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,10 +137,7 @@ const page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4">
|
<View className="flex flex-col pt-4">
|
||||||
<View className="px-4 py-4">
|
<SeriesHeader item={item} />
|
||||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
|
||||||
<Text className="">{item?.Overview}</Text>
|
|
||||||
</View>
|
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<NextUp seriesId={seriesId} />
|
<NextUp seriesId={seriesId} />
|
||||||
</View>
|
</View>
|
||||||
@@ -141,8 +141,6 @@ const Page = () => {
|
|||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !library) return null;
|
if (!api || !library) return null;
|
||||||
|
|
||||||
console.log("[libraryId] ~", library);
|
|
||||||
|
|
||||||
let itemType: BaseItemKind | undefined;
|
let itemType: BaseItemKind | undefined;
|
||||||
|
|
||||||
// This fix makes sure to only return 1 type of items, if defined.
|
// This fix makes sure to only return 1 type of items, if defined.
|
||||||
@@ -151,6 +149,8 @@ const Page = () => {
|
|||||||
itemType = "Movie";
|
itemType = "Movie";
|
||||||
} else if (library.CollectionType === "tvshows") {
|
} else if (library.CollectionType === "tvshows") {
|
||||||
itemType = "Series";
|
itemType = "Series";
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
itemType = "BoxSet";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
@@ -161,7 +161,8 @@ const Page = () => {
|
|||||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||||
sortOrder: [sortOrder[0]],
|
sortOrder: [sortOrder[0]],
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
recursive: false,
|
// true is needed for merged versions
|
||||||
|
recursive: true,
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
@@ -29,6 +29,10 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="jellyseerr/page"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import axios from "axios";
|
|||||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
@@ -29,6 +30,15 @@ import React, {
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
||||||
|
import { sortBy } from "lodash";
|
||||||
|
|
||||||
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
const exampleSearches = [
|
const exampleSearches = [
|
||||||
"Lord of the rings",
|
"Lord of the rings",
|
||||||
@@ -45,6 +55,7 @@ export default function search() {
|
|||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||||
|
|
||||||
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
const [debouncedSearch] = useDebounce(search, 500);
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
@@ -53,6 +64,7 @@ export default function search() {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const searchEngine = useMemo(() => {
|
const searchEngine = useMemo(() => {
|
||||||
return settings?.searchEngine || "Jellyfin";
|
return settings?.searchEngine || "Jellyfin";
|
||||||
@@ -132,9 +144,51 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Movie"],
|
types: ["Movie"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
||||||
|
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await jellyseerrApi?.search({
|
||||||
|
query: new URLSearchParams(debouncedSearch).toString(),
|
||||||
|
page: 1, // todo: maybe rework page & page-size if first results are not enough...
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response?.results;
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
||||||
|
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
||||||
|
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||||
|
enabled:
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length == 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
jellyseerrResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.MOVIE
|
||||||
|
) as MovieResult[],
|
||||||
|
[jellyseerrResults]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
jellyseerrResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.TV
|
||||||
|
) as TvResult[],
|
||||||
|
[jellyseerrResults]
|
||||||
|
);
|
||||||
|
|
||||||
const { data: series, isFetching: l2 } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search", "series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -142,7 +196,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Series"],
|
types: ["Series"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: episodes, isFetching: l3 } = useQuery({
|
const { data: episodes, isFetching: l3 } = useQuery({
|
||||||
@@ -152,7 +206,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Episode"],
|
types: ["Episode"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: collections, isFetching: l7 } = useQuery({
|
const { data: collections, isFetching: l7 } = useQuery({
|
||||||
@@ -162,7 +216,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["BoxSet"],
|
types: ["BoxSet"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: actors, isFetching: l8 } = useQuery({
|
const { data: actors, isFetching: l8 } = useQuery({
|
||||||
@@ -172,7 +226,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Person"],
|
types: ["Person"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: artists, isFetching: l4 } = useQuery({
|
const { data: artists, isFetching: l4 } = useQuery({
|
||||||
@@ -182,7 +236,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["MusicArtist"],
|
types: ["MusicArtist"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: albums, isFetching: l5 } = useQuery({
|
const { data: albums, isFetching: l5 } = useQuery({
|
||||||
@@ -192,7 +246,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["MusicAlbum"],
|
types: ["MusicAlbum"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: songs, isFetching: l6 } = useQuery({
|
const { data: songs, isFetching: l6 } = useQuery({
|
||||||
@@ -202,7 +256,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Audio"],
|
types: ["Audio"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const noResults = useMemo(() => {
|
const noResults = useMemo(() => {
|
||||||
@@ -214,13 +268,25 @@ export default function search() {
|
|||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length ||
|
collections?.length ||
|
||||||
actors?.length
|
actors?.length ||
|
||||||
|
jellyseerrMovieResults?.length ||
|
||||||
|
jellyseerrTvResults?.length
|
||||||
);
|
);
|
||||||
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
}, [
|
||||||
|
artists,
|
||||||
|
episodes,
|
||||||
|
albums,
|
||||||
|
songs,
|
||||||
|
movies,
|
||||||
|
series,
|
||||||
|
collections,
|
||||||
|
actors,
|
||||||
|
jellyseerrResults,
|
||||||
|
]);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
|
||||||
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -245,6 +311,28 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{jellyseerrApi && (
|
||||||
|
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
|
<Tag
|
||||||
|
text="Library"
|
||||||
|
textClass="p-1"
|
||||||
|
className={
|
||||||
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
|
<Tag
|
||||||
|
text="Discover"
|
||||||
|
textClass="p-1"
|
||||||
|
className={
|
||||||
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{!!q && (
|
{!!q && (
|
||||||
<View className="px-4 flex flex-col space-y-2">
|
<View className="px-4 flex flex-col space-y-2">
|
||||||
<Text className="text-neutral-500 ">
|
<Text className="text-neutral-500 ">
|
||||||
@@ -252,130 +340,153 @@ export default function search() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<SearchItemWrapper
|
{searchType === "Library" && (
|
||||||
header="Movies"
|
<>
|
||||||
ids={movies?.map((m) => m.Id!)}
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
header="Movies"
|
||||||
<TouchableItemRouter
|
ids={movies?.map((m) => m.Id!)}
|
||||||
key={item.Id}
|
renderItem={(item: BaseItemDto) => (
|
||||||
className="flex flex-col w-28 mr-2"
|
<TouchableItemRouter
|
||||||
item={item}
|
key={item.Id}
|
||||||
>
|
className="flex flex-col w-28 mr-2"
|
||||||
<MoviePoster item={item} key={item.Id} />
|
item={item}
|
||||||
<Text numberOfLines={2} className="mt-2">
|
>
|
||||||
{item.Name}
|
<MoviePoster item={item} key={item.Id} />
|
||||||
</Text>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
<Text className="opacity-50 text-xs">
|
{item.Name}
|
||||||
{item.ProductionYear}
|
</Text>
|
||||||
</Text>
|
<Text className="opacity-50 text-xs">
|
||||||
</TouchableItemRouter>
|
{item.ProductionYear}
|
||||||
)}
|
</Text>
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={series?.map((m) => m.Id!)}
|
/>
|
||||||
header="Series"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={series?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Series"
|
||||||
key={item.Id}
|
renderItem={(item: BaseItemDto) => (
|
||||||
item={item}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
key={item.Id}
|
||||||
>
|
item={item}
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<Text numberOfLines={2} className="mt-2">
|
>
|
||||||
{item.Name}
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
</Text>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
<Text className="opacity-50 text-xs">
|
{item.Name}
|
||||||
{item.ProductionYear}
|
</Text>
|
||||||
</Text>
|
<Text className="opacity-50 text-xs">
|
||||||
</TouchableItemRouter>
|
{item.ProductionYear}
|
||||||
)}
|
</Text>
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
/>
|
||||||
header="Episodes"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Episodes"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-44 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<ContinueWatchingPoster item={item} />
|
className="flex flex-col w-44 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<ContinueWatchingPoster item={item} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={collections?.map((m) => m.Id!)}
|
/>
|
||||||
header="Collections"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={collections?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Collections"
|
||||||
key={item.Id}
|
renderItem={(item: BaseItemDto) => (
|
||||||
item={item}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
key={item.Id}
|
||||||
>
|
item={item}
|
||||||
<MoviePoster item={item} key={item.Id} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<Text numberOfLines={2} className="mt-2">
|
>
|
||||||
{item.Name}
|
<MoviePoster item={item} key={item.Id} />
|
||||||
</Text>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
</TouchableItemRouter>
|
{item.Name}
|
||||||
)}
|
</Text>
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={actors?.map((m) => m.Id!)}
|
/>
|
||||||
header="Actors"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={actors?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Actors"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<MoviePoster item={item} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<MoviePoster item={item} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={artists?.map((m) => m.Id!)}
|
/>
|
||||||
header="Artists"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={artists?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Artists"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<AlbumCover id={item.Id} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<AlbumCover id={item.Id} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={albums?.map((m) => m.Id!)}
|
/>
|
||||||
header="Albums"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={albums?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Albums"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<AlbumCover id={item.Id} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<AlbumCover id={item.Id} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={songs?.map((m) => m.Id!)}
|
/>
|
||||||
header="Songs"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={songs?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Songs"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<AlbumCover id={item.AlbumId} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<AlbumCover id={item.AlbumId} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{searchType === "Discover" && (
|
||||||
|
<>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header="Request Movies"
|
||||||
|
items={jellyseerrMovieResults}
|
||||||
|
renderItem={(item: MovieResult) => (
|
||||||
|
<JellyseerrPoster item={item} key={item.id} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header="Request Series"
|
||||||
|
items={jellyseerrTvResults}
|
||||||
|
renderItem={(item: TvResult) => (
|
||||||
|
<JellyseerrPoster item={item} key={item.id} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<View className="mt-4 flex justify-center items-center">
|
<View className="mt-4 flex justify-center items-center">
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -389,7 +500,7 @@ export default function search() {
|
|||||||
"{debouncedSearch}"
|
"{debouncedSearch}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : debouncedSearch.length === 0 ? (
|
) : debouncedSearch.length === 0 && searchType === "Library" ? (
|
||||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||||
{exampleSearches.map((e) => (
|
{exampleSearches.map((e) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -401,6 +512,15 @@ export default function search() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
||||||
|
<View className="flex flex-col px-4">
|
||||||
|
{sortBy?.(
|
||||||
|
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||||
|
"order"
|
||||||
|
).map((slide) => (
|
||||||
|
<DiscoverSlide key={slide.id} slide={slide} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -408,13 +528,19 @@ export default function search() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props<T> = {
|
||||||
ids?: string[] | null;
|
ids?: string[] | null;
|
||||||
renderItem: (item: BaseItemDto) => React.ReactNode;
|
items?: T[];
|
||||||
|
renderItem: (item: any) => React.ReactNode;
|
||||||
header?: string;
|
header?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
const SearchItemWrapper = <T extends unknown>({
|
||||||
|
ids,
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
header,
|
||||||
|
}: PropsWithChildren<Props<T>>) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -444,7 +570,7 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -454,7 +580,11 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
|||||||
className="px-4 mb-2"
|
className="px-4 mb-2"
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{data.map((item) => renderItem(item))}
|
{data && data?.length > 0
|
||||||
|
? data.map((item) => renderItem(item))
|
||||||
|
: items && items?.length > 0
|
||||||
|
? items.map((i) => renderItem(i))
|
||||||
|
: undefined}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/house.fill.png")
|
require("@/assets/icons/house.fill.png")
|
||||||
: () => ({ sfSymbol: "house" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "house.fill" }
|
||||||
|
: { sfSymbol: "house" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -59,7 +62,26 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
require("@/assets/icons/magnifyingglass.png")
|
||||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "magnifyingglass" }
|
||||||
|
: { sfSymbol: "magnifyingglass" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name="(favorites)"
|
||||||
|
options={{
|
||||||
|
title: "Favorites",
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS == "android"
|
||||||
|
? ({ color, focused, size }) =>
|
||||||
|
focused
|
||||||
|
? require("@/assets/icons/heart.fill.png")
|
||||||
|
: require("@/assets/icons/heart.png")
|
||||||
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "heart.fill" }
|
||||||
|
: { sfSymbol: "heart" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -70,7 +92,10 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/server.rack.png")
|
require("@/assets/icons/server.rack.png")
|
||||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
|
: { sfSymbol: "rectangle.stack" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -81,8 +106,11 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? () => require("@/assets/icons/list.png")
|
? ({ focused }) => require("@/assets/icons/list.png")
|
||||||
: () => ({ sfSymbol: "list.dash" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
: { sfSymbol: "list.dash" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -282,13 +282,6 @@ export default function page() {
|
|||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
if (!item?.Id || !stream) return;
|
||||||
|
|
||||||
console.log(
|
|
||||||
"onProgress ~",
|
|
||||||
currentTimeInTicks,
|
|
||||||
isPlaying,
|
|
||||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
itemId: item.Id,
|
itemId: item.Id,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
|||||||
46
app/(auth)/trailer/page.tsx
Normal file
46
app/(auth)/trailer/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useGlobalSearchParams } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, Dimensions, View } from "react-native";
|
||||||
|
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const searchParams = useGlobalSearchParams();
|
||||||
|
console.log(searchParams);
|
||||||
|
|
||||||
|
const { url } = searchParams as { url: string };
|
||||||
|
|
||||||
|
const videoId = useMemo(() => {
|
||||||
|
return url.split("v=")[1];
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
|
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
||||||
|
if (state === "ended") {
|
||||||
|
setPlaying(false);
|
||||||
|
Alert.alert("video has finished playing!");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlaying = useCallback(() => {
|
||||||
|
setPlaying((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
togglePlaying();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col bg-black items-center justify-center h-full">
|
||||||
|
<YoutubePlayer
|
||||||
|
height={300}
|
||||||
|
play={playing}
|
||||||
|
videoId={videoId}
|
||||||
|
onChangeState={onStateChange}
|
||||||
|
width={screenWidth}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "@/augmentations";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
@@ -35,7 +37,7 @@ import * as SplashScreen from "expo-splash-screen";
|
|||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Appearance, AppState } from "react-native";
|
import { Appearance, AppState, TouchableOpacity } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
@@ -334,6 +336,14 @@ function Layout() {
|
|||||||
header: () => null,
|
header: () => null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/trailer/page"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "modal",
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
BIN
assets/icons/heart.fill.png
Normal file
BIN
assets/icons/heart.fill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/heart.png
Normal file
BIN
assets/icons/heart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
65
assets/images/not-rotten-tomatoes.svg
Normal file
65
assets/images/not-rotten-tomatoes.svg
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<svg
|
||||||
|
type="certified"
|
||||||
|
viewBox="0 0 80 80"
|
||||||
|
preserveAspectRatio="xMidYMid"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
>
|
||||||
|
<g transform="translate(2.29, 0)">
|
||||||
|
<path
|
||||||
|
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
||||||
|
id="Fill-2"
|
||||||
|
fill="#00912D"
|
||||||
|
></path>
|
||||||
|
<mask id="mask-2" fill="white">
|
||||||
|
<polygon
|
||||||
|
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
||||||
|
></polygon>
|
||||||
|
</mask>
|
||||||
|
<path
|
||||||
|
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
||||||
|
fill="#FFD700"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
||||||
|
fill="#FA6E0F"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
||||||
|
fill="#0AC855"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
||||||
|
fill="#0B4902"
|
||||||
|
></path>
|
||||||
|
<g transform="translate(0, 20.57)">
|
||||||
|
<mask id="mask-4" fill="white">
|
||||||
|
<polygon
|
||||||
|
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
||||||
|
></polygon>
|
||||||
|
</mask>
|
||||||
|
<path
|
||||||
|
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
||||||
|
fill="#FA3200"
|
||||||
|
mask="url(#mask-4)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
||||||
|
fill="#0AC855"
|
||||||
|
mask="url(#mask-4)"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
||||||
|
fill="#00912D"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
||||||
|
fill="#0B4902"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.4 KiB |
3
augmentations/index.ts
Normal file
3
augmentations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./mmkv";
|
||||||
|
export * from "./number";
|
||||||
|
export * from "./string";
|
||||||
17
augmentations/mmkv.ts
Normal file
17
augmentations/mmkv.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {MMKV} from "react-native-mmkv";
|
||||||
|
|
||||||
|
declare module "react-native-mmkv" {
|
||||||
|
interface MMKV {
|
||||||
|
get<T>(key: string): T | undefined
|
||||||
|
setAny(key: string, value: any | undefined): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MMKV.prototype.get = function <T> (key: string): T | undefined {
|
||||||
|
const serializedItem = this.getString(key);
|
||||||
|
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
37
augmentations/number.ts
Normal file
37
augmentations/number.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
declare global {
|
||||||
|
interface Number {
|
||||||
|
bytesToReadable(): string;
|
||||||
|
secondsToMilliseconds(): number
|
||||||
|
minutesToMilliseconds(): number
|
||||||
|
hoursToMilliseconds(): number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Number.prototype.bytesToReadable = function () {
|
||||||
|
const bytes = this.valueOf();
|
||||||
|
const gb = bytes / 1e9;
|
||||||
|
|
||||||
|
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||||
|
|
||||||
|
const mb = bytes / 1024.0 / 1024.0;
|
||||||
|
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||||
|
|
||||||
|
const kb = bytes / 1024.0;
|
||||||
|
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||||
|
|
||||||
|
return `${bytes.toFixed(2)} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
|
return this.valueOf() * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
Number.prototype.minutesToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).secondsToMilliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).minutesToMilliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
16
augmentations/string.ts
Normal file
16
augmentations/string.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
declare global {
|
||||||
|
interface String {
|
||||||
|
toTitle(): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toTitle = function () {
|
||||||
|
return this
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -47,7 +47,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className={`
|
className={`
|
||||||
p-3 rounded-xl items-center justify-center
|
p-3 rounded-xl items-center justify-center
|
||||||
${loading || (disabled && "opacity-50")}
|
${(loading || disabled) && "opacity-50"}
|
||||||
${colorClasses}
|
${colorClasses}
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View } from "react-native";
|
import {View, ViewProps} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface GenreTagsProps {
|
interface TagProps {
|
||||||
genres?: string[];
|
tags?: string[];
|
||||||
|
textClass?: ViewProps["className"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
||||||
if (!genres || genres.length === 0) return null;
|
text,
|
||||||
|
textClass,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||||
|
<Text className={textClass}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
|
||||||
|
if (!tags || tags.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row flex-wrap mt-2">
|
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||||
{genres.map((genre, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
|
<View>
|
||||||
<Text className="text-xs">{genre}</Text>
|
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
|
||||||
|
return (
|
||||||
|
<View className="mt-2">
|
||||||
|
<Tags tags={genres}/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
MediaStream,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
@@ -31,10 +31,9 @@ import { View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
import { Chromecast } from "./Chromecast";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -68,7 +67,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(defaultAudioIndex, defaultSubtitleIndex);
|
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource,
|
||||||
@@ -220,7 +218,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
className="mr-1"
|
className="mr-1"
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
console.log(val);
|
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
(prev) =>
|
(prev) =>
|
||||||
prev && {
|
prev && {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { GenreTags } from "./GenreTags";
|
||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
import { Ratings } from "./Ratings";
|
import { Ratings } from "./Ratings";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
import { GenreTags } from "./GenreTags";
|
import { ItemActions } from "./series/SeriesActions";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -27,7 +28,10 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Ratings item={item} className="mb-2" />
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
<Ratings item={item} className="mb-2" />
|
||||||
|
<ItemActions item={item} />
|
||||||
|
</View>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<>
|
<>
|
||||||
<EpisodeTitleHeader item={item} />
|
<EpisodeTitleHeader item={item} />
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<View className="flex flex-col overflow-visible">
|
<View className="flex flex-col overflow-visible">
|
||||||
<Text className="font-bold ">{title}</Text>
|
<Text className="font-bold ">{title}</Text>
|
||||||
{subTitle && (
|
{subTitle && (
|
||||||
<Text uiTextView selectable className="text-xs">
|
<Text uiTextView selectable className="text-xs text-neutral-400">
|
||||||
{subTitle}
|
{subTitle}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { View, ViewProps } from "react-native";
|
|||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -17,7 +21,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
)}
|
)}
|
||||||
{item.CommunityRating && (
|
{item.CommunityRating && (
|
||||||
<Badge
|
<Badge
|
||||||
text={item.CommunityRating}
|
text={item.CommunityRating.toFixed(1)}
|
||||||
variant="gray"
|
variant="gray"
|
||||||
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
||||||
/>
|
/>
|
||||||
@@ -28,7 +32,11 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
variant="gray"
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/images/rotten-tomatoes.png")}
|
source={
|
||||||
|
item.CriticRating < 60
|
||||||
|
? require("@/assets/images/rotten-tomatoes.png")
|
||||||
|
: require("@/assets/images/not-rotten-tomatoes.svg")
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
@@ -40,3 +48,86 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
|
||||||
|
queryFn: async () => {
|
||||||
|
return result.mediaType === MediaType.MOVIE
|
||||||
|
? jellyseerrApi?.movieRatings(result.id)
|
||||||
|
: jellyseerrApi?.tvRatings(result.id);
|
||||||
|
},
|
||||||
|
staleTime: (5).minutesToMilliseconds(),
|
||||||
|
retry: false,
|
||||||
|
enabled: !!jellyseerrApi,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
(isLoading ||
|
||||||
|
!!result.voteCount ||
|
||||||
|
(data?.criticsRating && !!data?.criticsScore) ||
|
||||||
|
(data?.audienceRating && !!data?.audienceScore)) && (
|
||||||
|
<View className="flex flex-row flex-wrap space-x-1">
|
||||||
|
{data?.criticsRating && !!data?.criticsScore && (
|
||||||
|
<Badge
|
||||||
|
text={`${data.criticsScore}%`}
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Image
|
||||||
|
className="mr-1"
|
||||||
|
source={
|
||||||
|
data?.criticsRating === "Rotten"
|
||||||
|
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
||||||
|
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data?.audienceRating && !!data?.audienceScore && (
|
||||||
|
<Badge
|
||||||
|
text={`${data.audienceScore}%`}
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Image
|
||||||
|
className="mr-1"
|
||||||
|
source={
|
||||||
|
data?.audienceRating === "Spilled"
|
||||||
|
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||||
|
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!result.voteCount && (
|
||||||
|
<Badge
|
||||||
|
text={`${Math.round(result.voteAverage * 10)}%`}
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Image
|
||||||
|
className="mr-1"
|
||||||
|
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
onPress: () => void;
|
onPress?: () => void,
|
||||||
icon?: keyof typeof Ionicons.glyphMap;
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
background?: boolean;
|
background?: boolean;
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
@@ -34,7 +34,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
if (hapticFeedback) {
|
if (hapticFeedback) {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
}
|
}
|
||||||
onPress();
|
onPress?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fillColor)
|
if (fillColor)
|
||||||
|
|||||||
103
components/common/JellyseerrItemRouter.tsx
Normal file
103
components/common/JellyseerrItemRouter.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {useRouter, useSegments} from "expo-router";
|
||||||
|
import React, {PropsWithChildren, useCallback, useMemo} from "react";
|
||||||
|
import {TouchableOpacity, TouchableOpacityProps} from "react-native";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
|
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
result: MovieResult | TvResult;
|
||||||
|
mediaTitle: string;
|
||||||
|
releaseYear: number;
|
||||||
|
canRequest: boolean;
|
||||||
|
posterSrc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
result,
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest,
|
||||||
|
posterSrc,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
|
||||||
|
|
||||||
|
const from = segments[2];
|
||||||
|
|
||||||
|
const autoApprove = useMemo(() => {
|
||||||
|
return jellyseerrUser && hasPermission(
|
||||||
|
Permission.AUTO_APPROVE,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
{type: 'or'}
|
||||||
|
)
|
||||||
|
}, [jellyseerrApi, jellyseerrUser])
|
||||||
|
|
||||||
|
const request = useCallback(() =>
|
||||||
|
requestMedia(mediaTitle, {
|
||||||
|
mediaId: result.id,
|
||||||
|
mediaType: result.mediaType
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[jellyseerrApi, result]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ContextMenu.Root>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
// @ts-ignore
|
||||||
|
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Content
|
||||||
|
avoidCollisions
|
||||||
|
alignOffset={0}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={false}
|
||||||
|
key={"content"}
|
||||||
|
>
|
||||||
|
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||||
|
{canRequest && result.mediaType === MediaType.MOVIE && (
|
||||||
|
<ContextMenu.Item
|
||||||
|
key="item-1"
|
||||||
|
onSelect={() => {
|
||||||
|
if (autoApprove) {
|
||||||
|
request()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
shouldDismissMenuOnSelect
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle>
|
||||||
|
<ContextMenu.ItemIcon
|
||||||
|
ios={{
|
||||||
|
name: "arrow.down.to.line",
|
||||||
|
pointSize: 18,
|
||||||
|
weight: "semibold",
|
||||||
|
scale: "medium",
|
||||||
|
hierarchicalColor: {
|
||||||
|
dark: "purple",
|
||||||
|
light: "purple",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
androidIconName="download"
|
||||||
|
/>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
)}
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -66,7 +66,12 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (
|
||||||
|
from === "(home)" ||
|
||||||
|
from === "(search)" ||
|
||||||
|
from === "(libraries)" ||
|
||||||
|
from === "(favorites)"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Trigger>
|
<ContextMenu.Trigger>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { TextProps } from "react-native";
|
import { TextProps } from "react-native";
|
||||||
@@ -29,7 +29,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
s += size;
|
s += size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSize(bytesToReadable(s));
|
setSize(s.bytesToReadable());
|
||||||
}, [itemIds]);
|
}, [itemIds]);
|
||||||
|
|
||||||
const sizeText = useMemo(() => {
|
const sizeText = useMemo(() => {
|
||||||
|
|||||||
119
components/home/Favorites.tsx
Normal file
119
components/home/Favorites.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ScrollingCollectionList } from "./ScrollingCollectionList";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
export const Favorites = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const fetchFavoritesByType = useCallback(
|
||||||
|
async (itemType: BaseItemKind) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id!,
|
||||||
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
filters: ["IsFavorite"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeLocationTypes: ["Virtual"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
limit: 20,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
[api, user]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchFavoriteSeries = useCallback(
|
||||||
|
() => fetchFavoritesByType("Series"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteMovies = useCallback(
|
||||||
|
() => fetchFavoritesByType("Movie"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteEpisodes = useCallback(
|
||||||
|
() => fetchFavoritesByType("Episode"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteVideos = useCallback(
|
||||||
|
() => fetchFavoritesByType("Video"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteBoxsets = useCallback(
|
||||||
|
() => fetchFavoritesByType("BoxSet"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoritePlaylists = useCallback(
|
||||||
|
() => fetchFavoritesByType("Playlist"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteMusicAlbum = useCallback(
|
||||||
|
() => fetchFavoritesByType("MusicAlbum"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteAudio = useCallback(
|
||||||
|
() => fetchFavoritesByType("Audio"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col space-y-4">
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteSeries}
|
||||||
|
queryKey={["home", "favorites", "series"]}
|
||||||
|
title="Series"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteMovies}
|
||||||
|
queryKey={["home", "favorites", "movies"]}
|
||||||
|
title="Movies"
|
||||||
|
hideIfEmpty
|
||||||
|
orientation="vertical"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteEpisodes}
|
||||||
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
|
title="Episodes"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteVideos}
|
||||||
|
queryKey={["home", "favorites", "videos"]}
|
||||||
|
title="Videos"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteBoxsets}
|
||||||
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
|
title="Boxsets"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoritePlaylists}
|
||||||
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
|
title="Playlists"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteMusicAlbum}
|
||||||
|
queryKey={["home", "favorites", "musicAlbums"]}
|
||||||
|
title="Music Albums"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteAudio}
|
||||||
|
queryKey={["home", "favorites", "audio"]}
|
||||||
|
title="Audio"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ interface Props extends ViewProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
hideIfEmpty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -26,10 +27,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// console.log(queryKey);
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
@@ -41,8 +41,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
|
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="">
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -82,15 +84,13 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{data?.map((item, index) => (
|
{data?.map((item) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={index}
|
key={item.Id}
|
||||||
className={`
|
className={`mr-2
|
||||||
mr-2
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
|
`}
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
|
|||||||
72
components/icons/JellyseerrIconStatus.tsx
Normal file
72
components/icons/JellyseerrIconStatus.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
|
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||||
|
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mediaStatus?: MediaStatus;
|
||||||
|
showRequestIcon: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
||||||
|
mediaStatus,
|
||||||
|
showRequestIcon,
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [badgeIcon, setBadgeIcon] = useState<keyof typeof MaterialCommunityIcons.glyphMap>();
|
||||||
|
const [badgeStyle, setBadgeStyle] = useState<string>();
|
||||||
|
|
||||||
|
// Match similar to what Jellyseerr is currently using
|
||||||
|
// https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4
|
||||||
|
useEffect(() => {
|
||||||
|
switch (mediaStatus) {
|
||||||
|
case MediaStatus.PROCESSING:
|
||||||
|
setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100');
|
||||||
|
setBadgeIcon('clock');
|
||||||
|
break;
|
||||||
|
case MediaStatus.AVAILABLE:
|
||||||
|
setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100');
|
||||||
|
setBadgeIcon('check')
|
||||||
|
break;
|
||||||
|
case MediaStatus.PENDING:
|
||||||
|
setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100');
|
||||||
|
setBadgeIcon('bell')
|
||||||
|
break;
|
||||||
|
case MediaStatus.BLACKLISTED:
|
||||||
|
setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white');
|
||||||
|
setBadgeIcon('eye-off')
|
||||||
|
break;
|
||||||
|
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||||
|
setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100');
|
||||||
|
setBadgeIcon("minus");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (showRequestIcon) {
|
||||||
|
setBadgeStyle('bg-green-600');
|
||||||
|
setBadgeIcon("plus")
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon])
|
||||||
|
|
||||||
|
return (
|
||||||
|
badgeIcon &&
|
||||||
|
<TouchableOpacity onPress={onPress} disabled={onPress == undefined}>
|
||||||
|
<View
|
||||||
|
className={`${badgeStyle ?? 'bg-purple-600'} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={badgeIcon}
|
||||||
|
size={18}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JellyseerrIconStatus;
|
||||||
100
components/jellyseerr/DiscoverSlide.tsx
Normal file
100
components/jellyseerr/DiscoverSlide.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import {
|
||||||
|
DiscoverEndpoint,
|
||||||
|
Endpoints,
|
||||||
|
useJellyseerr,
|
||||||
|
} from "@/hooks/useJellyseerr";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
slide: DiscoverSlider;
|
||||||
|
}
|
||||||
|
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ["jellyseerr", "discover", slide.id],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
let endpoint: DiscoverEndpoint | undefined = undefined;
|
||||||
|
let params: any = {
|
||||||
|
page: Number(pageParam),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (slide.type) {
|
||||||
|
case DiscoverSliderType.TRENDING:
|
||||||
|
endpoint = Endpoints.DISCOVER_TRENDING;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
|
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||||
|
endpoint = Endpoints.DISCOVER_MOVIES;
|
||||||
|
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_TV:
|
||||||
|
case DiscoverSliderType.UPCOMING_TV:
|
||||||
|
endpoint = Endpoints.DISCOVER_TV;
|
||||||
|
if (slide.type === DiscoverSliderType.UPCOMING_TV)
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
firstAirDateGte: new Date().toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, pages) =>
|
||||||
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
|
1,
|
||||||
|
enabled: !!jellyseerrApi,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(
|
||||||
|
() =>
|
||||||
|
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
flatData &&
|
||||||
|
flatData?.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="font-bold text-lg mb-2">
|
||||||
|
{DiscoverSliderType[slide.type].toString().toTitle()}
|
||||||
|
</Text>
|
||||||
|
<FlashList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
keyExtractor={(item) => item!!.id.toString()}
|
||||||
|
estimatedItemSize={250}
|
||||||
|
data={flatData}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) fetchNextPage();
|
||||||
|
}}
|
||||||
|
renderItem={({ item }) =>
|
||||||
|
item ? (
|
||||||
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverSlide;
|
||||||
92
components/posters/JellyseerrPoster.tsx
Normal file
92
components/posters/JellyseerrPoster.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {View, ViewProps} from "react-native";
|
||||||
|
import {Image} from "expo-image";
|
||||||
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import {useEffect, useMemo, useState} from "react";
|
||||||
|
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
|
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
||||||
|
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: MovieResult | TvResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JellyseerrPoster: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
|
||||||
|
// const imageSource =
|
||||||
|
|
||||||
|
const imageSrc = useMemo(() =>
|
||||||
|
item.posterPath ?
|
||||||
|
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
|
||||||
|
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
|
||||||
|
[item, jellyseerrApi]
|
||||||
|
)
|
||||||
|
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
|
||||||
|
const releaseYear = useMemo(() =>
|
||||||
|
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
|
||||||
|
[item]
|
||||||
|
)
|
||||||
|
|
||||||
|
const showRequestButton = useMemo(() =>
|
||||||
|
jellyseerrUser && hasPermission(
|
||||||
|
[
|
||||||
|
Permission.REQUEST,
|
||||||
|
item.mediaType === 'movie'
|
||||||
|
? Permission.REQUEST_MOVIE
|
||||||
|
: Permission.REQUEST_TV,
|
||||||
|
],
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
{type: 'or'}
|
||||||
|
),
|
||||||
|
[item, jellyseerrUser]
|
||||||
|
)
|
||||||
|
|
||||||
|
const canRequest = useMemo(() => {
|
||||||
|
const status = item?.mediaInfo?.status
|
||||||
|
return showRequestButton && !status || status === MediaStatus.UNKNOWN
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableJellyseerrRouter
|
||||||
|
result={item}
|
||||||
|
mediaTitle={title}
|
||||||
|
releaseYear={releaseYear}
|
||||||
|
canRequest={canRequest}
|
||||||
|
posterSrc={imageSrc}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col w-28 mr-2">
|
||||||
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||||
|
<Image
|
||||||
|
key={item.id}
|
||||||
|
id={item.id.toString()}
|
||||||
|
source={{uri: imageSrc}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<JellyseerrIconStatus
|
||||||
|
className="absolute bottom-1 right-1"
|
||||||
|
showRequestIcon={canRequest}
|
||||||
|
mediaStatus={item?.mediaInfo?.status}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
<View className="mt-2 flex flex-col">
|
||||||
|
<Text numberOfLines={2}>{title}</Text>
|
||||||
|
<Text className="text-xs opacity-50">{releaseYear}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableJellyseerrRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default JellyseerrPoster;
|
||||||
275
components/series/JellyseerrSeasons.tsx
Normal file
275
components/series/JellyseerrSeasons.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { orderBy } from "lodash";
|
||||||
|
import { Tags } from "@/components/GenreTags";
|
||||||
|
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||||
|
import Season from "@/utils/jellyseerr/server/entity/Season";
|
||||||
|
import {
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
|
const JellyseerrSeasonEpisodes: React.FC<{
|
||||||
|
details: TvDetails;
|
||||||
|
seasonNumber: number;
|
||||||
|
}> = ({ details, seasonNumber }) => {
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
|
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
|
||||||
|
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
|
||||||
|
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HorizontalScroll
|
||||||
|
horizontal
|
||||||
|
loading={isLoading}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
estimatedItemSize={50}
|
||||||
|
data={seasonWithEpisodes?.episodes}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={(item, index) => (
|
||||||
|
<RenderItem key={index} item={item} index={index} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RenderItem = ({ item, index }: any) => {
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col w-44 mt-2">
|
||||||
|
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||||
|
{!imageError ? (
|
||||||
|
<Image
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
className="w-full h-full"
|
||||||
|
onError={(e) => {
|
||||||
|
setImageError(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
|
||||||
|
<Ionicons
|
||||||
|
name="image-outline"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="shrink mt-1">
|
||||||
|
<Text numberOfLines={2} className="">
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||||
|
{`S${item.seasonNumber}:E${item.episodeNumber}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||||
|
{item.overview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const JellyseerrSeasons: React.FC<{
|
||||||
|
isLoading: boolean;
|
||||||
|
result?: TvResult;
|
||||||
|
details?: TvDetails;
|
||||||
|
}> = ({ isLoading, result, details }) => {
|
||||||
|
if (!details) return null;
|
||||||
|
|
||||||
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
const [seasonStates, setSeasonStates] = useState<{
|
||||||
|
[key: number]: boolean;
|
||||||
|
}>();
|
||||||
|
const seasons = useMemo(() => {
|
||||||
|
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
|
||||||
|
(s: Season) => s.seasonNumber !== 0
|
||||||
|
);
|
||||||
|
const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
|
||||||
|
(r: MediaRequest) => r.seasons
|
||||||
|
);
|
||||||
|
return details.seasons?.map((season) => {
|
||||||
|
return {
|
||||||
|
...season,
|
||||||
|
status:
|
||||||
|
// What our library status is
|
||||||
|
mediaInfoSeasons?.find(
|
||||||
|
(mediaSeason: Season) =>
|
||||||
|
mediaSeason.seasonNumber === season.seasonNumber
|
||||||
|
)?.status ??
|
||||||
|
// What our request status is
|
||||||
|
requestedSeasons?.find(
|
||||||
|
(s: Season) => s.seasonNumber === season.seasonNumber
|
||||||
|
)?.status ??
|
||||||
|
// Otherwise set it as unknown
|
||||||
|
MediaStatus.UNKNOWN,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const allSeasonsAvailable = useMemo(
|
||||||
|
() => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||||
|
[seasons]
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestAll = useCallback(() => {
|
||||||
|
if (details && jellyseerrApi) {
|
||||||
|
requestMedia(result?.name!!, {
|
||||||
|
mediaId: details.id,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
|
seasons: seasons
|
||||||
|
.filter(
|
||||||
|
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
|
||||||
|
)
|
||||||
|
.map((s) => s.seasonNumber),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, seasons, details]);
|
||||||
|
|
||||||
|
const promptRequestAll = useCallback(
|
||||||
|
() =>
|
||||||
|
Alert.alert("Confirm", "Are you sure you want to request all seasons?", [
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Yes",
|
||||||
|
onPress: requestAll,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
[requestAll]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className="flex flex-row justify-between items-end px-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Seasons</Text>
|
||||||
|
{!allSeasonsAvailable && (
|
||||||
|
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||||
|
<Ionicons name="bag-add" color="white" size={26} />
|
||||||
|
</RoundButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={orderBy(
|
||||||
|
details.seasons.filter((s) => s.seasonNumber !== 0),
|
||||||
|
"seasonNumber",
|
||||||
|
"desc"
|
||||||
|
)}
|
||||||
|
ListHeaderComponent={() => (
|
||||||
|
<View className="flex flex-row justify-between items-end px-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Seasons</Text>
|
||||||
|
{!allSeasonsAvailable && (
|
||||||
|
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||||
|
<Ionicons name="bag-add" color="white" size={26} />
|
||||||
|
</RoundButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||||
|
estimatedItemSize={250}
|
||||||
|
renderItem={({ item: season }) => (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
setSeasonStates((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
[season.seasonNumber]: !prevState?.[season.seasonNumber],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
|
||||||
|
key={season.id}
|
||||||
|
>
|
||||||
|
<Tags
|
||||||
|
textClass=""
|
||||||
|
tags={[
|
||||||
|
`Season ${season.seasonNumber}`,
|
||||||
|
`${season.episodeCount} Episodes`,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{[0].map(() => {
|
||||||
|
const canRequest =
|
||||||
|
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
||||||
|
?.status === MediaStatus.UNKNOWN;
|
||||||
|
return (
|
||||||
|
<JellyseerrIconStatus
|
||||||
|
key={0}
|
||||||
|
onPress={
|
||||||
|
canRequest
|
||||||
|
? () =>
|
||||||
|
requestMedia(
|
||||||
|
`${result?.name!!}, Season ${
|
||||||
|
season.seasonNumber
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
mediaId: details.id,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
|
seasons: [season.seasonNumber],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||||
|
mediaStatus={
|
||||||
|
seasons?.find(
|
||||||
|
(s) => s.seasonNumber === season.seasonNumber
|
||||||
|
)?.status
|
||||||
|
}
|
||||||
|
showRequestIcon={canRequest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{seasonStates?.[season.seasonNumber] && (
|
||||||
|
<JellyseerrSeasonEpisodes
|
||||||
|
key={season.seasonNumber}
|
||||||
|
details={details}
|
||||||
|
seasonNumber={season.seasonNumber}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JellyseerrSeasons;
|
||||||
@@ -11,6 +11,7 @@ import { Text } from "../common/Text";
|
|||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
|
||||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -43,10 +44,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
<Text className="text-lg font-bold px-4 mb-2">Next up</Text>
|
||||||
<HorizontalScroll
|
<FlashList
|
||||||
|
contentContainerStyle={{ paddingLeft: 16 }}
|
||||||
|
horizontal
|
||||||
|
estimatedItemSize={172}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
data={items}
|
data={items}
|
||||||
renderItem={(item, index) => (
|
renderItem={({ item, index }) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type SeasonKeys = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SeasonIndexState = {
|
export type SeasonIndexState = {
|
||||||
[seriesId: string]: number | null | undefined;
|
[seriesId: string]: number | string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SeasonDropdown: React.FC<Props> = ({
|
export const SeasonDropdown: React.FC<Props> = ({
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
const seasonIndex = useMemo(
|
||||||
|
() => seasonIndexState[item.Id ?? ""],
|
||||||
|
[item, seasonIndexState]
|
||||||
|
);
|
||||||
|
|
||||||
const { data: seasons } = useQuery({
|
const { data: seasons } = useQuery({
|
||||||
queryKey: ["seasons", item.Id],
|
queryKey: ["seasons", item.Id],
|
||||||
@@ -53,19 +56,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
|
|
||||||
return response.data.Items;
|
return response.data.Items;
|
||||||
},
|
},
|
||||||
|
staleTime: 60,
|
||||||
enabled: !!api && !!user?.Id && !!item.Id,
|
enabled: !!api && !!user?.Id && !!item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedSeasonId: string | null = useMemo(
|
const selectedSeasonId: string | null = useMemo(() => {
|
||||||
() =>
|
const season: BaseItemDto = seasons?.find(
|
||||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
(s: BaseItemDto) =>
|
||||||
[seasons, seasonIndex]
|
s.IndexNumber === seasonIndex || s.Name === seasonIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!season?.Id) return null;
|
||||||
|
|
||||||
|
return season.Id!;
|
||||||
|
}, [seasons, seasonIndex]);
|
||||||
|
|
||||||
const { data: episodes, isFetching } = useQuery({
|
const { data: episodes, isFetching } = useQuery({
|
||||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const res = await getTvShowsApi(api).getEpisodes({
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
seriesId: item.Id,
|
seriesId: item.Id,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
@@ -74,6 +86,12 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.data.TotalRecordCount === 0)
|
||||||
|
console.warn(
|
||||||
|
"No episodes found for season with ID ~",
|
||||||
|
selectedSeasonId
|
||||||
|
);
|
||||||
|
|
||||||
return res.data.Items;
|
return res.data.Items;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||||
@@ -118,25 +136,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
seasons={seasons}
|
seasons={seasons}
|
||||||
state={seasonIndexState}
|
state={seasonIndexState}
|
||||||
onSelect={(season) => {
|
onSelect={(season) => {
|
||||||
|
if (!item.Id) return;
|
||||||
setSeasonIndexState((prev) => ({
|
setSeasonIndexState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[item.Id ?? ""]: season.IndexNumber,
|
[item.Id!]: season.IndexNumber ?? season.Name,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<DownloadItems
|
{episodes?.length || 0 > 0 ? (
|
||||||
title="Download Season"
|
<DownloadItems
|
||||||
className="ml-2"
|
title="Download Season"
|
||||||
items={episodes || []}
|
className="ml-2"
|
||||||
MissingDownloadIconComponent={() => (
|
items={episodes || []}
|
||||||
<Ionicons name="download" size={20} color="white" />
|
MissingDownloadIconComponent={() => (
|
||||||
)}
|
<Ionicons name="download" size={20} color="white" />
|
||||||
DownloadedIconComponent={() => (
|
)}
|
||||||
<Ionicons name="download" size={20} color="#9333ea" />
|
DownloadedIconComponent={() => (
|
||||||
)}
|
<Ionicons name="download" size={20} color="#9333ea" />
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className="px-4 flex flex-col my-4">
|
<View className="px-4 flex flex-col mt-4">
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -186,6 +207,13 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{(episodes?.length || 0) === 0 ? (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
No episodes for this season
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
32
components/series/SeriesActions.tsx
Normal file
32
components/series/SeriesActions.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemActions = ({ item, ...props }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
|
||||||
|
|
||||||
|
const openTrailer = useCallback(async () => {
|
||||||
|
if (!trailerLink) return;
|
||||||
|
|
||||||
|
const encodedTrailerLink = encodeURIComponent(trailerLink);
|
||||||
|
router.push(`/trailer/page?url=${encodedTrailerLink}`);
|
||||||
|
}, [router, trailerLink]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="" {...props}>
|
||||||
|
{trailerLink && (
|
||||||
|
<TouchableOpacity onPress={openTrailer}>
|
||||||
|
<Ionicons name="film-outline" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
components/series/SeriesHeader.tsx
Normal file
64
components/series/SeriesHeader.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { Ratings } from "../Ratings";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ItemActions } from "./SeriesActions";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SeriesHeader = ({ item }: Props) => {
|
||||||
|
const startYear = useMemo(() => {
|
||||||
|
if (item?.StartDate) {
|
||||||
|
return new Date(item.StartDate)
|
||||||
|
.toLocaleDateString("sv-SE", {
|
||||||
|
calendar: "gregory",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return item.ProductionYear?.toString().trim();
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const endYear = useMemo(() => {
|
||||||
|
if (item.EndDate) {
|
||||||
|
return new Date(item.EndDate)
|
||||||
|
.toLocaleDateString("sv-SE", {
|
||||||
|
calendar: "gregory",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const yearString = useMemo(() => {
|
||||||
|
if (startYear && endYear) {
|
||||||
|
if (startYear === endYear) return startYear;
|
||||||
|
return `${startYear} - ${endYear}`;
|
||||||
|
}
|
||||||
|
if (startYear) {
|
||||||
|
return startYear;
|
||||||
|
}
|
||||||
|
if (endYear) {
|
||||||
|
return endYear;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [startYear, endYear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4 py-4">
|
||||||
|
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||||
|
<Text className="">{yearString}</Text>
|
||||||
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
<Ratings item={item} className="mb-2" />
|
||||||
|
<ItemActions item={item} />
|
||||||
|
</View>
|
||||||
|
<Text className="">{item?.Overview}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
207
components/settings/Jellyseerr.tsx
Normal file
207
components/settings/Jellyseerr.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
import { ListItem } from "../ListItem";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const JellyseerrSettings = () => {
|
||||||
|
const {
|
||||||
|
jellyseerrApi,
|
||||||
|
jellyseerrUser,
|
||||||
|
setJellyseerrUser,
|
||||||
|
clearAllJellyseerData,
|
||||||
|
} = useJellyseerr();
|
||||||
|
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(settings?.jellyseerrServerUrl || undefined);
|
||||||
|
|
||||||
|
const loginToJellyseerrMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
|
||||||
|
throw new Error("Missing required information for login");
|
||||||
|
}
|
||||||
|
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||||
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
|
||||||
|
},
|
||||||
|
onSuccess: (user) => {
|
||||||
|
setJellyseerrUser(user);
|
||||||
|
updateSettings({ jellyseerrServerUrl });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to login");
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setJellyseerrPassword(undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testJellyseerrServerUrlMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!jellyseerrServerUrl || jellyseerrApi) return null;
|
||||||
|
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||||
|
return jellyseerrTempApi.test();
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result && result.isValid) {
|
||||||
|
if (result.requiresPass) {
|
||||||
|
setPromptForJellyseerrPass(true);
|
||||||
|
} else {
|
||||||
|
updateSettings({ jellyseerrServerUrl });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPromptForJellyseerrPass(false);
|
||||||
|
setjellyseerrServerUrl(undefined);
|
||||||
|
clearAllJellyseerData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearData = () => {
|
||||||
|
clearAllJellyseerData().finally(() => {
|
||||||
|
setjellyseerrServerUrl(undefined);
|
||||||
|
setPromptForJellyseerrPass(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mt-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
|
||||||
|
<View>
|
||||||
|
{jellyseerrUser ? (
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
|
||||||
|
<ListItem
|
||||||
|
title="Total media requests"
|
||||||
|
subTitle={jellyseerrUser?.requestCount?.toString()}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Movie quota limit"
|
||||||
|
subTitle={
|
||||||
|
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Movie quota days"
|
||||||
|
subTitle={
|
||||||
|
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="TV quota limit"
|
||||||
|
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="TV quota days"
|
||||||
|
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
||||||
|
/>
|
||||||
|
<View className="p-4">
|
||||||
|
<Button color="red" onPress={clearData}>
|
||||||
|
Reset Jellyseerr config
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
||||||
|
<Text className="text-xs text-red-600 mb-2">
|
||||||
|
This integration is in its early stages. Expect things to change.
|
||||||
|
</Text>
|
||||||
|
<Text className="font-bold mb-1">Server URL</Text>
|
||||||
|
<View className="flex flex-col shrink mb-2">
|
||||||
|
<Text className="text-xs text-gray-600">
|
||||||
|
Example: http(s)://your-host.url
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-600">
|
||||||
|
(add port if required)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Input
|
||||||
|
placeholder="Jellyseerr URL..."
|
||||||
|
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
||||||
|
defaultValue={
|
||||||
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
|
}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={setjellyseerrServerUrl}
|
||||||
|
editable={!testJellyseerrServerUrlMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={testJellyseerrServerUrlMutation.isPending}
|
||||||
|
disabled={testJellyseerrServerUrlMutation.isPending}
|
||||||
|
color={promptForJellyseerrPass ? "red" : "purple"}
|
||||||
|
className="h-12 mt-2"
|
||||||
|
onPress={() => {
|
||||||
|
if (promptForJellyseerrPass) {
|
||||||
|
clearData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
testJellyseerrServerUrlMutation.mutate();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{promptForJellyseerrPass ? "Clear" : "Save"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<View
|
||||||
|
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
|
||||||
|
style={{
|
||||||
|
opacity: promptForJellyseerrPass ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="font-bold mb-2">Password</Text>
|
||||||
|
<Input
|
||||||
|
autoFocus={true}
|
||||||
|
focusable={true}
|
||||||
|
placeholder={`Enter password for Jellyfin user ${user?.Name}`}
|
||||||
|
value={jellyseerrPassword}
|
||||||
|
keyboardType="default"
|
||||||
|
secureTextEntry={true}
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="password"
|
||||||
|
onChangeText={setJellyseerrPassword}
|
||||||
|
editable={
|
||||||
|
!loginToJellyseerrMutation.isPending &&
|
||||||
|
promptForJellyseerrPass
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={loginToJellyseerrMutation.isPending}
|
||||||
|
disabled={loginToJellyseerrMutation.isPending}
|
||||||
|
color="purple"
|
||||||
|
className="h-12 mt-2"
|
||||||
|
onPress={() => loginToJellyseerrMutation.mutate()}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,8 +57,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
updateSettings(update);
|
updateSettings(update);
|
||||||
|
|
||||||
console.log("update", update);
|
|
||||||
|
|
||||||
let updatePayload = {
|
let updatePayload = {
|
||||||
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
|
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
|
||||||
PlayDefaultAudioTrack:
|
PlayDefaultAudioTrack:
|
||||||
@@ -84,8 +82,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
console.log("updatePayload", updatePayload);
|
|
||||||
|
|
||||||
updateUserConfiguration(updatePayload);
|
updateUserConfiguration(updatePayload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import * as BackgroundFetch from "expo-background-fetch";
|
|||||||
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 { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -40,6 +40,9 @@ import { Stepper } from "@/components/inputs/Stepper";
|
|||||||
import { MediaProvider } from "./MediaContext";
|
import { MediaProvider } from "./MediaContext";
|
||||||
import { SubtitleToggles } from "./SubtitleToggles";
|
import { SubtitleToggles } from "./SubtitleToggles";
|
||||||
import { AudioToggles } from "./AudioToggles";
|
import { AudioToggles } from "./AudioToggles";
|
||||||
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { ListItem } from "@/components/ListItem";
|
||||||
|
import { JellyseerrSettings } from "./Jellyseerr";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -258,6 +261,21 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="shrink">
|
||||||
|
<Text className="font-semibold">Safe area in controls</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Enable safe area in video player controls
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.safeAreaInControlsEnabled}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
@@ -618,6 +636,8 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<JellyseerrSettings />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type ICommonScreenOptions =
|
|||||||
navigation: any;
|
navigation: any;
|
||||||
}) => NativeStackNavigationOptions);
|
}) => NativeStackNavigationOptions);
|
||||||
|
|
||||||
const commonScreenOptions: ICommonScreenOptions = {
|
export const commonScreenOptions: ICommonScreenOptions = {
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerTransparent: true,
|
headerTransparent: true,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
const fetchInitialVolume = async () => {
|
const fetchInitialVolume = async () => {
|
||||||
try {
|
try {
|
||||||
const { volume: initialVolume } = await VolumeManager.getVolume();
|
const { volume: initialVolume } = await VolumeManager.getVolume();
|
||||||
console.log("initialVolume", initialVolume);
|
|
||||||
volume.value = initialVolume * 100;
|
volume.value = initialVolume * 100;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching initial volume:", error);
|
console.error("Error fetching initial volume:", error);
|
||||||
@@ -39,7 +38,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
|
|
||||||
const handleValueChange = async (value: number) => {
|
const handleValueChange = async (value: number) => {
|
||||||
volume.value = value;
|
volume.value = value;
|
||||||
console.log("volume through slider", value);
|
|
||||||
await VolumeManager.setVolume(value / 100);
|
await VolumeManager.setVolume(value / 100);
|
||||||
|
|
||||||
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
||||||
@@ -48,7 +46,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const volumeListener = VolumeManager.addVolumeListener((result) => {
|
const volumeListener = VolumeManager.addVolumeListener((result) => {
|
||||||
console.log("Volume through device", result.volume);
|
|
||||||
volume.value = result.volume * 100;
|
volume.value = result.volume * 100;
|
||||||
setVisibility(true);
|
setVisibility(true);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const BrightnessSlider = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchInitialBrightness = async () => {
|
const fetchInitialBrightness = async () => {
|
||||||
const initialBrightness = await Brightness.getBrightnessAsync();
|
const initialBrightness = await Brightness.getBrightnessAsync();
|
||||||
console.log("initialBrightness", initialBrightness);
|
|
||||||
brightness.value = initialBrightness * 100;
|
brightness.value = initialBrightness * 100;
|
||||||
};
|
};
|
||||||
fetchInitialBrightness();
|
fetchInitialBrightness();
|
||||||
|
|||||||
@@ -240,8 +240,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
? maxValue - currentProgress
|
? maxValue - currentProgress
|
||||||
: ticksToSeconds(maxValue - currentProgress);
|
: ticksToSeconds(maxValue - currentProgress);
|
||||||
|
|
||||||
console.log("remaining: ", remaining);
|
|
||||||
|
|
||||||
setCurrentTime(current);
|
setCurrentTime(current);
|
||||||
setRemainingTime(remaining);
|
setRemainingTime(remaining);
|
||||||
},
|
},
|
||||||
@@ -349,7 +347,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
console.log(curr);
|
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
const newTime = isVlc
|
const newTime = isVlc
|
||||||
? curr + secondsToMs(settings.forwardSkipTime)
|
? curr + secondsToMs(settings.forwardSkipTime)
|
||||||
@@ -375,8 +372,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const tileWidth = 150;
|
const tileWidth = 150;
|
||||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
|
||||||
console.log("time, ", time);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -513,12 +508,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: insets.top,
|
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
||||||
left: insets.left,
|
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
||||||
opacity: showControls ? 1 : 0,
|
opacity: showControls ? 1 : 0,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
||||||
>
|
>
|
||||||
{!mediaSource?.TranscodingUrl ? (
|
{!mediaSource?.TranscodingUrl ? (
|
||||||
<DropdownViewDirect showControls={showControls} />
|
<DropdownViewDirect showControls={showControls} />
|
||||||
@@ -543,8 +539,8 @@ export const Controls: React.FC<Props> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: insets.top,
|
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
||||||
right: insets.right,
|
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||||
opacity: showControls ? 1 : 0,
|
opacity: showControls ? 1 : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -606,8 +602,8 @@ export const Controls: React.FC<Props> = ({
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%", // Center vertically
|
top: "50%", // Center vertically
|
||||||
left: insets.left,
|
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
||||||
right: insets.right,
|
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -720,9 +716,9 @@ export const Controls: React.FC<Props> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: insets.right,
|
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||||
left: insets.left,
|
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
||||||
bottom: insets.bottom,
|
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={`flex flex-col p-4`}
|
className={`flex flex-col p-4`}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
|||||||
},
|
},
|
||||||
(finished) => {
|
(finished) => {
|
||||||
if (finished && onFinish) {
|
if (finished && onFinish) {
|
||||||
console.log("finish");
|
|
||||||
runOnJS(onFinish)();
|
runOnJS(onFinish)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,19 +106,12 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||||
setSubtitleURL &&
|
setSubtitleURL &&
|
||||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Set external subtitle: ",
|
|
||||||
api?.basePath + sub.deliveryUrl
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.log("Set sub index: ", sub.index);
|
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||||
}
|
}
|
||||||
router.setParams({
|
router.setParams({
|
||||||
subtitleIndex: sub.index.toString(),
|
subtitleIndex: sub.index.toString(),
|
||||||
});
|
});
|
||||||
console.log("Subtitle: ", sub);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
|
|
||||||
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
||||||
|
|
||||||
console.log("sortedSubtitles", sortedSubtitles);
|
|
||||||
return [disableSubtitle, ...sortedSubtitles];
|
return [disableSubtitle, ...sortedSubtitles];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +103,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
|
|
||||||
const ChangeTranscodingAudio = useCallback(
|
const ChangeTranscodingAudio = useCallback(
|
||||||
(audioIndex: number) => {
|
(audioIndex: number) => {
|
||||||
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
@@ -167,7 +165,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
}
|
}
|
||||||
key={`subtitle-item-${idx}`}
|
key={`subtitle-item-${idx}`}
|
||||||
onValueChange={() => {
|
onValueChange={() => {
|
||||||
console.log("sub", sub);
|
|
||||||
if (
|
if (
|
||||||
subtitleIndex ===
|
subtitleIndex ===
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
@@ -216,7 +213,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
value={audioIndex === track.index.toString()}
|
value={audioIndex === track.index.toString()}
|
||||||
onValueChange={() => {
|
onValueChange={() => {
|
||||||
if (audioIndex === track.index.toString()) return;
|
if (audioIndex === track.index.toString()) return;
|
||||||
console.log("Setting audio track to: ", track.index);
|
|
||||||
router.setParams({
|
router.setParams({
|
||||||
audioIndex: track.index.toString(),
|
audioIndex: track.index.toString(),
|
||||||
});
|
});
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.22.0",
|
"channel": "0.23.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.22.0",
|
"channel": "0.23.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export const useIntroSkipper = (
|
|||||||
}, [introTimestamps, currentTime]);
|
}, [introTimestamps, currentTime]);
|
||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
console.log("skipIntro");
|
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|||||||
378
hooks/useJellyseerr.ts
Normal file
378
hooks/useJellyseerr.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import axios, { AxiosError, AxiosInstance } from "axios";
|
||||||
|
import { Results } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { inRange } from "lodash";
|
||||||
|
import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { useAtom } from "jotai/index";
|
||||||
|
import "@/augmentations";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import {
|
||||||
|
SeasonWithEpisodes,
|
||||||
|
TvDetails,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import {
|
||||||
|
IssueStatus,
|
||||||
|
IssueType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
|
import Issue from "@/utils/jellyseerr/server/entity/Issue";
|
||||||
|
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
||||||
|
import { writeErrorLog } from "@/utils/log";
|
||||||
|
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
|
||||||
|
interface SearchParams {
|
||||||
|
query: string;
|
||||||
|
page: number;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResults {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalResults: number;
|
||||||
|
results: Results[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const JELLYSEERR_USER = "JELLYSEERR_USER";
|
||||||
|
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||||
|
|
||||||
|
export const clearJellyseerrStorageData = () => {
|
||||||
|
storage.delete(JELLYSEERR_USER);
|
||||||
|
storage.delete(JELLYSEERR_COOKIES);
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum Endpoints {
|
||||||
|
STATUS = "/status",
|
||||||
|
API_V1 = "/api/v1",
|
||||||
|
SEARCH = "/search",
|
||||||
|
REQUEST = "/request",
|
||||||
|
MOVIE = "/movie",
|
||||||
|
RATINGS = "/ratings",
|
||||||
|
ISSUE = "/issue",
|
||||||
|
TV = "/tv",
|
||||||
|
SETTINGS = "/settings",
|
||||||
|
DISCOVER = "/discover",
|
||||||
|
DISCOVER_TRENDING = DISCOVER + "/trending",
|
||||||
|
DISCOVER_MOVIES = DISCOVER + "/movies",
|
||||||
|
DISCOVER_TV = DISCOVER + TV,
|
||||||
|
AUTH_JELLYFIN = "/auth/jellyfin",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscoverEndpoint =
|
||||||
|
| Endpoints.DISCOVER_TRENDING
|
||||||
|
| Endpoints.DISCOVER_MOVIES
|
||||||
|
| Endpoints.DISCOVER_TV;
|
||||||
|
|
||||||
|
export type TestResult =
|
||||||
|
| {
|
||||||
|
isValid: true;
|
||||||
|
requiresPass: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
isValid: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class JellyseerrApi {
|
||||||
|
axios: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
withCredentials: true,
|
||||||
|
withXSRFToken: true,
|
||||||
|
xsrfHeaderName: "XSRF-TOKEN",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setInterceptors();
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(): Promise<TestResult> {
|
||||||
|
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||||
|
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||||
|
|
||||||
|
if (user && cookies) {
|
||||||
|
return Promise.resolve({
|
||||||
|
isValid: true,
|
||||||
|
requiresPass: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.axios
|
||||||
|
.get(Endpoints.API_V1 + Endpoints.STATUS)
|
||||||
|
.then((response) => {
|
||||||
|
const { status, headers, data } = response;
|
||||||
|
if (inRange(status, 200, 299)) {
|
||||||
|
if (data.version < "2.0.0") {
|
||||||
|
const error =
|
||||||
|
"Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0";
|
||||||
|
toast.error(error);
|
||||||
|
throw Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.setAny(
|
||||||
|
JELLYSEERR_COOKIES,
|
||||||
|
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? []
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
requiresPass: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
toast.error(`Jellyseerr test failed. Please try again.`);
|
||||||
|
writeErrorLog(
|
||||||
|
`Jellyseerr returned a ${status} for url:\n` +
|
||||||
|
response.config.url +
|
||||||
|
"\n" +
|
||||||
|
JSON.stringify(response.data)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
requiresPass: false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
const msg = "Failed to test jellyseerr server url";
|
||||||
|
toast.error(msg);
|
||||||
|
console.error(msg, e);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
requiresPass: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string): Promise<JellyseerrUser> {
|
||||||
|
return this.axios
|
||||||
|
?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
email: username,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const user = response?.data;
|
||||||
|
if (!user) throw Error("Login failed");
|
||||||
|
storage.setAny(JELLYSEERR_USER, user);
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async discoverSettings(): Promise<DiscoverSlider[]> {
|
||||||
|
return this.axios
|
||||||
|
?.get<DiscoverSlider[]>(
|
||||||
|
Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async discover(
|
||||||
|
endpoint: DiscoverEndpoint,
|
||||||
|
params: any
|
||||||
|
): Promise<SearchResults> {
|
||||||
|
return this.axios
|
||||||
|
?.get<SearchResults>(Endpoints.API_V1 + endpoint, { params })
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(params: SearchParams): Promise<SearchResults> {
|
||||||
|
const response = await this.axios?.get<SearchResults>(
|
||||||
|
Endpoints.API_V1 + Endpoints.SEARCH,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(request: MediaRequestBody): Promise<MediaRequest> {
|
||||||
|
return this.axios
|
||||||
|
?.post<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST, request)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async movieDetails(id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
|
||||||
|
.then((response) => {
|
||||||
|
return response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async movieRatings(id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<RTRating>(
|
||||||
|
`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async tvDetails(id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<TvDetails>(`${Endpoints.API_V1}${Endpoints.TV}/${id}`)
|
||||||
|
.then((response) => {
|
||||||
|
return response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async tvRatings(id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<RTRating>(
|
||||||
|
`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async tvSeason(id: number, seasonId: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<SeasonWithEpisodes>(
|
||||||
|
`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
return response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
|
||||||
|
return (
|
||||||
|
this.axios.defaults.baseURL +
|
||||||
|
`/_next/image?` +
|
||||||
|
new URLSearchParams(
|
||||||
|
`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
|
||||||
|
).toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
|
||||||
|
return this.axios
|
||||||
|
?.post<Issue>(Endpoints.API_V1 + Endpoints.ISSUE, {
|
||||||
|
mediaId,
|
||||||
|
issueType,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const issue = response.data;
|
||||||
|
|
||||||
|
if (issue.status === IssueStatus.OPEN) {
|
||||||
|
toast.success("Issue submitted!");
|
||||||
|
}
|
||||||
|
return issue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setInterceptors() {
|
||||||
|
this.axios.interceptors.response.use(
|
||||||
|
async (response) => {
|
||||||
|
const cookies = response.headers["set-cookie"];
|
||||||
|
if (cookies) {
|
||||||
|
storage.setAny(
|
||||||
|
JELLYSEERR_COOKIES,
|
||||||
|
response.headers["set-cookie"]?.flatMap((c) => c.split("; "))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const errorMsg = "Jellyseerr response error";
|
||||||
|
console.error(errorMsg, error, error.response?.data);
|
||||||
|
writeErrorLog(
|
||||||
|
errorMsg +
|
||||||
|
`\n` +
|
||||||
|
`error: ${error.toString()}\n` +
|
||||||
|
`url: ${error?.config?.url}\n` +
|
||||||
|
`data:\n` +
|
||||||
|
JSON.stringify(error.response?.data)
|
||||||
|
);
|
||||||
|
if (error.status === 403) {
|
||||||
|
clearJellyseerrStorageData();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.axios.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||||
|
if (cookies) {
|
||||||
|
const headerName = this.axios.defaults.xsrfHeaderName!!;
|
||||||
|
const xsrfToken = cookies
|
||||||
|
.find((c) => c.includes(headerName))
|
||||||
|
?.split(headerName + "=")?.[1];
|
||||||
|
if (xsrfToken) {
|
||||||
|
config.headers[headerName] = xsrfToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("Jellyseerr request error", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
||||||
|
|
||||||
|
export const useJellyseerr = () => {
|
||||||
|
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const jellyseerrApi = useMemo(() => {
|
||||||
|
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||||
|
if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
|
||||||
|
return new JellyseerrApi(settings?.jellyseerrServerUrl);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [settings?.jellyseerrServerUrl, jellyseerrUser]);
|
||||||
|
|
||||||
|
const clearAllJellyseerData = useCallback(async () => {
|
||||||
|
clearJellyseerrStorageData();
|
||||||
|
setJellyseerrUser(undefined);
|
||||||
|
updateSettings({ jellyseerrServerUrl: undefined });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestMedia = useCallback(
|
||||||
|
(title: string, request: MediaRequestBody) => {
|
||||||
|
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
|
||||||
|
switch (mediaRequest.status) {
|
||||||
|
case MediaRequestStatus.PENDING:
|
||||||
|
case MediaRequestStatus.APPROVED:
|
||||||
|
toast.success(`Requested ${title}!`);
|
||||||
|
break;
|
||||||
|
case MediaRequestStatus.DECLINED:
|
||||||
|
toast.error(`You don't have permission to request!`);
|
||||||
|
break;
|
||||||
|
case MediaRequestStatus.FAILED:
|
||||||
|
toast.error(`Something went wrong requesting media!`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[jellyseerrApi]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isJellyseerrResult = (
|
||||||
|
items: any[] | null | undefined
|
||||||
|
): items is Results[] => {
|
||||||
|
return (
|
||||||
|
!items ||
|
||||||
|
(items.length >= 0 &&
|
||||||
|
Object.hasOwn(items[0], "mediaType") &&
|
||||||
|
Object.values(MediaType).includes(items[0]["mediaType"]))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
jellyseerrApi,
|
||||||
|
jellyseerrUser,
|
||||||
|
setJellyseerrUser,
|
||||||
|
clearAllJellyseerData,
|
||||||
|
isJellyseerrResult,
|
||||||
|
requestMedia,
|
||||||
|
};
|
||||||
|
};
|
||||||
11
package.json
11
package.json
@@ -3,11 +3,12 @@
|
|||||||
"main": "./index",
|
"main": "./index",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"submodule-reload": "git submodule update --init --remote --recursive",
|
||||||
|
"start": "bun run submodule-reload && expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo run:android",
|
"android": "bun run submodule-reload && expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "bun run submodule-reload && expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "bun run submodule-reload && expo start --web",
|
||||||
"test": "jest --watchAll",
|
"test": "jest --watchAll",
|
||||||
"lint": "expo lint",
|
"lint": "expo lint",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
@@ -97,6 +98,8 @@
|
|||||||
"react-native-video": "^6.7.0",
|
"react-native-video": "^6.7.0",
|
||||||
"react-native-volume-manager": "^1.10.0",
|
"react-native-volume-manager": "^1.10.0",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
|
"react-native-webview": "^13.12.5",
|
||||||
|
"react-native-youtube-iframe": "^2.3.0",
|
||||||
"sonner-native": "^0.14.2",
|
"sonner-native": "^0.14.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {AppState, AppStateStatus, Platform} from "react-native";
|
import { AppState, AppStateStatus, Platform } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { apiAtom } from "./JellyfinProvider";
|
import { apiAtom } from "./JellyfinProvider";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
@@ -195,7 +195,7 @@ function useDownloadProvider() {
|
|||||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||||
);
|
);
|
||||||
|
|
||||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`
|
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
||||||
|
|
||||||
const startDownload = useCallback(
|
const startDownload = useCallback(
|
||||||
async (process: JobStatus) => {
|
async (process: JobStatus) => {
|
||||||
@@ -423,32 +423,25 @@ function useDownloadProvider() {
|
|||||||
throw new Error("Base directory not found");
|
throw new Error("Base directory not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`ignoreList length: ${ignoreList?.length}`);
|
|
||||||
|
|
||||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||||
for (const item of dirContents) {
|
for (const item of dirContents) {
|
||||||
// Exclude mmkv directory.
|
// Exclude mmkv directory.
|
||||||
// Deleting this deletes all user information as well. Logout should handle this.
|
// Deleting this deletes all user information as well. Logout should handle this.
|
||||||
if (
|
if (
|
||||||
(item == "mmkv" && !includeMMKV) ||
|
(item == "mmkv" && !includeMMKV) ||
|
||||||
ignoreList.some(i => item.includes(i))
|
ignoreList.some((i) => item.includes(i))
|
||||||
) {
|
) {
|
||||||
console.log("Skipping read for item", item)
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
|
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
|
||||||
.then((itemInfo) => {
|
.then((itemInfo) => {
|
||||||
console.log("Loading itemInfo", itemInfo);
|
|
||||||
|
|
||||||
if (itemInfo.exists && !itemInfo.isDirectory) {
|
if (itemInfo.exists && !itemInfo.isDirectory) {
|
||||||
callback(itemInfo);
|
callback(itemInfo);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(e =>
|
.catch((e) => console.error(e));
|
||||||
console.error(e)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteLocalFiles = async (): Promise<void> => {
|
const deleteLocalFiles = async (): Promise<void> => {
|
||||||
await forEveryDocumentDirFile(false, [], (file) => {
|
await forEveryDocumentDirFile(false, [], (file) => {
|
||||||
@@ -545,28 +538,36 @@ function useDownloadProvider() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cleanCacheDirectory = async () => {
|
const cleanCacheDirectory = async () => {
|
||||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
const cacheDir = await FileSystem.getInfoAsync(
|
||||||
|
APP_CACHE_DOWNLOAD_DIRECTORY
|
||||||
|
);
|
||||||
if (cacheDir.exists) {
|
if (cacheDir.exists) {
|
||||||
const cachedFiles = await FileSystem.readDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY)
|
const cachedFiles = await FileSystem.readDirectoryAsync(
|
||||||
let position = 0
|
APP_CACHE_DOWNLOAD_DIRECTORY
|
||||||
const batchSize = 3
|
);
|
||||||
|
let position = 0;
|
||||||
|
const batchSize = 3;
|
||||||
|
|
||||||
// batching promise.all to avoid OOM
|
// batching promise.all to avoid OOM
|
||||||
while (position < cachedFiles.length) {
|
while (position < cachedFiles.length) {
|
||||||
const itemsForBatch = cachedFiles.slice(position, position + batchSize)
|
const itemsForBatch = cachedFiles.slice(position, position + batchSize);
|
||||||
await Promise.all(itemsForBatch.map(async file => {
|
await Promise.all(
|
||||||
const info = await FileSystem.getInfoAsync(`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`)
|
itemsForBatch.map(async (file) => {
|
||||||
if (info.exists) {
|
const info = await FileSystem.getInfoAsync(
|
||||||
await FileSystem.deleteAsync(info.uri, { idempotent: true })
|
`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`
|
||||||
return Promise.resolve(file)
|
);
|
||||||
}
|
if (info.exists) {
|
||||||
return Promise.reject()
|
await FileSystem.deleteAsync(info.uri, { idempotent: true });
|
||||||
}))
|
return Promise.resolve(file);
|
||||||
|
}
|
||||||
|
return Promise.reject();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
position += batchSize
|
position += batchSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -583,20 +584,22 @@ function useDownloadProvider() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const appSizeUsage = useMemo(async () => {
|
const appSizeUsage = useMemo(async () => {
|
||||||
const sizes: number[] = downloadedFiles?.map(d => {
|
const sizes: number[] =
|
||||||
return getDownloadedItemSize(d.item.Id!!)
|
downloadedFiles?.map((d) => {
|
||||||
}) || [];
|
return getDownloadedItemSize(d.item.Id!!);
|
||||||
|
}) || [];
|
||||||
|
|
||||||
await forEveryDocumentDirFile(
|
await forEveryDocumentDirFile(
|
||||||
true,
|
true,
|
||||||
getAllDownloadedItems().map(d => d.item.Id!!),
|
getAllDownloadedItems().map((d) => d.item.Id!!),
|
||||||
(file) => {
|
(file) => {
|
||||||
if (file.exists) {
|
if (file.exists) {
|
||||||
sizes.push(file.size);
|
sizes.push(file.size);
|
||||||
}
|
}
|
||||||
}).catch(e => {
|
}
|
||||||
console.error(e)
|
).catch((e) => {
|
||||||
})
|
console.error(e);
|
||||||
|
});
|
||||||
|
|
||||||
return sizes.reduce((sum, size) => sum + size, 0);
|
return sizes.reduce((sum, size) => sum + size, 0);
|
||||||
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
|
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
|
||||||
@@ -690,7 +693,7 @@ function useDownloadProvider() {
|
|||||||
appSizeUsage,
|
appSizeUsage,
|
||||||
getDownloadedItemSize,
|
getDownloadedItemSize,
|
||||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||||
cleanCacheDirectory
|
cleanCacheDirectory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,16 +714,3 @@ export function useDownload() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bytesToReadable(bytes: number): string {
|
|
||||||
const gb = bytes / 1e9;
|
|
||||||
|
|
||||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
|
||||||
|
|
||||||
const mb = bytes / 1024.0 / 1024.0;
|
|
||||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
|
||||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
|
||||||
return `${bytes.toFixed(2)} B`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.22.0" },
|
clientInfo: { name: "Streamyfin", version: "0.23.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -91,7 +91,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.22.0"`,
|
}, DeviceId="${deviceId}", Version="0.23.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ export type Settings = {
|
|||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
showCustomMenuLinks: boolean;
|
showCustomMenuLinks: boolean;
|
||||||
subtitleSize: number;
|
subtitleSize: number;
|
||||||
remuxConcurrentLimit: 1 | 2 | 3 | 4; // TODO: Maybe let people choose their own limit? 4 seems like a safe max?
|
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
||||||
|
safeAreaInControlsEnabled: boolean;
|
||||||
|
jellyseerrServerUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = (): Settings => {
|
const loadSettings = (): Settings => {
|
||||||
@@ -122,6 +124,8 @@ const loadSettings = (): Settings => {
|
|||||||
showCustomMenuLinks: false,
|
showCustomMenuLinks: false,
|
||||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||||
remuxConcurrentLimit: 1,
|
remuxConcurrentLimit: 1,
|
||||||
|
safeAreaInControlsEnabled: true,
|
||||||
|
jellyseerrServerUrl: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
1
utils/jellyseerr
Submodule
1
utils/jellyseerr
Submodule
Submodule utils/jellyseerr added at e69d160e25
Reference in New Issue
Block a user