Compare commits

...

35 Commits

Author SHA1 Message Date
herrrta
2be8096f4a repos + plugin config updates 2025-01-10 19:28:22 -05:00
Fredrik Burmester
38aad9610b Merge branch 'feat/401' of https://github.com/streamyfin/streamyfin into feat/401 2025-01-09 15:49:02 +01:00
herrrta
54af64abef api augmentations & added streamyfin plugin id 2025-01-09 09:35:24 -05:00
herrrta
e1720a00da initial changes 2025-01-09 09:33:55 -05:00
herrrta
882d0ea188 api augmentations & added streamyfin plugin id 2025-01-09 08:51:53 -05:00
Fredrik Burmester
f3b539232f Merge pull request #403 from streamyfin/feature/mediasourcenames
Feature: Remove duplicate names from media sources
2025-01-09 11:33:06 +01:00
sarendsen
33ea657a5c Filter out duplicate names in media sources 2025-01-09 10:13:57 +01:00
herrrta
75820adcbc initial changes 2025-01-08 21:52:31 -05:00
herrrta
76cdb2b3f8 fix cast npe 2025-01-08 17:31:39 -05:00
Fredrik Burmester
0a2ea33635 Merge pull request #397 from topiga/master 2025-01-08 18:50:02 +01:00
Théo FORTIN
aad6093852 Added 1 Mb/s as bitrate 2025-01-08 15:22:11 +01:00
herrrta
c553cff9d1 Added clean script 2025-01-08 08:05:06 -05:00
herrrta
dcd458bd3d [Jellyseerr] "Currently Streaming On" misaligned text
fixes #392
2025-01-08 08:04:48 -05:00
Fredrik Burmester
05dc61d17d Merge pull request #395 from streamyfin/feat/331
[Jellyseerr] Show media configuration for admins
2025-01-08 11:40:07 +01:00
Fredrik Burmester
e4de11127f chore 2025-01-08 11:39:50 +01:00
herrrta
2dc49735f4 [Jellyseerr] Show media configuration for admins
implements #331
2025-01-07 23:53:10 -05:00
Fredrik Burmester
ef42207174 Merge pull request #383 from Ryan0204/master
Change ScreenOrientation to landscape right by default and added toggleSafeArea for all videos
2025-01-07 11:01:43 +01:00
Fredrik Burmester
efa5638b12 fix: remove tab sidebar 2025-01-07 10:59:21 +01:00
Fredrik Burmester
c63cea891d chore: remove imports 2025-01-07 10:27:56 +01:00
Fredrik Burmester
4e80f58823 fix: backdrop not filling screen 2025-01-07 10:27:30 +01:00
ryan0204
cfe39d504c Rotate ScreenOrientation back on exit player 2025-01-07 14:13:22 +08:00
herrrta
cf43d1a657 cleanup 2025-01-06 20:56:59 -05:00
herrrta
cbe3b18226 fix enter animation 2025-01-06 20:55:50 -05:00
herrrta
b637a0f7d2 use JellyseerrPoster component 2025-01-06 20:55:48 -05:00
Fredrik Burmester
a0ce7cc6d0 chore 2025-01-06 22:58:11 +01:00
Fredrik Burmester
a640df30bc chore 2025-01-06 22:32:27 +01:00
Fredrik Burmester
062e6e6c23 chore 2025-01-06 22:21:27 +01:00
Fredrik Burmester
d709e3b13e Merge pull request #389 from streamyfin/feat/326
[Jellyseerr] Show genre/studio/network discover sliders
2025-01-06 20:57:10 +01:00
herrrta
b232bebd73 [Jellyseerr] Show genre/studio/network discover sliders
implements #326
2025-01-06 14:25:14 -05:00
Fredrik Burmester
90ef8ef6f9 feat: fade in images 2025-01-06 17:38:59 +01:00
Fredrik Burmester
0df6b8e2a0 chore 2025-01-06 17:33:49 +01:00
Fredrik Burmester
f48b26076d feat: loading skeleton for search (including jellyseerr) 2025-01-06 17:33:27 +01:00
ryan0204
c86a8438e5 Bring back toggleSafeArea button for all videos 2025-01-06 23:15:00 +08:00
Ryan
faa2baae68 Merge branch 'streamyfin:master' into master 2025-01-06 20:28:12 +08:00
ryan0204
ed42371353 Change ScreenOrientation to landscape right by default 2025-01-06 20:27:34 +08:00
51 changed files with 1843 additions and 1038 deletions

View File

@@ -43,7 +43,7 @@ body:
label: Version
description: What version of Streamyfin are you running?
options:
- 0.24.0
- 0.25.0
- 0.22.0
- 0.21.0
- older

4
.gitignore vendored
View File

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

3
.idea/.gitignore generated vendored
View File

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

View File

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

6
.idea/misc.xml generated
View File

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

8
.idea/modules.xml generated
View File

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

9
.idea/streamyfin.iml generated
View File

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

6
.idea/vcs.xml generated
View File

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

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.24.0",
"version": "0.25.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -78,18 +78,16 @@
"useFrameworks": "static"
},
"android": {
"android": {
"compileSdkVersion": 34,
"targetSdkVersion": 34,
"buildToolsVersion": "34.0.0"
},
"compileSdkVersion": 34,
"targetSdkVersion": 34,
"buildToolsVersion": "34.0.0",
"minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
"useLegacyPackaging": true
}
}
"extraMavenRepos": [
"https://repo.maven.apache.org/maven2",
"https://uk.maven.org/maven2"
],
"useLegacyPackaging": true
}
}
],

View File

@@ -1,247 +0,0 @@
import {
router,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { TouchableOpacity, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Text } from "@/components/common/Text";
import { Animated } from "react-native";
import { Image } from "expo-image";
import { OverviewText } from "@/components/OverviewText";
import { orderBy } from "lodash";
import { FlashList } from "@shopify/flash-list";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
const ANIMATION_ENTER = 250;
const ANIMATION_EXIT = 250;
const BACKDROP_DURATION = 5000;
export default function page() {
const insets = useSafeAreaInsets();
const local = useLocalSearchParams();
const segments = useSegments();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { personId } = local as { personId: string };
const from = segments[2];
const [currentIndex, setCurrentIndex] = useState(0);
const fadeAnim = useRef(new Animated.Value(0)).current;
const { data, isLoading, isFetching } = useQuery({
queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId),
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
}),
enabled: !!jellyseerrApi && !!personId,
});
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo(
() =>
orderBy(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc"
),
[data?.combinedCredits]
);
const backdrops = useMemo(
() => castedRoles.map((c) => c.backdropPath),
[data?.combinedCredits]
);
const enterAnimation = useCallback(
() =>
Animated.timing(fadeAnim, {
toValue: 1,
duration: ANIMATION_ENTER,
useNativeDriver: true,
}),
[fadeAnim]
);
const exitAnimation = useCallback(
() =>
Animated.timing(fadeAnim, {
toValue: 0,
duration: ANIMATION_EXIT,
useNativeDriver: true,
}),
[fadeAnim]
);
useEffect(() => {
if (backdrops?.length) {
enterAnimation().start();
const intervalId = setInterval(() => {
exitAnimation().start((end) => {
if (end.finished)
setCurrentIndex((prevIndex) => (prevIndex + 1) % backdrops?.length);
});
}, BACKDROP_DURATION);
return () => clearInterval(intervalId);
}
}, [backdrops, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
const viewDetails = (credit: PersonCreditCast) => {
router.push({
//@ts-ignore
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
//@ts-ignore
params: {
...credit,
mediaTitle: credit.title,
releaseYear: new Date(credit.releaseDate).getFullYear(),
canRequest: "false",
posterSrc: jellyseerrApi?.imageProxy(
credit.posterPath,
"w300_and_h450_face"
),
},
});
};
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
headerHeight={300}
headerImage={
<Animated.Image
source={{
uri: jellyseerrApi?.imageProxy(
backdrops?.[currentIndex],
"w1920_and_h800_multi_faces"
),
}}
style={{
width: "100%",
height: "100%",
opacity: fadeAnim,
}}
/>
}
logo={
<Image
key={data?.details?.id}
id={data?.details?.id.toString()}
className="rounded-full bottom-1"
source={{
uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2"
),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
width: 125,
height: 125,
}}
/>
}
>
<View className="flex flex-col space-y-4 px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-full">
<Text className="font-bold text-2xl mb-1">
{data?.details?.name}
</Text>
<Text className="opacity-50">
Born{" "}
{new Date(data?.details?.birthday!!).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
}
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</View>
</View>
<OverviewText text={data?.details?.biography} className="mt-4" />
<View>
<FlashList
data={castedRoles}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">
No results
</Text>
</View>
}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={
<Text className="text-lg font-bold my-2">Appearances</Text>
}
renderItem={({ item }) => (
<TouchableOpacity
className="w-full flex flex-col pr-2"
onPress={() => viewDetails(item)}
>
<Poster
id={item.id.toString()}
url={jellyseerrApi?.imageProxy(item.posterPath)}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item.mediaType as "movie" | "tv"}
/>
{/*<Text numberOfLines={1}>{item.title}</Text>*/}
{item.character && (
<Text
className="text-xs opacity-50 align-bottom mt-1"
numberOfLines={1}
>
as {item.character}
</Text>
)}
</TouchableOpacity>
)}
keyExtractor={(item) => item.id.toString()}
estimatedItemSize={255}
numColumns={3}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
/>
</View>
</View>
</ParallaxScrollView>
</View>
);
}

View File

@@ -0,0 +1,95 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import {Image} from "expo-image";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {uniqBy} from "lodash";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const {companyId, name, image, type} = local as unknown as {
companyId: string,
name: string,
image: string,
type: DiscoverSliderType
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({pageParam}) => {
let params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
(
type == DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
) + `/${companyId}`,
params
)
},
enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
staleTime: 0,
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
logo={
<Image
id={companyId}
key={companyId}
className="bottom-1 w-1/2"
source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit="contain"
style={{
aspectRatio: "4/3",
}}
/>
}
renderItem={(item, index) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
);
}

View File

@@ -0,0 +1,87 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {uniqBy} from "lodash";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const {genreId, name, type} = local as unknown as {
genreId: string,
name: string,
type: DiscoverSliderType
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({pageParam}) => {
let params: any = {
page: Number(pageParam),
genre: genreId
};
return jellyseerrApi?.discover(
type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params
)
},
enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
staleTime: 0,
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
logo={
<Text
className="text-4xl font-bold text-center bottom-1"
style={{
...textShadowStyle.shadow,
shadowRadius: 10
}}>
{name}
</Text>
}
renderItem={(item, index) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
);
}

View File

@@ -1,28 +1,23 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLocalSearchParams, useNavigation } 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 {
MediaRequestStatus,
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
@@ -30,19 +25,16 @@ import {
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import { TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as DropdownMenu from "zeego/dropdown-menu";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { JellyserrRatings } from "@/components/Ratings";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { ItemActions } from "@/components/series/SeriesActions";
import Cast from "@/components/jellyseerr/Cast";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import RequestModal from "@/components/jellyseerr/RequestModal";
import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
@@ -60,6 +52,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {
@@ -83,7 +76,7 @@ const Page: React.FC = () => {
},
});
const canRequest = useJellyseerrCanRequest(details);
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -109,19 +102,27 @@ const Page: React.FC = () => {
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const request = useCallback(async () => {
requestMedia(
mediaTitle,
{
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
},
refetch
);
}, [details, result, requestMedia]);
const body: MediaRequestBody = {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}
if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body)
return
}
requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo(
() => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV,
[details]
)
useEffect(() => {
if (details) {
@@ -240,6 +241,10 @@ const Page: React.FC = () => {
result={result as TvResult}
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data)
}
/>
)}
<DetailFacts
@@ -250,6 +255,17 @@ const Page: React.FC = () => {
</View>
</View>
</ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
title={mediaTitle}
id={result.id!!}
type={result.mediaType as MediaType}
isAnime={isAnime}
onRequested={() => {
advancedReqModalRef?.current?.close()
refetch()
}}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing

View File

@@ -0,0 +1,107 @@
import {
useLocalSearchParams,
useSegments,
} from "expo-router";
import React, { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Text } from "@/components/common/Text";
import { Image } from "expo-image";
import { OverviewText } from "@/components/OverviewText";
import {orderBy, uniqBy} from "lodash";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { personId } = local as { personId: string };
const { data, isLoading, isFetching } = useQuery({
queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId),
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
}),
enabled: !!jellyseerrApi && !!personId,
});
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo(
() =>
uniqBy(orderBy(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc"
), 'id'),
[data?.combinedCredits]
);
const backdrops = useMemo(
() => jellyseerrApi
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, data?.combinedCredits]
);
return (
<ParallaxSlideShow
data={castedRoles}
images={backdrops}
listHeader="Appearances"
keyExtractor={(item) => item.id.toString()}
logo={
<Image
key={data?.details?.id}
id={data?.details?.id.toString()}
className="rounded-full bottom-1"
source={{
uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2"
),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
width: 125,
height: 125,
}}
/>
}
HeaderContent={() => (
<>
<Text className="font-bold text-2xl mb-1">
{data?.details?.name}
</Text>
<Text className="opacity-50">
Born{" "}
{new Date(data?.details?.birthday!!).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
}
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>
)}
MainContent={() => (
<OverviewText text={data?.details?.biography} className="mt-4" />
)}
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
/>
);
}

View File

@@ -36,7 +36,9 @@ export default function SearchLayout() {
}}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/[personId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
</Stack>
);
}

View File

@@ -2,14 +2,17 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import {
BaseItemDto,
BaseItemKind,
@@ -20,7 +23,6 @@ import axios from "axios";
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
PropsWithChildren,
useCallback,
useEffect,
useLayoutEffect,
@@ -30,19 +32,6 @@ 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,
PersonResult,
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";
import PersonPoster from "@/components/jellyseerr/PersonPoster";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
type SearchType = "Library" | "Discover";
@@ -155,57 +144,6 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "results", 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 } =
useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", 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 jellyseerrPersonResults: PersonResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === "person"
) as PersonResult[],
[jellyseerrResults]
);
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -285,25 +223,13 @@ export default function search() {
episodes?.length ||
series?.length ||
collections?.length ||
actors?.length ||
jellyseerrMovieResults?.length ||
jellyseerrTvResults?.length
actors?.length
);
}, [
artists,
episodes,
albums,
songs,
movies,
series,
collections,
actors,
jellyseerrResults,
]);
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
return (
<>
@@ -350,15 +276,13 @@ export default function search() {
</TouchableOpacity>
</View>
)}
{!!q && (
<View className="px-4 flex flex-col space-y-2">
<Text className="text-neutral-500 ">
Results for <Text className="text-purple-600">{q}</Text>
</Text>
</View>
)}
{searchType === "Library" && (
<>
<View className="mt-2">
<LoadingSkeleton isLoading={loading} />
</View>
{searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
@@ -483,139 +407,39 @@ export default function search() {
</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} />
)}
/>
<SearchItemWrapper
header="Actors"
items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => (
<PersonPoster
className="mr-2"
key={item.id}
id={item.id.toString()}
name={item.name}
posterPath={item.profilePath}
/>
)}
/>
</>
</View>
) : (
<JellyserrIndexPage searchQuery={debouncedSearch} />
)}
{loading ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
</View>
) : noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
</Text>
<Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 && searchType === "Library" ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className="mb-2"
>
<Text className="text-purple-600">{e}</Text>
</TouchableOpacity>
))}
</View>
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
<View className="flex flex-col">
{sortBy?.(
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
"order"
).map((slide) => (
<DiscoverSlide key={slide.id} slide={slide} />
))}
</View>
) : null}
{searchType === "Library" && (
<>
{!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
</Text>
<Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className="mb-2"
>
<Text className="text-purple-600">{e}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</>
)}
</View>
</ScrollView>
</>
);
}
type Props<T> = {
ids?: string[] | null;
items?: T[];
renderItem: (item: any) => React.ReactNode;
header?: string;
};
const SearchItemWrapper = <T extends unknown>({
ids,
items,
renderItem,
header,
}: PropsWithChildren<Props<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: l1 } = useQuery({
queryKey: ["items", ids],
queryFn: async () => {
if (!user?.Id || !api || !ids || ids.length === 0) {
return [];
}
const itemPromises = ids.map((id) =>
getUserItemData({
api,
userId: user.Id,
itemId: id,
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data && (!items || items.length === 0)) return null;
return (
<>
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data && data?.length > 0
? data.map((item) => renderItem(item))
: items && items?.length > 0
? items.map((i) => renderItem(i))
: undefined}
</ScrollView>
</>
);
};

View File

@@ -51,7 +51,7 @@ export default function TabLayout() {
<>
<SystemBars hidden={false} style="light" />
<NativeTabs
sidebarAdaptable
sidebarAdaptable={false}
ignoresTopSafeArea
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
tabBarActiveTintColor={Colors.primary}

View File

@@ -48,6 +48,7 @@ import {
import { useSharedValue } from "react-native-reanimated";
import settings from "../(tabs)/(home)/settings";
import { useSettings } from "@/utils/atoms/settings";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -413,6 +414,8 @@ export default function page() {
}
}
const insets = useSafeAreaInsets();
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
@@ -437,6 +440,8 @@ export default function page() {
position: "relative",
flexDirection: "column",
justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}}
>
<VlcPlayerView

46
augmentations/api.ts Normal file
View File

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

View File

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

View File

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

BIN
bun.lockb

Binary file not shown.

View File

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

View File

@@ -29,6 +29,27 @@ export const MediaSourceSelector: React.FC<Props> = ({
[item, selected]
);
const commonPrefix = useMemo(() => {
const mediaSources = item.MediaSources || [];
if (!mediaSources.length) return "";
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name.length; i++) {
const char = mediaSources[0].Name[i];
if (mediaSources.every((source) => source.Name[i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
}
return commonPrefix;
}, [item.MediaSources]);
const name = (name?: string | null) => {
return name?.replace(commonPrefix, "").toLowerCase();
};
return (
<View
className="flex shrink"
@@ -63,9 +84,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}}
>
<DropdownMenu.ItemTitle>
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
{`${name(source.Name)}`}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
@@ -74,9 +93,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
</View>
);
};
const name = (name?: string | null) => {
if (name && name.length > 40)
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
return name;
};

View File

@@ -1,6 +1,6 @@
import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react";
import { View, ViewProps } from "react-native";
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
logo?: ReactElement;
episodePoster?: ReactElement;
headerHeight?: number;
onEndReached?: (() => void) | null | undefined;
}
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
@@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
episodePoster,
headerHeight = 400,
logo,
onEndReached,
...props
}: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>();
@@ -47,6 +49,11 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
};
});
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
}
return (
<View className="flex-1" {...props}>
<Animated.ScrollView
@@ -55,6 +62,10 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
}}
ref={scrollRef}
scrollEventThrottle={16}
onScroll={e => {
if (isCloseToBottom(e.nativeEvent))
onEndReached?.()
}}
>
{logo && (
<View

View File

@@ -0,0 +1,95 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import {TouchableOpacity, View, ViewProps} from "react-native";
import {Text} from "@/components/common/Text";
import React, {PropsWithChildren, useEffect, useState} from "react";
interface Props<T> {
data: T[]
placeholderText?: string,
keyExtractor: (item: T) => string
titleExtractor: (item: T) => string
title: string,
label: string,
onSelected: (...item: T[]) => void
multi?: boolean
}
const Dropdown = <T extends unknown>({
data,
placeholderText,
keyExtractor,
titleExtractor,
title,
label,
onSelected,
multi = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const [selected, setSelected] = useState<T[]>();
useEffect(() => {
if (selected !== undefined) {
onSelected(...selected)
}
}, [selected]);
return (
<View {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
{title}
</Text>
<TouchableOpacity
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) => (
multi ? (
<DropdownMenu.CheckboxItem
value={selected?.some(s => keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'}
key={keyExtractor(item)}
onValueChange={(next, previous) =>
setSelected((p) => {
const prev = p || []
if (next == 'on') {
return [...prev, item]
}
return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))]
})
}
>
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
)
: (
<DropdownMenu.Item
key={keyExtractor(item)}
onSelect={() => setSelected([item])}
>
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
)
};
export default Dropdown;

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
import Discover from "@/components/jellyseerr/discover/Discover";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
import React, { useMemo } from "react";
import { View, ViewProps } from "react-native";
import {
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Text } from "../common/Text";
import JellyseerrPoster from "../posters/JellyseerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
interface Props extends ViewProps {
searchQuery: string;
}
export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
const { jellyseerrApi } = useJellyseerr();
const opacity = useSharedValue(1);
const {
data: jellyseerrDiscoverSettings,
isFetching: f1,
isLoading: l1,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled: !!jellyseerrApi && searchQuery.length == 0,
});
const {
data: jellyseerrResults,
isFetching: f2,
isLoading: l2,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "results", searchQuery],
queryFn: async () => {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(searchQuery).toString(),
page: 1,
language: "en",
});
return response?.results;
},
enabled: !!jellyseerrApi && searchQuery.length > 0,
});
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
useAnimatedReaction(
() => f1 || f2 || l1 || l2,
(isLoading) => {
if (isLoading) {
opacity.value = withTiming(1, { duration: 200 });
} else {
opacity.value = withTiming(0, { duration: 200 });
}
}
);
const jellyseerrMovieResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE
) as MovieResult[],
[jellyseerrResults]
);
const jellyseerrTvResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV
) as TvResult[],
[jellyseerrResults]
);
const jellyseerrPersonResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === "person"
) as PersonResult[],
[jellyseerrResults]
);
if (!searchQuery.length)
return (
<View className="flex flex-col">
<Discover sliders={jellyseerrDiscoverSettings} />
</View>
);
return (
<View>
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
{!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length &&
!f1 &&
!f2 &&
!l1 &&
!l2 && (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
</Text>
<Text className="text-xs text-purple-600 text-center">
"{searchQuery}"
</Text>
</View>
)}
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
<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} />
)}
/>
<SearchItemWrapper
header="Actors"
items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => (
<PersonPoster
className="mr-2"
key={item.id}
id={item.id.toString()}
name={item.name}
posterPath={item.profilePath}
/>
)}
/>
</View>
</View>
);
};

View File

@@ -0,0 +1,160 @@
import React, {
PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {Dimensions, View, ViewProps} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Text } from "@/components/common/Text";
import { Animated } from "react-native";
import { FlashList } from "@shopify/flash-list";
import {useFocusEffect} from "expo-router";
const ANIMATION_ENTER = 250;
const ANIMATION_EXIT = 250;
const BACKDROP_DURATION = 5000;
type Render = React.ComponentType<any>
| React.ReactElement
| null
| undefined;
interface Props<T> {
data: T[]
images: string[];
logo?: React.ReactElement;
HeaderContent?: () => React.ReactElement;
MainContent?: () => React.ReactElement;
listHeader: string;
renderItem: (item: T, index: number) => Render;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}
const ParallaxSlideShow = <T extends unknown>({
data,
images,
logo,
HeaderContent,
MainContent,
listHeader,
renderItem,
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>
) => {
const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0);
const fadeAnim = useRef(new Animated.Value(0)).current;
const enterAnimation = useCallback(
() =>
Animated.timing(fadeAnim, {
toValue: 1,
duration: ANIMATION_ENTER,
useNativeDriver: true,
}),
[fadeAnim]
);
const exitAnimation = useCallback(
() =>
Animated.timing(fadeAnim, {
toValue: 0,
duration: ANIMATION_EXIT,
useNativeDriver: true,
}),
[fadeAnim]
);
useEffect(() => {
if (images?.length) {
enterAnimation().start();
const intervalId = setInterval(() => {
Animated.sequence([
enterAnimation(),
exitAnimation()
]).start(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
})
}, BACKDROP_DURATION);
return () => {
clearInterval(intervalId)
};
}
}, [fadeAnim, images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
headerHeight={300}
onEndReached={onEndReached}
headerImage={
<Animated.Image
key={images?.[currentIndex]}
id={images?.[currentIndex]}
source={{
uri: images?.[currentIndex],
}}
style={{
width: "100%",
height: "100%",
opacity: fadeAnim,
}}
/>
}
logo={logo}
>
<View className="flex flex-col space-y-4 px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-full">
{HeaderContent && HeaderContent()}
</View>
</View>
{MainContent && MainContent()}
<View>
<FlashList
data={data}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">
No results
</Text>
</View>
}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={
<Text className="text-lg font-bold my-2">{listHeader}</Text>
}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
//@ts-ignore
renderItem={({ item, index}) => renderItem(item, index)}
keyExtractor={keyExtractor}
numColumns={3}
estimatedItemSize={214}
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
/>
</View>
</View>
</ParallaxScrollView>
</View>
);
}
export default ParallaxSlideShow;

View File

@@ -26,7 +26,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}>
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)}>
<View className="flex flex-col w-28" {...props}>
<Poster
id={id}

View File

@@ -0,0 +1,233 @@
import React, {forwardRef, useCallback, useMemo, useState} from "react";
import {View, ViewProps} from "react-native";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {useQuery} from "@tanstack/react-query";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import Dropdown from "@/components/common/Dropdown";
import {QualityProfile, RootFolder, Tag} from "@/utils/jellyseerr/server/api/servarr/base";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types";
import {Button} from "@/components/Button";
import {Text} from "@/components/common/Text";
interface Props {
id: number;
title: string,
type: MediaType;
isAnime?: boolean;
is4k?: boolean;
onRequested?: () => void;
}
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
id,
title,
type,
isAnime = false,
onRequested,
...props
}, ref) => {
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
const [requestOverrides, setRequestOverrides] =
useState<MediaRequestBody>({
mediaId: Number(id),
mediaType: type,
userId: jellyseerrUser?.id
});
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
const {data: serviceSettings} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service'],
queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: 'always'
});
const {data: users} = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () => jellyseerrApi?.user({take: 1000, sort: 'displayname'}),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: 'always'
});
const defaultService = useMemo(
() => serviceSettings?.find?.(v => v.isDefault),
[serviceSettings]
);
const {data: defaultServiceDetails} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service', 'details', defaultService?.id],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id
}))
return jellyseerrApi?.serviceDetails(type === 'movie' ? 'radarr' : 'sonarr', defaultService!!.id)
},
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
refetchOnMount: 'always',
});
const defaultProfile: QualityProfile = useMemo(
() => defaultServiceDetails?.profiles
.find(p =>
p.id === (isAnime ? defaultServiceDetails.server?.activeAnimeProfileId : defaultServiceDetails.server?.activeProfileId)
),
[defaultServiceDetails]
);
const defaultFolder: RootFolder = useMemo(
() => defaultServiceDetails?.rootFolders
.find(f =>
f.path === (isAnime ? defaultServiceDetails?.server.activeAnimeDirectory : defaultServiceDetails.server?.activeDirectory)
),
[defaultServiceDetails]
);
const defaultTags: Tag[] = useMemo(
() => {
const tags = defaultServiceDetails?.tags
.filter(t =>
(isAnime
? defaultServiceDetails?.server.activeAnimeTags
: defaultServiceDetails?.server.activeTags
)?.includes(t.id)
) ?? []
console.log(tags)
return tags
},
[defaultServiceDetails]
);
const seasonTitle = useMemo(
() => modalRequestProps?.seasons?.length ? `Season (${modalRequestProps?.seasons})` : undefined,
[modalRequestProps?.seasons]
);
const request = useCallback(() => {requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
{
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile.id,
rootFolder: defaultFolder.path,
tags: defaultTags.map(t => t.id),
...modalRequestProps,
...requestOverrides
},
onRequested
)
}, [requestOverrides, defaultProfile, defaultFolder, defaultTags]);
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
return (
<BottomSheetModal
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={() => setModalRequestProps(undefined)}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
}
>
{(data) => {
setModalRequestProps(data?.data as MediaRequestBody)
return <BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">Advanced</Text>
{seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text>
}
</View>
<View className="flex flex-col space-y-2">
{(defaultService && defaultServiceDetails && users) && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={defaultProfile.name}
keyExtractor={(item) => item.id.toString()}
label={"Quality Profile"}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
profileId: item?.id
}))
}
title={"Quality Profile"}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={"Root Folder"}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={"Root Folder"}
/>
<Dropdown
multi={true}
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={"Tags"}
onSelected={(...item) =>
item && setRequestOverrides((prev) => ({
...prev,
tags: item.map(i => i.id)
}))
}
title={"Tags"}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={"Request As"}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
userId: item?.id
}))
}
title={"Request As"}
/>
</>
)
}
</View>
<Button
className="mt-auto"
onPress={request}
color="purple"
>
Request
</Button>
</View>
</BottomSheetView>
}}
</BottomSheetModal>
);
});
export default RequestModal;

View File

@@ -0,0 +1,41 @@
import React, {useCallback} from "react";
import {
useJellyseerr,
} from "@/hooks/useJellyseerr";
import {TouchableOpacity, ViewProps} from "react-native";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {COMPANY_LOGO_IMAGE_FILTER, Network} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import {router, useSegments} from "expo-router";
const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewProps> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = segments[2];
const navigate = useCallback(({id, image, name}: Network | Studio) => router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
params: {id, image, name, type: slide.type }
}), [slide]);
return (
<Slide
{...props}
slide={slide}
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
<GenericSlideCard
className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4"
id={item.id.toString()}
url={jellyseerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
/>
</TouchableOpacity>
)}
/>
);
};
export default CompanySlide;

View File

@@ -0,0 +1,47 @@
import React, {useMemo} from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import {sortBy} from "lodash";
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
import {View} from "react-native";
import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
interface Props {
sliders?: DiscoverSlider[];
}
const Discover: React.FC<Props> = ({ sliders }) => {
if (!sliders)
return;
const sortedSliders = useMemo(
() => sortBy(sliders.filter((s) => s.enabled), 'order', 'asc'),
[sliders]
);
return (
<View className="flex flex-col space-y-4 mb-8">
{sortedSliders.map(slide => {
switch (slide.type) {
case DiscoverSliderType.NETWORKS:
return <CompanySlide key={slide.id} slide={slide} data={networks}/>
case DiscoverSliderType.STUDIOS:
return <CompanySlide key={slide.id} slide={slide} data={studios}/>
case DiscoverSliderType.MOVIE_GENRES:
case DiscoverSliderType.TV_GENRES:
return <GenreSlide key={slide.id} slide={slide} />
case DiscoverSliderType.TRENDING:
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
return <MovieTvSlide key={slide.id} slide={slide}/>
}
})}
</View>
)
};
export default Discover;

View File

@@ -0,0 +1,59 @@
import React from "react";
import {StyleSheet, View, ViewProps} from "react-native";
import {Image, ImageContentFit} from "expo-image";
import {Text} from "@/components/common/Text";
import {LinearGradient} from "expo-linear-gradient";
export const textShadowStyle = StyleSheet.create({
shadow: {
shadowColor: "#000",
shadowOffset: {
width: 1,
height: 1,
},
shadowOpacity: 1,
shadowRadius: .5,
elevation: 6,
}
})
const GenericSlideCard: React.FC<{id: string; url?: string, title?: string, colors?: string[], contentFit?: ImageContentFit} & ViewProps> = ({
id,
url,
title,
colors = ['#9333ea', 'transparent'],
contentFit = "contain",
...props
}) => (
<>
<LinearGradient
colors={colors}
start={{x: 0.5, y: 1.75}}
end={{x: 0.5, y: 0}}
className="rounded-xl"
>
<View className="rounded-xl" {...props}>
<Image
key={id}
id={id}
source={url ? {uri: url} : null}
cachePolicy={"memory-disk"}
contentFit={contentFit}
style={{
aspectRatio: "4/3",
}}
/>
{title &&
<View
className="absolute justify-center top-0 left-0 right-0 bottom-0 items-center"
>
<Text className="text-center font-bold" style={textShadowStyle.shadow}>{title}</Text>
</View>
}
</View>
</LinearGradient>
</>
);
export default GenericSlideCard;

View File

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

View File

@@ -1,5 +1,4 @@
import React, { useMemo } from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import React, {useMemo} from "react";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
DiscoverEndpoint,
@@ -9,17 +8,13 @@ import {
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";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native";
interface Props {
slide: DiscoverSlider;
}
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined = undefined;
@@ -62,42 +57,28 @@ const DiscoverSlide: React.FC<Props> = ({ slide }) => {
});
const flatData = useMemo(
() =>
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
() => 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 px-4">
{DiscoverSliderType[slide.type].toString().toTitle()}
</Text>
<FlashList
horizontal
contentContainerStyle={{
paddingLeft: 16,
}}
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>
<Slide
{...props}
slide={slide}
data={flatData}
keyExtractor={(item) => item!!.id.toString()}
onEndReached={() => {
if (hasNextPage)
fetchNextPage()
}}
renderItem={(item) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
)
);
};
export default DiscoverSlide;
export default MovieTvSlide;

View File

@@ -0,0 +1,55 @@
import React, {PropsWithChildren} from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { Text } from "@/components/common/Text";
import { FlashList } from "@shopify/flash-list";
import {View, ViewProps} from "react-native";
export interface SlideProps {
slide: DiscoverSlider;
}
interface Props<T> extends SlideProps {
data: T[]
renderItem: (item: T, index: number) =>
| React.ComponentType<any>
| React.ReactElement
| null
| undefined;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}
const Slide = <T extends unknown>({
data,
slide,
renderItem,
keyExtractor,
onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>
) => {
return (
<View {...props}>
<Text className="font-bold text-lg mb-2 px-4">
{DiscoverSliderType[slide.type].toString().toTitle()}
</Text>
<FlashList
horizontal
contentContainerStyle={{
paddingHorizontal: 16,
}}
showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor}
estimatedItemSize={250}
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-ignore
renderItem={({item, index}) => item ? renderItem(item, index) : <></>}
/>
</View>
);
};
export default Slide;

View File

@@ -1,36 +1,52 @@
import { View, ViewProps } from "react-native";
import { Image } from "expo-image";
import { Text } from "@/components/common/Text";
import { useMemo } from "react";
import { MovieResult, 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 JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { Text } from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
interface Props extends ViewProps {
item: MovieResult | TvResult;
}
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0);
const loadingAnimatedStyle = useAnimatedStyle(() => ({
opacity: loadingOpacity.value,
}));
const imageAnimatedStyle = useAnimatedStyle(() => ({
opacity: imageOpacity.value,
}));
const handleImageLoad = () => {
loadingOpacity.value = withTiming(0, { duration: 200 });
imageOpacity.value = withTiming(1, { duration: 300 });
};
const imageSrc = useMemo(
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi]
);
const title = useMemo(
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
[item]
);
const releaseYear = useMemo(
() =>
new Date(
@@ -41,7 +57,7 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
[item]
);
const canRequest = useJellyseerrCanRequest(item);
const [canRequest] = useJellyseerrCanRequest(item);
return (
<TouchableJellyseerrRouter
@@ -53,23 +69,25 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
>
<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%",
}}
/>
<Animated.View style={imageAnimatedStyle}>
<Image
key={item.id}
id={item.id.toString()}
source={{ uri: imageSrc }}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
onLoad={handleImageLoad}
/>
</Animated.View>
<JellyseerrStatusIcon
className="absolute bottom-1 right-1"
showRequestIcon={canRequest}
mediaStatus={item?.mediaInfo?.status}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item?.mediaType}

View File

@@ -0,0 +1,66 @@
import { View } from "react-native";
import { Text } from "../common/Text";
import Animated, {
useAnimatedStyle,
useAnimatedReaction,
useSharedValue,
withTiming,
} from "react-native-reanimated";
interface Props {
isLoading: boolean;
}
export const LoadingSkeleton: React.FC<Props> = ({ isLoading }) => {
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
useAnimatedReaction(
() => isLoading,
(loading) => {
if (loading) {
opacity.value = withTiming(1, { duration: 200 });
} else {
opacity.value = withTiming(0, { duration: 200 });
}
}
);
return (
<Animated.View style={animatedStyle} className="mt-2 absolute w-full">
{[1, 2, 3].map((s) => (
<View className="px-4 mb-4" key={s}>
<View className="w-1/2 bg-neutral-900 h-6 mb-2 rounded-lg"></View>
<View className="flex flex-row gap-2">
{[1, 2, 3].map((i) => (
<View className="w-28" key={i}>
<View className="bg-neutral-900 h-40 w-full rounded-md mb-1"></View>
<View className="rounded-md overflow-hidden mb-1 self-start">
<Text
className="text-neutral-900 bg-neutral-900 rounded-md"
numberOfLines={1}
>
Nisi mollit voluptate amet.
</Text>
</View>
<View className="rounded-md overflow-hidden self-start mb-1">
<Text
className="text-neutral-900 bg-neutral-900 text-xs rounded-md"
numberOfLines={1}
>
Lorem ipsum
</Text>
</View>
</View>
))}
</View>
</View>
))}
</Animated.View>
);
};

View File

@@ -0,0 +1,70 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { PropsWithChildren } from "react";
import { ScrollView } from "react-native";
import { Text } from "../common/Text";
type SearchItemWrapperProps<T> = {
ids?: string[] | null;
items?: T[];
renderItem: (item: any) => React.ReactNode;
header?: string;
};
export const SearchItemWrapper = <T extends unknown>({
ids,
items,
renderItem,
header,
}: PropsWithChildren<SearchItemWrapperProps<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: l1 } = useQuery({
queryKey: ["items", ids],
queryFn: async () => {
if (!user?.Id || !api || !ids || ids.length === 0) {
return [];
}
const itemPromises = ids.map((id) =>
getUserItemData({
api,
userId: user.Id,
itemId: id,
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data && (!items || items.length === 0)) return null;
return (
<>
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data && data?.length > 0
? data.map((item) => renderItem(item))
: items && items?.length > 0
? items.map((i) => renderItem(i))
: undefined}
</ScrollView>
</>
);
};

View File

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

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import {
@@ -29,12 +29,18 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useHaptic } from "@/hooks/useHaptic";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Pressable,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
@@ -42,10 +48,7 @@ import {
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
@@ -118,6 +121,7 @@ export const Controls: React.FC<Props> = ({
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = useAdjacentItems({ item });
const {
trickPlayUrl,
@@ -505,9 +509,13 @@ export const Controls: React.FC<Props> = ({
}}
style={{
position: "absolute",
width: Dimensions.get("window").width,
height: Dimensions.get("window").height,
width: screenWidth,
height: screenHeight,
backgroundColor: "black",
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: showControls ? 0.5 : 0,
}}
></Pressable>
@@ -519,8 +527,8 @@ export const Controls: React.FC<Props> = ({
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
width: settings?.safeAreaInControlsEnabled
? Dimensions.get("window").width - insets.left - insets.right
: Dimensions.get("window").width,
? screenWidth - insets.left - insets.right
: screenWidth,
opacity: showControls ? 1 : 0,
},
]}
@@ -572,21 +580,24 @@ export const Controls: React.FC<Props> = ({
</TouchableOpacity>
)}
{mediaSource?.TranscodingUrl && (
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
)}
{/* {mediaSource?.TranscodingUrl && ( */}
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
{/* )} */}
<TouchableOpacity
onPress={async () => {
lightHapticFeedback();
await ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"

View File

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

View File

@@ -33,6 +33,12 @@ import {
PersonDetails,
} from "@/utils/jellyseerr/server/models/Person";
import { useQueryClient } from "@tanstack/react-query";
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import {UserResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/userInterfaces";
import {
ServiceCommonServer,
ServiceCommonServerWithDetails
} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces";
interface SearchParams {
query: string;
@@ -65,16 +71,24 @@ export enum Endpoints {
MOVIE = "/movie",
RATINGS = "/ratings",
ISSUE = "/issue",
USER = "/user",
SERVICE = "/service",
TV = "/tv",
SETTINGS = "/settings",
NETWORK = "/network",
STUDIO = "/studio",
GENRE_SLIDER = "/genreslider",
DISCOVER = "/discover",
DISCOVER_TRENDING = DISCOVER + "/trending",
DISCOVER_MOVIES = DISCOVER + "/movies",
DISCOVER_TV = DISCOVER + TV,
DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
DISCOVER_MOVIES_STUDIO = DISCOVER + `${MOVIE}s` + STUDIO,
AUTH_JELLYFIN = "/auth/jellyfin",
}
export type DiscoverEndpoint =
| Endpoints.DISCOVER_TV_NETWORK
| Endpoints.DISCOVER_TRENDING
| Endpoints.DISCOVER_MOVIES
| Endpoints.DISCOVER_TV;
@@ -181,7 +195,7 @@ export class JellyseerrApi {
}
async discover(
endpoint: DiscoverEndpoint,
endpoint: DiscoverEndpoint | string,
params: any
): Promise<SearchResults> {
return this.axios
@@ -189,6 +203,15 @@ export class JellyseerrApi {
.then(({ data }) => data);
}
async getGenreSliders(
endpoint: Endpoints.TV | Endpoints.MOVIE,
params: any = undefined
): Promise<GenreSliderItem[]> {
return this.axios
?.get<GenreSliderItem[]>(Endpoints.API_V1 + Endpoints.DISCOVER + Endpoints.GENRE_SLIDER + endpoint, { params })
.then(({ data }) => data);
}
async search(params: SearchParams): Promise<SearchResults> {
const response = await this.axios?.get<SearchResults>(
Endpoints.API_V1 + Endpoints.SEARCH,
@@ -266,9 +289,15 @@ export class JellyseerrApi {
});
}
async user(params: any) {
return this.axios
?.get<UserResultsResponse>(`${Endpoints.API_V1}${Endpoints.USER}`, { params })
.then(({data}) => data.results)
}
imageProxy(
path?: string,
tmdbPath: string = "original",
filter: string = "original",
width: number = 1920,
quality: number = 75
) {
@@ -276,7 +305,7 @@ export class JellyseerrApi {
? this.axios.defaults.baseURL +
`/_next/image?` +
new URLSearchParams(
`url=https://image.tmdb.org/t/p/${tmdbPath}/${path}&w=${width}&q=${quality}`
`url=https://image.tmdb.org/t/p/${filter}/${path}&w=${width}&q=${quality}`
).toString()
: this.axios?.defaults.baseURL +
`/images/overseerr_poster_not_found_logo_top.png`;
@@ -299,6 +328,18 @@ export class JellyseerrApi {
});
}
async service(type: 'radarr' | 'sonarr') {
return this.axios
?.get<ServiceCommonServer[]>(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}`)
.then(({data}) => data);
}
async serviceDetails(type: 'radarr' | 'sonarr', id: number) {
return this.axios
?.get<ServiceCommonServerWithDetails>(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}` + `/${id}`)
.then(({data}) => data);
}
private setInterceptors() {
this.axios.interceptors.response.use(
async (response) => {

View File

@@ -7,7 +7,9 @@ export const useOrientationSettings = () => {
useEffect(() => {
if (settings?.autoRotate) {
// Don't need to do anything
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
);
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}

View File

@@ -4,6 +4,7 @@
"version": "1.0.0",
"scripts": {
"submodule-reload": "git submodule update --init --remote --recursive",
"clean": "echo y | expo prebuild --clean",
"start": "bun run submodule-reload && expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "bun run submodule-reload && expo run:android",
@@ -72,7 +73,7 @@
"react-dom": "18.2.0",
"react-native": "0.74.5",
"react-native-awesome-slider": "^2.5.6",
"react-native-bottom-tabs": "0.7.1",
"react-native-bottom-tabs": "0.7.8",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.9.0",
"react-native-country-flag": "^2.0.2",

View File

@@ -1,3 +1,4 @@
import "@/augmentations";
import { useInterval } from "@/hooks/useInterval";
import { storage } from "@/utils/mmkv";
import { Api, Jellyfin } from "@jellyfin/sdk";
@@ -19,7 +20,8 @@ import React, {
import { Platform } from "react-native";
import uuid from "react-native-uuid";
import { getDeviceName } from "react-native-device-info";
import { toast } from "sonner-native";
import { useSettings } from "@/utils/atoms/settings";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
interface Server {
address: string;
@@ -55,7 +57,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.24.0" },
clientInfo: { name: "Streamyfin", version: "0.25.0" },
deviceInfo: {
name: deviceName,
id,
@@ -70,6 +72,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null);
const [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
useQuery({
queryKey: ["user", api],
@@ -92,7 +96,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.24.0"`,
}, DeviceId="${deviceId}", Version="0.25.0"`,
};
}, [deviceId]);
@@ -226,6 +230,16 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
storage.set("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
storage.set("token", auth.data?.AccessToken);
const recentPluginSettings = await refreshStreamyfinPluginSettings();
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
const jellyseerrApi = new JellyseerrApi(recentPluginSettings.jellyseerrServerUrl.value);
await jellyseerrApi.test().then((result) => {
if (result.isValid && result.requiresPass) {
jellyseerrApi.login(username, password).then(setJellyseerrUser);
}
})
}
}
} catch (error) {
if (axios.isAxiosError(error)) {
@@ -262,6 +276,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
mutationFn: async () => {
storage.delete("token");
setUser(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
},
onError: (error) => {
console.error("Logout failed:", error);

View File

@@ -48,5 +48,20 @@ export const useJellyseerrCanRequest = (
return userHasPermission && !canNotRequest;
}, [item, jellyseerrUser]);
return canRequest;
const hasAdvancedRequestPermission = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
[
Permission.REQUEST_ADVANCED,
Permission.MANAGE_REQUESTS
],
jellyseerrUser.permissions,
{type: 'or'}
)
},
[jellyseerrUser]
);
return [canRequest, hasAdvancedRequestPermission];
};

View File

@@ -1,12 +1,19 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import * as ScreenOrientation from "expo-screen-orientation";
import { storage } from "../mmkv";
import { Platform } from "react-native";
import {
CultureDto,
PluginStatus,
SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client";
import {apiAtom} from "@/providers/JellyfinProvider";
import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api";
import {writeErrorLog} from "@/utils/log";
const STREAMYFIN_PLUGIN_ID = "1e9e5d38-6e67-4615-8719-e98a5c34f004"
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"
export type DownloadQuality = "original" | "high" | "low";
@@ -92,6 +99,16 @@ export type Settings = {
hiddenLibraries?: string[];
};
export interface Lockable<T> {
lockable: boolean;
value: T
}
export type PluginLockableSettings = { [K in keyof Settings]: Lockable<Settings[K]> };
export type StreamyfinPluginConfig = {
settings: PluginLockableSettings
}
const loadSettings = (): Settings => {
const defaultValues: Settings = {
autoRotate: true,
@@ -150,9 +167,12 @@ const saveSettings = (settings: Settings) => {
};
export const settingsAtom = atom<Settings | null>(null);
export const pluginSettingsAtom = atom(storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS));
export const useSettings = () => {
const [api] = useAtom(apiAtom);
const [settings, setSettings] = useAtom(settingsAtom);
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
useEffect(() => {
if (settings === null) {
@@ -161,6 +181,42 @@ export const useSettings = () => {
}
}, [settings, setSettings]);
const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => {
storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings)
_setPluginSettings(settings)
},
[_setPluginSettings]
)
const refreshStreamyfinPluginSettings = useCallback(
async () => {
if (!api)
return
const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data);
if (plugins && plugins.length > 0) {
const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID);
if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) {
writeErrorLog(
"Streamyfin plugin is currently not active.\n" +
`Current status is: ${streamyfinPlugin?.Status}`
);
setPluginSettings(undefined);
return;
}
const settings = await api.getStreamyfinPluginConfig()
.then(({data}) => data.settings)
setPluginSettings(settings);
return settings;
}
},
[api]
)
const updateSettings = (update: Partial<Settings>) => {
if (settings) {
const newSettings = { ...settings, ...update };
@@ -170,5 +226,5 @@ export const useSettings = () => {
}
};
return [settings, updateSettings] as const;
return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const;
};