Compare commits

..

73 Commits

Author SHA1 Message Date
Fredrik Burmester
ece3bc001f fix: id collision 2025-01-01 16:11:04 +01:00
Fredrik Burmester
27609e7789 feat: favorites tab 2025-01-01 15:46:22 +01:00
Fredrik Burmester
a994868be4 chore 2024-12-31 23:13:58 +01:00
Fredrik Burmester
ee6d43e3e8 Merge branch 'master' of https://github.com/streamyfin/streamyfin 2024-12-31 21:47:46 +01:00
Fredrik Burmester
f8d22bb7d6 fix: trailers for movies 2024-12-31 21:47:43 +01:00
Fredrik Burmester
a0391b484d Merge pull request #338 from streamyfin/retardgerman-patch-1
feat: explaining new way to enter Beta in README
2024-12-31 17:25:05 +01:00
retardgerman
681aadb121 feat: explaining new way to enter Beta in README 2024-12-31 17:24:09 +01:00
Fredrik Burmester
479a1f037e fix: padding 2024-12-31 16:07:16 +01:00
Fredrik Burmester
ae5b88ab56 feat: series info and trailer 2024-12-31 16:01:30 +01:00
Fredrik Burmester
9091b9b66a fix: poster not clickable 2024-12-31 16:01:19 +01:00
Fredrik Burmester
cccb26c9cc chore 2024-12-31 15:00:30 +01:00
Fredrik Burmester
28568cbb9c fix: incorrect logic 2024-12-31 14:56:41 +01:00
Fredrik Burmester
8344d4025b fix: not possible to select seasons without index 2024-12-31 14:54:31 +01:00
Fredrik Burmester
0f69448081 fix: design 2024-12-31 13:40:23 +01:00
Fredrik Burmester
a936916da4 fix: design 2024-12-31 13:35:59 +01:00
Fredrik Burmester
c753e33f38 chore 2024-12-31 13:32:51 +01:00
Fredrik Burmester
48422fa93e fix: design 2024-12-31 13:32:43 +01:00
Fredrik Burmester
5adf943fd9 fix: finally fix not rotten tomatoes score 2024-12-31 13:05:23 +01:00
Fredrik Burmester
9174a8104d fix: color 2024-12-31 10:55:23 +01:00
Fredrik Burmester
56f1bd489c fix: improve readme 2024-12-31 10:35:16 +01:00
Fredrik Burmester
5e79b5a581 fix: improve readme 2024-12-31 10:33:59 +01:00
Fredrik Burmester
36a689f59d fix: improved login flow for jellyseerr 2024-12-31 10:29:50 +01:00
herrrta
47211ba009 Merge pull request #323 from herrrta/fix/movie-request-crash
Fix movie request crashing
2024-12-30 16:53:03 -05:00
herrrta
e86a2af9a9 Fix movie request crashing 2024-12-30 16:52:44 -05:00
herrrta
c46b4cc34d Merge pull request #322 from herrrta/fix/discover-spacing
Fix spacing between slides on discover page
2024-12-30 16:34:40 -05:00
herrrta
ec0d9d7788 Fix spacing between slides on discover page 2024-12-30 16:34:15 -05:00
Fredrik Burmester
d2eda1365c Merge pull request #321 from herrrta/fix/jellyseerr-pass-prompt
Better jellyseerr password input with loading indicator
2024-12-30 22:02:09 +01:00
herrrta
b58fa86a6b Better jellyseerr password input with loading indicator 2024-12-30 16:00:04 -05:00
Fredrik Burmester
400dfe3679 Merge pull request #320 from herrrta/fix/jellyseerr-cookies
Some jellyseerr clients dont set cookies
2024-12-30 15:17:50 +01:00
herrrta
cf58a5e749 Some jellyseerr clients dont set cookies 2024-12-30 09:12:00 -05:00
Fredrik Burmester
001eba02b4 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-12-30 13:45:15 +01:00
Fredrik Burmester
67e767f298 chore: versions 2024-12-30 13:44:28 +01:00
Fredrik Burmester
5f1c5f7b34 Merge pull request #317 from herrrta/feat/jellyseerr-discover
Basic Jellyseerr discover page
2024-12-30 11:42:29 +01:00
Fredrik Burmester
e54cac1e09 Merge pull request #315 from herrrta/fix/more-jellyseerr-logs
More logs on failed jellyseerr status test
2024-12-30 11:41:38 +01:00
herrrta
cbce83e109 Basic Jellyseerr discover page 2024-12-29 13:50:02 -05:00
herrrta
c6b58c5c28 More logs on failed jellyseerr status test 2024-12-28 12:20:49 -05:00
Fredrik Burmester
0468756317 Merge pull request #309 from herrrta/jellyseer-integration
Jellyseerr Integration
2024-12-28 13:37:20 +01:00
herrrta
9f12ee027f Jellyseerr Integration
## Note this is early stages of said integration. Things will change!
series and season working

- added jellyseerr git submodule
- augmentations
- working jellyseerr search integration
- working jellyseerr requests & updated interceptors to persist cookies from every response
2024-12-27 22:20:33 -05:00
Fredrik Burmester
78b7425c6b Merge pull request #311 from lostb1t/fix/mergedversions
Fix merged versions showing as separate media in listings.
2024-12-26 11:44:03 +01:00
Fredrik Burmester
c38c1d06ad Merge pull request #307 from Alexk2309/hotfix/dropdown-spacing
Added padding and spacing for dropdown button
2024-12-26 11:43:44 +01:00
sarendsen
5af735065a Remove console.log 2024-12-26 11:31:37 +01:00
sarendsen
600276cb69 Use recursive param so that merged versions show up as a single entity 2024-12-26 11:27:26 +01:00
Alex Kim
ba3104f87e Added padding and spacing for dropdown button 2024-12-26 11:07:33 +11:00
Fredrik Burmester
3aef9458e3 Merge pull request #303 from fredrikburmester/feat/context-menu-action-for-items
feat: context menu actions for items
2024-12-23 12:02:09 +01:00
Fredrik Burmester
5bce394836 Merge pull request #304 from fredrikburmester/feat/safe-areas-in-controls
feat: setting toggle for safe areas in controls
2024-12-23 12:01:31 +01:00
Fredrik Burmester
90930d478c fix: call with correct args 2024-12-23 11:25:24 +01:00
Fredrik Burmester
dd09f3d4d9 feat: settings toggle for safe areas in controls 2024-12-23 10:26:15 +01:00
Fredrik Burmester
8608ad02f7 feat: context menu actions for items 2024-12-22 11:57:44 +01:00
Fredrik Burmester
030947fc38 fix: login design 2024-12-22 11:33:15 +01:00
Fredrik Burmester
9b18188b32 fix: live tv design 2024-12-22 11:28:28 +01:00
Fredrik Burmester
d86853dec9 fix: login design 2024-12-22 11:28:21 +01:00
Fredrik Burmester
0750acdc13 Merge pull request #302 from Alexk2309/fix/remove-episode-list-and-next-up-in-offline-playback
Removed episodelist button and previous/next buttons for offline playback
2024-12-22 10:40:08 +01:00
Alex Kim
d8231f5b80 Fixed compile error 2024-12-22 17:46:56 +11:00
Alex Kim
41d17499bb Removed episodelist button and previous/next buttons for offline playback 2024-12-22 17:43:58 +11:00
Fredrik Burmester
60f1217cae Merge pull request #300 from herrrta/fix/storage-read-crash
dont rely on cache for downloadedItems
2024-12-21 22:02:49 +01:00
herrrta
834de10e34 dont rely on cache for downloadedItems 2024-12-21 14:10:55 -05:00
Fredrik Burmester
51f17f983d fix: add safe areas back to controls 2024-12-21 13:18:56 +01:00
Fredrik Burmester
ba4a2c0b79 fix: haptics 2024-12-21 13:03:32 +01:00
Fredrik Burmester
a32eb710ec feat: haptics 2024-12-21 12:56:04 +01:00
Fredrik Burmester
cb05da782a feat: optimistic update 2024-12-21 12:55:58 +01:00
Fredrik Burmester
5a680a4392 fix: smoother item page loading 2024-12-21 12:45:32 +01:00
Fredrik Burmester
8a44d2ff15 fix: correct types 2024-12-21 12:31:00 +01:00
Fredrik Burmester
f3f260625f fix: refactor buttons 2024-12-21 12:22:53 +01:00
Fredrik Burmester
6908620f4e fix: downloaded movie title overflowing 2024-12-19 15:10:21 +01:00
Fredrik Burmester
9932266203 fix: key error 2024-12-19 15:06:26 +01:00
Fredrik Burmester
cb2268e39c fix: don't show empty folders in movie/tvshow libraries 2024-12-19 15:05:31 +01:00
Fredrik Burmester
bf9be278d3 fix: texts and icons 2024-12-19 14:48:51 +01:00
Fredrik Burmester
584fcc09d6 fix: use ionicons 2024-12-19 14:18:53 +01:00
Fredrik Burmester
7a26b5004b fix: text reorder 2024-12-19 14:18:47 +01:00
Fredrik Burmester
ae92692ea0 fix: title props 2024-12-19 14:18:37 +01:00
Fredrik Burmester
92e4b3b8cf fix: use ionicons 2024-12-19 14:18:26 +01:00
Fredrik Burmester
127ec1391b fix: text 2024-12-19 14:12:29 +01:00
Fredrik Burmester
0ac4f826bc Merge pull request #297 from fredrikburmester/feat/technical-details
feat: add technical details to item
2024-12-19 12:06:12 +01:00
88 changed files with 3429 additions and 781 deletions

View File

@@ -1,13 +1,13 @@
name: Bug report
description: Create a report to help us improve
title: '[Bug]: '
title: "[Bug]: "
labels:
- ['❌ bug']
projects:
- ['fredrikburmester/5']
- ["❌ bug"]
projects:
- ["fredrikburmester/5"]
assignees:
- fredrikburmester
body:
- type: textarea
id: what-happened
@@ -43,8 +43,9 @@ body:
id: version
attributes:
label: Version
description: What version of Streamyfin are you running?
description: What version of Streamyfin are you running?
options:
- 0.23.0
- 0.22.0
- 0.21.0
- older
@@ -54,6 +55,5 @@ body:
- type: textarea
id: screenshots
attributes:
label:
If applicable, please add screenshots to help explain your problem.
label: 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.

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "utils/jellyseerr"]
path = utils/jellyseerr
url = https://github.com/herrrta/jellyseerr
branch = models

View File

@@ -15,10 +15,10 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
- 🚀 **Skp intro / credits support**
- 🖼️ **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.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 🤖 **Jellyseerr integration**: Request media directly in the app.
## 🧪 Experimental Features
@@ -70,11 +70,9 @@ Or download the APKs [here on GitHub](https://github.com/fredrikburmester/stream
### 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">
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
## 🚀 Getting Started
@@ -89,36 +87,10 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
### Development info
1. Use node `20`
2. Install dependencies `bun i`
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
## 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>
```
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed.
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
## 📄 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.
- [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.
## Star History

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.22.0",
"version": "0.23.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,7 +36,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 47,
"versionCode": 49,
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png"
},

View 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>
);
}

View 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>
);
}

View File

@@ -1,6 +1,6 @@
import { Chromecast } from "@/components/Chromecast";
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 { Platform, TouchableOpacity, View } from "react-native";

View File

@@ -107,9 +107,9 @@ export default function index() {
setIsConnected(state.isConnected);
});
cleanCacheDirectory()
.then(r => console.log("Cache directory cleaned"))
.catch(e => console.error("Something went wrong cleaning cache directory"))
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
return () => {
unsubscribe();
};

View File

@@ -2,18 +2,18 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
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 {clearLogs, useLog} from "@/utils/log";
import { clearLogs, useLog } from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
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 { toast } from "sonner-native";
import * as Progress from 'react-native-progress';
import * as FileSystem from "expo-file-system";
export default function settings() {
const { logout } = useJellyfin();
@@ -25,17 +25,17 @@ export default function settings() {
const insets = useSafeAreaInsets();
const {data: size , isLoading: appSizeLoading } = useQuery({
const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
queryFn: async () => {
const app = await appSizeUsage
const app = await appSizeUsage;
const remaining = await FileSystem.getFreeDiskStorageAsync()
const total = await FileSystem.getTotalDiskCapacityAsync()
const remaining = await FileSystem.getFreeDiskStorageAsync();
const total = await FileSystem.getTotalDiskCapacityAsync();
return {app, remaining, total, used: (total - remaining) / total}
}
})
return { app, remaining, total, used: (total - remaining) / total };
},
});
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
@@ -69,22 +69,16 @@ export default function settings() {
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
toast.error("Error deleting files");
}
}
};
const onClearLogsClicked = async () => {
clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
return (
@@ -128,7 +122,7 @@ export default function settings() {
<View className="flex flex-col space-y-2">
<Text className="font-bold text-lg mb-2">Storage</Text>
<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
className="bg-gray-100/10"
indeterminate={appSizeLoading}
@@ -140,19 +134,16 @@ export default function settings() {
progress={size?.used}
/>
{size && (
<Text>Available: {bytesToReadable(size.remaining)}, Total: {bytesToReadable(size.total)}</Text>
<Text>
Available: {size.remaining?.bytesToReadable()}, Total:{" "}
{size.total?.bytesToReadable()}
</Text>
)}
</View>
<Button
color="red"
onPress={onDeleteClicked}
>
<Button color="red" onPress={onDeleteClicked}>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={onClearLogsClicked}
>
<Button color="red" onPress={onClearLogsClicked}>
Delete all logs
</Button>
</View>

View File

@@ -104,9 +104,12 @@ const page: React.FC = () => {
"CanDelete",
"MediaSourceCount",
],
// true is needed for merged versions
recursive: true,
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
});
return response.data || null;

View File

@@ -1,11 +1,7 @@
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import {
getMediaInfoApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
@@ -48,20 +44,25 @@ const Page: React.FC = () => {
});
const fadeOut = (callback: any) => {
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
setTimeout(() => {
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
const fadeIn = (callback: any) => {
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
setTimeout(() => {
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
useEffect(() => {
if (item) {
fadeOut(() => {});
@@ -84,14 +85,24 @@ const Page: React.FC = () => {
style={[animatedStyle]}
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
>
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className="bg-transparent rounded-lg mb-4 w-full"
></View>
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
<View className="flex flex-row space-x-1 mb-8">
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
</View>
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
</Animated.View>
{item && <ItemContent item={item} />}
</View>

View File

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

View File

@@ -1,13 +1,21 @@
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { HourHeader } from "@/components/livetv/HourHeader";
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import { Button, Dimensions, ScrollView, View } from "react-native";
import {
Button,
Dimensions,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const HOUR_HEIGHT = 30;
@@ -78,8 +86,6 @@ export default function page() {
const screenWidth = Dimensions.get("window").width;
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
const [scrollX, setScrollX] = useState(0);
const handleNextPage = useCallback(() => {
@@ -100,24 +106,15 @@ export default function page() {
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-row bg-neutral-800 w-full items-end">
<Button
title="Previous"
onPress={handlePrevPage}
disabled={currentPage === 1}
/>
<Button
title="Next"
onPress={handleNextPage}
disabled={
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
}
/>
</View>
<PageButtons
currentPage={currentPage}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
isNextDisabled={
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
}
/>
<View className="flex flex-row">
<View className="flex flex-col w-[64px]">
@@ -166,3 +163,57 @@ export default function page() {
</ScrollView>
);
}
interface PageButtonsProps {
currentPage: number;
onPrevPage: () => void;
onNextPage: () => void;
isNextDisabled: boolean;
}
const PageButtons: React.FC<PageButtonsProps> = ({
currentPage,
onPrevPage,
onNextPage,
isNextDisabled,
}) => {
return (
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
<TouchableOpacity
onPress={onPrevPage}
disabled={currentPage === 1}
className="flex flex-row items-center"
>
<Ionicons
name="chevron-back"
size={24}
color={currentPage === 1 ? "gray" : "white"}
/>
<Text
className={`ml-1 ${
currentPage === 1 ? "text-gray-500" : "text-white"
}`}
>
Previous
</Text>
</TouchableOpacity>
<Text className="text-white">Page {currentPage}</Text>
<TouchableOpacity
onPress={onNextPage}
disabled={isNextDisabled}
className="flex flex-row items-center"
>
<Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
>
Next
</Text>
<Ionicons
name="chevron-forward"
size={24}
color={isNextDisabled ? "gray" : "white"}
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -5,10 +5,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import React from "react";
import {
ScrollView,
View
} from "react-native";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
@@ -27,9 +24,6 @@ export default function page() {
paddingBottom: 16,
paddingTop: 8,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col space-y-2">
<ScrollingCollectionList

View File

@@ -1,21 +1,23 @@
import { Text } from "@/components/common/Text";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Ratings } from "@/components/Ratings";
import { NextUp } from "@/components/series/NextUp";
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 { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import {useLocalSearchParams, useNavigation} from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {useEffect} from "react";
import { useMemo } from "react";
import React, { useEffect, useMemo } from "react";
import { View } from "react-native";
import {DownloadItems} from "@/components/DownloadItem";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {getTvShowsApi} from "@jellyfin/sdk/lib/utils/api";
const page: React.FC = () => {
const navigation = useNavigation();
@@ -60,7 +62,7 @@ const page: React.FC = () => {
[item]
);
const {data: allEpisodes, isLoading} = useQuery({
const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({
@@ -69,34 +71,39 @@ const page: React.FC = () => {
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return res?.data.Items || []
return res?.data.Items || [];
},
enabled: !!api && !!user?.Id && !!item?.Id
staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id,
});
useEffect(() => {
navigation.setOptions({
headerRight: () => (
(!isLoading && allEpisodes && allEpisodes.length > 0) && (
headerRight: () =>
!isLoading &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className="flex flex-row items-center space-x-2">
<DownloadItems
title="Download Series"
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons name="folder-download" size={24} color="white"/>
<Ionicons name="download" size={22} color="white" />
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons name="folder-check" size={26} color="#9333ea"/>
<Ionicons
name="checkmark-done-outline"
size={24}
color="#9333ea"
/>
)}
/>
</View>
)
)
})
),
});
}, [allEpisodes, isLoading]);
if (!item || !backdropUrl)
return null;
if (!item || !backdropUrl) return null;
return (
<ParallaxScrollView
@@ -130,10 +137,7 @@ const page: React.FC = () => {
}
>
<View className="flex flex-col pt-4">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
<SeriesHeader item={item} />
<View className="mb-4">
<NextUp seriesId={seriesId} />
</View>

View File

@@ -32,6 +32,7 @@ import {
import {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -40,8 +41,7 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
const Page = () => {
const searchParams = useLocalSearchParams();
@@ -141,6 +141,18 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null;
let itemType: BaseItemKind | undefined;
// This fix makes sure to only return 1 type of items, if defined.
// This is because the underlying directory some times contains other types, and we don't want to show them.
if (library.CollectionType === "movies") {
itemType = "Movie";
} else if (library.CollectionType === "tvshows") {
itemType = "Series";
} else if (library.CollectionType === "boxsets") {
itemType = "BoxSet";
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: libraryId,
@@ -149,12 +161,14 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: false,
// true is needed for merged versions
recursive: true,
imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined,
});
return response.data || null;

View File

@@ -1,4 +1,4 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
@@ -29,6 +29,10 @@ export default function SearchLayout() {
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="jellyseerr/page"
options={commonScreenOptions}
/>
</Stack>
);
}

View File

@@ -20,6 +20,7 @@ import axios from "axios";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
PropsWithChildren,
useCallback,
useEffect,
useLayoutEffect,
@@ -29,6 +30,15 @@ import React, {
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
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 = [
"Lord of the rings",
@@ -45,6 +55,7 @@ export default function search() {
const { q, prev } = params as { q: string; prev: Href<string> };
const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500);
@@ -53,6 +64,7 @@ export default function search() {
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr();
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
@@ -132,9 +144,51 @@ export default function search() {
query: debouncedSearch,
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({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -142,7 +196,7 @@ export default function search() {
query: debouncedSearch,
types: ["Series"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: episodes, isFetching: l3 } = useQuery({
@@ -152,7 +206,7 @@ export default function search() {
query: debouncedSearch,
types: ["Episode"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: collections, isFetching: l7 } = useQuery({
@@ -162,7 +216,7 @@ export default function search() {
query: debouncedSearch,
types: ["BoxSet"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: actors, isFetching: l8 } = useQuery({
@@ -172,7 +226,7 @@ export default function search() {
query: debouncedSearch,
types: ["Person"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: artists, isFetching: l4 } = useQuery({
@@ -182,7 +236,7 @@ export default function search() {
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: albums, isFetching: l5 } = useQuery({
@@ -192,7 +246,7 @@ export default function search() {
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: songs, isFetching: l6 } = useQuery({
@@ -202,7 +256,7 @@ export default function search() {
query: debouncedSearch,
types: ["Audio"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const noResults = useMemo(() => {
@@ -214,13 +268,25 @@ export default function search() {
episodes?.length ||
series?.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(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [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, j1, j2]);
return (
<>
@@ -245,6 +311,28 @@ export default function search() {
/>
</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 && (
<View className="px-4 flex flex-col space-y-2">
<Text className="text-neutral-500 ">
@@ -252,130 +340,153 @@ export default function search() {
</Text>
</View>
)}
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
{searchType === "Library" && (
<>
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</>
)}
{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 ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
@@ -389,7 +500,7 @@ export default function search() {
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
) : debouncedSearch.length === 0 && searchType === "Library" ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
@@ -401,6 +512,15 @@ export default function search() {
</TouchableOpacity>
))}
</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}
</View>
</ScrollView>
@@ -408,13 +528,19 @@ export default function search() {
);
}
type Props = {
type Props<T> = {
ids?: string[] | null;
renderItem: (item: BaseItemDto) => React.ReactNode;
items?: T[];
renderItem: (item: any) => React.ReactNode;
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 [user] = useAtom(userAtom);
@@ -444,7 +570,7 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
staleTime: Infinity,
});
if (!data) return null;
if (!data && (!items || items.length === 0)) return null;
return (
<>
@@ -454,7 +580,11 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
className="px-4 mb-2"
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>
</>
);

View File

@@ -48,7 +48,10 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: () => ({ sfSymbol: "house" }),
: ({ focused }) =>
focused
? { sfSymbol: "house.fill" }
: { sfSymbol: "house" },
}}
/>
<NativeTabs.Screen
@@ -59,7 +62,26 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
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
@@ -70,7 +92,10 @@ export default function TabLayout() {
Platform.OS == "android"
? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: () => ({ sfSymbol: "rectangle.stack" }),
: ({ focused }) =>
focused
? { sfSymbol: "rectangle.stack.fill" }
: { sfSymbol: "rectangle.stack" },
}}
/>
<NativeTabs.Screen
@@ -81,8 +106,11 @@ export default function TabLayout() {
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon:
Platform.OS == "android"
? () => require("@/assets/icons/list.png")
: () => ({ sfSymbol: "list.dash" }),
? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
}}
/>
</NativeTabs>

View File

@@ -43,6 +43,7 @@ import {
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
@@ -281,13 +282,6 @@ export default function page() {
if (!item?.Id || !stream) return;
console.log(
"onProgress ~",
currentTimeInTicks,
isPlaying,
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
);
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
@@ -448,7 +442,7 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
opacity: showControls ? 0.5 : 1,
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
}}
>
<VlcPlayerView
@@ -501,7 +495,7 @@ export default function page() {
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={false}
offline={offline}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}

View 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>
);
}

View File

@@ -1,3 +1,5 @@
import "@/augmentations";
import { Text } from "@/components/common/Text";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
@@ -35,7 +37,7 @@ import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
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 { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
@@ -334,9 +336,21 @@ function Layout() {
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/trailer/page"
options={{
headerShown: false,
presentation: "modal",
title: "",
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>

View File

@@ -6,7 +6,7 @@ import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
import {
@@ -14,6 +14,7 @@ import {
KeyboardAvoidingView,
Platform,
SafeAreaView,
TouchableOpacity,
View,
} from "react-native";
@@ -65,6 +66,23 @@ const Login: React.FC = () => {
})();
}, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerLeft: () =>
api?.basePath ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
>
<Ionicons name="chevron-back" size={24} color="white" />
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
@@ -176,38 +194,21 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col w-full h-full relative items-center justify-center">
<View className="px-4 -mt-20">
<View className="mb-4">
<Text className="text-3xl font-bold mb-1">
{serverName || "Streamyfin"}
</Text>
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
<Text className="">URL</Text>
<Text numberOfLines={1} className="shrink">
{api.basePath}
</Text>
</View>
<Button
color="black"
onPress={() => {
removeServer();
}}
justify="between"
iconLeft={
<Ionicons
name="arrow-back-outline"
size={18}
color={"white"}
/>
}
>
Change server
</Button>
</View>
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-2xl font-bold -mb-2">
Log in
<>
{serverName ? (
<>
{" to "}
<Text className="text-purple-600">{serverName}</Text>
</>
) : null}
</>
</Text>
<Text className="text-xs text-neutral-400">{serverURL}</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
@@ -281,7 +282,7 @@ const Login: React.FC = () => {
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
Enter the URL to your Jellyfin server
</Text>
<Input
placeholder="Server URL"
@@ -293,6 +294,9 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Text className="text-xs text-neutral-500">
Make sure to include http or https
</Text>
</View>
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
<Button

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View 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
View File

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

17
augmentations/mmkv.ts Normal file
View 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
View 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
View 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 {};

BIN
bun.lockb

Binary file not shown.

View File

@@ -47,7 +47,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<TouchableOpacity
className={`
p-3 rounded-xl items-center justify-center
${loading || (disabled && "opacity-50")}
${(loading || disabled) && "opacity-50"}
${colorClasses}
${className}
`}

View File

@@ -10,6 +10,7 @@ import GoogleCast, {
useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
@@ -53,51 +54,30 @@ export const Chromecast: React.FC<Props> = ({
if (background === "transparent")
return (
<>
<TouchableOpacity
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center b"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
<AndroidCastButton />
</>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
<RoundButton
size="large"
className="mr-2"
background={false}
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</TouchableOpacity>
</RoundButton>
);
return (
<TouchableOpacity
<RoundButton
size="large"
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<BlurView
intensity={100}
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
{...props}
>
<Feather name="cast" size={22} color={"white"} />
</BlurView>
<AndroidCastButton />
</TouchableOpacity>
<Feather name="cast" size={22} color={"white"} />
</RoundButton>
);
};

View File

@@ -21,7 +21,7 @@ import {
import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { Alert, View, ViewProps } from "react-native";
import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
@@ -30,18 +30,25 @@ import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
interface DownloadProps extends ViewProps {
items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement;
DownloadedIconComponent: () => React.ReactElement;
title?: string;
subtitle?: string;
size?: "default" | "large";
}
export const DownloadItems: React.FC<DownloadProps> = ({
items,
MissingDownloadIconComponent,
DownloadedIconComponent,
title = "Download",
subtitle = "",
size = "default",
...props
}) => {
const [api] = useAtom(apiAtom);
@@ -71,9 +78,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[settings]
);
/**
* Bottom sheet
*/
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => {
@@ -86,18 +90,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.dismiss();
}, []);
// region computed
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
const pendingItems = useMemo(
const itemsNotDownloaded = useMemo(
() =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles]
);
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
return pendingItems.length == 0;
}, [downloadedFiles, pendingItems]);
const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false;
return itemsNotDownloaded.length === 0;
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds]
@@ -116,13 +120,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsQueued = useMemo(() => {
return (
pendingItems.length > 0 &&
pendingItems.every((p) => queue.some((q) => p.Id == q.item.Id))
itemsNotDownloaded.length > 0 &&
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
);
}, [queue, pendingItems]);
// endregion computed
// region helper functions
}, [queue, itemsNotDownloaded]);
const navigateToDownloads = () => router.push("/downloads");
const onDownloadedPress = () => {
@@ -141,17 +142,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (pendingItems.some((i) => !i.Id)) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
if (usingOptimizedServer) initiateDownload(...pendingItems);
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
else {
queueActions.enqueue(
queue,
setQueue,
...pendingItems.map((item) => ({
...itemsNotDownloaded.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
@@ -164,27 +165,22 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}, [
queue,
setQueue,
pendingItems,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
// Need to be reference at the time async lambda is created for initiateDownload
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
/**
* Start download
*/
const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => {
if (
!api ||
!user?.Id ||
items.some((p) => !p.Id) ||
(pendingItems.length === 1 && !selectedMediaSource?.Id)
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
@@ -195,7 +191,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
let subtitleIndex: number | undefined = selectedSubtitleStream;
for (const item of items) {
if (pendingItems.length > 1) {
if (itemsNotDownloaded.length > 1) {
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
item,
settings!
@@ -238,7 +234,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[
api,
user?.Id,
pendingItems,
itemsNotDownloaded,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
@@ -260,58 +256,61 @@ export const DownloadItems: React.FC<DownloadProps> = ({
),
[]
);
// endregion helper functions
// Allow to select & set settings for single download
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (pendingItems.length !== 1) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
// 4. Set states
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, pendingItems, settings])
}, [items, itemsNotDownloaded, settings])
);
return (
<View
className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center"
{...props}
>
{processes && itemsProcesses.length > 0 ? (
<TouchableOpacity onPress={navigateToDownloads}>
{progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
)}
</TouchableOpacity>
) : itemsQueued ? (
<TouchableOpacity onPress={navigateToDownloads}>
<Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity>
) : isDownloaded ? (
<TouchableOpacity onPress={onDownloadedPress}>
{DownloadedIconComponent()}
</TouchableOpacity>
const renderButtonContent = () => {
if (processes && itemsProcesses.length > 0) {
return progress === 0 ? (
<Loader />
) : (
<TouchableOpacity onPress={handlePresentModalPress}>
{MissingDownloadIconComponent()}
</TouchableOpacity>
)}
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
);
} else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />;
} else if (allItemsDownloaded) {
return <DownloadedIconComponent />;
} else {
return <MissingDownloadIconComponent />;
}
};
const onButtonPress = () => {
if (processes && itemsProcesses.length > 0) {
navigateToDownloads();
} else if (itemsQueued) {
navigateToDownloads();
} else if (allItemsDownloaded) {
onDownloadedPress();
} else {
handlePresentModalPress();
}
};
return (
<View {...props}>
<RoundButton size={size} onPress={onButtonPress}>
{renderButtonContent()}
</RoundButton>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
@@ -326,16 +325,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<Text className="font-bold text-2xl text-neutral-10">
Download options
</Text>
<View>
<Text className="font-bold text-2xl text-neutral-100">
{title}
</Text>
<Text className="text-neutral-300">
{subtitle || `Download ${itemsNotDownloaded.length} items`}
</Text>
</View>
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={setMaxBitrate}
selected={maxBitrate}
/>
{pendingItems.length === 1 && (
{itemsNotDownloaded.length === 1 && (
<>
<MediaSourceSelector
item={items[0]}
@@ -380,11 +384,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
};
export const DownloadSingleItem: React.FC<{ item: BaseItemDto }> = ({
item,
}) => {
export const DownloadSingleItem: React.FC<{
size?: "default" | "large";
item: BaseItemDto;
}> = ({ item, size = "default" }) => {
return (
<DownloadItems
size={size}
title="Download Episode"
subtitle={item.Name!}
items={[item]}
MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" />

View File

@@ -1,22 +1,43 @@
// GenreTags.tsx
import React from "react";
import { View } from "react-native";
import {View, ViewProps} from "react-native";
import { Text } from "./common/Text";
interface GenreTagsProps {
genres?: string[];
interface TagProps {
tags?: string[];
textClass?: ViewProps["className"]
}
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
if (!genres || genres.length === 0) return null;
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
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 (
<View className="flex flex-row flex-wrap mt-2">
{genres.map((genre, idx) => (
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
<Text className="text-xs">{genre}</Text>
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
{tags.map((tag, idx) => (
<View>
<Tag key={idx} textClass={textClass} text={tag}/>
</View>
))}
</View>
);
};
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
return (
<View className="mt-2">
<Tags tags={genres}/>
</View>
);
};

View File

@@ -13,12 +13,13 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
<View className="mt-2 flex flex-col">
{item.Type === "Episode" ? (
<>
<Text numberOfLines={2} className="">
{item.SeriesName}
<Text numberOfLines={1} className="">
{item.Name}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
{item.Name}
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : (

View File

@@ -15,12 +15,12 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
BaseItemDto,
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
@@ -31,10 +31,9 @@ import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
export type SelectedOptions = {
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.
useEffect(() => {
console.log(defaultAudioIndex, defaultSubtitleIndex);
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
@@ -90,7 +88,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<Chromecast background="blur" width={22} height={22} />
{item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2">
<DownloadSingleItem item={item} />
<DownloadSingleItem item={item} size="large" />
<PlayedStatus item={item} />
</View>
)}
@@ -220,7 +218,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className="mr-1"
source={selectedOptions.mediaSource}
onChange={(val) => {
console.log(val);
setSelectedOptions(
(prev) =>
prev && {
@@ -286,8 +283,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
<SimilarItems itemId={item.Id} />
</>
)}
<View className="h-16"></View>
</View>
</ParallaxScrollView>
</View>

View File

@@ -1,10 +1,11 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { View, ViewProps } from "react-native";
import { GenreTags } from "./GenreTags";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { GenreTags } from "./GenreTags";
import React from "react";
import { ItemActions } from "./series/SeriesActions";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -27,7 +28,10 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<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" && (
<>
<EpisodeTitleHeader item={item} />

View File

@@ -124,11 +124,11 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return (
<View className="flex flex-col">
{audioStreams.map((audioStreams, index) => (
<View className="flex flex-col">
<View key={index} className="flex flex-col">
<Text className="mb-3 text-neutral-400 text-xs">
{audioStreams.DisplayTitle}
</Text>
<View key={index} className="flex-row flex-wrap gap-2">
<View className="flex-row flex-wrap gap-2">
<Badge
variant="gray"
iconLeft={

View File

@@ -24,7 +24,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text uiTextView selectable className="text-xs">
<Text uiTextView selectable className="text-xs text-neutral-400">
{subTitle}
</Text>
)}

View File

@@ -32,6 +32,7 @@ import Animated, {
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import * as Haptics from "expo-haptics";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
@@ -78,6 +79,8 @@ export const PlayButton: React.FC<Props> = ({
const onPress = useCallback(async () => {
if (!item) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",

View File

@@ -1,22 +1,15 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { Ionicons } from "@expo/vector-icons";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const invalidateQueries = () => {
@@ -46,44 +39,16 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
});
};
const markAsPlayedStatus = useMarkAsPlayed(item);
return (
<View
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{item.UserData?.Played ? (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsNotPlayed({
api: api,
itemId: item?.Id,
userId: user?.Id,
});
invalidateQueries();
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={24} color="white" />
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsPlayed({
api: api,
item: item,
userId: user?.Id,
});
invalidateQueries();
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
</View>
</TouchableOpacity>
)}
<View {...props}>
<RoundButton
fillColor={item.UserData?.Played ? "primary" : undefined}
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
size="large"
/>
</View>
);
};

View File

@@ -3,6 +3,10 @@ import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
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 {
item?: BaseItemDto | null;
@@ -17,7 +21,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
)}
{item.CommunityRating && (
<Badge
text={item.CommunityRating}
text={item.CommunityRating.toFixed(1)}
variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />}
/>
@@ -28,7 +32,11 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
variant="gray"
iconLeft={
<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={{
width: 14,
height: 14,
@@ -40,3 +48,86 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
</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>
)
);
};

114
components/RoundButton.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { PropsWithChildren } from "react";
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
import * as Haptics from "expo-haptics";
interface Props extends TouchableOpacityProps {
onPress?: () => void,
icon?: keyof typeof Ionicons.glyphMap;
background?: boolean;
size?: "default" | "large";
fillColor?: "primary";
hapticFeedback?: boolean;
}
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
background = true,
icon,
onPress,
children,
size = "default",
fillColor,
hapticFeedback = true,
...props
}) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
const handlePress = () => {
if (hapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
onPress?.();
};
if (fillColor)
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
if (background === false)
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
if (Platform.OS === "android")
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-neutral-800/80"
}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
return (
<TouchableOpacity onPress={handlePress} {...props}>
<BlurView
intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...props}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={"white"}
/>
) : null}
{children ? children : null}
</BlurView>
</TouchableOpacity>
);
};

View 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>
</>
);
};

View File

@@ -1,8 +1,10 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as Haptics from "expo-haptics";
import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
@@ -62,18 +64,87 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const from = segments[2];
if (from === "(home)" || from === "(search)" || from === "(libraries)")
const markAsPlayedStatus = useMarkAsPlayed(item);
if (
from === "(home)" ||
from === "(search)" ||
from === "(libraries)" ||
from === "(favorites)"
)
return (
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const url = itemRouter(item, from);
// @ts-ignore
router.push(url);
}}
{...props}
>
{children}
</TouchableOpacity>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={() => {
const url = itemRouter(item, from);
// @ts-ignore
router.push(url);
}}
{...props}
>
{children}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
<ContextMenu.Item
key="item-1"
onSelect={() => {
markAsPlayedStatus(true);
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key="item-1-title">
Mark as watched
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "green", // Changed to green for "watched"
light: "green",
},
}}
androidIconName="checkmark-circle"
></ContextMenu.ItemIcon>
</ContextMenu.Item>
<ContextMenu.Item
key="item-2"
onSelect={() => {
markAsPlayedStatus(false);
}}
shouldDismissMenuOnSelect
destructive
>
<ContextMenu.ItemTitle key="item-2-title">
Mark as not watched
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
pointSize: 18, // Adjusted for better visibility
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "red", // Changed to red for "not watched"
light: "red",
},
// Removed paletteColors as it's not necessary in this case
}}
androidIconName="eye-slash"
></ContextMenu.ItemIcon>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -1,5 +1,5 @@
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 React, { useEffect, useMemo, useState } from "react";
import { TextProps } from "react-native";
@@ -29,7 +29,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
s += size;
}
}
setSize(bytesToReadable(s));
setSize(s.bytesToReadable());
}, [itemIds]);
const sizeText = useMemo(() => {

View File

@@ -97,7 +97,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
/>
</View>
)}
<ItemCardText item={item} />
<View className="w-28">
<ItemCardText item={item} />
</View>
<DownloadSize items={[item]} />
</TouchableOpacity>
);

View 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>
);
};

View File

@@ -18,6 +18,7 @@ interface Props extends ViewProps {
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean;
}
export const ScrollingCollectionList: React.FC<Props> = ({
@@ -26,10 +27,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
disabled = false,
queryFn,
queryKey,
hideIfEmpty = false,
...props
}) => {
// console.log(queryKey);
const { data, isLoading } = useQuery({
queryKey: queryKey,
queryFn,
@@ -41,8 +41,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
if (disabled || !title) return null;
if (hideIfEmpty === true && data?.length === 0) return null;
return (
<View {...props} className="">
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{title}
</Text>
@@ -82,15 +84,13 @@ export const ScrollingCollectionList: React.FC<Props> = ({
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{data?.map((item, index) => (
{data?.map((item) => (
<TouchableItemRouter
item={item}
key={index}
className={`
mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
key={item.Id}
className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />

View 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;

View 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;

View 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;

View 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;

View File

@@ -11,6 +11,7 @@ import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { FlashList } from "@shopify/flash-list";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -43,10 +44,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
<HorizontalScroll
<Text className="text-lg font-bold px-4 mb-2">Next up</Text>
<FlashList
contentContainerStyle={{ paddingLeft: 16 }}
horizontal
estimatedItemSize={172}
showsHorizontalScrollIndicator={false}
data={items}
renderItem={(item, index) => (
renderItem={({ item, index }) => (
<TouchableItemRouter
item={item}
key={index}

View File

@@ -19,7 +19,7 @@ type SeasonKeys = {
};
export type SeasonIndexState = {
[seriesId: string]: number | null | undefined;
[seriesId: string]: number | string | null | undefined;
};
export const SeasonDropdown: React.FC<Props> = ({

View File

@@ -30,7 +30,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const seasonIndex = seasonIndexState[item.Id ?? ""];
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
[item, seasonIndexState]
);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
@@ -53,19 +56,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
return response.data.Items;
},
staleTime: 60,
enabled: !!api && !!user?.Id && !!item.Id,
});
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex]
);
const selectedSeasonId: string | null = useMemo(() => {
const season: BaseItemDto = seasons?.find(
(s: BaseItemDto) =>
s.IndexNumber === seasonIndex || s.Name === seasonIndex
);
if (!season?.Id) return null;
return season.Id!;
}, [seasons, seasonIndex]);
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
return [];
}
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
@@ -74,6 +86,12 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
fields: ["MediaSources", "MediaStreams", "Overview"],
});
if (res.data.TotalRecordCount === 0)
console.warn(
"No episodes found for season with ID ~",
selectedSeasonId
);
return res.data.Items;
},
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
@@ -118,32 +136,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
if (!item.Id) return;
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
[item.Id!]: season.IndexNumber ?? season.Name,
}));
}}
/>
<DownloadItems
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<MaterialCommunityIcons
name="download-multiple"
size={20}
color="white"
/>
)}
DownloadedIconComponent={() => (
<MaterialCommunityIcons
name="check-all"
size={20}
color="#9333ea"
/>
)}
/>
{episodes?.length || 0 > 0 ? (
<DownloadItems
title="Download Season"
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={20} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="download" size={20} color="#9333ea" />
)}
/>
) : null}
</View>
<View className="px-4 flex flex-col my-4">
<View className="px-4 flex flex-col mt-4">
{isFetching ? (
<View
style={{
@@ -193,6 +207,13 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</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 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -57,8 +57,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
updateSettings(update);
console.log("update", update);
let updatePayload = {
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
PlayDefaultAudioTrack:
@@ -84,8 +82,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
"";
console.log("updatePayload", updatePayload);
updateUserConfiguration(updatePayload);
};

View File

@@ -21,7 +21,7 @@ import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Linking,
Switch,
@@ -40,6 +40,9 @@ import { Stepper } from "@/components/inputs/Stepper";
import { MediaProvider } from "./MediaContext";
import { SubtitleToggles } from "./SubtitleToggles";
import { AudioToggles } from "./AudioToggles";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { ListItem } from "@/components/ListItem";
import { JellyseerrSettings } from "./Jellyseerr";
interface Props extends ViewProps {}
@@ -258,6 +261,21 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Root>
</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-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
@@ -618,6 +636,8 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</View>
</View>
</View>
<JellyseerrSettings />
</View>
);
};

View File

@@ -1,6 +1,15 @@
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { HeaderBackButton } from "../common/HeaderBackButton";
import { ParamListBase, RouteProp } from "@react-navigation/native";
const commonScreenOptions = {
type ICommonScreenOptions =
| NativeStackNavigationOptions
| ((prop: {
route: RouteProp<ParamListBase, string>;
navigation: any;
}) => NativeStackNavigationOptions);
export const commonScreenOptions: ICommonScreenOptions = {
title: "",
headerShown: true,
headerTransparent: true,
@@ -17,5 +26,5 @@ const routes = [
"series/[id]",
];
export const nestedTabPageScreenOptions: { [key: string]: any } =
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

View File

@@ -20,7 +20,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const fetchInitialVolume = async () => {
try {
const { volume: initialVolume } = await VolumeManager.getVolume();
console.log("initialVolume", initialVolume);
volume.value = initialVolume * 100;
} catch (error) {
console.error("Error fetching initial volume:", error);
@@ -39,7 +38,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const handleValueChange = async (value: number) => {
volume.value = value;
console.log("volume through slider", value);
await VolumeManager.setVolume(value / 100);
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
@@ -48,7 +46,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
useEffect(() => {
const volumeListener = VolumeManager.addVolumeListener((result) => {
console.log("Volume through device", result.volume);
volume.value = result.volume * 100;
setVisibility(true);

View File

@@ -14,7 +14,6 @@ const BrightnessSlider = () => {
useEffect(() => {
const fetchInitialBrightness = async () => {
const initialBrightness = await Brightness.getBrightnessAsync();
console.log("initialBrightness", initialBrightness);
brightness.value = initialBrightness * 100;
};
fetchInitialBrightness();

View File

@@ -1,3 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
@@ -33,7 +34,6 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import { useCallback, useEffect, useRef, useState } from "react";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
@@ -42,7 +42,10 @@ import {
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
@@ -79,7 +82,7 @@ interface Props {
setSubtitleURL?: (url: string, customName: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
stop?: (() => Promise<void>) | (() => void);
stop: (() => Promise<void>) | (() => void);
isVlc?: boolean;
}
@@ -237,8 +240,6 @@ export const Controls: React.FC<Props> = ({
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
console.log("remaining: ", remaining);
setCurrentTime(current);
setRemainingTime(remaining);
},
@@ -346,7 +347,6 @@ export const Controls: React.FC<Props> = ({
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
try {
const curr = progress.value;
console.log(curr);
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
@@ -372,8 +372,6 @@ export const Controls: React.FC<Props> = ({
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
console.log("time, ", time);
return (
<View
style={{
@@ -506,11 +504,24 @@ export const Controls: React.FC<Props> = ({
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
<View
style={[
{
position: "absolute",
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
opacity: showControls ? 1 : 0,
zIndex: 1000,
},
]}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{!mediaSource?.TranscodingUrl ? (
<DropdownViewDirect showControls={showControls} />
) : (
<DropdownViewTranscoding showControls={showControls} />
)}
</View>
</VideoProvider>
<Pressable
@@ -528,15 +539,15 @@ export const Controls: React.FC<Props> = ({
style={[
{
position: "absolute",
top: 0,
right: 0,
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
{item?.Type === "Episode" && (
{item?.Type === "Episode" && !offline && (
<TouchableOpacity
onPress={() => {
switchOnEpisodeMode();
@@ -546,7 +557,7 @@ export const Controls: React.FC<Props> = ({
<Ionicons name="list" size={24} color="white" />
</TouchableOpacity>
)}
{previousItem && (
{previousItem && !offline && (
<TouchableOpacity
onPress={goToPreviousItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
@@ -555,7 +566,7 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity>
)}
{nextItem && (
{nextItem && !offline && (
<TouchableOpacity
onPress={goToNextItem}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
@@ -578,6 +589,7 @@ export const Controls: React.FC<Props> = ({
)}
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
@@ -590,8 +602,8 @@ export const Controls: React.FC<Props> = ({
style={{
position: "absolute",
top: "50%", // Center vertically
left: 0,
right: 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
@@ -704,9 +716,9 @@ export const Controls: React.FC<Props> = ({
style={[
{
position: "absolute",
right: 0,
left: 0,
bottom: 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
},
]}
className={`flex flex-col p-4`}

View File

@@ -35,7 +35,6 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
},
(finished) => {
if (finished && onFinish) {
console.log("finish");
runOnJS(onFinish)();
}
}

View File

@@ -72,105 +72,86 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
}>();
return (
<View
style={{
position: "absolute",
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => {
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
setSubtitleURL(
api?.basePath + sub.deliveryUrl,
sub.name
);
console.log(
"Set external subtitle: ",
api?.basePath + sub.deliveryUrl
);
} else {
console.log("Set sub index: ", sub.index);
setSubtitleTrack && setSubtitleTrack(sub.index);
}
router.setParams({
subtitleIndex: sub.index.toString(),
});
console.log("Subtitle: ", sub);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
setAudioTrack && setAudioTrack(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="subtitle-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => {
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
} else {
setSubtitleTrack && setSubtitleTrack(sub.index);
}
router.setParams({
subtitleIndex: sub.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="audio-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={audioIndex === track.index.toString()}
onValueChange={() => {
setAudioTrack && setAudioTrack(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -66,7 +66,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
console.log("sortedSubtitles", sortedSubtitles);
return [disableSubtitle, ...sortedSubtitles];
}
@@ -104,7 +103,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const ChangeTranscodingAudio = useCallback(
(audioIndex: number) => {
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
@@ -167,7 +165,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
}
key={`subtitle-item-${idx}`}
onValueChange={() => {
console.log("sub", sub);
if (
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
@@ -216,7 +213,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
value={audioIndex === track.index.toString()}
onValueChange={() => {
if (audioIndex === track.index.toString()) return;
console.log("Setting audio track to: ", track.index);
router.setParams({
audioIndex: track.index.toString(),
});

View File

@@ -22,13 +22,13 @@
}
},
"production": {
"channel": "0.22.0",
"channel": "0.23.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.22.0",
"channel": "0.23.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -5,6 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
import * as Haptics from "expo-haptics";
interface CreditTimestamps {
Introduction: {
@@ -78,6 +79,7 @@ export const useCreditSkipper = (
if (!creditTimestamps) return;
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
wrappedSeek(creditTimestamps.Credits.End);
setTimeout(() => {
play();

View File

@@ -5,6 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
import * as Haptics from "expo-haptics";
interface IntroTimestamps {
EpisodeId: string;
@@ -75,9 +76,9 @@ export const useIntroSkipper = (
}, [introTimestamps, currentTime]);
const skipIntro = useCallback(() => {
console.log("skipIntro");
if (!introTimestamps) return;
try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
wrappedSeek(introTimestamps.IntroEnd);
setTimeout(() => {
play();

378
hooks/useJellyseerr.ts Normal file
View 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,
};
};

88
hooks/useMarkAsPlayed.ts Normal file
View File

@@ -0,0 +1,88 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
export const useMarkAsPlayed = (item: BaseItemDto) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const invalidateQueries = () => {
const queriesToInvalidate = [
["item", item.Id],
["resumeItems"],
["continueWatching"],
["nextUp-all"],
["nextUp"],
["episodes"],
["seasons"],
["home"],
];
queriesToInvalidate.forEach((queryKey) => {
queryClient.invalidateQueries({ queryKey });
});
};
const markAsPlayedStatus = async (played: boolean) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Optimistic update
queryClient.setQueryData(
["item", item.Id],
(oldData: BaseItemDto | undefined) => {
if (oldData) {
return {
...oldData,
UserData: {
...oldData.UserData,
Played: !played,
},
};
}
return oldData;
}
);
try {
if (played) {
await markAsNotPlayed({
api: api,
itemId: item?.Id,
userId: user?.Id,
});
} else {
await markAsPlayed({
api: api,
item: item,
userId: user?.Id,
});
}
invalidateQueries();
} catch (error) {
// Revert optimistic update on error
queryClient.setQueryData(
["item", item.Id],
(oldData: BaseItemDto | undefined) => {
if (oldData) {
return {
...oldData,
UserData: {
...oldData.UserData,
Played: played,
},
};
}
return oldData;
}
);
console.error("Error updating played status:", error);
}
};
return markAsPlayedStatus;
};

View File

@@ -3,11 +3,12 @@
"main": "./index",
"version": "1.0.0",
"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",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"android": "bun run submodule-reload && expo run:android",
"ios": "bun run submodule-reload && expo run:ios",
"web": "bun run submodule-reload && expo start --web",
"test": "jest --watchAll",
"lint": "expo lint",
"postinstall": "patch-package"
@@ -97,6 +98,8 @@
"react-native-video": "^6.7.0",
"react-native-volume-manager": "^1.10.0",
"react-native-web": "~0.19.13",
"react-native-webview": "^13.12.5",
"react-native-youtube-iframe": "^2.3.0",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",

View File

@@ -39,7 +39,7 @@ import React, {
useMemo,
useState,
} from "react";
import {AppState, AppStateStatus, Platform} from "react-native";
import { AppState, AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native";
import { apiAtom } from "./JellyfinProvider";
import * as Notifications from "expo-notifications";
@@ -195,7 +195,7 @@ function useDownloadProvider() {
[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(
async (process: JobStatus) => {
@@ -427,14 +427,19 @@ function useDownloadProvider() {
for (const item of dirContents) {
// Exclude mmkv directory.
// Deleting this deletes all user information as well. Logout should handle this.
if ((item == "mmkv" && !includeMMKV) || ignoreList.some(i => item.includes(i))) {
console.log("Skipping read for item", item)
if (
(item == "mmkv" && !includeMMKV) ||
ignoreList.some((i) => item.includes(i))
) {
continue;
}
const itemInfo = await FileSystem.getInfoAsync(`${baseDirectory}${item}`);
if (!itemInfo.isDirectory && itemInfo.exists) {
callback(itemInfo);
}
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
.then((itemInfo) => {
if (itemInfo.exists && !itemInfo.isDirectory) {
callback(itemInfo);
}
})
.catch((e) => console.error(e));
}
};
@@ -533,28 +538,36 @@ function useDownloadProvider() {
};
const cleanCacheDirectory = async () => {
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
const cacheDir = await FileSystem.getInfoAsync(
APP_CACHE_DOWNLOAD_DIRECTORY
);
if (cacheDir.exists) {
const cachedFiles = await FileSystem.readDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY)
let position = 0
const batchSize = 3
const cachedFiles = await FileSystem.readDirectoryAsync(
APP_CACHE_DOWNLOAD_DIRECTORY
);
let position = 0;
const batchSize = 3;
// batching promise.all to avoid OOM
while (position < cachedFiles.length) {
const itemsForBatch = cachedFiles.slice(position, position + batchSize)
await Promise.all(itemsForBatch.map(async file => {
const info = await FileSystem.getInfoAsync(`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`)
if (info.exists) {
await FileSystem.deleteAsync(info.uri, { idempotent: true })
return Promise.resolve(file)
}
return Promise.reject()
}))
const itemsForBatch = cachedFiles.slice(position, position + batchSize);
await Promise.all(
itemsForBatch.map(async (file) => {
const info = await FileSystem.getInfoAsync(
`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`
);
if (info.exists) {
await FileSystem.deleteAsync(info.uri, { idempotent: true });
return Promise.resolve(file);
}
return Promise.reject();
})
);
position += batchSize
position += batchSize;
}
}
}
};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
await Promise.all(
@@ -571,21 +584,25 @@ function useDownloadProvider() {
};
const appSizeUsage = useMemo(async () => {
const ignore: string[] = [];
const sizes: number[] = downloadedFiles?.map(d => {
ignore.push(d.item.Id!!)
return getDownloadedItemSize(d.item.Id!!)
}) || [];
const sizes: number[] =
downloadedFiles?.map((d) => {
return getDownloadedItemSize(d.item.Id!!);
}) || [];
await forEveryDocumentDirFile(true, ignore, (file) => {
// Skip reading downloaded files since these are saved in storage
if (!downloadedFiles?.some(d => file.uri.includes(d.item.Id!!)) && file.exists) {
sizes.push(file.size);
await forEveryDocumentDirFile(
true,
getAllDownloadedItems().map((d) => d.item.Id!!),
(file) => {
if (file.exists) {
sizes.push(file.size);
}
}
).catch((e) => {
console.error(e);
});
return sizes.reduce((sum, size) => sum + size, 0);
}, [logs, downloadedFiles]);
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
function getDownloadedItem(itemId: string): DownloadedItem | null {
try {
@@ -676,7 +693,7 @@ function useDownloadProvider() {
appSizeUsage,
getDownloadedItemSize,
APP_CACHE_DOWNLOAD_DIRECTORY,
cleanCacheDirectory
cleanCacheDirectory,
};
}
@@ -697,16 +714,3 @@ export function useDownload() {
}
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`;
}

View File

@@ -54,7 +54,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.22.0" },
clientInfo: { name: "Streamyfin", version: "0.23.0" },
deviceInfo: {
name: deviceName,
id,
@@ -91,7 +91,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.22.0"`,
}, DeviceId="${deviceId}", Version="0.23.0"`,
};
}, [deviceId]);

0
svenska_kyrkan.sql Normal file
View File

View File

@@ -85,7 +85,9 @@ export type Settings = {
autoDownload: boolean;
showCustomMenuLinks: boolean;
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 => {
@@ -122,6 +124,8 @@ const loadSettings = (): Settings => {
showCustomMenuLinks: false,
subtitleSize: Platform.OS === "ios" ? 60 : 100,
remuxConcurrentLimit: 1,
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
};
try {

View File

@@ -0,0 +1,53 @@
import {
BaseItemKind,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client";
/**
* Converts a ColletionType to a BaseItemKind (also called ItemType)
*
* CollectionTypes
* readonly Unknown: "unknown";
readonly Movies: "movies";
readonly Tvshows: "tvshows";
readonly Music: "music";
readonly Musicvideos: "musicvideos";
readonly Trailers: "trailers";
readonly Homevideos: "homevideos";
readonly Boxsets: "boxsets";
readonly Books: "books";
readonly Photos: "photos";
readonly Livetv: "livetv";
readonly Playlists: "playlists";
readonly Folders: "folders";
*/
export const colletionTypeToItemType = (
collectionType?: CollectionType | null
): BaseItemKind | undefined => {
if (!collectionType) return undefined;
switch (collectionType) {
case CollectionType.Movies:
return BaseItemKind.Movie;
case CollectionType.Tvshows:
return BaseItemKind.Series;
case CollectionType.Homevideos:
return BaseItemKind.Video;
case CollectionType.Musicvideos:
return BaseItemKind.MusicVideo;
case CollectionType.Books:
return BaseItemKind.Book;
case CollectionType.Playlists:
return BaseItemKind.Playlist;
case CollectionType.Folders:
return BaseItemKind.Folder;
case CollectionType.Photos:
return BaseItemKind.Photo;
case CollectionType.Trailers:
return BaseItemKind.Trailer;
case CollectionType.Playlists:
return BaseItemKind.Playlist;
}
return undefined;
};

1
utils/jellyseerr Submodule

Submodule utils/jellyseerr added at e69d160e25