Compare commits

...

123 Commits

Author SHA1 Message Date
Fredrik Burmester
d39e3aeb80 fix: refactor 2024-08-16 19:56:40 +02:00
Fredrik Burmester
d15d02d61d fix: refactor 2024-08-16 19:11:06 +02:00
Fredrik Burmester
349fc1f21e fix: refactor 2024-08-16 19:06:06 +02:00
Fredrik Burmester
83131cabe1 fix: device profile 2024-08-16 18:52:16 +02:00
Fredrik Burmester
4aec9d350d fix: refactor file layout 2024-08-16 18:51:59 +02:00
Fredrik Burmester
a493f0cad2 fix: force direct play 2024-08-16 18:51:27 +02:00
Fredrik Burmester
329072a7a8 fix: better error handling 2024-08-16 18:51:11 +02:00
Fredrik Burmester
d2ff29a370 fix: more error info 2024-08-16 18:51:01 +02:00
Fredrik Burmester
8f4cb55050 fix: new device profiles 2024-08-16 18:50:45 +02:00
Fredrik Burmester
2c0df550f6 fix: more settings 2024-08-16 18:50:34 +02:00
Fredrik Burmester
a7533bbee0 fix: change profile 2024-08-16 18:50:05 +02:00
Fredrik Burmester
9ec37e26bf fix: poster 2024-08-16 18:49:45 +02:00
Fredrik Burmester
648df75f39 fix: stale time 2024-08-16 18:49:34 +02:00
Fredrik Burmester
84d7983df7 chore 2024-08-16 18:49:15 +02:00
Fredrik Burmester
1f0ff1594b wip: refactoring file structure 2024-08-15 21:08:35 +02:00
Fredrik Burmester
d0baf56fd8 feat: formatting function 2024-08-15 21:08:03 +02:00
Fredrik Burmester
e9182dc4ca chore: remove file 2024-08-15 21:07:51 +02:00
Fredrik Burmester
9817938d44 chore: version 2024-08-15 16:47:05 +02:00
Fredrik Burmester
42ce8f15af fix: new plugin option 2024-08-15 16:47:00 +02:00
Fredrik Burmester
aa0fbd6fad fix: refactor 2024-08-15 16:46:50 +02:00
Fredrik Burmester
92a260b6f5 fix: header and tab background 2024-08-15 16:46:36 +02:00
Fredrik Burmester
ea95cae685 fix: default values 2024-08-15 10:39:40 +02:00
Fredrik Burmester
17641bb529 fix: link 2024-08-15 10:39:35 +02:00
Fredrik Burmester
a8fef13aa8 fix: cls issues
content layout shift
2024-08-15 10:38:17 +02:00
Fredrik Burmester
f91a37ddc2 fix: link correctly 2024-08-15 10:37:26 +02:00
Fredrik Burmester
c67b0ac6cd fix: refactor home screen stack 2024-08-15 10:36:55 +02:00
Fredrik Burmester
33eeebd287 chore: versions 2024-08-14 22:19:36 +02:00
Fredrik Burmester
359aa44ad0 feat: refactor home screen 2024-08-14 22:19:32 +02:00
Fredrik Burmester
5929882ba2 fix: app links 2024-08-14 21:30:59 +02:00
Fredrik Burmester
d976c4b7d6 chore 2024-08-14 18:07:35 +02:00
Fredrik Burmester
cd321301ae feat: choose to open full screen immediately
fixes #47
2024-08-14 18:07:31 +02:00
Fredrik Burmester
2a34851fc6 feat: settings 2024-08-14 15:04:31 +02:00
Fredrik Burmester
a76ed9be66 chore 2024-08-14 15:04:20 +02:00
Fredrik Burmester
1d26b57425 chore 2024-08-14 15:04:13 +02:00
Fredrik Burmester
7bb5060f5f fix: join discord link 2024-08-14 14:39:10 +02:00
Fredrik Burmester
ad8bc954c1 feat: download queue 2024-08-14 13:30:43 +02:00
Fredrik Burmester
f87824ec58 chore 2024-08-14 13:30:35 +02:00
Fredrik Burmester
78556e8764 fix: missing or incorrect pts Issues 2024-08-14 11:10:52 +02:00
Fredrik Burmester
3c678add0f fix: limit 2024-08-14 10:47:31 +02:00
Fredrik Burmester
0c98980b1d feat: ratings 2024-08-14 10:46:19 +02:00
Fredrik Burmester
66179a68ea fix: refactor 2024-08-14 10:46:15 +02:00
Fredrik Burmester
fdd07dce3b chore 2024-08-14 10:10:45 +02:00
Fredrik Burmester
0dc32d58cf chore 2024-08-14 10:10:15 +02:00
Fredrik Burmester
e56c3e5c97 fix: error message 2024-08-14 10:10:07 +02:00
Fredrik Burmester
bd8bf8349f fix: move cast button + ask user which device to play on 2024-08-14 10:09:59 +02:00
Fredrik Burmester
ede390e74b fix: design 2024-08-14 10:09:20 +02:00
Fredrik Burmester
0eca453c9a fix: try to improve download speed 2024-08-14 08:59:43 +02:00
Fredrik Burmester
65838034b6 fix: design 2024-08-14 08:59:25 +02:00
Fredrik Burmester
e715b3daa4 fix: design 2024-08-14 08:59:21 +02:00
Fredrik Burmester
37b7fc1c20 fix: report pause playback on pause 2024-08-14 08:59:17 +02:00
Fredrik Burmester
9ee30ff1ce feat: more bitrate options 2024-08-14 08:58:53 +02:00
Fredrik Burmester
026a286ebf fix: re-fetch hls stream url on sub change 2024-08-14 08:58:42 +02:00
Fredrik Burmester
e522e1dcc0 chore: deps 2024-08-14 08:13:29 +02:00
Fredrik Burmester
a80e065cdb fix: formatting of specials season number 2024-08-14 08:13:14 +02:00
Fredrik Burmester
f4f2d37aea fix: add headers to hls stream url 2024-08-14 08:13:02 +02:00
Fredrik Burmester
e65ed3db0e fix: text 2024-08-13 21:00:33 +02:00
Fredrik Burmester
cb9dfe2c83 feat: app store link 2024-08-13 21:00:08 +02:00
Fredrik Burmester
bc4b07c76b fix: padding 2024-08-13 20:29:40 +02:00
Fredrik Burmester
150eb1809f feat: prev/next episode buttons 2024-08-13 20:26:02 +02:00
Fredrik Burmester
8afe7dc5e4 chore: version 2024-08-13 20:25:50 +02:00
Fredrik Burmester
855e00a676 fix: refactor 2024-08-13 20:25:30 +02:00
Fredrik Burmester
5289c0519f chore: update readme 2024-08-13 16:43:58 +02:00
Fredrik Burmester
4b1eb2218f fix: invalid config 2024-08-13 16:43:50 +02:00
Fredrik Burmester
a99e7b950e fix: pip 2024-08-13 16:21:56 +02:00
Fredrik Burmester
51fc2a0edb chore: versions 2024-08-13 16:02:47 +02:00
Fredrik Burmester
3a13503d1d fix: enable background playback 2024-08-13 16:01:52 +02:00
Fredrik Burmester
2fdf90ab4b fix: enable background play 2024-08-13 16:00:50 +02:00
Fredrik Burmester
6fed0c1c77 fix: change colors 2024-08-13 16:00:43 +02:00
Fredrik Burmester
ee7ff3444e chore: todo 2024-08-13 16:00:39 +02:00
Fredrik Burmester
dec175a300 fix: route to download page 2024-08-13 16:00:26 +02:00
Fredrik Burmester
27099d3184 fix: improve pause/play logic 2024-08-13 16:00:07 +02:00
Fredrik Burmester
bfad77dd7a fix: change color to purple 2024-08-13 15:59:34 +02:00
Fredrik Burmester
74a33f8f82 fix: update download list when a download is finished 2024-08-13 15:59:27 +02:00
Fredrik Burmester
75de878618 fix: show loader for videos but not music 2024-08-13 14:41:37 +02:00
Fredrik Burmester
9628285701 fix: remove parsing of the url 2024-08-13 14:00:18 +02:00
Fredrik Burmester
b206be6bcf chore: version 2024-08-13 13:36:04 +02:00
Fredrik Burmester
656d4ba46b Merge branch 'feat/audio-select' 2024-08-13 13:27:09 +02:00
Fredrik Burmester
b1025c81ae Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-08-13 13:26:33 +02:00
Fredrik Burmester
b05b43c12e feat: poster 2024-08-13 13:26:31 +02:00
Fredrik Burmester
11f9d0fe33 feat: support audio streams 2024-08-13 13:26:27 +02:00
Fredrik Burmester
0498f2e718 chore: version 2024-08-13 11:29:40 +02:00
Fredrik Burmester
077f99fd46 fix: display any collection
fixes #21
2024-08-13 11:29:33 +02:00
Fredrik Burmester
3e433afd4d Update issue templates 2024-08-13 10:13:41 +02:00
Fredrik Burmester
3e1fd5a0ad chore: deps & versions 2024-08-13 10:00:04 +02:00
Fredrik Burmester
0ae8a0a58c fix: change text to remove the word Jellyfin
Because apple denied my app because of it
2024-08-13 09:59:55 +02:00
Fredrik Burmester
34d9392a8b feat: enable screen rotation 2024-08-13 09:59:25 +02:00
Fredrik Burmester
1b463382c5 chore 2024-08-13 09:15:23 +02:00
Fredrik Burmester
4b94bd33ce chore: version 2024-08-13 08:54:16 +02:00
Fredrik Burmester
315d9cbc63 fix: Support for unsecure plaintext authentication (HTTP) logins 2024-08-13 08:54:12 +02:00
Fredrik Burmester
d939f7c9e3 chore 2024-08-13 08:53:59 +02:00
Fredrik Burmester
4d5e544fb0 chore 2024-08-13 08:53:55 +02:00
Fredrik Burmester
5e17f2ac88 fix: check for google play services before chromecast 2024-08-13 08:53:47 +02:00
Fredrik Burmester
74fa279f8d fix: wrong user agent
fixes #14
2024-08-12 22:52:52 +02:00
Fredrik Burmester
4382e585fe fix: typing indicator on android
fixes #15
2024-08-12 22:50:50 +02:00
Fredrik Burmester
a9486c57d2 chore: tipjar 2024-08-12 22:25:04 +02:00
Fredrik Burmester
cc72186a80 feat: audio and subtitle picker 2024-08-12 22:24:51 +02:00
Fredrik Burmester
da9ac3efde fix: download instructions 2024-08-12 20:51:53 +02:00
Fredrik Burmester
7bab4a78bc chore: version 2024-08-12 20:48:09 +02:00
Fredrik Burmester
65837cd303 Merge branch 'master' into feat/audio-select 2024-08-12 20:45:49 +02:00
Fredrik Burmester
5f323d5132 chore: version 2024-08-12 20:45:03 +02:00
Fredrik Burmester
18152b9d5b fix: casting should now work 2024-08-12 20:44:57 +02:00
Fredrik Burmester
d5ee79d740 wip 2024-08-12 19:38:17 +02:00
Fredrik Burmester
040ef3b79a Merge branch 'master' into feat/audio-select 2024-08-12 19:26:48 +02:00
Fredrik Burmester
6b69250ecb fix: splash screen background color 2024-08-12 19:26:39 +02:00
Fredrik Burmester
07c0f81f36 Merge branch 'master' into feat/audio-select 2024-08-12 19:17:52 +02:00
Fredrik Burmester
89a992e7c1 chore: versions 2024-08-12 19:10:08 +02:00
Fredrik Burmester
a62e5d24da wip 2024-08-12 19:09:56 +02:00
Fredrik Burmester
1368fbd935 fix: allow login without password 2024-08-12 18:00:25 +02:00
Fredrik Burmester
cb95ccff3a chore: version 2024-08-12 16:42:17 +02:00
Fredrik Burmester
d854699cc8 fix: google play open beta link 2024-08-12 16:42:12 +02:00
Fredrik Burmester
49c95a091c feat: currently playing floating bar 2024-08-12 14:03:22 +02:00
Fredrik Burmester
ed301a9152 chore: assets 2024-08-12 14:03:09 +02:00
Fredrik Burmester
ecc31c3593 fix: design changes 2024-08-12 14:03:00 +02:00
Fredrik Burmester
b7a9c41a9a fix: nav bar colors android 2024-08-12 14:02:19 +02:00
Fredrik Burmester
680838fee1 chore 2024-08-12 14:02:06 +02:00
Fredrik Burmester
0041aa981b fix: preserve selected season on route change 2024-08-11 14:18:07 +02:00
Fredrik Burmester
8ca9fba583 fix: input placeholder color on android 2024-08-11 14:17:53 +02:00
Fredrik Burmester
694a5d6d21 fix: header color on android 2024-08-11 14:17:44 +02:00
Fredrik Burmester
46ff07a800 chore 2024-08-11 14:17:32 +02:00
Fredrik Burmester
2fe83b4209 chore: file 2024-08-11 12:36:17 +02:00
Fredrik Burmester
b1c6842c8e chore 2024-08-11 12:24:47 +02:00
Fredrik Burmester
437da25a63 chore 2024-08-11 11:54:31 +02:00
Fredrik Burmester
03244f318d chore 2024-08-11 11:54:15 +02:00
113 changed files with 5137 additions and 5201 deletions

26
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,26 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone15Pro]
- OS: [e.g. iOS18]
- Version [e.g. 0.3.1]

View File

@@ -0,0 +1,14 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ Streamyfin.app
/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa

View File

@@ -12,23 +12,42 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
## 🌟 Features
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
- 📱 Native video player: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 📥 Download media (Experimental): Save your media locally and watch it offline.
- 📡 Chromecast media (Experimental): Cast your media to any Chromecast-enabled device.
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
## 🧪 Experimental Features
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
## 🛠️ TestFlight (pending review)
### Downloading
Soon iOS users can test Streamyfin in beta via TestFlight. To join the beta program, click the link below.
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
## Get it now
<div style="display:flex;">
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
<img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
</a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
</a>
</div>
### Beta testing
Get the latest updates by using the TestFlight version of the app.
<a href="https://testflight.apple.com/join/CWBaAAK2">
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
Or download the APKs here on GitHub for Android.
## 🚀 Getting Started
### Prerequisites
@@ -83,10 +102,17 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/zyGKHJZvv4)
If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
-
## Support
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
## 📝 Credits

View File

@@ -2,35 +2,43 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.0.6",
"orientation": "portrait",
"version": "0.6.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#29164B"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
"ios": {
"userInterfaceStyle": "dark",
"requireFullScreen": true,
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin"
},
"android": {
"jsEngine": "jsc",
"userInterfaceStyle": "light",
"jsEngine": "hermes",
"versionCode": 17,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png",
"backgroundColor": "#ffffff"
"foregroundImage": "./assets/images/icon.png"
},
"package": "com.fredrikburmester.streamyfin"
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
]
},
"web": {
"bundler": "metro",
@@ -40,7 +48,6 @@
"plugins": [
"expo-router",
"expo-font",
"react-native-compressor",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
@@ -52,31 +59,24 @@
"react-native-video",
{
"enableNotificationControls": true,
"enableBackgroundAudio": true,
"androidExtensions": {
"useExoplayerRtsp": false,
"useExoplayerSmoothStreaming": false,
"useExoplayerHls": false,
"useExoplayerHls": true,
"useExoplayerDash": false
}
}
],
[
"react-native-vlc-media-player",
{
"ios": {
"includeVLCKit": false
},
"android": {
"legacyJetifier": false
}
}
],
[
"expo-build-properties",
{
"ios": { "deploymentTarget": "14.0" },
"ios": {
"deploymentTarget": "14.0"
},
"android": {
"minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
"useLegacyPackaging": true
@@ -84,6 +84,18 @@
}
}
}
],
[
"expo-screen-orientation",
{
"initialOrientation": "DEFAULT"
}
],
[
"expo-sensors",
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
"experiments": {
@@ -97,6 +109,12 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"owner": "fredrikburmester"
"owner": "fredrikburmester",
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
}
}

View File

@@ -1,25 +1,56 @@
import { router, Tabs } from "expo-router";
import React from "react";
import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import { TouchableOpacity } from "react-native";
import { Platform, TouchableOpacity, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
import { BlurView } from "expo-blur";
import { StyleSheet } from "react-native";
export default function TabLayout() {
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
NavigationBar.setBorderColorAsync("#121212");
}
}, []);
return (
<Tabs
initialRouteName="home"
screenOptions={{
tabBarActiveTintColor: Colors.tabIconSelected,
headerShown: false,
tabBarStyle: {
position: "absolute",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderTopWidth: 0,
paddingTop: 8,
},
tabBarBackground: () =>
Platform.OS === "ios" ? (
<BlurView
experimentalBlurMethod="dimezisBlurView"
intensity={95}
style={{
...StyleSheet.absoluteFillObject,
overflow: "hidden",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
backgroundColor: "black",
}}
/>
) : undefined,
}}
>
<Tabs.Screen redirect name="index" />
<Tabs.Screen
name="index"
name="home"
options={{
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
headerShown: false,
title: "Home",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
@@ -27,34 +58,12 @@ export default function TabLayout() {
color={color}
/>
),
headerLeft: () => (
<TouchableOpacity
style={{ marginHorizontal: 17 }}
onPress={() => {
router.push("/(auth)/downloads");
}}
>
<Feather name="download" color={"white"} size={24} />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
style={{ marginHorizontal: 17 }}
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={24} />
</TouchableOpacity>
),
}}
/>
<Tabs.Screen
name="search"
options={{
headerStyle: { backgroundColor: "black" },
headerShown: true,
headerShadowVisible: false,
headerShown: false,
title: "Search",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? "search" : "search"} color={color} />

View File

@@ -0,0 +1,50 @@
import { Chromecast } from "@/components/Chromecast";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, View } from "react-native";
import { TouchableOpacity } from "react-native";
export default function IndexLayout() {
const router = useRouter();
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Home",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
style={{
marginRight: Platform.OS === "android" ? 17 : 0,
}}
onPress={() => {
router.push("/(auth)/downloads");
}}
>
<Feather name="download" color={"white"} size={22} />
</TouchableOpacity>
),
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<View className="h-10 aspect-square flex items-center justify-center rounded">
<Feather name="settings" color={"white"} size={22} />
</View>
</TouchableOpacity>
</View>
),
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,344 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
BaseItemDto,
ItemFields,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getChannelsApi,
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
import { Button } from "@/components/Button";
import { Ionicons } from "@expo/vector-icons";
import MoviePoster from "@/components/MoviePoster";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { useSettings } from "@/utils/atoms/settings";
export default function index() {
const router = useRouter();
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
queryKey: ["resumeItems", user?.Id],
queryFn: async () =>
(api &&
(
await getItemsApi(api).getResumeItems({
userId: user?.Id,
})
).data.Items) ||
[],
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const { data: _nextUpData, isLoading: isLoadingNextUp } = useQuery({
queryKey: ["nextUp-all", user?.Id],
queryFn: async () =>
(api &&
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
})
).data.Items) ||
[],
enabled: !!api && !!user?.Id,
staleTime: 0,
});
const nextUpData = useMemo(() => {
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
}, [_nextUpData]);
const { data: collections, isLoading: isLoadingCollections } = useQuery({
queryKey: ["collections", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return [];
}
const data = (
await getItemsApi(api).getItems({
userId: user.Id,
})
).data;
const order = ["boxsets", "tvshows", "movies"];
const cs = data.Items?.sort((a, b) => {
if (
order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!)
) {
return 1;
}
return -1;
});
return data.Items || [];
},
enabled: !!api && !!user?.Id,
staleTime: 0,
});
const movieCollectionId = useMemo(() => {
return collections?.find((c) => c.CollectionType === "movies")?.Id;
}, [collections]);
const tvShowCollectionId = useMemo(() => {
return collections?.find((c) => c.CollectionType === "tvshows")?.Id;
}, [collections]);
const {
data: recentlyAddedInMovies,
isLoading: isLoadingRecentlyAddedMovies,
} = useQuery<BaseItemDto[]>({
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
queryFn: async () =>
(api &&
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
parentId: movieCollectionId,
})
).data) ||
[],
enabled: !!api && !!user?.Id && !!movieCollectionId,
staleTime: 60 * 1000,
});
const {
data: recentlyAddedInTVShows,
isLoading: isLoadingRecentlyAddedTVShows,
} = useQuery<BaseItemDto[]>({
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
queryFn: async () =>
(api &&
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
parentId: tvShowCollectionId,
})
).data) ||
[],
enabled: !!api && !!user?.Id && !!tvShowCollectionId,
staleTime: 60 * 1000,
});
const { data: suggestions, isLoading: isLoadingSuggestions } = useQuery<
BaseItemDto[]
>({
queryKey: ["suggestions", user?.Id],
queryFn: async () =>
(api &&
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 5,
mediaType: ["Video"],
})
).data.Items) ||
[],
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const { data: mediaListCollection } = useQuery<string | null>({
queryKey: ["mediaListCollection", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items?.[0].Id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 60 * 1000,
});
const { data: popularItems, isLoading: isLoadingPopular } = useQuery<
BaseItemDto[]
>({
queryKey: ["popular", user?.Id],
queryFn: async () => {
if (!api || !user?.Id || !mediaListCollection) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: mediaListCollection,
limit: 10,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!mediaListCollection,
staleTime: 60 * 1000,
});
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
setLoading(false);
}, [queryClient, user?.Id]);
const [isConnected, setIsConnected] = useState<boolean | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
</View>
</View>
);
}
if (isError)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View>
);
if (isLoading)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
>
<View className="flex flex-col pt-4 pb-24 gap-y-4">
<ScrollingCollectionList
title="Continue Watching"
data={data}
loading={isLoading}
orientation="horizontal"
/>
<ScrollingCollectionList
title="Popular"
data={popularItems}
loading={isLoadingPopular}
disabled={!mediaListCollection}
/>
<ScrollingCollectionList
title="Next Up"
data={nextUpData}
loading={isLoadingNextUp}
orientation="horizontal"
/>
<ScrollingCollectionList
title="Recently Added in Movies"
data={recentlyAddedInMovies}
loading={isLoadingRecentlyAddedMovies}
/>
<ScrollingCollectionList
title="Recently Added in TV-Shows"
data={recentlyAddedInTVShows}
loading={isLoadingRecentlyAddedTVShows}
/>
<ScrollingCollectionList
title="Collections"
data={collections}
loading={isLoadingCollections}
orientation="horizontal"
/>
<ScrollingCollectionList
title="Suggestions"
data={suggestions}
loading={isLoadingSuggestions}
orientation="horizontal"
/>
</View>
</ScrollView>
);
}

View File

@@ -1,365 +1,5 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
BaseItemDto,
ItemFields,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getChannelsApi,
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
import { Button } from "@/components/Button";
import { Ionicons } from "@expo/vector-icons";
import MoviePoster from "@/components/MoviePoster";
const Index = () => {
return null;
};
export default function index() {
const router = useRouter();
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [loading, setLoading] = useState(false);
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
queryKey: ["resumeItems", user?.Id],
queryFn: async () =>
(api &&
(
await getItemsApi(api).getResumeItems({
userId: user?.Id,
})
).data.Items) ||
[],
enabled: !!api && !!user?.Id,
staleTime: 60,
});
const { data: _nextUpData } = useQuery({
queryKey: ["nextUp-all", user?.Id],
queryFn: async () =>
(api &&
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
})
).data.Items) ||
[],
enabled: !!api && !!user?.Id,
staleTime: 0,
});
const nextUpData = useMemo(() => {
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
}, [_nextUpData]);
const { data: collections } = useQuery({
queryKey: ["collections", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return [];
}
const data = (
await getItemsApi(api).getItems({
userId: user.Id,
})
).data;
const order = ["boxsets", "tvshows", "movies"];
const cs = data.Items?.sort((a, b) => {
if (
order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!)
) {
return 1;
}
return -1;
});
return data.Items || [];
},
enabled: !!api && !!user?.Id,
staleTime: 0,
});
const movieCollectionId = useMemo(() => {
return collections?.find((c) => c.CollectionType === "movies")?.Id;
}, [collections]);
const tvShowCollectionId = useMemo(() => {
return collections?.find((c) => c.CollectionType === "tvshows")?.Id;
}, [collections]);
const { data: recentlyAddedInMovies } = useQuery<BaseItemDto[]>({
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
queryFn: async () =>
(api &&
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
parentId: movieCollectionId,
})
).data) ||
[],
enabled: !!api && !!user?.Id && !!movieCollectionId,
staleTime: 60,
});
const { data: recentlyAddedInTVShows } = useQuery<BaseItemDto[]>({
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
queryFn: async () =>
(api &&
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
parentId: tvShowCollectionId,
})
).data) ||
[],
enabled: !!api && !!user?.Id && !!tvShowCollectionId,
staleTime: 60,
});
const { data: suggestions } = useQuery<BaseItemDto[]>({
queryKey: ["suggestions", user?.Id],
queryFn: async () =>
(api &&
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 5,
mediaType: ["Video"],
})
).data.Items) ||
[],
enabled: !!api && !!user?.Id,
staleTime: 60,
});
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
setLoading(false);
}, [queryClient, user?.Id]);
const [isConnected, setIsConnected] = useState<boolean | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
</View>
</View>
);
}
if (isError)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View>
);
if (isLoading)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
return (
<ScrollView
nestedScrollEnabled
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
>
<View className="flex flex-col py-4 gap-y-4">
<View>
<Text className="px-4 text-2xl font-bold mb-2">
Continue Watching
</Text>
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">Next Up</Text>
<HorizontalScroll<BaseItemDto>
data={nextUpData}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">
Recently Added in Movies
</Text>
<HorizontalScroll<BaseItemDto>
data={recentlyAddedInMovies}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-32"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">
Recently Added in TV-Shows
</Text>
<HorizontalScroll<BaseItemDto>
data={recentlyAddedInTVShows}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/series/${item.Id}/page`)}
className="flex flex-col w-32"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">Collections</Text>
<HorizontalScroll<BaseItemDto>
data={collections}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/collections/${item.Id}/page`)}
className="flex flex-col w-48"
>
<View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">Suggestions</Text>
<HorizontalScroll<BaseItemDto>
data={suggestions}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
</View>
</View>
</ScrollView>
);
}
export default Index;

View File

@@ -1,193 +0,0 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/MoviePoster";
import Poster from "@/components/Poster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React, { useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
export default function search() {
const [search, setSearch] = useState<string>("");
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: movies } = useQuery({
queryKey: ["search-movies", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Movie"],
});
return searchApi.data.SearchHints;
},
});
const { data: series } = useQuery({
queryKey: ["search-series", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Series"],
});
return searchApi.data.SearchHints;
},
});
const { data: episodes } = useQuery({
queryKey: ["search-episodes", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Episode"],
});
return searchApi.data.SearchHints;
},
});
return (
<ScrollView keyboardDismissMode="on-drag">
<View className="flex flex-col py-2">
<View className="mb-4 px-4">
<Input
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder="Search here..."
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
<SearchItemWrapper
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-32"
onPress={() => router.push(`/items/${item.Id}/page`)}
>
<MoviePoster item={item} key={item.Id} />
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}/page`)}
className="flex flex-col w-32"
>
<Poster
item={item}
key={item.Id}
url={getPrimaryImageUrl({ api, item })}
/>
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
)}
/>
</View>
</ScrollView>
);
}
type Props = {
ids?: string[] | null;
renderItem: (data: BaseItemDto[]) => React.ReactNode;
};
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
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) return <Text className="opacity-50 text-xs px-4">No results</Text>;
return renderItem(data);
};

View File

@@ -0,0 +1,20 @@
import { Stack } from "expo-router";
import { Platform } from "react-native";
export default function SearchLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Search",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,213 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/MoviePoster";
import Poster from "@/components/Poster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, Stack, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useLayoutEffect, useState } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
export default function search() {
const [search, setSearch] = useState<string>("");
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
useLayoutEffect(() => {
if (Platform.OS === "ios")
navigation.setOptions({
headerSearchBarOptions: {
placeholder: "Search...",
onChangeText: (e: any) => setSearch(e.nativeEvent.text),
hideWhenScrolling: false,
autoFocus: true,
},
});
}, [navigation]);
const { data: movies } = useQuery({
queryKey: ["search-movies", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Movie"],
});
return searchApi.data.SearchHints;
},
});
const { data: series } = useQuery({
queryKey: ["search-series", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Series"],
});
return searchApi.data.SearchHints;
},
});
const { data: episodes } = useQuery({
queryKey: ["search-episodes", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Episode"],
});
return searchApi.data.SearchHints;
},
});
return (
<>
<ScrollView
keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="automatic"
>
<View className="flex flex-col pt-2 pb-20">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder="Search here..."
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
)}
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
<SearchItemWrapper
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-32"
onPress={() => router.push(`/items/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}`)}
className="flex flex-col w-32"
>
<Poster
item={item}
key={item.Id}
url={getPrimaryImageUrl({ api, item })}
/>
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
)}
/>
</View>
</ScrollView>
</>
);
}
type Props = {
ids?: string[] | null;
renderItem: (data: BaseItemDto[]) => React.ReactNode;
};
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
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) return <Text className="opacity-50 text-xs px-4">No results</Text>;
return renderItem(data);
};

View File

@@ -0,0 +1,133 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { SongsList } from "@/components/music/SongsList";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getArtistsApi,
getItemsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId, artistId, albumId } = searchParams as {
collectionId: string;
artistId: string;
albumId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [startIndex, setStartIndex] = useState<number>(0);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const { data: album } = useQuery({
queryKey: ["album", albumId, artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [albumId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!albumId,
staleTime: 0,
});
const {
data: songs,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["songs", artistId, albumId],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: albumId,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
if (!album) return null;
return (
<ScrollView>
<View className="px-4 pb-24">
<View className="flex flex-row space-x-4 items-start mb-4">
<View className="w-24">
<ArtistPoster item={album} />
</View>
<View className="flex flex-col shrink">
<Text className="font-bold text-3xl">{album?.Name}</Text>
<Text className="">{album?.ProductionYear}</Text>
</View>
</View>
<SongsList
albumId={albumId}
songs={songs?.Items}
collectionId={collectionId}
artistId={artistId}
/>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,131 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { artistId } = searchParams as {
artistId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [startIndex, setStartIndex] = useState<number>(0);
const { data: artist } = useQuery({
queryKey: ["album", artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [artistId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!artistId,
staleTime: 0,
});
const {
data: albums,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["albums", artistId, startIndex],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: artistId,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["MusicAlbum"],
recursive: true,
fields: [
"ParentId",
"PrimaryImageAspectRatio",
"ParentId",
"PrimaryImageAspectRatio",
],
collapseBoxSetItems: false,
albumArtistIds: [artistId],
startIndex,
limit: 100,
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
useEffect(() => {
navigation.setOptions({
title: albums?.Items?.[0].AlbumArtist,
});
}, [albums]);
if (!artist || !albums) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-2">
<View className="w-32 mb-4">
<ArtistPoster item={artist} />
</View>
<Text className="font-bold text-2xl mb-4">Albums</Text>
</View>
}
nestedScrollEnabled
data={albums.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableOpacity
style={{ width: "30%" }}
key={index}
onPress={() => {
router.push(`/albums/${item.Id}`);
}}
>
<View className="flex flex-col gap-y-2">
<ArtistPoster item={item} />
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableOpacity>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

130
app/(auth)/artists/page.tsx Normal file
View File

@@ -0,0 +1,130 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
FlatList,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
const [startIndex, setStartIndex] = useState<number>(0);
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getArtistsApi(api).getArtists({
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
parentId: collectionId,
userId: user?.Id,
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!collection?.Id && !!api && !!user?.Id,
});
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
}, [data]);
if (!data) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-4">
<Text className="font-bold text-3xl mb-2">Artists</Text>
</View>
}
nestedScrollEnabled
data={data.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableOpacity
style={{
maxWidth: "30%",
width: "100%",
}}
key={index}
onPress={() => {
router.push(`/artists/${item.Id}/page`);
}}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableOpacity>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -1,14 +1,19 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
@@ -18,22 +23,23 @@ import {
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collection: collectionId } = searchParams as { collection: string };
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () =>
(api &&
(
await getItemsApi(api).getItems({
userId: user?.Id,
})
).data.Items?.find((item) => item.Id == collectionId)) ||
null,
enabled: !!api && !!user?.Id,
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
@@ -43,41 +49,68 @@ const page: React.FC = () => {
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collectionId, startIndex],
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api) return [];
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await api.axiosInstance.get(
`${api.basePath}/Users/${user?.Id}/Items`,
{
params: {
SortBy:
collection?.CollectionType === "movies"
? "SortName,ProductionYear"
: "SortName",
SortOrder: "Ascending",
IncludeItemTypes:
collection?.CollectionType === "movies" ? "Movie" : "Series",
Recursive: true,
Fields:
collection?.CollectionType === "movies"
? "PrimaryImageAspectRatio,MediaSourceCount"
: "PrimaryImageAspectRatio",
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
ParentId: collectionId,
Limit: 100,
StartIndex: startIndex,
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
);
const sortBy: ItemSortBy[] = [];
const includeItemTypes: BaseItemKind[] = [];
return response.data || [];
switch (collection?.CollectionType) {
case "movies":
sortBy.push("SortName", "ProductionYear");
break;
case "boxsets":
sortBy.push("IsFolder", "SortName");
break;
default:
sortBy.push("SortName");
break;
}
switch (collection?.CollectionType) {
case "movies":
includeItemTypes.push("Movie");
break;
case "boxsets":
includeItemTypes.push("BoxSet");
break;
case "tvshows":
includeItemTypes.push("Series");
break;
case "music":
includeItemTypes.push("MusicAlbum");
break;
default:
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 100,
startIndex,
sortBy,
sortOrder: ["Ascending"],
includeItemTypes,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: true,
imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!collection && !!api,
enabled: !!collection?.Id && !!api && !!user?.Id,
});
const totalItems = useMemo(() => {
@@ -91,7 +124,8 @@ const page: React.FC = () => {
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text>
{startIndex + 1}-{startIndex + 100} of {totalItems}
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
{totalItems}
</Text>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
@@ -125,7 +159,7 @@ const page: React.FC = () => {
</View>
) : (
<View className="flex flex-row flex-wrap">
{data?.Items?.map((item: any, index: number) => (
{data?.Items?.map((item: BaseItemDto, index: number) => (
<TouchableOpacity
style={{
maxWidth: "33%",
@@ -134,15 +168,23 @@ const page: React.FC = () => {
}}
key={index}
onPress={() => {
if (collection?.CollectionType === "movies") {
router.push(`/items/${item.Id}/page`);
} else if (collection?.CollectionType === "tvshows") {
router.push(`/series/${item.Id}/page`);
if (item?.Type === "Series") {
router.push(`/series/${item.Id}`);
} else if (item.IsFolder) {
router.push(`/collections/${item?.Id}`);
} else {
router.push(`/items/${item.Id}`);
}
}}
>
<View className="flex flex-col gap-y-2">
<MoviePoster item={item} />
{collection?.CollectionType === "movies" ? (
<MoviePoster item={item} />
) : collection?.CollectionType === "music" ? (
<ArtistPoster item={item} />
) : (
<MoviePoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}

View File

@@ -16,14 +16,20 @@ import { runningProcesses } from "@/utils/atoms/downloads";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import * as FileSystem from "expo-file-system";
import { queueAtom } from "@/utils/atoms/queue";
const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { data: downloadedFiles, isLoading } = useQuery({
queryKey: ["downloaded_files"],
queryKey: ["downloaded_files", process?.item.Id],
queryFn: async () =>
JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
) as BaseItemDto[],
staleTime: 0,
});
const movies = useMemo(
@@ -41,8 +47,6 @@ const downloads: React.FC = () => {
return Object.values(series);
}, [downloadedFiles]);
const [process, setProcess] = useAtom(runningProcesses);
const eta = useMemo(() => {
const length = process?.item?.RunTimeTicks || 0;
@@ -65,50 +69,82 @@ const downloads: React.FC = () => {
return (
<ScrollView>
<View className="px-4 py-4">
<View className="mb-4">
<Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/${process.item.Id}/page`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">{process.item.Type}</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-red-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
<View className="mb-4 flex flex-col space-y-4">
<View>
<Text className="text-2xl font-bold mb-2">Queue</Text>
<View className="flex flex-col space-y-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/${q.item.Id}`)}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="text-xs">ETA {eta}</Text>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
setQueue((prev) => prev.filter((i) => i.id !== q.id));
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
<View>
<Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
{process.item.Type}
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
<Text className="text-xs">
{process.speed?.toFixed(2)}x
</Text>
<View>
<Text className="text-xs">ETA {eta}</Text>
</View>
</View>
</View>
</View>
<TouchableOpacity
onPress={() => {
FFmpegKit.cancel();
setProcess(null);
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
<View
className={`
absolute bottom-0 left-0 h-1 bg-red-600
<TouchableOpacity
onPress={() => {
FFmpegKit.cancel();
setProcess(null);
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
<View
className={`
absolute bottom-0 left-0 h-1 bg-purple-600
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
</TouchableOpacity>
) : (
<Text className="opacity-50">No active downloads</Text>
)}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
</TouchableOpacity>
) : (
<Text className="opacity-50">No active downloads</Text>
)}
</View>
</View>
{movies.length > 0 && (
<View className="mb-4">

325
app/(auth)/items/[id].tsx Normal file
View File

@@ -0,0 +1,325 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { DownloadItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
import { Ratings } from "@/components/Ratings";
import { SimilarItems } from "@/components/SimilarItems";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { Text } from "@/components/common/Text";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
import { useSettings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { id } = local as { id: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const castDevice = useCastDevice();
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const client = useRemoteMediaClient();
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: id,
}),
enabled: !!id && !!api,
staleTime: 60,
});
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!item?.Id && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackUrl } = useQuery({
queryKey: [
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
settings,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
let deviceProfile: any = ios;
if (castDevice?.deviceId) {
deviceProfile = chromecastProfile;
} else if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
forceDirectPlay: settings?.forceDirectPlay,
});
console.log("Transcode URL: ", url);
return url;
},
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
staleTime: 0,
});
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: playbackUrl,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCurrentlyPlying({
item,
playbackUrl,
});
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setFullscreen(true);
}
}
},
[playbackUrl, item, settings],
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item],
);
if (l1)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<SeriesTitleHeader item={item} />
) : (
<>
<MoviesTitleHeader item={item} />
</>
)}
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
<Ratings item={item} />
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
<PlayedStatus item={item} />
</View>
<OverviewText text={item.Overview} />
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Video</Text>
<Text className="text-sm opacity-70">Audio</Text>
<Text className="text-sm opacity-70">Subtitles</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{
item.MediaStreams?.find((i) => i.Type === "Subtitle")
?.DisplayTitle
}
</Text>
</View>
</View>
</ScrollView>
<CastAndCrew item={item} />
{item.Type === "Episode" && (
<View className="mb-4">
<CurrentSeries item={item} />
</View>
)}
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -1,208 +0,0 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem";
import { PlayedStatus } from "@/components/PlayedStatus";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SimilarItems } from "@/components/SimilarItems";
import { VideoPlayer } from "@/components/VideoPlayer";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { ParallaxScrollView } from "../../../../components/ParallaxPage";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { id } = local as { id: string };
const [playbackURL, setPlaybackURL] = useState<string | null>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: id,
}),
enabled: !!id && !!api,
staleTime: 60,
});
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item],
);
if (l1)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 mb-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<>
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/series/${item.SeriesId}/page`)
}
>
<Text className="text-center opacity-50">
{item?.SeriesName}
</Text>
</TouchableOpacity>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<View>
<View className="flex flex-row items-center self-center">
<TouchableOpacity onPress={() => {}}>
<Text className="text-center opacity-50">
{item?.SeasonName}
</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
</View>
</View>
<Text className="text-center opacity-50">
{item.ProductionYear}
</Text>
</>
) : (
<>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<Text className="text-center opacity-50">
{item?.ProductionYear}
</Text>
</>
)}
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackURL && (
<DownloadItem item={item} playbackURL={playbackURL} />
)}
<Chromecast />
</View>
<Text>{item.Overview}</Text>
</View>
<View className="flex flex-col p-4">
<VideoPlayer
itemId={item.Id}
onChangePlaybackURL={(val) => {
setPlaybackURL(val);
}}
/>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Video</Text>
<Text className="text-sm opacity-70">Audio</Text>
<Text className="text-sm opacity-70">Subtitles</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{
item.MediaStreams?.find((i) => i.Type === "Subtitle")
?.DisplayTitle
}
</Text>
</View>
</View>
</ScrollView>
<CastAndCrew item={item} />
{item.Type === "Episode" && (
<View className="mb-4">
<CurrentSeries item={item} />
</View>
)}
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -1,23 +0,0 @@
import { OfflineVideoPlayer } from "@/components/OfflineVideoPlayer";
import * as FileSystem from "expo-file-system";
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { itemId, url } = searchParams as { itemId: string; url: string };
const fileUrl = useMemo(() => {
const u = FileSystem.documentDirectory + url;
return u;
}, [url]);
if (!fileUrl) return null;
return (
<View className="h-screen w-screen items-center justify-center">
{url && <OfflineVideoPlayer url={fileUrl} />}
</View>
);
}

View File

@@ -10,12 +10,15 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { View } from "react-native";
const page: React.FC = () => {
const params = useLocalSearchParams();
const { id: seriesId } = params as { id: string };
const { id: seriesId, seasonIndex } = params as {
id: string;
seasonIndex: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -29,7 +32,7 @@ const page: React.FC = () => {
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: 0,
staleTime: 60 * 1000,
});
const backdropUrl = useMemo(
@@ -84,7 +87,7 @@ const page: React.FC = () => {
</>
}
>
<View className="flex flex-col pt-4 pb-12">
<View className="flex flex-col pt-4 pb-24">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>

View File

@@ -4,11 +4,11 @@ import { ListItem } from "@/components/ListItem";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
import { useAtom } from "jotai";
import { ScrollView, View } from "react-native";
import * as Haptics from "expo-haptics";
import { useFiles } from "@/hooks/useFiles";
import { SettingToggles } from "@/components/settings/SettingToggles";
export default function settings() {
const { logout } = useJellyfin();
@@ -28,11 +28,13 @@ export default function settings() {
<View className="p-4 flex flex-col gap-y-4 pb-12">
<Text className="font-bold text-2xl">Information</Text>
<View className="rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-neutral-900">
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} />
</View>
<SettingToggles />
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
@@ -64,19 +66,17 @@ export default function settings() {
<Text className="font-bold text-2xl">Logs</Text>
<View className="flex flex-col space-y-2">
{logs?.map((log, index) => (
<View
key={index}
className="bg-neutral-800 border border-neutral-900 rounded p-2"
>
<View key={index} className="bg-neutral-900 rounded-xl p-3">
<Text
className={`
mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text>{log.message}</Text>
<Text className="text-xs">{log.message}</Text>
</View>
))}
{logs?.length === 0 && (

View File

@@ -0,0 +1,280 @@
import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem";
import { SimilarItems } from "@/components/SimilarItems";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { PlayButton } from "@/components/PlayButton";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import {
currentlyPlayingItemAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Chromecast } from "@/components/Chromecast";
import ios from "@/utils/profiles/ios";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { songId: id } = local as { songId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [, setPlaying] = useAtom(playingAtom);
const castDevice = useCastDevice();
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: id,
}),
enabled: !!id && !!api,
staleTime: 60 * 1000,
});
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item],
);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!item?.Id && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackUrl } = useQuery({
queryKey: [
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
});
console.log("Transcode URL: ", url);
return url;
},
enabled: !!sessionData,
staleTime: 0,
});
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const client = useRemoteMediaClient();
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: playbackUrl,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCp({
item,
playbackUrl,
});
setPlaying(true);
}
},
[playbackUrl, item],
);
if (l1)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
<MoviesTitleHeader item={item} />
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
</View>
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Audio</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
</View>
</View>
</ScrollView>
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -1,16 +1,23 @@
import { Link, Stack } from "expo-router";
import { Link, Stack, usePathname } from "expo-router";
import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
import { useEffect } from "react";
export default function NotFoundScreen() {
const pathname = usePathname();
useEffect(() => {
console.log(`Navigated to ${pathname}`);
}, [pathname]);
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href={"/"} style={styles.link}>
<Link href={"/home"} style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText>
</Link>
</ThemedView>

View File

@@ -1,23 +1,26 @@
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { useFonts } from "expo-font";
import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect, useRef } from "react";
import "react-native-reanimated";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider as JotaiProvider } from "jotai";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { TouchableOpacity } from "react-native";
import Feather from "@expo/vector-icons/Feather";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import "react-native-reanimated";
import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar";
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { useJobProcessor } from "@/utils/atoms/queue";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { useKeepAwake } from "expo-keep-awake";
import { useSettings } from "@/utils/atoms/settings";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export const unstable_settings = {
initialRouteName: "(auth)/(tabs)/",
initialRouteName: "/index",
};
export default function RootLayout() {
@@ -25,20 +28,6 @@ export default function RootLayout() {
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
}),
);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
@@ -49,80 +38,142 @@ export default function RootLayout() {
return null;
}
return (
<JotaiProvider>
<Layout />
</JotaiProvider>
);
}
function Layout() {
const [settings, updateSettings] = useSettings();
useKeepAwake();
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
}),
);
useEffect(() => {
if (settings?.autoRotate === true)
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
else
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}, [settings]);
return (
<QueryClientProvider client={queryClientRef.current}>
<JotaiProvider>
<JellyfinProvider>
<StatusBar style="auto" />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "Home",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
presentation: "modal",
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()}>
<Feather name="x-circle" size={24} color="white" />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
}}
/>
<Stack.Screen
name="(auth)/player/offline/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
}}
/>
<Stack.Screen
name="(auth)/items/[id]/page"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collection]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]/page"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
</JellyfinProvider>
</JotaiProvider>
<JobQueueProvider>
<ActionSheetProvider>
<JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</JellyfinProvider>
</ActionSheetProvider>
</JobQueueProvider>
</QueryClientProvider>
);
}

148
app/login.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { AxiosError } from "axios";
import { useAtom } from "jotai";
import React, { useMemo, useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import { z } from "zod";
const CredentialsSchema = z.object({
username: z.string().min(1, "Username is required"),
});
const Login: React.FC = () => {
const { setServer, login, removeServer } = useJellyfin();
const [api] = useAtom(apiAtom);
const [serverURL, setServerURL] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: "",
password: "",
});
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
setLoading(true);
try {
const result = CredentialsSchema.safeParse(credentials);
if (result.success) {
await login(credentials.username, credentials.password);
}
} catch (error) {
const e = error as AxiosError;
setError(e.message);
} finally {
setLoading(false);
}
};
const handleConnect = (url: string) => {
setServer({ address: url.trim() });
};
if (api?.basePath) {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
<View>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
<Button
color="black"
onPress={() => {
removeServer();
setServerURL("");
}}
justify="between"
iconLeft={
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
}
>
Change server
</Button>
</View>
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Input
className="mb-2"
placeholder="Password"
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
</View>
<Text className="text-red-600 mb-2">{error}</Text>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
</View>
</KeyboardAvoidingView>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-center h-full">
<View className="flex flex-col gap-y-2">
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="opacity-50">Enter a server adress</Text>
<Input
className="mb-2"
placeholder="http(s)://..."
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
</View>
</View>
</KeyboardAvoidingView>
);
};
export default Login;

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
assets/images/featured.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
assets/images/icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -0,0 +1,58 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type ArtistPosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
const ArtistPoster: React.FC<ArtistPosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item,
}),
[item],
);
if (!url)
return (
<View
className="rounded-md overflow-hidden border border-neutral-900"
style={{
aspectRatio: "1/1",
}}
></View>
);
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default ArtistPoster;

View File

@@ -0,0 +1,80 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const AudioTrackSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const audioStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
[item],
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected],
);
useEffect(() => {
const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
if (index !== undefined && index !== null) onChange(index);
}, []);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedAudioSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

36
components/Badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
text?: string | number | null;
variant?: "gray" | "purple";
iconLeft?: React.ReactNode;
}
export const Badge: React.FC<Props> = ({
iconLeft,
text,
variant = "purple",
...props
}) => {
return (
<View
{...props}
className={`
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
${variant === "purple" && "bg-purple-600"}
${variant === "gray" && "bg-neutral-800"}
`}
>
{iconLeft && <View className="mr-1">{iconLeft}</View>}
<Text
className={`
text-xs
${variant === "purple" && "text-white"}
`}
>
{text}
</Text>
</View>
);
};

View File

@@ -0,0 +1,87 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
export type Bitrate = {
key: string;
value: number | undefined;
};
const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
},
{
key: "8 Mb/s",
value: 8000000,
},
{
key: "4 Mb/s",
value: 4000000,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "500 Kb/s",
value: 500000,
},
{
key: "250 Kb/s",
value: 250000,
},
];
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected: Bitrate;
}
export const BitrateSelector: React.FC<Props> = ({
onChange,
selected,
...props
}) => {
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === selected.value)?.key}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b, index: number) => (
<DropdownMenu.Item
key={index.toString()}
onSelect={() => {
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -7,7 +7,7 @@ interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
className?: string;
textClassName?: string;
disabled?: boolean;
children?: string;
children?: string | ReactNode;
loading?: boolean;
color?: "purple" | "red" | "black";
iconRight?: ReactNode;

View File

@@ -1,5 +1,6 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect } from "react";
import { View } from "react-native";
import {
CastButton,
useCastDevice,
@@ -9,11 +10,11 @@ import {
import GoogleCast from "react-native-google-cast";
type Props = {
item?: BaseItemDto | null;
startTimeTicks?: number | null;
width?: number;
height?: number;
};
export const Chromecast: React.FC<Props> = () => {
export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
@@ -30,5 +31,9 @@ export const Chromecast: React.FC<Props> = () => {
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
<CastButton style={{ tintColor: "white", height, width }} />
</View>
);
};

View File

@@ -61,7 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
style={{
width: `${progress}%`,
}}
className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
></View>
</>
)}

View File

@@ -0,0 +1,370 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Platform,
TouchableOpacity,
View,
} from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import { Text } from "./common/Text";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
playbackUrl: string;
} | null>(null);
export const playingAtom = atom(false);
export const fullScreenAtom = atom(false);
export const CurrentlyPlayingBar: React.FC = () => {
const queryClient = useQueryClient();
const segments = useSegments();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [playing, setPlaying] = useAtom(playingAtom);
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
currentlyPlayingItemAtom,
);
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
const videoRef = useRef<VideoRef | null>(null);
const [progress, setProgress] = useState(0);
const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0);
const aHeight = useSharedValue(100);
const router = useRouter();
const animatedOuterStyle = useAnimatedStyle(() => {
return {
bottom: withTiming(aBottom.value, { duration: 500 }),
height: withTiming(aHeight.value, { duration: 500 }),
padding: withTiming(aPadding.value, { duration: 500 }),
};
});
const aPaddingBottom = useSharedValue(30);
const aPaddingInner = useSharedValue(12);
const aBorderRadiusBottom = useSharedValue(12);
const animatedInnerStyle = useAnimatedStyle(() => {
return {
padding: withTiming(aPaddingInner.value, { duration: 500 }),
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
duration: 500,
}),
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
duration: 500,
}),
};
});
useEffect(() => {
if (segments.find((s) => s.includes("tabs"))) {
// Tab screen - i.e. home
aBottom.value = Platform.OS === "ios" ? 78 : 50;
aHeight.value = 80;
aPadding.value = 8;
aPaddingBottom.value = 8;
aPaddingInner.value = 8;
} else {
// Inside a normal screen
aBottom.value = Platform.OS === "ios" ? 0 : 0;
aHeight.value = Platform.OS === "ios" ? 110 : 80;
aPadding.value = Platform.OS === "ios" ? 0 : 8;
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
}
}, [segments]);
const { data: item } = useQuery({
queryKey: ["item", currentlyPlaying?.item.Id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: currentlyPlaying?.item.Id,
}),
enabled: !!currentlyPlaying?.item.Id && !!api,
staleTime: 60,
});
const { data: sessionData } = useQuery({
queryKey: ["sessionData", currentlyPlaying?.item.Id],
queryFn: async () => {
if (!currentlyPlaying?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
staleTime: 0,
});
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (
!currentTime ||
!sessionData?.PlaySessionId ||
!playing ||
!api ||
!currentlyPlaying?.item.Id
)
return;
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id],
);
useEffect(() => {
if (!item || !api) return;
if (playing) {
videoRef.current?.resume();
} else {
videoRef.current?.pause();
if (progress > 0 && sessionData?.PlaySessionId)
reportPlaybackStopped({
api,
itemId: item?.Id,
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
}
}, [playing, progress, item, sessionData]);
useEffect(() => {
console.log("Full screen changed", fullScreen);
if (fullScreen === true) {
videoRef.current?.presentFullscreenPlayer();
} else {
videoRef.current?.dismissFullscreenPlayer();
}
}, [fullScreen]);
const startPosition = useMemo(
() =>
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0,
[item],
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 70,
width: 200,
}),
[item],
);
if (!currentlyPlaying || !api) return null;
return (
<Animated.View
style={[animatedOuterStyle]}
className="absolute left-0 w-screen"
>
<BlurView
intensity={Platform.OS === "android" ? 60 : 100}
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
className={`h-full w-full rounded-xl overflow-hidden ${
Platform.OS === "android" && "bg-black"
}`}
>
<Animated.View
style={[
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
animatedInnerStyle,
]}
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
>
<View className="flex flex-row items-center space-x-4 shrink">
<TouchableOpacity
onPress={() => {
videoRef.current?.presentFullscreenPlayer();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
`}
>
{currentlyPlaying.playbackUrl && (
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
poster={
backdropUrl && item?.Type === "Audio"
? backdropUrl
: undefined
}
debug={{
enable: true,
thread: true,
}}
paused={!playing}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
source={{
uri: currentlyPlaying.playbackUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
}}
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onFullscreenPlayerDidDismiss={() => {
setFullScreen(false);
}}
onFullscreenPlayerDidPresent={() => {
setFullScreen(true);
}}
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setPlaying(true);
} else if (e.isSeeking) {
return;
} else {
setPlaying(false);
}
}}
progressUpdateInterval={1000}
onError={(e) => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e),
);
Alert.alert("Error", "Cannot play this video file.");
setPlaying(false);
setCurrentlyPlaying(null);
}}
renderLoader={
item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<ActivityIndicator size={"small"} color={"white"} />
</View>
)
}
/>
)}
</TouchableOpacity>
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
console.log(JSON.stringify(item));
if (item?.Type === "Audio")
router.push(`/albums/${item?.AlbumId}`);
else router.push(`/items/${item?.Id}`);
}}
>
<Text>{item?.Name}</Text>
</TouchableOpacity>
{item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/series/${item.SeriesId}`);
}}
className="text-xs opacity-50"
>
<Text>{item.SeriesName}</Text>
</TouchableOpacity>
)}
{item?.Type === "Movie" && (
<View>
<Text className="text-xs opacity-50">
{item?.ProductionYear}
</Text>
</View>
)}
{item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
console.log(JSON.stringify(item));
router.push(`/albums/${item?.AlbumId}`);
}}
>
<Text className="text-xs opacity-50">{item?.Album}</Text>
</TouchableOpacity>
)}
</View>
</View>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
if (playing) setPlaying(false);
else setPlaying(true);
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
{playing ? (
<Ionicons name="pause" size={24} color="white" />
) : (
<Ionicons name="play" size={24} color="white" />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setCurrentlyPlaying(null);
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
</Animated.View>
</BlurView>
</Animated.View>
);
};

View File

@@ -1,144 +1,137 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
import Ionicons from "@expo/vector-icons/Ionicons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
import { Text } from "./common/Text";
import { useDownloadMedia } from "@/hooks/useDownloadMedia";
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
type DownloadProps = {
item: BaseItemDto;
playbackURL: string;
playbackUrl: string;
};
export const DownloadItem: React.FC<DownloadProps> = ({
item,
playbackURL,
playbackUrl,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { downloadMedia, isDownloading, error, cancelDownload } =
useDownloadMedia(api, user?.Id);
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackURL, item);
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
});
const downloadFile = useCallback(async () => {
if (!playbackInfo) return;
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
if (!item.Id) return false;
const source = playbackInfo.MediaSources?.[0];
if (source?.SupportsDirectPlay && item.CanDownload) {
downloadMedia(item);
} else {
throw new Error(
"Direct play not supported thus the file cannot be downloaded",
);
}
}, [item, user, playbackInfo]);
const [downloaded, setDownloaded] = useState<boolean>(false);
useEffect(() => {
(async () => {
const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
);
if (data.find((d) => d.Id === item.Id)) setDownloaded(true);
})();
}, [process]);
return data.some((d) => d.Id === item.Id);
},
enabled: !!item.Id,
});
if (isLoading) {
return <ActivityIndicator size={"small"} color={"white"} />;
if (isLoading || isLoadingDownloaded) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
<ActivityIndicator size={"small"} color={"white"} />
</View>
);
}
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
return (
<View style={{ opacity: 0.5 }}>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
);
}
if (process && process.item.Id !== item.Id!) {
if (process && process?.item.Id === item.Id) {
return (
<TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
)}
</View>
</TouchableOpacity>
);
}
return (
<View>
{process ? (
<TouchableOpacity
onPress={() => {
cancelRemuxing();
}}
className="flex flex-row items-center"
>
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<View className="relative">
<View className="-rotate-45">
<ProgressCircle
size={28}
fill={process.progress}
width={4}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
</View>
<View className="absolute top-0 left-0 font-bold w-full h-full flex flex-col items-center justify-center">
<Text className="text-[7px]">
{process.progress.toFixed(0)}%
</Text>
</View>
</View>
)}
if (queue.some((i) => i.id === item.Id)) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="hourglass" size={24} color="white" />
</View>
</TouchableOpacity>
);
}
{process?.speed && process.speed > 0 ? (
<View className="ml-2">
<Text>{process.speed.toFixed(2)}x</Text>
</View>
) : null}
</TouchableOpacity>
) : downloaded ? (
<TouchableOpacity
onPress={() => {
router.push(
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`,
);
}}
>
<Ionicons name="cloud-download" size={26} color="#16a34a" />
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
// downloadFile();
startRemuxing();
}}
>
if (downloaded) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</View>
</TouchableOpacity>
);
} else {
return (
<TouchableOpacity
onPress={() => {
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await startRemuxing();
},
item,
});
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download-outline" size={26} color="white" />
</TouchableOpacity>
)}
</View>
);
</View>
</TouchableOpacity>
);
}
};

View File

@@ -2,24 +2,34 @@ import React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
type ItemCardProps = {
item: BaseItemDto;
};
function seasonNameToIndex(seasonName: string | null | undefined) {
if (!seasonName) return -1;
if (seasonName.startsWith("Season")) {
return parseInt(seasonName.replace("Season ", ""));
}
if (seasonName.startsWith("Specials")) {
return 0;
}
return -1;
}
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col grow-0">
<View className="mt-2 flex flex-col h-12">
{item.Type === "Episode" ? (
<>
<Text className="">{item.SeriesName}</Text>
<Text
style={{ flexWrap: "wrap" }}
className="flex text-xs opacity-50 break-all"
>
{`S${item.SeasonName?.replace(
"Season ",
""
<Text numberOfLines={2} className="">
{item.SeriesName}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
{`S${seasonNameToIndex(
item?.SeasonName,
)}:E${item.IndexNumber?.toString()}`}{" "}
{item.Name}
</Text>

View File

@@ -1,22 +1,26 @@
import { PropsWithChildren, ReactNode } from "react";
import { View } from "react-native";
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
type Props = {
interface Props extends ViewProps {
title?: string | null | undefined;
subTitle?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
};
}
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
subTitle,
iconAfter,
children,
...props
}) => {
return (
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-col">
<Text className="font-bold ">{title}</Text>
{subTitle && <Text className="text-xs">{subTitle}</Text>}

View File

@@ -29,15 +29,9 @@ export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
uri: url,
isNetwork: false,
}}
controls
ref={videoRef}
onError={onError}
resizeMode="contain"
reportBandwidth
style={{
width: "100%",
aspectRatio: 16 / 9,
}}
ignoreSilentSwitch="ignore"
/>
);
};

View File

@@ -0,0 +1,38 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools";
import { useState } from "react";
interface Props extends ViewProps {
text?: string | null;
}
const LIMIT = 140;
export const OverviewText: React.FC<Props> = ({ text, ...props }) => {
const [limit, setLimit] = useState(LIMIT);
if (!text) return null;
if (text.length > LIMIT)
return (
<TouchableOpacity
onPress={() =>
setLimit((prev) => (prev === LIMIT ? text.length : LIMIT))
}
>
<View {...props} className="">
<Text>{tc(text, limit)}</Text>
<Text className="text-purple-600 mt-1">
{limit === LIMIT ? "Show more" : "Show less"}
</Text>
</View>
</TouchableOpacity>
);
return (
<View {...props}>
<Text>{text}</Text>
</View>
);
};

View File

@@ -9,6 +9,7 @@ import Animated, {
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
const HEADER_HEIGHT = 400;
@@ -32,14 +33,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
),
},
{
scale: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1]
[2, 1, 1],
),
},
],
@@ -61,7 +62,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
onPress={() => router.back()}
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
style={{
top: inset.top,
top: inset.top + 17,
}}
>
<Ionicons
@@ -72,6 +73,15 @@ export const ParallaxScrollView: React.FC<Props> = ({
/>
</TouchableOpacity>
<View
className="absolute right-4 z-50 bg-black rounded-full p-0.5"
style={{
top: inset.top + 17,
}}
>
<Chromecast width={22} height={22} />
</View>
{logo && (
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
{logo}
@@ -89,7 +99,9 @@ export const ParallaxScrollView: React.FC<Props> = ({
>
{headerImage}
</Animated.View>
<View className="flex-1 overflow-hidden bg-black">{children}</View>
<View className="flex-1 overflow-hidden bg-black pb-24">
{children}
</View>
</Animated.ScrollView>
</View>
);

65
components/PlayButton.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View } from "react-native";
import { Button } from "./Button";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
onPress: (type?: "cast" | "device") => void;
chromecastReady: boolean;
}
export const PlayButton: React.FC<Props> = ({
item,
onPress,
chromecastReady,
...props
}) => {
const { showActionSheetWithOptions } = useActionSheet();
const _onPress = () => {
if (!chromecastReady) {
onPress("device");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
onPress("cast");
break;
case 1:
onPress("device");
break;
case cancelButtonIndex:
break;
}
},
);
};
return (
<Button
onPress={_onPress}
iconRight={
<View className="flex flex-row items-center space-x-2">
<Ionicons name="play-circle" size={24} color="white" />
{chromecastReady && <Feather name="cast" size={22} color="white" />}
</View>
}
{...props}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>
);
};

View File

@@ -47,7 +47,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
invalidateQueries();
}}
>
<Ionicons name="checkmark-circle" size={26} color="white" />
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={30} color="white" />
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
@@ -61,7 +63,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
invalidateQueries();
}}
>
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
</View>
</TouchableOpacity>
)}
</View>

41
components/Ratings.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const Ratings: React.FC<Props> = ({ item }) => {
return (
<View className="flex flex-row items-center justify-center mt-2 space-x-2">
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant="gray" />
)}
{item.CommunityRating && (
<Badge
text={item.CommunityRating}
variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />}
/>
)}
{item.CriticRating && (
<Badge
text={item.CriticRating}
variant="gray"
iconLeft={
<Image
source={require("@/assets/images/rotten-tomatoes.png")}
style={{
width: 14,
height: 14,
}}
/>
}
/>
)}
</View>
);
};

View File

@@ -42,7 +42,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
const movies = useMemo(
() => similarItems?.filter((i) => i.Type === "Movie") || [],
[similarItems]
[similarItems],
);
return (
@@ -58,7 +58,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
{movies.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}/page`)}
onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-32"
>
<MoviePoster item={item} />

View File

@@ -0,0 +1,92 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const subtitleStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) ?? [],
[item],
);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected],
);
useEffect(() => {
const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
if (index !== undefined && index !== null) {
onChange(index);
} else {
// Get first subtitle stream
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
if (firstSubtitle?.Index !== undefined) {
onChange(firstSubtitle.Index);
}
}
}, []);
if (subtitleStreams.length === 0) return null;
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -1,288 +0,0 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Feather, Ionicons } from "@expo/vector-icons";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { TouchableOpacity, View } from "react-native";
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "./Button";
import { Text } from "./common/Text";
import ios12 from "../utils/profiles/ios12";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
type VideoPlayerProps = {
itemId: string;
onChangePlaybackURL: (url: string | null) => void;
};
const BITRATES = [
{
key: "Max",
value: undefined,
},
{
key: "4 Mb/s",
value: 4000000,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "500 Kb/s",
value: 500000,
},
];
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
itemId,
onChangePlaybackURL,
}) => {
const videoRef = useRef<VideoRef | null>(null);
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
const [paused, setPaused] = useState(true);
const [progress, setProgress] = useState(0);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const client = useRemoteMediaClient();
const queryClient = useQueryClient();
const { data: item } = useQuery({
queryKey: ["item", itemId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId,
}),
enabled: !!itemId && !!api,
staleTime: 60,
});
const { data: sessionData } = useQuery({
queryKey: ["sessionData", itemId],
queryFn: async () => {
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!itemId && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackURL } = useQuery({
queryKey: ["playbackUrl", itemId, maxBitrate, castDevice],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
});
onChangePlaybackURL(url);
return url;
},
enabled: !!sessionData,
staleTime: 0,
});
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (!currentTime || !sessionData?.PlaySessionId || paused) return;
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, item, api, paused],
);
const play = () => {
if (videoRef.current) {
videoRef.current.resume();
setPaused(false);
}
};
const pause = useCallback(() => {
videoRef.current?.pause();
setPaused(true);
if (progress > 0)
reportPlaybackStopped({
api,
itemId: item?.Id,
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
}, [api, item, progress, sessionData, queryClient]);
const startPosition = useMemo(
() =>
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0,
[item],
);
const enableVideo = useMemo(
() => !!(playbackURL && item && startPosition !== null && sessionData),
[playbackURL, item, startPosition, sessionData],
);
const chromecastReady = useMemo(
() => !!(castDevice?.deviceId && item),
[castDevice, item],
);
const cast = useCallback(() => {
if (!client || !playbackURL || !item) return;
client.loadMedia({
mediaInfo: {
contentUrl: playbackURL,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
streamDuration: Math.floor((item.RunTimeTicks || 0) / 10000),
},
startTime: Math.floor(
(item.UserData?.PlaybackPositionTicks || 0) / 10000,
),
});
}, [item, client, playbackURL]);
useEffect(() => {
videoRef.current?.pause();
}, []);
return (
<View>
{enableVideo === true && startPosition !== null && !!playbackURL ? (
<Video
style={{ width: 0, height: 0 }}
source={{
uri: playbackURL,
isNetwork: true,
startPosition,
}}
ref={videoRef}
onBuffer={(e) => (e.isBuffering ? console.log("Buffering...") : null)}
onProgress={(e) => onProgress(e)}
onFullscreenPlayerDidDismiss={() => {
pause();
}}
onFullscreenPlayerDidPresent={() => {
play();
}}
paused={paused}
ignoreSilentSwitch="ignore"
/>
) : null}
<View className="flex flex-row items-center justify-between">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === maxBitrate)?.key}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b: any, index: number) => (
<DropdownMenu.Item
key={index.toString()}
onSelect={() => {
setMaxbitrate(b.value);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col w-full">
<Button
disabled={!enableVideo}
onPress={() => {
if (chromecastReady) {
cast();
} else {
setTimeout(() => {
if (!videoRef.current) return;
videoRef.current.presentFullscreenPlayer();
}, 1000);
}
}}
iconRight={
chromecastReady ? (
<Feather name="cast" size={20} color="white" />
) : (
<Ionicons name="play-circle" size={24} color="white" />
)
}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>
</View>
</View>
);
};

12
components/_template.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
interface Props extends ViewProps {}
export const TitleHeader: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<Text></Text>
</View>
);
};

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
import {
ScrollView,
View,
@@ -6,6 +6,12 @@ import {
ActivityIndicator,
ScrollViewProps,
} from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Text } from "./Text";
interface HorizontalScrollProps<T> extends ScrollViewProps {
data?: T[] | null;
@@ -13,21 +19,42 @@ interface HorizontalScrollProps<T> extends ScrollViewProps {
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
}
export function HorizontalScroll<T>({
data,
data = [],
renderItem,
containerStyle,
contentContainerStyle,
loadingContainerStyle,
loading = false,
height = 164,
...props
}: HorizontalScrollProps<T>): React.ReactElement {
if (!data) {
const animatedOpacity = useSharedValue(0);
const animatedStyle1 = useAnimatedStyle(() => {
return {
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
};
});
useEffect(() => {
if (data) {
animatedOpacity.value = 1;
}
}, [data]);
if (data === undefined || data === null || loading) {
return (
<View
style={[
{ flex: 1, justifyContent: "center", alignItems: "center" },
{
flex: 1,
justifyContent: "center",
alignItems: "center",
},
loadingContainerStyle,
]}
>
@@ -44,13 +71,23 @@ export function HorizontalScroll<T>({
showsHorizontalScrollIndicator={false}
{...props}
>
<View className="flex flex-row px-4">
<Animated.View
className={`
flex flex-row px-4
`}
style={[animatedStyle1]}
>
{data.map((item, index) => (
<View className="mr-2" key={index}>
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
))}
</View>
{data.length === 0 && (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
</View>
)}
</Animated.View>
</ScrollView>
);
}

View File

@@ -1,15 +1,26 @@
import React from "react";
import { useFocusEffect } from "expo-router";
import React, { useEffect } from "react";
import { TextInputProps, TextProps } from "react-native";
import { TextInput } from "react-native";
export function Input(props: TextInputProps) {
const { style, ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null);
useFocusEffect(
React.useCallback(() => {
inputRef.current?.focus();
}, []),
);
return (
<TextInput
ref={inputRef}
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
/>
);
}

View File

@@ -1,97 +1,96 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import React, { useCallback } from "react";
import { TouchableOpacity } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as ContextMenu from "zeego/context-menu";
import * as Haptics from "expo-haptics";
import * as FileSystem from "expo-file-system";
import { useAtom } from "jotai";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import * as Haptics from "expo-haptics";
import { useRef, useMemo, useState } from "react";
import Video, { VideoRef } from "react-native-video";
import * as FileSystem from "expo-file-system";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "../CurrentlyPlayingBar";
import { useSettings } from "@/utils/atoms/settings";
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
interface EpisodeCardProps {
item: BaseItemDto;
}
/**
* EpisodeCard component displays an episode with context menu options.
* @param {EpisodeCardProps} props - The component props.
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const videoRef = useRef<VideoRef | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const [settings] = useSettings();
const openFile = () => {
videoRef.current?.presentFullscreenPlayer();
};
/**
* Handles opening the file for playback.
*/
const handleOpenFile = useCallback(async () => {
setCurrentlyPlaying({
item,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true)
setFullscreen(true);
}, [item, setCurrentlyPlaying, settings]);
const fileUrl = useMemo(() => {
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
}, [item]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}, [deleteFile, item.Id]);
const options = [
const contextMenuOptions = [
{
label: "Delete",
onSelect: (id: string) => {
deleteFile(id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
onSelect: handleDeleteFile,
destructive: true,
},
];
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={openFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className=" font-bold">{item.Name}</Text>
<Text className=" text-xs opacity-50">
Episode {item.IndexNumber}
</Text>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions
collisionPadding={10}
loop={false}
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={handleOpenFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
{options.map((i) => (
<ContextMenu.Item
onSelect={() => {
i.onSelect(item.Id!);
}}
key={i.label}
destructive={i.destructive}
>
<ContextMenu.ItemTitle
style={{
color: "red",
}}
>
{i.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<Video
style={{ width: 0, height: 0 }}
source={{
uri: fileUrl,
isNetwork: false,
}}
controls
onFullscreenPlayerDidDismiss={() => {
setIsPlaying(false);
videoRef.current?.pause();
}}
onFullscreenPlayerDidPresent={() => {
setIsPlaying(true);
videoRef.current?.resume();
}}
ref={videoRef}
resizeMode="contain"
paused={!isPlaying}
/>
</>
<Text className="font-bold">{item.Name}</Text>
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions
collisionPadding={10}
loop={false}
>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}
onSelect={option.onSelect}
destructive={option.destructive}
>
<ContextMenu.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -1,107 +1,99 @@
import React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "../common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runtimeTicksToMinutes } from "@/utils/time";
import * as ContextMenu from "zeego/context-menu";
import { router } from "expo-router";
import { useFiles } from "@/hooks/useFiles";
import Video, {
OnBufferData,
OnPlaybackStateChangedData,
OnProgressData,
OnVideoErrorData,
VideoRef,
} from "react-native-video";
import * as FileSystem from "expo-file-system";
import { useMemo, useRef, useState } from "react";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "../CurrentlyPlayingBar";
import { useSettings } from "@/utils/atoms/settings";
interface MovieCardProps {
item: BaseItemDto;
}
/**
* MovieCard component displays a movie with context menu options.
* @param {MovieCardProps} props - The component props.
* @returns {React.ReactElement} The rendered MovieCard component.
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const videoRef = useRef<VideoRef | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const [settings] = useSettings();
const openFile = () => {
videoRef.current?.presentFullscreenPlayer();
};
/**
* Handles opening the file for playback.
*/
const handleOpenFile = useCallback(() => {
console.log("Open movie file", item.Name);
setCurrentlyPlaying({
item,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setFullscreen(true);
}
}, [item, setCurrentlyPlaying, setPlaying, settings]);
const fileUrl = useMemo(() => {
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
}, [item]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}, [deleteFile, item.Id]);
const options = [
const contextMenuOptions = [
{
label: "Delete",
onSelect: (id: string) => {
deleteFile(id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
onSelect: handleDeleteFile,
destructive: true,
},
];
return (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={openFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className=" font-bold">{item.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
<Text className=" text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
</View>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions={false}
collisionPadding={0}
loop={false}
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={handleOpenFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
{options.map((i) => (
<ContextMenu.Item
onSelect={() => {
i.onSelect(item.Id!);
}}
key={i.label}
destructive={i.destructive}
>
<ContextMenu.ItemTitle
style={{
color: "red",
}}
>
{i.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<Video
style={{ width: 0, height: 0 }}
source={{
uri: fileUrl,
isNetwork: false,
}}
controls
onFullscreenPlayerDidDismiss={() => {
setIsPlaying(false);
videoRef.current?.pause();
}}
onFullscreenPlayerDidPresent={() => {
setIsPlaying(true);
videoRef.current?.resume();
}}
ref={videoRef}
resizeMode="contain"
paused={!isPlaying}
/>
</>
<Text className="font-bold">{item.Name}</Text>
<View className="flex flex-col">
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
<Text className="text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
</View>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}
onSelect={option.onSelect}
destructive={option.destructive}
>
<ContextMenu.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -0,0 +1,65 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import MoviePoster from "../MoviePoster";
interface Props extends ViewProps {
title: string;
loading?: boolean;
orientation?: "horizontal" | "vertical";
data?: BaseItemDto[] | null;
height?: "small" | "large";
disabled?: boolean;
}
export const ScrollingCollectionList: React.FC<Props> = ({
title,
data,
orientation = "vertical",
height = "small",
loading = false,
disabled = false,
...props
}) => {
if (disabled) return null;
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2">{title}</Text>
<HorizontalScroll<BaseItemDto>
data={data}
height={orientation === "vertical" ? 247 : 164}
loading={loading}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => {
if (item.Type === "Series") router.push(`/series/${item.Id}`);
else if (item.CollectionType === "music")
router.push(`/artists/page?collectionId=${item.Id}`);
else if (item.Type === "CollectionFolder")
router.push(`/collections/${item.Id}`);
else router.push(`/items/${item.Id}`);
}}
className={`flex flex-col
${orientation === "vertical" ? "w-32" : "w-48"}
`}
>
<View>
{orientation === "vertical" ? (
<MoviePoster item={item} />
) : (
<ContinueWatchingPoster item={item} />
)}
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
);
};

View File

@@ -0,0 +1,21 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
</View>
</>
);
};

View File

@@ -0,0 +1,38 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import ArtistPoster from "../ArtistPoster";
import { runtimeTicksToMinutes, runtimeTicksToSeconds } from "@/utils/time";
import { useRouter } from "expo-router";
import { SongsListItem } from "./SongsListItem";
interface Props extends ViewProps {
songs?: BaseItemDto[] | null;
collectionId: string;
artistId: string;
albumId: string;
}
export const SongsList: React.FC<Props> = ({
collectionId,
artistId,
albumId,
songs = [],
...props
}) => {
const router = useRouter();
return (
<View className="flex flex-col space-y-2" {...props}>
{songs?.map((item: BaseItemDto, index: number) => (
<SongsListItem
key={item.Id}
item={item}
index={index}
collectionId={collectionId}
artistId={artistId}
albumId={albumId}
/>
))}
</View>
);
};

View File

@@ -0,0 +1,147 @@
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import index from "@/app/(auth)/(tabs)/home";
import { runtimeTicksToSeconds } from "@/utils/time";
import { router } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
import { useActionSheet } from "@expo/react-native-action-sheet";
import ios from "@/utils/profiles/ios";
interface Props extends TouchableOpacityProps {
collectionId: string;
artistId: string;
albumId: string;
item: BaseItemDto;
index: number;
}
export const SongsListItem: React.FC<Props> = ({
collectionId,
artistId,
albumId,
item,
index,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const openSelect = () => {
if (!castDevice?.deviceId) {
play("device");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
play("cast");
break;
case 1:
play("device");
break;
case cancelButtonIndex:
break;
}
},
);
};
const play = async (type: "device" | "cast") => {
if (!user?.Id || !api || !item.Id) return;
const response = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
const sessionData = response.data;
const url = await getStreamUrl({
api,
userId: user.Id,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
});
if (!url || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCp({
item,
playbackUrl: url,
});
setPlaying(true);
}
};
return (
<TouchableOpacity
onPress={() => {
openSelect();
}}
{...props}
>
<View className="flex flex-row items-center space-x-4 bg-neutral-900 border-neutral-800 px-4 py-4 rounded-xl">
<Text className="opacity-50">{index + 1}</Text>
<View>
<Text className="mb-0.5 font-semibold">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -20,7 +20,7 @@ export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.SeriesId}/page`)}
onPress={() => router.push(`/series/${item.SeriesId}`)}
className="flex flex-col space-y-2 w-32"
>
<Poster

View File

@@ -0,0 +1,105 @@
import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useMemo } from "react";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
type?: "next" | "previous";
}
export const NextEpisodeButton: React.FC<Props> = ({
item,
type = "next",
...props
}) => {
const router = useRouter();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
// const { data: seasons } = useQuery({
// queryKey: ["seasons", item.SeriesId],
// queryFn: async () => {
// if (
// !api ||
// !user?.Id ||
// !item?.Id ||
// !item?.SeriesId ||
// !item?.IndexNumber
// )
// return [];
// const response = await getItemsApi(api).getItems({
// parentId: item?.SeriesId,
// });
// console.log("seasons ~", type, response.data);
// return (response.data.Items as BaseItemDto[]) ?? [];
// },
// enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
// });
// const nextSeason = useMemo(() => {
// if (!seasons) return null;
// const currentSeasonIndex = seasons.findIndex(
// (season) => season.Id === item.SeasonId,
// );
// if (currentSeasonIndex === seasons.length - 1) return null;
// return seasons[currentSeasonIndex + 1];
// }, [seasons]);
const { data: nextEpisode } = useQuery({
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
queryFn: async () => {
if (
!api ||
!user?.Id ||
!item?.Id ||
!item?.ParentId ||
!item?.IndexNumber
)
return null;
const response = await getItemsApi(api).getItems({
parentId: item?.ParentId,
limit: 1,
startIndex: type === "next" ? item.IndexNumber : item.IndexNumber - 2,
});
return (response.data.Items?.[0] as BaseItemDto) || null;
},
enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
});
const disabled = useMemo(() => {
if (!nextEpisode) return true;
if (nextEpisode.Id === item.Id) return true;
return false;
}, [nextEpisode, type]);
if (item.Type !== "Episode") return null;
return (
<Button
onPress={() => router.replace(`/items/${nextEpisode?.Id}`)}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}
>
{type === "next" ? (
<Ionicons name="chevron-forward" size={24} color="white" />
) : (
<Ionicons name="chevron-back" size={24} color="white" />
)}
</Button>
);
};

View File

@@ -49,7 +49,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/items/${item.Id}/page`);
router.push(`/(auth)/items/${item.Id}`);
}}
key={item.Id}
className="flex flex-col w-32"

View File

@@ -1,26 +1,28 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
type Props = {
item: BaseItemDto;
};
export const seasonIndexAtom = atom<number>(1);
export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const [selectedSeasonId, setSelectedSeasonId] = useState<string | null>(null);
const router = useRouter();
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
@@ -38,7 +40,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
},
);
return response.data.Items;
@@ -46,6 +48,12 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id,
});
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex],
);
const { data: episodes } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
@@ -62,7 +70,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
},
);
return response.data.Items as BaseItemDto[];
@@ -70,22 +78,13 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
useEffect(() => {
if (!seasons || seasons.length === 0) return;
setSelectedSeasonId(
seasons.find((season: any) => season.IndexNumber === 1)?.Id
);
setSelectedSeason(1);
}, [seasons]);
return (
<View className="mb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row px-4">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {selectedSeason}</Text>
<Text>Season {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -103,8 +102,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSelectedSeason(season.IndexNumber);
setSelectedSeasonId(season.Id);
setSeasonIndex(season.IndexNumber);
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
@@ -120,7 +118,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<TouchableOpacity
key={item.Id}
onPress={() => {
router.push(`/(auth)/items/${item.Id}/page`);
router.push(`/(auth)/items/${item.Id}`);
}}
className="flex flex-col w-48"
>

View File

@@ -0,0 +1,37 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const SeriesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<>
<TouchableOpacity
onPress={() => router.push(`/(auth)/series/${item.SeriesId}`)}
>
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
</TouchableOpacity>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
</View>
<View>
<View className="flex flex-row items-center self-center">
<TouchableOpacity onPress={() => {}}>
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
</View>
</View>
</>
);
};

View File

@@ -0,0 +1,129 @@
import { Linking, Switch, TouchableOpacity, View } from "react-native";
import { Text } from "../common/Text";
import { useSettings } from "@/utils/atoms/settings";
import * as DropdownMenu from "zeego/dropdown-menu";
export const SettingToggles: React.FC = () => {
const [settings, updateSettings] = useSettings();
return (
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Auto rotate</Text>
<Text className="text-xs opacity-50">
Important on android since the video player orientation is locked to
the app orientation.
</Text>
</View>
<Switch
value={settings?.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Start videos in fullscreen</Text>
<Text className="text-xs opacity-50">
Clicking a video will start it in fullscreen mode, instead of
inline.
</Text>
</View>
<Switch
value={settings?.openFullScreenVideoPlayerByDefault}
onValueChange={(value) =>
updateSettings({ openFullScreenVideoPlayerByDefault: value })
}
/>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">Use popular lists plugin</Text>
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-media-lists",
);
}}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
</View>
<Switch
value={settings?.usePopularPlugin}
onValueChange={(value) => updateSettings({ usePopularPlugin: value })}
/>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Force direct play</Text>
<Text className="text-xs opacity-50 shrink">
This will always request direct play. This is good if you want to
try to stream movies you think the device supports.
</Text>
</View>
<Switch
value={settings?.forceDirectPlay}
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Device profile</Text>
<Text className="text-xs opacity-50">
A profile used for deciding what audio and video codecs the device
supports.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.deviceProfile}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ deviceProfile: "Expo" });
}}
>
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ deviceProfile: "Native" });
}}
>
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({ deviceProfile: "Old" });
}}
>
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
</View>
);
};

View File

@@ -12,5 +12,5 @@ export const Colors = {
tint: tintColorDark,
icon: "#9BA1A6",
tabIconDefault: "#9BA1A6",
tabIconSelected: "#EE4B2B",
tabIconSelected: "#9333ea",
};

View File

@@ -20,7 +20,19 @@
"simulator": true
}
},
"production": {}
"production": {
"channel": "0.6.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.6.0",
"android": {
"buildType": "apk",
"image": "latest"
}
}
},
"submit": {
"production": {}

View File

@@ -23,7 +23,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
}
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const command = `-y -fflags +genpts -i ${url} -c copy -bufsize 10M -max_muxing_queue_size 4096 ${output}`;
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(async () => {
writeToLog(
@@ -54,28 +54,38 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
);
});
await FFmpegKit.executeAsync(command, async (session) => {
const returnCode = await session.getReturnCode();
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
}
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
resolve();
}
setProgress(null);
setProgress(null);
} catch (error) {
reject(error);
}
});
});
} catch (error) {
console.error("Failed to remux:", error);
@@ -84,6 +94,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
setProgress(null);
throw error; // Re-throw the error to propagate it to the caller
}
}, [output, item, command, setProgress]);

30
ios/.gitignore vendored
View File

@@ -1,30 +0,0 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
project.xcworkspace
.xcode.env.local
# Bundle artifacts
*.jsbundle
# CocoaPods
/Pods/

View File

@@ -1,11 +0,0 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)

View File

@@ -1,58 +0,0 @@
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0'
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
platform :ios, podfile_properties['ios.deploymentTarget'] || '13.4'
install! 'cocoapods',
:deterministic_uuids => false
prepare_react_native_project!
target 'Streamyfin' do
use_expo_modules!
config = use_native_modules!
use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks']
use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes',
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/..",
:privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false',
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
# This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices.
installer.target_installation_results.pod_target_installation_results
.each do |pod_name, target_installation_result|
target_installation_result.resource_bundle_targets.each do |resource_bundle_target|
resource_bundle_target.build_configurations.each do |config|
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end
end
end
post_integrate do |installer|
begin
expo_patch_react_imports!(installer)
rescue => e
Pod::UI.warn e
end
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
{
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"ios.deploymentTarget": "14.0",
"apple.extraPods": "[]",
"apple.ccacheEnabled": "false",
"apple.privacyManifestAggregationEnabled": "true"
}

View File

@@ -1,579 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objects = {
/* Begin PBXBuildFile section */
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
303D6C1B994E4AB730BB4D25 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 5677A7148111D7C0235E3185 /* PrivacyInfo.xcprivacy */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
7B8C1837932545C6A7001A29 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46971613083A41BAB9656F14 /* noop-file.swift */; };
96905EF65AED1B983A6B3ABC /* libPods-Streamyfin.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Streamyfin.a */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Streamyfin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Streamyfin.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Streamyfin/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Streamyfin/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Streamyfin/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Streamyfin/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Streamyfin/main.m; sourceTree = "<group>"; };
46971613083A41BAB9656F14 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Streamyfin/noop-file.swift"; sourceTree = "<group>"; };
5677A7148111D7C0235E3185 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Streamyfin/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Streamyfin.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Streamyfin.a"; sourceTree = BUILT_PRODUCTS_DIR; };
6C2E3173556A471DD304B334 /* Pods-Streamyfin.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Streamyfin.debug.xcconfig"; path = "Target Support Files/Pods-Streamyfin/Pods-Streamyfin.debug.xcconfig"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Streamyfin.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Streamyfin.release.xcconfig"; path = "Target Support Files/Pods-Streamyfin/Pods-Streamyfin.release.xcconfig"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Streamyfin/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
E9097609D0714599AF91F55B /* Streamyfin-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Streamyfin-Bridging-Header.h"; path = "Streamyfin/Streamyfin-Bridging-Header.h"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Streamyfin/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
96905EF65AED1B983A6B3ABC /* libPods-Streamyfin.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* Streamyfin */ = {
isa = PBXGroup;
children = (
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
46971613083A41BAB9656F14 /* noop-file.swift */,
E9097609D0714599AF91F55B /* Streamyfin-Bridging-Header.h */,
5677A7148111D7C0235E3185 /* PrivacyInfo.xcprivacy */,
);
name = Streamyfin;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Streamyfin.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* Streamyfin */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
D65327D7A22EEC0BE12398D9 /* Pods */,
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* Streamyfin.app */,
);
name = Products;
sourceTree = "<group>";
};
92DBD88DE9BF7D494EA9DA96 /* Streamyfin */ = {
isa = PBXGroup;
children = (
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */,
);
name = Streamyfin;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
BB2F792C24A3F905000567C9 /* Expo.plist */,
);
name = Supporting;
path = Streamyfin/Supporting;
sourceTree = "<group>";
};
D65327D7A22EEC0BE12398D9 /* Pods */ = {
isa = PBXGroup;
children = (
6C2E3173556A471DD304B334 /* Pods-Streamyfin.debug.xcconfig */,
7A4D352CD337FB3A3BF06240 /* Pods-Streamyfin.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
92DBD88DE9BF7D494EA9DA96 /* Streamyfin */,
);
name = ExpoModulesProviders;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* Streamyfin */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Streamyfin" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
5AE4F51AB7DD565AD6F401F0 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
E8388EA82119C85BBE90EA67 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = Streamyfin;
productName = Streamyfin;
productReference = 13B07F961A680F5B00A75B9A /* Streamyfin.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1130;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Streamyfin" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* Streamyfin */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
303D6C1B994E4AB730BB4D25 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Streamyfin-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
5AE4F51AB7DD565AD6F401F0 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Streamyfin/expo-configure-project.sh\"\n";
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Protobuf/Protobuf_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
"${PODS_ROOT}/google-cast-sdk/Resources/GoogleCastCoreResources.bundle",
"${PODS_ROOT}/google-cast-sdk/Resources/GoogleCastUIResources.bundle",
"${PODS_ROOT}/google-cast-sdk/Resources/MaterialDialogs.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/google-cast-sdk/GoogleCast.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Protobuf_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastCoreResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCastUIResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialDialogs.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleCast.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-resources.sh\"\n";
showEnvVarsInLog = 0;
};
E8388EA82119C85BBE90EA67 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/MobileVLCKit/MobileVLCKit.framework/MobileVLCKit",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/ffmpegkit.framework/ffmpegkit",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavcodec.framework/libavcodec",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavdevice.framework/libavdevice",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavfilter.framework/libavfilter",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavformat.framework/libavformat",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libavutil.framework/libavutil",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libswresample.framework/libswresample",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ffmpeg-kit-ios-https/libswscale.framework/libswscale",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MobileVLCKit.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ffmpegkit.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavcodec.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavdevice.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavfilter.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavformat.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavutil.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswresample.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libswscale.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Streamyfin/Pods-Streamyfin-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
7B8C1837932545C6A7001A29 /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-Streamyfin.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Streamyfin/Streamyfin.entitlements;
CURRENT_PROJECT_VERSION = 1;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"FB_SONARKIT_ENABLED=1",
);
INFOPLIST_FILE = Streamyfin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.fredrikburmester.streamyfin;
PRODUCT_NAME = Streamyfin;
SWIFT_OBJC_BRIDGING_HEADER = "Streamyfin/Streamyfin-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-Streamyfin.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Streamyfin/Streamyfin.entitlements;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = Streamyfin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.fredrikburmester.streamyfin;
PRODUCT_NAME = Streamyfin;
SWIFT_OBJC_BRIDGING_HEADER = "Streamyfin/Streamyfin-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CC = "";
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CXX = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CC = "";
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
CXX = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Streamyfin" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Streamyfin" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View File

@@ -1,88 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1130"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Streamyfin.app"
BlueprintName = "Streamyfin"
ReferencedContainer = "container:Streamyfin.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "StreamyfinTests.xctest"
BlueprintName = "StreamyfinTests"
ReferencedContainer = "container:Streamyfin.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Streamyfin.app"
BlueprintName = "Streamyfin"
ReferencedContainer = "container:Streamyfin.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "Streamyfin.app"
BlueprintName = "Streamyfin"
ReferencedContainer = "container:Streamyfin.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Streamyfin.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -1,7 +0,0 @@
#import <RCTAppDelegate.h>
#import <UIKit/UIKit.h>
#import <Expo/Expo.h>
@interface AppDelegate : EXAppDelegateWrapper
@end

View File

@@ -1,79 +0,0 @@
#import "AppDelegate.h"
// @generated begin react-native-google-cast-import - expo prebuild (DO NOT MODIFY) sync-da0acf16745f87cea5bffba9c0cc3a4f5e4387ea
#if __has_include(<GoogleCast/GoogleCast.h>)
#import <GoogleCast/GoogleCast.h>
#endif
// @generated end react-native-google-cast-import
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
#if __has_include(<GoogleCast/GoogleCast.h>)
NSString *receiverAppID = kGCKDefaultMediaReceiverApplicationID;
GCKDiscoveryCriteria *criteria = [[GCKDiscoveryCriteria alloc] initWithApplicationID:receiverAppID];
GCKCastOptions* options = [[GCKCastOptions alloc] initWithDiscoveryCriteria:criteria];
options.disableDiscoveryAutostart = false;
options.startDiscoveryAfterFirstTapOnCastButton = true;
options.suspendSessionsWhenBackgrounded = true;
[GCKCastContext setSharedInstanceWithOptions:options];
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;
#endif
// @generated end react-native-google-cast-didFinishLaunchingWithOptions
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
// Linking API
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
}
// Universal Links
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}
@end

Binary file not shown.

Before

Width:  |  Height:  |  Size: 812 KiB

View File

@@ -1,14 +0,0 @@
{
"images": [
{
"filename": "App-Icon-1024x1024@1x.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"version" : 1,
"author" : "expo"
}
}

View File

@@ -1,21 +0,0 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

View File

@@ -1,21 +0,0 @@
{
"images": [
{
"idiom": "universal",
"filename": "image.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "expo"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

View File

@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Streamyfin</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.0.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>streamyfin</string>
<string>com.fredrikburmester.streamyfin</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+streamyfin</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>The app needs access to your camera to scan barcodes.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network</string>
<key>NSMicrophoneUsageDescription</key>
<string>The app needs access to your microphone.</string>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
</dict>
</plist>

View File

@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>0A2A.1</string>
<string>3B52.1</string>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
<string>85F4.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View File

@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<device id="retina5_5" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<scene sceneID="EXPO-SCENE-1">
<objects>
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
<rect key="frame" x="0.0" y="0.0" width="414" height="736"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" insetsLayoutMarginsFromSafeArea="NO" image="SplashScreenBackground" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreenBackground" userLabel="SplashScreenBackground">
<rect key="frame" x="0.0" y="0.0" width="414" height="736"/>
</imageView>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreen" image="SplashScreen" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="0" y="0" width="414" height="736"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="EXPO-SplashScreenBackground" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="1gX-mQ-vu6"/>
<constraint firstItem="EXPO-SplashScreenBackground" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="6tX-OG-Sck"/>
<constraint firstItem="EXPO-SplashScreenBackground" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="ABX-8g-7v4"/>
<constraint firstItem="EXPO-SplashScreenBackground" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="jkI-2V-eW5"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="2VS-Uz-0LU"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="LhH-Ei-DKo"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="I6l-TP-6fn"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="nbp-HC-eaG"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="83fcb9b545b870ba44c24f0feeb116490c499c52"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="61d16215e44b98e39d0a2c74fdbfaaa22601b12c"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="f934da460e9ab5acae3ad9987d5b676a108796c1"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="d6a0be88096b36fb132659aa90203d39139deda9"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="140.625" y="129.4921875"/>
</scene>
</scenes>
<resources>
<image name="SplashScreenBackground" width="1" height="1"/>
<image name="SplashScreen" width="414" height="736"/>
</resources>
</document>

View File

@@ -1,3 +0,0 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesEnabled</key>
<false/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
</dict>
</plist>

View File

@@ -1,10 +0,0 @@
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

View File

@@ -1,4 +0,0 @@
//
// @generated
// A blank Swift file must be created for native modules with Swift files to work correctly.
//

View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.2",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
@@ -25,7 +26,9 @@
"@react-navigation/native": "^6.0.2",
"@tanstack/react-query": "^5.51.16",
"@types/uuid": "^10.0.0",
"expo": "~51.0.26",
"axios": "^1.7.3",
"expo": "~51.0.27",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.22",
@@ -35,10 +38,14 @@
"expo-image": "~1.12.13",
"expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-router": "~3.5.21",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.22",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.1",
@@ -60,7 +67,6 @@
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.4.3",
"react-native-vlc-media-player": "^1.0.67",
"react-native-web": "~0.19.10",
"tailwindcss": "3.3.2",
"uuid": "^10.0.0",

View File

@@ -12,6 +12,7 @@ import React, {
useEffect,
useState,
} from "react";
import { Platform } from "react-native";
import uuid from "react-native-uuid";
interface Server {
@@ -30,7 +31,7 @@ interface JellyfinContextValue {
}
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
undefined
undefined,
);
const getOrSetDeviceId = async () => {
@@ -55,9 +56,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "1.0.0" },
deviceInfo: { name: "iOS", id },
})
clientInfo: { name: "Streamyfin", version: "0.6.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
}),
);
})();
}, []);
@@ -66,9 +67,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom);
const discoverServers = async (url: string): Promise<Server[]> => {
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
url
);
const servers =
await jellyfin?.discovery.getRecommendedServerCandidates(url);
return servers?.map((server) => ({ address: server.address })) || [];
};
@@ -144,7 +144,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const token = await AsyncStorage.getItem("token");
const serverUrl = await AsyncStorage.getItem("serverUrl");
const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string
(await AsyncStorage.getItem("user")) as string,
) as UserDto;
if (serverUrl && token && user.Id && jellyfin) {
@@ -198,7 +198,7 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
if (!user?.Id && inAuthGroup) {
router.replace("/login");
} else if (user?.Id && !inAuthGroup) {
router.replace("/");
router.replace("/home");
}
}, [user, segments, loading]);
}

View File

@@ -0,0 +1,14 @@
import React, { createContext } from "react";
import { useJobProcessor } from "@/utils/atoms/queue";
const JobQueueContext = createContext(null);
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
useJobProcessor();
return (
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
);
};

Some files were not shown because too many files have changed in this diff Show More