Compare commits

...

31 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
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
38 changed files with 731 additions and 663 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

@@ -12,13 +12,12 @@ 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 segments = useSegments();
const {jellyseerrApi} = useJellyseerr();
const from = segments[2];
const {companyId, name, image, type} = local as unknown as {
companyId: string,
name: string,
@@ -62,32 +61,6 @@ export default function page() {
[jellyseerrApi, flatData]
);
const viewDetails = (result: Results) => {
router.push({
//@ts-ignore
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
//@ts-ignore
params: {
...result,
mediaTitle: getName(result),
releaseYear: getYear(result),
canRequest: "false",
posterSrc: jellyseerrApi?.imageProxy(
(result as MovieResult | TvResult).posterPath,
"w300_and_h450_face"
),
},
});
};
const getName = (result: Results) => {
return (result as TvResult).name || (result as MovieResult).title
}
const getYear = (result: Results) => {
return new Date((result as TvResult).firstAirDate || (result as MovieResult).releaseDate).getFullYear()
}
return (
<ParallaxSlideShow
data={flatData}
@@ -114,23 +87,9 @@ export default function page() {
}}
/>
}
renderItem={(item, index) => (
<TouchableOpacity
className="w-full flex flex-col pr-2"
onPress={() => viewDetails(item)}
>
<Poster
id={item.id.toString()}
url={jellyseerrApi?.imageProxy((item as MovieResult | TvResult).posterPath)}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item.mediaType as "movie" | "tv"}
/>
<Text className="mt-2" numberOfLines={1}>{getName(item)}</Text>
<Text className="text-xs opacity-50">{getYear(item)}</Text>
</TouchableOpacity>
)}
renderItem={(item, index) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
/>
);
}

View File

@@ -11,13 +11,12 @@ 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 segments = useSegments();
const {jellyseerrApi} = useJellyseerr();
const from = segments[2];
const {genreId, name, type} = local as unknown as {
genreId: string,
name: string,
@@ -59,32 +58,6 @@ export default function page() {
[jellyseerrApi, flatData]
);
const viewDetails = (result: Results) => {
router.push({
//@ts-ignore
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
//@ts-ignore
params: {
...result,
mediaTitle: getName(result),
releaseYear: getYear(result),
canRequest: "false",
posterSrc: jellyseerrApi?.imageProxy(
(result as MovieResult | TvResult).posterPath,
"w300_and_h450_face"
),
},
});
};
const getName = (result: Results) => {
return (result as TvResult).name || (result as MovieResult).title
}
const getYear = (result: Results) => {
return new Date((result as TvResult).firstAirDate || (result as MovieResult).releaseDate).getFullYear()
}
return (
<ParallaxSlideShow
data={flatData}
@@ -106,23 +79,9 @@ export default function page() {
{name}
</Text>
}
renderItem={(item, index) => (
<TouchableOpacity
className="w-full flex flex-col pr-2"
onPress={() => viewDetails(item)}
>
<Poster
id={item.id.toString()}
url={jellyseerrApi?.imageProxy((item as MovieResult | TvResult).posterPath)}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item.mediaType as "movie" | "tv"}
/>
<Text className="mt-2" numberOfLines={1}>{getName(item)}</Text>
<Text className="text-xs opacity-50">{getYear(item)}</Text>
</TouchableOpacity>
)}
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

@@ -1,28 +1,24 @@
import {
router,
useLocalSearchParams,
useSegments,
} from "expo-router";
import React, { useMemo } from "react";
import { TouchableOpacity } from "react-native";
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 } from "lodash";
import {orderBy, uniqBy} from "lodash";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
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 segments = useSegments();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { personId } = local as { personId: string };
const from = segments[2];
const { data, isLoading, isFetching } = useQuery({
queryKey: ["jellyseerr", "person", personId],
@@ -44,11 +40,11 @@ export default function page() {
const castedRoles: PersonCreditCast[] = useMemo(
() =>
orderBy(
uniqBy(orderBy(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc"
),
), 'id'),
[data?.combinedCredits]
);
const backdrops = useMemo(
@@ -58,24 +54,6 @@ export default function page() {
[jellyseerrApi, data?.combinedCredits]
);
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 (
<ParallaxSlideShow
data={castedRoles}
@@ -123,29 +101,7 @@ export default function page() {
MainContent={() => (
<OverviewText text={data?.details?.biography} className="mt-4" />
)}
renderItem={(item, index) => (
<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"}
/>
{item.character && (
<Text
className="text-xs opacity-50 align-bottom mt-1"
numberOfLines={1}
>
as {item.character}
</Text>
)}
</TouchableOpacity>
)}
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
/>
);
}

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

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

@@ -1,3 +1,4 @@
import Discover from "@/components/jellyseerr/discover/Discover";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
@@ -19,7 +20,6 @@ import JellyseerrPoster from "../posters/JellyseerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
import Discover from "@/components/jellyseerr/discover/Discover";
interface Props extends ViewProps {
searchQuery: string;

View File

@@ -50,7 +50,6 @@ const ParallaxSlideShow = <T extends unknown>({
const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0);
const [onEnd, setOnEnd] = useState<boolean>(true);
const fadeAnim = useRef(new Animated.Value(0)).current;
const enterAnimation = useCallback(
@@ -76,16 +75,22 @@ const ParallaxSlideShow = <T extends unknown>({
useEffect(() => {
if (images?.length) {
enterAnimation().start();
const intervalId = setInterval(() => {
exitAnimation().start((end) => {
if (end.finished)
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
});
Animated.sequence([
enterAnimation(),
exitAnimation()
]).start(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
})
}, BACKDROP_DURATION);
return () => clearInterval(intervalId);
return () => {
clearInterval(intervalId)
};
}
}, [images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
}, [fadeAnim, images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
return (
<View

View File

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

View File

@@ -1,28 +1,19 @@
import { View, ViewProps } from "react-native";
import { Image } from "expo-image";
import { Text } from "@/components/common/Text";
import { useMemo, useRef, useState } 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, {
FadeIn,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
interface Props extends ViewProps {
item: MovieResult | TvResult;
@@ -66,7 +57,7 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
[item]
);
const canRequest = useJellyseerrCanRequest(item);
const [canRequest] = useJellyseerrCanRequest(item);
return (
<TouchableJellyseerrRouter

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

@@ -34,6 +34,11 @@ import {
} from "@/utils/jellyseerr/server/models/Person";
import { useQueryClient } from "@tanstack/react-query";
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import {UserResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/userInterfaces";
import {
ServiceCommonServer,
ServiceCommonServerWithDetails
} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces";
interface SearchParams {
query: string;
@@ -66,6 +71,8 @@ export enum Endpoints {
MOVIE = "/movie",
RATINGS = "/ratings",
ISSUE = "/issue",
USER = "/user",
SERVICE = "/service",
TV = "/tv",
SETTINGS = "/settings",
NETWORK = "/network",
@@ -282,6 +289,12 @@ 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,
filter: string = "original",
@@ -315,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;
};