mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-24 12:08:05 +00:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24277135a8 | ||
|
|
23d9cd36d1 | ||
|
|
b243524a7d | ||
|
|
8288682e68 | ||
|
|
58ec915699 | ||
|
|
cad03a3566 | ||
|
|
9baa4063bd | ||
|
|
41db34ed8e | ||
|
|
5aba66ce05 | ||
|
|
79407ccd70 | ||
|
|
9a93b3b3bb | ||
|
|
2b846a1aca | ||
|
|
55d61172f4 | ||
|
|
57173a62dc | ||
|
|
78f65be09d | ||
|
|
293a9517a5 | ||
|
|
38b6215046 | ||
|
|
fc4a11d916 | ||
|
|
cf2beb8299 | ||
|
|
49d157a95a | ||
|
|
9692c173ae | ||
|
|
a297ac4843 | ||
|
|
a061f9f480 | ||
|
|
0fb6f2fb30 | ||
|
|
0773f773ba | ||
|
|
39bb3a9370 | ||
|
|
b79e534692 | ||
|
|
e9336e9a67 | ||
|
|
adfde1a7cd | ||
|
|
cab6257fb2 | ||
|
|
3f0f0090af | ||
|
|
b278632581 | ||
|
|
5ee1a9cabb | ||
|
|
2169bea031 | ||
|
|
95cf252349 | ||
|
|
8470cbe8d5 | ||
|
|
636a27246f | ||
|
|
a488c68633 | ||
|
|
7342b7eb92 | ||
|
|
8370519758 | ||
|
|
85e21edbf1 | ||
|
|
8d4115f5a0 | ||
|
|
c5d7a6729b | ||
|
|
db4046267f | ||
|
|
1e869a2c2f | ||
|
|
b6502c042a | ||
|
|
b506871c46 | ||
|
|
734678b1d5 | ||
|
|
68e98bbb94 | ||
|
|
d84ed558f3 | ||
|
|
ad39e8e10a | ||
|
|
29bba04fdd | ||
|
|
5a24957e88 | ||
|
|
39f2735756 | ||
|
|
5dc86d4765 | ||
|
|
d13731c28f | ||
|
|
7f0446b85f | ||
|
|
11fbe19f80 | ||
|
|
5c97b85492 | ||
|
|
e60cec69f8 | ||
|
|
7bc1c22770 | ||
|
|
e86dab5613 | ||
|
|
eeb803223c | ||
|
|
1a43f7ef1b | ||
|
|
f4624bdc25 | ||
|
|
3c5f2b4079 | ||
|
|
955190a9cc | ||
|
|
e1e4f4833c | ||
|
|
3b987646a6 | ||
|
|
0e574ea18d | ||
|
|
1a5fcdcb10 | ||
|
|
62b00837ec | ||
|
|
0fc48497d0 | ||
|
|
7e12136211 | ||
|
|
7639de153b | ||
|
|
ea3cc18b3c | ||
|
|
c9fb52086e | ||
|
|
878edc6909 | ||
|
|
74f0aca517 | ||
|
|
60bb3b905d | ||
|
|
fdde5fb56c | ||
|
|
49ae9c6f57 | ||
|
|
2254adb8d6 | ||
|
|
d4c722aeac | ||
|
|
eefcfb8be5 | ||
|
|
4af2712cc0 | ||
|
|
958b870bf0 | ||
|
|
ce7e1b255f | ||
|
|
acae4b4544 | ||
|
|
f7bbb20c38 | ||
|
|
2c655b9482 | ||
|
|
b8dbce6bf2 | ||
|
|
730823c520 | ||
|
|
77f14a7d5b | ||
|
|
07c7cb7ab5 | ||
|
|
5333d53d61 | ||
|
|
82e50b9ba3 | ||
|
|
663605b9e8 | ||
|
|
00847c8d3d | ||
|
|
f20ad67186 | ||
|
|
91527b83dd | ||
|
|
14138151a3 | ||
|
|
6c2bfe2a45 | ||
|
|
996cd36a9e | ||
|
|
6aa2e00d93 | ||
|
|
344e0932dc | ||
|
|
eaffffb2f0 | ||
|
|
f6c0513d2d | ||
|
|
013f064280 | ||
|
|
cd2c3f359e | ||
|
|
123c6bba05 | ||
|
|
a1ea926342 | ||
|
|
6a17ac02af | ||
|
|
815be2a175 | ||
|
|
ece3bc001f | ||
|
|
27609e7789 | ||
|
|
347f196a6a | ||
|
|
468f58e531 | ||
|
|
a994868be4 |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -4,9 +4,7 @@ title: "[Bug]: "
|
|||||||
labels:
|
labels:
|
||||||
- ["❌ bug"]
|
- ["❌ bug"]
|
||||||
projects:
|
projects:
|
||||||
- ["fredrikburmester/5"]
|
- ["streamyfin/3"]
|
||||||
assignees:
|
|
||||||
- fredrikburmester
|
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -45,7 +43,7 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
- 0.23.0
|
- 0.24.0
|
||||||
- 0.22.0
|
- 0.22.0
|
||||||
- 0.21.0
|
- 0.21.0
|
||||||
- older
|
- older
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -4,7 +4,8 @@ about: Suggest an idea for this project
|
|||||||
title: ''
|
title: ''
|
||||||
labels: '✨ enhancement'
|
labels: '✨ enhancement'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
projects:
|
||||||
|
- streamyfin/3
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
|
|||||||
49
.github/workflows/build-ios.yaml
vendored
Normal file
49
.github/workflows/build-ios.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Automatic Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-15
|
||||||
|
name: Build IOS
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
name: Check out repository
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
- run: |
|
||||||
|
bun i && bun run submodule-reload
|
||||||
|
npx expo prebuild
|
||||||
|
- uses: sparkfabrik/ios-build-action@v2.3.0
|
||||||
|
with:
|
||||||
|
upload-to-testflight: false
|
||||||
|
increment-build-number: false
|
||||||
|
build-pods: true
|
||||||
|
pods-path: "ios/Podfile"
|
||||||
|
configuration: Release
|
||||||
|
# Change later to app-store if wanted
|
||||||
|
export-method: appstore
|
||||||
|
#export-method: ad-hoc
|
||||||
|
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
||||||
|
project-path: "ios/Streamyfin.xcodeproj"
|
||||||
|
scheme: Streamyfin
|
||||||
|
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
||||||
|
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
||||||
|
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
||||||
|
team-id: ${{ secrets.TEAM_ID }}
|
||||||
|
team-name: ${{ secrets.TEAM_NAME }}
|
||||||
|
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
||||||
|
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||||
|
#match-build-type: "appstore"
|
||||||
|
#browserstack-upload: true
|
||||||
|
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||||
|
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||||
|
#fastlane-env: stage
|
||||||
|
ios-app-id: com.stetsed.teststreamyfin
|
||||||
|
output-path: build-${{ github.sha }}.ipa
|
||||||
18
.github/workflows/notification.yaml
vendored
Normal file
18
.github/workflows/notification.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: Discord Pull Request Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: joelwmale/webhook-action@master
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
body: |
|
||||||
|
{
|
||||||
|
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
|
||||||
|
}
|
||||||
12
README.md
12
README.md
@@ -8,12 +8,12 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||||
|
<img width=150 src="./assets/images/jellyseerr.PNG"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skp intro / credits support**
|
- 🚀 **Skip Intro / Credits Support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
@@ -66,7 +66,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
|||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ Key points of the MPL-2.0:
|
|||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
|
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
If you have questions or need support, feel free to reach out:
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ If you have questions or need support, feel free to reach out:
|
|||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
@@ -130,4 +130,4 @@ I'd like to thank the following people and projects for their contributions to S
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|||||||
7
app.json
7
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.23.0",
|
"version": "0.24.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 49,
|
"versionCode": 50,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -111,7 +111,8 @@
|
|||||||
{ "android": { "parentTheme": "Material3" } }
|
{ "android": { "parentTheme": "Material3" } }
|
||||||
],
|
],
|
||||||
["react-native-bottom-tabs"],
|
["react-native-bottom-tabs"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withGoogleCastActivity.js"]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import {FlatList, TouchableOpacity, View} from "react-native";
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import React, {useCallback, useEffect, useState} from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {useAtom} from "jotai/index";
|
import { useAtom } from "jotai/index";
|
||||||
import {apiAtom} from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import {ListItem} from "@/components/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from "expo-web-browser";
|
||||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {Text} from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
export interface MenuLink {
|
export interface MenuLink {
|
||||||
name: string,
|
name: string;
|
||||||
url: string,
|
url: string;
|
||||||
icon: string
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function menuLinks() {
|
export default function menuLinks() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets();
|
||||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||||
|
|
||||||
const getMenuLinks = useCallback(async () => {
|
const getMenuLinks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
const response = await api?.axiosInstance.get(
|
||||||
|
api?.basePath + "/web/config.json"
|
||||||
|
);
|
||||||
const config = response?.data;
|
const config = response?.data;
|
||||||
|
|
||||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||||
@@ -29,15 +31,15 @@ export default function menuLinks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMenuLinks(config?.menuLinks as MenuLink[])
|
setMenuLinks(config?.menuLinks as MenuLink[]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to retrieve config:", error);
|
console.error("Failed to retrieve config:", error);
|
||||||
}
|
}
|
||||||
},
|
}, [api]);
|
||||||
[api]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => { getMenuLinks() }, []);
|
useEffect(() => {
|
||||||
|
getMenuLinks();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
@@ -47,27 +49,27 @@ export default function menuLinks() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={menuLinks}
|
data={menuLinks}
|
||||||
renderItem={({item}) => (
|
renderItem={({ item }) => (
|
||||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={item.name}
|
title={item.name}
|
||||||
iconAfter={<Ionicons name="link" size={24} color="white"/>}
|
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
ItemSeparatorComponent={() => (
|
ItemSeparatorComponent={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}/>
|
}}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
27
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function SearchLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: "Favorites",
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
36
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function favorites() {
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="my-4">
|
||||||
|
<Favorites />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
@@ -15,6 +16,9 @@ export default function IndexLayout() {
|
|||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Home",
|
headerTitle: "Home",
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@@ -49,6 +53,44 @@ export default function IndexLayout() {
|
|||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/optimized-server/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/marlin-search/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/jellyseerr/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/popular-lists/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/hide-libraries/page"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="intro/page"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
presentation: "modal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {useNavigation, useRouter} from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {useEffect, useMemo, useRef} from "react";
|
import React, { useEffect, useMemo, useRef } from "react";
|
||||||
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
import {
|
||||||
import {toast} from "sonner-native";
|
BottomSheetBackdrop,
|
||||||
import {writeToLog} from "@/utils/log";
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -56,28 +61,29 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||||
onPress={bottomSheetModalRef.current?.present}
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
>
|
|
||||||
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
),
|
||||||
})
|
});
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const deleteMovies = () => deleteFileByType("Movie")
|
const deleteMovies = () =>
|
||||||
.then(() => toast.success("Deleted all movies successfully!"))
|
deleteFileByType("Movie")
|
||||||
.catch((reason) => {
|
.then(() => toast.success("Deleted all movies successfully!"))
|
||||||
writeToLog("ERROR", reason);
|
.catch((reason) => {
|
||||||
toast.error("Failed to delete all movies");
|
writeToLog("ERROR", reason);
|
||||||
});
|
toast.error("Failed to delete all movies");
|
||||||
const deleteShows = () => deleteFileByType("Episode")
|
});
|
||||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
const deleteShows = () =>
|
||||||
.catch((reason) => {
|
deleteFileByType("Episode")
|
||||||
writeToLog("ERROR", reason);
|
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
||||||
toast.error("Failed to delete all TV-Series");
|
.catch((reason) => {
|
||||||
});
|
writeToLog("ERROR", reason);
|
||||||
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
toast.error("Failed to delete all TV-Series");
|
||||||
|
});
|
||||||
|
const deleteAllMedia = async () =>
|
||||||
|
await Promise.all([deleteMovies(), deleteShows()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -94,7 +100,7 @@ export default function page() {
|
|||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<Text className="text-lg font-bold">Queue</Text>
|
<Text className="text-lg font-bold">Queue</Text>
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
<Text className="text-xs opacity-70 text-red-600">
|
||||||
Queue and downloads will be lost on app restart
|
Queue and active downloads will be lost on app restart
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex flex-col space-y-2 mt-2">
|
<View className="flex flex-col space-y-2 mt-2">
|
||||||
{queue.map((q, index) => (
|
{queue.map((q, index) => (
|
||||||
@@ -107,7 +113,9 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
<Text className="font-semibold">{q.item.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
<Text className="text-xs opacity-50">
|
||||||
|
{q.item.Type}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -118,7 +126,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="red"/>
|
<Ionicons name="close" size={24} color="red" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
@@ -130,7 +138,7 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ActiveDownloads/>
|
<ActiveDownloads />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{movies.length > 0 && (
|
{movies.length > 0 && (
|
||||||
@@ -145,7 +153,7 @@ export default function page() {
|
|||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{movies?.map((item) => (
|
{movies?.map((item) => (
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||||
<MovieCard item={item.item}/>
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -157,13 +165,18 @@ export default function page() {
|
|||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||||
<Text className="text-lg font-bold">TV-Series</Text>
|
<Text className="text-lg font-bold">TV-Series</Text>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
<Text className="text-xs font-bold">
|
||||||
|
{groupedBySeries?.length}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{groupedBySeries?.map((items) => (
|
{groupedBySeries?.map((items) => (
|
||||||
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
<View
|
||||||
|
className="mb-2 last:mb-0"
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
>
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
items={items.map((i) => i.item)}
|
items={items.map((i) => i.item)}
|
||||||
key={items[0].item.SeriesId}
|
key={items[0].item.SeriesId}
|
||||||
@@ -200,9 +213,15 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="p-4 space-y-4 mb-4">
|
<View className="p-4 space-y-4 mb-4">
|
||||||
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
|
<Button color="purple" onPress={deleteMovies}>
|
||||||
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
Delete all Movies
|
||||||
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
</Button>
|
||||||
|
<Button color="purple" onPress={deleteShows}>
|
||||||
|
Delete all TV-Series
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onPress={deleteAllMedia}>
|
||||||
|
Delete all
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
@@ -107,16 +107,16 @@ export default function index() {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanCacheDirectory()
|
cleanCacheDirectory().catch((e) =>
|
||||||
.then(r => console.log("Cache directory cleaned"))
|
console.error("Something went wrong cleaning cache directory")
|
||||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
);
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: userViews,
|
data,
|
||||||
isError: e1,
|
isError: e1,
|
||||||
isLoading: l1,
|
isLoading: l1,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
@@ -136,6 +136,11 @@ export default function index() {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userViews = useMemo(
|
||||||
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
|
[data, settings?.hiddenLibraries]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: mediaListCollections,
|
data: mediaListCollections,
|
||||||
isError: e2,
|
isError: e2,
|
||||||
|
|||||||
109
app/(auth)/(tabs)/(home)/intro/page.tsx
Normal file
109
app/(auth)/(tabs)/(home)/intro/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
storage.set("hasShownIntro", true);
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-neutral-900 h-full py-32 px-4 space-y-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-3xl font-bold text-center mb-2">
|
||||||
|
Welcome to Streamyfin
|
||||||
|
</Text>
|
||||||
|
<Text className="text-center">
|
||||||
|
A free and open source client for Jellyfin.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold">Features</Text>
|
||||||
|
<Text className="text-xs">
|
||||||
|
Streamyfin has a bunch of features and integrates with a wide array of
|
||||||
|
software which you can find in the settings menu, these include:
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-row items-center mt-4">
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="shrink ml-2">
|
||||||
|
<Text className="font-bold mb-1">Jellyseerr</Text>
|
||||||
|
<Text className="shrink text-xs">
|
||||||
|
Connect to your Jellyseerr instance and request movies directly in
|
||||||
|
the app.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center mt-4">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
||||||
|
</View>
|
||||||
|
<View className="shrink ml-2">
|
||||||
|
<Text className="font-bold mb-1">Downloads</Text>
|
||||||
|
<Text className="shrink text-xs">
|
||||||
|
Download movies and tv-shows to view offline. Use either the
|
||||||
|
default method or install the optimize server to download files in
|
||||||
|
the background.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center mt-4">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Feather name="cast" size={28} color={"white"} />
|
||||||
|
</View>
|
||||||
|
<View className="shrink ml-2">
|
||||||
|
<Text className="font-bold mb-1">Chromecast</Text>
|
||||||
|
<Text className="shrink text-xs">
|
||||||
|
Cast movies and tv-shows to your Chromecast devices.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
router.push("/settings");
|
||||||
|
}}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Text className="text-purple-600 text-center">Go to settings</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,176 +1,105 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import {useDownload} from "@/providers/DownloadProvider";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { DownloadSettings } from "@/components/settings/DownloadSettings";
|
||||||
import { clearLogs, useLog } from "@/utils/log";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
import * as FileSystem from "expo-file-system";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
import * as Haptics from "expo-haptics";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { useAtom } from "jotai";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import * as Progress from "react-native-progress";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import { clearLogs } from "@/utils/log";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const router = useRouter();
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
|
||||||
const { logs } = useLog();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { logout } = useJellyfin();
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
const successHapticFeedback = useHaptic("success");
|
||||||
queryKey: ["appSize", appSizeUsage],
|
|
||||||
queryFn: async () => {
|
|
||||||
const app = await appSizeUsage;
|
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
|
||||||
|
|
||||||
return { app, remaining, total, used: (total - remaining) / total };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
|
||||||
Alert.prompt(
|
|
||||||
"Quick connect",
|
|
||||||
"Enter the quick connect code",
|
|
||||||
async (text) => {
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
|
||||||
code: text,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
|
||||||
} else {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
toast.error("Error deleting files");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
const onClearLogsClicked = async () => {
|
||||||
clearLogs();
|
clearLogs();
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
successHapticFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-red-600">Log out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
{/* <Button
|
<UserInfo />
|
||||||
onPress={() => {
|
<QuickConnect className="mb-4" />
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
registerBackgroundFetchAsync
|
|
||||||
</Button> */}
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<MediaProvider>
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<MediaToggles className="mb-4" />
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<AudioToggles className="mb-4" />
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
<SubtitleToggles className="mb-4" />
|
||||||
</View>
|
</MediaProvider>
|
||||||
<Button className="my-2.5" color="black" onPress={logout}>
|
|
||||||
Log out
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
<OtherSettings />
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
<DownloadSettings />
|
||||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
|
||||||
Authorize
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<SettingToggles />
|
<PluginSettings />
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<ListGroup title={"Intro"}>
|
||||||
<Text className="font-bold text-lg mb-2">Storage</Text>
|
<ListItem
|
||||||
<View className="mb-4 space-y-2">
|
onPress={() => {
|
||||||
{size && <Text>App usage: {size.app.bytesToReadable()}</Text>}
|
router.push("/intro/page");
|
||||||
<Progress.Bar
|
}}
|
||||||
className="bg-gray-100/10"
|
title={"Show intro"}
|
||||||
indeterminate={appSizeLoading}
|
/>
|
||||||
color="#9333ea"
|
<ListItem
|
||||||
width={null}
|
textColor="red"
|
||||||
height={10}
|
onPress={() => {
|
||||||
borderRadius={6}
|
storage.set("hasShownIntro", false);
|
||||||
borderWidth={0}
|
}}
|
||||||
progress={size?.used}
|
title={"Reset intro"}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<ListGroup title={"Logs"}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
|
showArrow
|
||||||
|
title={"Logs"}
|
||||||
/>
|
/>
|
||||||
{size && (
|
<ListItem
|
||||||
<Text>
|
textColor="red"
|
||||||
Available: {size.remaining?.bytesToReadable()}, Total:{" "}
|
onPress={onClearLogsClicked}
|
||||||
{size.total?.bytesToReadable()}
|
title={"Delete All Logs"}
|
||||||
</Text>
|
/>
|
||||||
)}
|
</ListGroup>
|
||||||
</View>
|
|
||||||
<Button color="red" onPress={onDeleteClicked}>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button color="red" onPress={onClearLogsClicked}>
|
|
||||||
Delete all logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
{logs?.map((log, index) => (
|
|
||||||
<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 uiTextView selectable className="text-xs">
|
|
||||||
{log.message}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{logs?.length === 0 && (
|
|
||||||
<Text className="opacity-50">No logs available</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<StorageSettings />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
61
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
61
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { Switch, View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className="mt-4">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4">
|
||||||
|
<ListGroup>
|
||||||
|
{data?.map((view) => (
|
||||||
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
|
<Switch
|
||||||
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({
|
||||||
|
hiddenLibraries: value
|
||||||
|
? [...(settings.hiddenLibraries || []), view.Id!]
|
||||||
|
: settings.hiddenLibraries?.filter((id) => id !== view.Id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
Select the libraries you want to hide from the Library tab and home page
|
||||||
|
sections.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
78
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||||
|
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (newVal: string) => {
|
||||||
|
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||||
|
toast.error("Invalid URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
||||||
|
|
||||||
|
updateSettings({
|
||||||
|
optimizedVersionsServerUrl: updatedUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getStatistics({
|
||||||
|
url: settings?.optimizedVersionsServerUrl,
|
||||||
|
authHeader: api?.accessToken,
|
||||||
|
deviceId: getOrSetDeviceId(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data) {
|
||||||
|
toast.success("Connected");
|
||||||
|
} else {
|
||||||
|
toast.error("Could not connect");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Could not connect");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSave = (newVal: string) => {
|
||||||
|
saveMutation.mutate(newVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// navigation.setOptions({
|
||||||
|
// title: "Optimized Server",
|
||||||
|
// headerRight: () =>
|
||||||
|
// saveMutation.isPending ? (
|
||||||
|
// <ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
// ) : (
|
||||||
|
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||||
|
// <Text className="text-blue-500">Save</Text>
|
||||||
|
// </TouchableOpacity>
|
||||||
|
// ),
|
||||||
|
// });
|
||||||
|
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="p-4">
|
||||||
|
<JellyseerrSettings />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
33
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useLog } from "@/utils/log";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { logs } = useLog();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView className="p-4">
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{logs?.map((log, index) => (
|
||||||
|
<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 uiTextView selectable className="text-xs">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{logs?.length === 0 && (
|
||||||
|
<Text className="opacity-50">No logs available</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
103
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const onSave = (val: string) => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
|
});
|
||||||
|
toast.success("Saved");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
|
<Text className="text-blue-500">Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, value]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4">
|
||||||
|
<ListGroup>
|
||||||
|
<ListItem
|
||||||
|
title={"Enable Marlin Search"}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`mt-2 ${
|
||||||
|
settings.searchEngine === "Marlin" ? "" : "opacity-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
||||||
|
<View
|
||||||
|
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
||||||
|
>
|
||||||
|
<Text className="mr-4">URL</Text>
|
||||||
|
<TextInput
|
||||||
|
editable={settings.searchEngine === "Marlin"}
|
||||||
|
className="text-white"
|
||||||
|
placeholder="http(s)://domain.org:port"
|
||||||
|
value={value}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={(text) => setValue(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
Enter the URL for the Marlin server. The URL should include http or
|
||||||
|
https and optionally the port.{" "}
|
||||||
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
|
Read more about Marlin.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
80
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
|
import { getStatistics } from "@/utils/optimize-server";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||||
|
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (newVal: string) => {
|
||||||
|
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||||
|
toast.error("Invalid URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
||||||
|
|
||||||
|
updateSettings({
|
||||||
|
optimizedVersionsServerUrl: updatedUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getStatistics({
|
||||||
|
url: settings?.optimizedVersionsServerUrl,
|
||||||
|
authHeader: api?.accessToken,
|
||||||
|
deviceId: getOrSetDeviceId(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data) {
|
||||||
|
toast.success("Connected");
|
||||||
|
} else {
|
||||||
|
toast.error("Could not connect");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Could not connect");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSave = (newVal: string) => {
|
||||||
|
saveMutation.mutate(newVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: "Optimized Server",
|
||||||
|
headerRight: () =>
|
||||||
|
saveMutation.isPending ? (
|
||||||
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||||
|
<Text className="text-blue-500">Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="p-4">
|
||||||
|
<OptimizedServerForm
|
||||||
|
value={optimizedVersionsServerUrl}
|
||||||
|
onChangeValue={setOptimizedVersionsServerUrl}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
Normal file
135
app/(auth)/(tabs)/(home)/settings/popular-lists/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { Linking, Switch, View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL(
|
||||||
|
"https://github.com/lostb1t/jellyfin-plugin-collection-import"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mediaListCollections,
|
||||||
|
isLoading: isLoadingMediaListCollections,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
tags: ["sf_promoted"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["Tags"],
|
||||||
|
includeItemTypes: ["BoxSet"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4 pt-4">
|
||||||
|
<ListGroup title={"Enable plugin"} className="">
|
||||||
|
<ListItem
|
||||||
|
title={"Enable Popular Lists"}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ usePopularPlugin: true });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.usePopularPlugin}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ usePopularPlugin: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
Popular Lists is a plugin that enables you to show custom Jellyfin lists
|
||||||
|
on the Streamyfin home page.{" "}
|
||||||
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
|
Read more about Popular Lists.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{settings.usePopularPlugin && (
|
||||||
|
<>
|
||||||
|
{!isLoadingMediaListCollections ? (
|
||||||
|
<>
|
||||||
|
{mediaListCollections?.length === 0 ? (
|
||||||
|
<Text className="text-xs opacity-50 p-4">
|
||||||
|
No collections found. Add some in Jellyfin.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ListGroup title="Media List Collections" className="mt-4">
|
||||||
|
{mediaListCollections?.map((mlc) => (
|
||||||
|
<ListItem key={mlc.Id} title={mlc.Name}>
|
||||||
|
<Switch
|
||||||
|
value={settings.mediaListCollectionIds?.includes(
|
||||||
|
mlc.Id!
|
||||||
|
)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!settings.mediaListCollectionIds) {
|
||||||
|
updateSettings({
|
||||||
|
mediaListCollectionIds: [mlc.Id!],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings({
|
||||||
|
mediaListCollectionIds:
|
||||||
|
settings.mediaListCollectionIds.includes(
|
||||||
|
mlc.Id!
|
||||||
|
)
|
||||||
|
? settings.mediaListCollectionIds.filter(
|
||||||
|
(id) => id !== mlc.Id
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
...settings.mediaListCollectionIds,
|
||||||
|
mlc.Id!,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
Select the lists you want displayed on the home screen.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import {
|
||||||
|
router,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
useSegments,
|
||||||
|
} from "expo-router";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Animated } from "react-native";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { orderBy } from "lodash";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||||
|
|
||||||
|
const ANIMATION_ENTER = 250;
|
||||||
|
const ANIMATION_EXIT = 250;
|
||||||
|
const BACKDROP_DURATION = 5000;
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const segments = useSegments();
|
||||||
|
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
|
||||||
|
|
||||||
|
const { personId } = local as { personId: string };
|
||||||
|
const from = segments[2];
|
||||||
|
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "person", personId],
|
||||||
|
queryFn: async () => ({
|
||||||
|
details: await jellyseerrApi?.personDetails(personId),
|
||||||
|
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
||||||
|
}),
|
||||||
|
enabled: !!jellyseerrApi && !!personId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const locale = useMemo(() => {
|
||||||
|
return jellyseerrUser?.settings?.locale || "en";
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const region = useMemo(
|
||||||
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
|
[jellyseerrUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
data?.combinedCredits?.cast,
|
||||||
|
["voteCount", "voteAverage"],
|
||||||
|
"desc"
|
||||||
|
),
|
||||||
|
[data?.combinedCredits]
|
||||||
|
);
|
||||||
|
|
||||||
|
const backdrops = useMemo(
|
||||||
|
() => castedRoles.map((c) => c.backdropPath),
|
||||||
|
[data?.combinedCredits]
|
||||||
|
);
|
||||||
|
|
||||||
|
const enterAnimation = useCallback(
|
||||||
|
() =>
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: ANIMATION_ENTER,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
[fadeAnim]
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitAnimation = useCallback(
|
||||||
|
() =>
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: ANIMATION_EXIT,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
[fadeAnim]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (backdrops?.length) {
|
||||||
|
enterAnimation().start();
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
exitAnimation().start((end) => {
|
||||||
|
if (end.finished)
|
||||||
|
setCurrentIndex((prevIndex) => (prevIndex + 1) % backdrops?.length);
|
||||||
|
});
|
||||||
|
}, BACKDROP_DURATION);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
}, [backdrops, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
|
||||||
|
|
||||||
|
const viewDetails = (credit: PersonCreditCast) => {
|
||||||
|
router.push({
|
||||||
|
//@ts-ignore
|
||||||
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
|
//@ts-ignore
|
||||||
|
params: {
|
||||||
|
...credit,
|
||||||
|
mediaTitle: credit.title,
|
||||||
|
releaseYear: new Date(credit.releaseDate).getFullYear(),
|
||||||
|
canRequest: "false",
|
||||||
|
posterSrc: jellyseerrApi?.imageProxy(
|
||||||
|
credit.posterPath,
|
||||||
|
"w300_and_h450_face"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 relative"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParallaxScrollView
|
||||||
|
className="flex-1 opacity-100"
|
||||||
|
headerHeight={300}
|
||||||
|
headerImage={
|
||||||
|
<Animated.Image
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.imageProxy(
|
||||||
|
backdrops?.[currentIndex],
|
||||||
|
"w1920_and_h800_multi_faces"
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: fadeAnim,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
logo={
|
||||||
|
<Image
|
||||||
|
key={data?.details?.id}
|
||||||
|
id={data?.details?.id.toString()}
|
||||||
|
className="rounded-full bottom-1"
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.imageProxy(
|
||||||
|
data?.details?.profilePath,
|
||||||
|
"w600_and_h600_bestv2"
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{
|
||||||
|
width: 125,
|
||||||
|
height: 125,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col space-y-4 px-4">
|
||||||
|
<View className="flex flex-row justify-between w-full">
|
||||||
|
<View className="flex flex-col w-full">
|
||||||
|
<Text className="font-bold text-2xl mb-1">
|
||||||
|
{data?.details?.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="opacity-50">
|
||||||
|
Born{" "}
|
||||||
|
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}
|
||||||
|
)}{" "}
|
||||||
|
| {data?.details?.placeOfBirth}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<OverviewText text={data?.details?.biography} className="mt-4" />
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<FlashList
|
||||||
|
data={castedRoles}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
|
<Text className="font-bold text-xl text-neutral-500">
|
||||||
|
No results
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Text className="text-lg font-bold my-2">Appearances</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-full flex flex-col pr-2"
|
||||||
|
onPress={() => viewDetails(item)}
|
||||||
|
>
|
||||||
|
<Poster
|
||||||
|
id={item.id.toString()}
|
||||||
|
url={jellyseerrApi?.imageProxy(item.posterPath)}
|
||||||
|
/>
|
||||||
|
<JellyseerrMediaIcon
|
||||||
|
className="absolute top-1 left-1"
|
||||||
|
mediaType={item.mediaType as "movie" | "tv"}
|
||||||
|
/>
|
||||||
|
{/*<Text numberOfLines={1}>{item.title}</Text>*/}
|
||||||
|
{item.character && (
|
||||||
|
<Text
|
||||||
|
className="text-xs opacity-50 align-bottom mt-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
as {item.character}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
estimatedItemSize={255}
|
||||||
|
numColumns={3}
|
||||||
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import React, { useCallback, useRef, useState } from "react";
|
import React, {
|
||||||
import { useLocalSearchParams } from "expo-router";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -9,7 +15,11 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
@@ -17,6 +27,7 @@ import {
|
|||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackdropProps,
|
BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import {
|
||||||
@@ -24,28 +35,27 @@ import {
|
|||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
||||||
mediaTitle,
|
params as unknown as {
|
||||||
releaseYear,
|
mediaTitle: string;
|
||||||
canRequest: canRequestString,
|
releaseYear: number;
|
||||||
posterSrc,
|
canRequest: string;
|
||||||
...result
|
posterSrc: string;
|
||||||
} = params as unknown as {
|
} & Partial<MovieResult | TvResult>;
|
||||||
mediaTitle: string;
|
|
||||||
releaseYear: number;
|
|
||||||
canRequest: string;
|
|
||||||
posterSrc: string;
|
|
||||||
} & Partial<MovieResult | TvResult>;
|
|
||||||
|
|
||||||
const canRequest = canRequestString === "true";
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
@@ -56,6 +66,7 @@ const Page: React.FC = () => {
|
|||||||
data: details,
|
data: details,
|
||||||
isFetching,
|
isFetching,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||||
@@ -64,6 +75,7 @@ const Page: React.FC = () => {
|
|||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
|
refetchInterval: 0,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return result.mediaType === MediaType.MOVIE
|
return result.mediaType === MediaType.MOVIE
|
||||||
? jellyseerrApi?.movieDetails(result.id!!)
|
? jellyseerrApi?.movieDetails(result.id!!)
|
||||||
@@ -71,6 +83,8 @@ const Page: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canRequest = useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -94,18 +108,32 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const request = useCallback(
|
const request = useCallback(async () => {
|
||||||
() =>
|
requestMedia(
|
||||||
requestMedia(mediaTitle, {
|
mediaTitle,
|
||||||
|
{
|
||||||
mediaId: Number(result.id!!),
|
mediaId: Number(result.id!!),
|
||||||
mediaType: result.mediaType!!,
|
mediaType: result.mediaType!!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
?.map?.((s) => s.seasonNumber),
|
?.map?.((s) => s.seasonNumber),
|
||||||
}),
|
},
|
||||||
[details, result, requestMedia]
|
refetch
|
||||||
);
|
);
|
||||||
|
}, [details, result, requestMedia]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (details) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
|
||||||
|
<ItemActions item={details} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -129,7 +157,10 @@ const Page: React.FC = () => {
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
uri: jellyseerrApi?.imageProxy(
|
||||||
|
result.backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces"
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -178,7 +209,9 @@ const Page: React.FC = () => {
|
|||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
</View>
|
</View>
|
||||||
{canRequest ? (
|
{isLoading || isFetching ? (
|
||||||
|
<Button loading={true} disabled={true} color="purple"></Button>
|
||||||
|
) : canRequest ? (
|
||||||
<Button color="purple" onPress={request}>
|
<Button color="purple" onPress={request}>
|
||||||
Request
|
Request
|
||||||
</Button>
|
</Button>
|
||||||
@@ -206,8 +239,14 @@ const Page: React.FC = () => {
|
|||||||
isLoading={isLoading || isFetching}
|
isLoading={isLoading || isFetching}
|
||||||
result={result as TvResult}
|
result={result as TvResult}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<DetailFacts
|
||||||
|
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
|
||||||
|
details={details}
|
||||||
|
/>
|
||||||
|
<Cast details={details} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
@@ -274,17 +313,20 @@ const Page: React.FC = () => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Input
|
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||||
className="w-full"
|
<BottomSheetTextInput
|
||||||
placeholder="(optional) Describe the issue..."
|
multiline
|
||||||
value={issueMessage}
|
maxLength={254}
|
||||||
keyboardType="default"
|
style={{ color: "white" }}
|
||||||
returnKeyType="done"
|
clearButtonMode="always"
|
||||||
autoCapitalize="none"
|
placeholder="(optional) Describe the issue..."
|
||||||
textContentType="none"
|
placeholderTextColor="#9CA3AF"
|
||||||
maxLength={254}
|
// Issue with multiline + Textinput inside a portal
|
||||||
onChangeText={setIssueMessage}
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
/>
|
defaultValue={issueMessage}
|
||||||
|
onChangeText={setIssueMessage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||||
Submit
|
Submit
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { Ratings } from "@/components/Ratings";
|
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
|
||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
@@ -38,7 +36,6 @@ const page: React.FC = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: seriesId,
|
itemId: seriesId,
|
||||||
}),
|
}),
|
||||||
enabled: !!seriesId && !!api,
|
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,10 +78,13 @@ const page: React.FC = () => {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
|
item &&
|
||||||
allEpisodes &&
|
allEpisodes &&
|
||||||
allEpisodes.length > 0 && (
|
allEpisodes.length > 0 && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<AddToFavorites item={item} type="series" />
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
|
size="large"
|
||||||
title="Download Series"
|
title="Download Series"
|
||||||
items={allEpisodes || []}
|
items={allEpisodes || []}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
@@ -101,7 +101,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [allEpisodes, isLoading]);
|
}, [allEpisodes, isLoading, item]);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
if (!item || !backdropUrl) return null;
|
||||||
|
|
||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -151,6 +150,8 @@ const Page = () => {
|
|||||||
itemType = "Series";
|
itemType = "Series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
itemType = "BoxSet";
|
itemType = "BoxSet";
|
||||||
|
} else if (library.CollectionType === "music") {
|
||||||
|
itemType = "MusicAlbum";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export default function IndexLayout() {
|
|||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Library",
|
headerTitle: "Library",
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
@@ -23,20 +23,20 @@ export default function index() {
|
|||||||
const { data, isLoading: isLoading } = useQuery({
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) {
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
return null;
|
userId: user?.Id,
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data.Items || null;
|
return response.data.Items || null;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
staleTime: 60,
|
||||||
staleTime: 60 * 1000 * 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const libraries = useMemo(
|
||||||
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
|
[data, settings?.hiddenLibraries]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const item of data || []) {
|
for (const item of data || []) {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
@@ -63,7 +63,7 @@ export default function index() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data)
|
if (!libraries)
|
||||||
return (
|
return (
|
||||||
<View className="h-full w-full flex justify-center items-center">
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
||||||
@@ -81,7 +81,7 @@ export default function index() {
|
|||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={data}
|
data={libraries}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
ItemSeparatorComponent={() =>
|
ItemSeparatorComponent={() =>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
import {
|
||||||
|
commonScreenOptions,
|
||||||
|
nestedTabPageScreenOptions,
|
||||||
|
} from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
@@ -11,6 +14,9 @@ export default function SearchLayout() {
|
|||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Search",
|
headerTitle: "Search",
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -29,10 +35,8 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
name="jellyseerr/page"
|
<Stack.Screen name="jellyseerr/[personId]" options={commonScreenOptions} />
|
||||||
options={commonScreenOptions}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,18 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import {
|
||||||
|
MovieResult,
|
||||||
|
PersonResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { Tag } from "@/components/GenreTags";
|
import { Tag } from "@/components/GenreTags";
|
||||||
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
||||||
import { sortBy } from "lodash";
|
import { sortBy } from "lodash";
|
||||||
|
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
||||||
|
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -95,6 +101,7 @@ export default function search() {
|
|||||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||||
} else {
|
} else {
|
||||||
if (!settings?.marlinServerUrl) return [];
|
if (!settings?.marlinServerUrl) return [];
|
||||||
|
|
||||||
const url = `${
|
const url = `${
|
||||||
settings.marlinServerUrl
|
settings.marlinServerUrl
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
@@ -102,6 +109,7 @@ export default function search() {
|
|||||||
.join("&includeItemTypes=")}`;
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
const response1 = await axios.get(url);
|
||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids || !ids.length) return [];
|
if (!ids || !ids.length) return [];
|
||||||
@@ -147,8 +155,8 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({
|
||||||
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
queryKey: ["search", "jellyseerr", "results", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await jellyseerrApi?.search({
|
const response = await jellyseerrApi?.search({
|
||||||
query: new URLSearchParams(debouncedSearch).toString(),
|
query: new URLSearchParams(debouncedSearch).toString(),
|
||||||
@@ -164,14 +172,15 @@ export default function search() {
|
|||||||
debouncedSearch.length > 0,
|
debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
const { data: jellyseerrDiscoverSettings, isFetching: j2 } =
|
||||||
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
useReactNavigationQuery({
|
||||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
queryKey: ["search", "jellyseerr", "discoverSettings", debouncedSearch],
|
||||||
enabled:
|
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||||
!!jellyseerrApi &&
|
enabled:
|
||||||
searchType === "Discover" &&
|
!!jellyseerrApi &&
|
||||||
debouncedSearch.length == 0,
|
searchType === "Discover" &&
|
||||||
});
|
debouncedSearch.length == 0,
|
||||||
|
});
|
||||||
|
|
||||||
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -189,6 +198,14 @@ export default function search() {
|
|||||||
[jellyseerrResults]
|
[jellyseerrResults]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const jellyseerrPersonResults: PersonResult[] | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
jellyseerrResults?.filter(
|
||||||
|
(r) => r.mediaType === "person"
|
||||||
|
) as PersonResult[],
|
||||||
|
[jellyseerrResults]
|
||||||
|
);
|
||||||
|
|
||||||
const { data: series, isFetching: l2 } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search", "series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -298,7 +315,7 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2">
|
<View className="flex flex-col">
|
||||||
{Platform.OS === "android" && (
|
{Platform.OS === "android" && (
|
||||||
<View className="mb-4 px-4">
|
<View className="mb-4 px-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -318,7 +335,7 @@ export default function search() {
|
|||||||
text="Library"
|
text="Library"
|
||||||
textClass="p-1"
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Library" ? "bg-neutral-600" : undefined
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -327,7 +344,7 @@ export default function search() {
|
|||||||
text="Discover"
|
text="Discover"
|
||||||
textClass="p-1"
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Discover" ? "bg-neutral-600" : undefined
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -484,6 +501,19 @@ export default function search() {
|
|||||||
<JellyseerrPoster item={item} key={item.id} />
|
<JellyseerrPoster item={item} key={item.id} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header="Actors"
|
||||||
|
items={jellyseerrPersonResults}
|
||||||
|
renderItem={(item: PersonResult) => (
|
||||||
|
<PersonPoster
|
||||||
|
className="mr-2"
|
||||||
|
key={item.id}
|
||||||
|
id={item.id.toString()}
|
||||||
|
name={item.name}
|
||||||
|
posterPath={item.profilePath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -513,7 +543,7 @@ export default function search() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
||||||
<View className="flex flex-col px-4">
|
<View className="flex flex-col">
|
||||||
{sortBy?.(
|
{sortBy?.(
|
||||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||||
"order"
|
"order"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
import { withLayoutContext } from "expo-router";
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
@@ -13,12 +13,13 @@ const { Navigator } = createNativeBottomTabNavigator();
|
|||||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -29,6 +30,23 @@ export const NativeTabs = withLayoutContext<
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
|
if (!hasShownIntro) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
router.push("/intro/page");
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style="light" />
|
<SystemBars hidden={false} style="light" />
|
||||||
@@ -48,7 +66,10 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/house.fill.png")
|
require("@/assets/icons/house.fill.png")
|
||||||
: () => ({ sfSymbol: "house" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "house.fill" }
|
||||||
|
: { sfSymbol: "house" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -59,7 +80,26 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
require("@/assets/icons/magnifyingglass.png")
|
||||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "magnifyingglass" }
|
||||||
|
: { sfSymbol: "magnifyingglass" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name="(favorites)"
|
||||||
|
options={{
|
||||||
|
title: "Favorites",
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS == "android"
|
||||||
|
? ({ color, focused, size }) =>
|
||||||
|
focused
|
||||||
|
? require("@/assets/icons/heart.fill.png")
|
||||||
|
: require("@/assets/icons/heart.png")
|
||||||
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "heart.fill" }
|
||||||
|
: { sfSymbol: "heart" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -70,7 +110,10 @@ export default function TabLayout() {
|
|||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ color, focused, size }) =>
|
||||||
require("@/assets/icons/server.rack.png")
|
require("@/assets/icons/server.rack.png")
|
||||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
|
: { sfSymbol: "rectangle.stack" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -81,8 +124,11 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS == "android"
|
||||||
? () => require("@/assets/icons/list.png")
|
? ({ focused }) => require("@/assets/icons/list.png")
|
||||||
: () => ({ sfSymbol: "list.dash" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
: { sfSymbol: "list.dash" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -68,9 +68,11 @@ export default function page() {
|
|||||||
const { getDownloadedItem } = useDownload();
|
const { getDownloadedItem } = useDownload();
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -104,7 +106,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
if (offline) {
|
||||||
const item = await getDownloadedItem(itemId);
|
const item = await getDownloadedItem(itemId);
|
||||||
if (item) return item.item;
|
if (item) return item.item;
|
||||||
@@ -128,7 +129,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
if (offline) {
|
||||||
const data = await getDownloadedItem(itemId);
|
const data = await getDownloadedItem(itemId);
|
||||||
if (!data?.mediaSource) return null;
|
if (!data?.mediaSource) return null;
|
||||||
@@ -177,7 +177,7 @@ export default function page() {
|
|||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
@@ -195,8 +195,6 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Actually marked as paused");
|
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
if (!offline && stream) {
|
if (!offline && stream) {
|
||||||
@@ -339,7 +337,6 @@ export default function page() {
|
|||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
return async () => {
|
return async () => {
|
||||||
stop();
|
stop();
|
||||||
console.log("Unmounted");
|
|
||||||
};
|
};
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
@@ -349,10 +346,8 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
||||||
console.log("App has come to the foreground!");
|
|
||||||
// Handle app coming to the foreground
|
// Handle app coming to the foreground
|
||||||
} else if (nextAppState.match(/inactive|background/)) {
|
} else if (nextAppState.match(/inactive|background/)) {
|
||||||
console.log("App has gone to the background!");
|
|
||||||
// Handle app going to the background
|
// Handle app going to the background
|
||||||
if (videoRef.current && videoRef.current.pause) {
|
if (videoRef.current && videoRef.current.pause) {
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
@@ -442,7 +437,6 @@ export default function page() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -45,6 +45,8 @@ export default function page() {
|
|||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex: audioIndexStr,
|
audioIndex: audioIndexStr,
|
||||||
@@ -124,7 +126,7 @@ export default function page() {
|
|||||||
|
|
||||||
const togglePlay = useCallback(
|
const togglePlay = useCallback(
|
||||||
async (ticks: number) => {
|
async (ticks: number) => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
@@ -169,18 +171,15 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.resume();
|
videoRef.current?.resume();
|
||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
console.log("stop");
|
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -48,6 +48,7 @@ const Player = () => {
|
|||||||
|
|
||||||
const firstTime = useRef(true);
|
const firstTime = useRef(true);
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
@@ -58,7 +59,7 @@ const Player = () => {
|
|||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
@@ -167,7 +168,7 @@ const Player = () => {
|
|||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
@@ -260,13 +261,6 @@ const Player = () => {
|
|||||||
progress.value = ticks;
|
progress.value = ticks;
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
|
||||||
console.log(
|
|
||||||
"onProgress ~",
|
|
||||||
ticks,
|
|
||||||
isPlaying,
|
|
||||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||||
// TODO: since playable duration is always 0 then.
|
// TODO: since playable duration is always 0 then.
|
||||||
setIsBuffering(data.playableDuration === 0);
|
setIsBuffering(data.playableDuration === 0);
|
||||||
@@ -339,11 +333,7 @@ const Player = () => {
|
|||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
// Most likely the subtitle is burned in.
|
||||||
if (embeddedTrackIndex === -1) return;
|
if (embeddedTrackIndex === -1) return;
|
||||||
console.log(
|
|
||||||
"Setting selected text track",
|
|
||||||
subtitleIndex,
|
|
||||||
embeddedTrackIndex
|
|
||||||
);
|
|
||||||
setSelectedTextTrack({
|
setSelectedTextTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: embeddedTrackIndex,
|
value: embeddedTrackIndex,
|
||||||
@@ -398,7 +388,6 @@ const Player = () => {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
opacity: showControls ? 0.5 : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{videoSource ? (
|
{videoSource ? (
|
||||||
@@ -439,7 +428,6 @@ const Player = () => {
|
|||||||
setIsBuffering(e.isBuffering);
|
setIsBuffering(e.isBuffering);
|
||||||
}}
|
}}
|
||||||
onAudioTracks={(e) => {
|
onAudioTracks={(e) => {
|
||||||
console.log("onAudioTracks: ", e.audioTracks);
|
|
||||||
setAudioTracks(
|
setAudioTracks(
|
||||||
e.audioTracks.map((t) => ({
|
e.audioTracks.map((t) => ({
|
||||||
index: t.index,
|
index: t.index,
|
||||||
@@ -493,7 +481,6 @@ const Player = () => {
|
|||||||
}}
|
}}
|
||||||
getAudioTracks={getAudioTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
setAudioTrack={(i) => {
|
setAudioTrack={(i) => {
|
||||||
console.log("setAudioTrack ~", i);
|
|
||||||
setSelectedAudioTrack({
|
setSelectedAudioTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: i,
|
value: i,
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useGlobalSearchParams } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Alert, Dimensions, View } from "react-native";
|
|
||||||
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useGlobalSearchParams();
|
|
||||||
console.log(searchParams);
|
|
||||||
|
|
||||||
const { url } = searchParams as { url: string };
|
|
||||||
|
|
||||||
const videoId = useMemo(() => {
|
|
||||||
return url.split("v=")[1];
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
|
|
||||||
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
|
||||||
if (state === "ended") {
|
|
||||||
setPlaying(false);
|
|
||||||
Alert.alert("video has finished playing!");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePlaying = useCallback(() => {
|
|
||||||
setPlaying((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
togglePlaying();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col bg-black items-center justify-center h-full">
|
|
||||||
<YoutubePlayer
|
|
||||||
height={300}
|
|
||||||
play={playing}
|
|
||||||
videoId={videoId}
|
|
||||||
onChangeState={onStateChange}
|
|
||||||
width={screenWidth}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
@@ -36,7 +37,7 @@ import * as SplashScreen from "expo-splash-screen";
|
|||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Appearance, AppState } from "react-native";
|
import { Appearance, AppState, TouchableOpacity } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
@@ -335,14 +336,6 @@ function Layout() {
|
|||||||
header: () => null,
|
header: () => null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/trailer/page"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "modal",
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
269
app/login.tsx
269
app/login.tsx
@@ -1,14 +1,20 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import {
|
||||||
|
Ionicons,
|
||||||
|
MaterialCommunityIcons,
|
||||||
|
MaterialIcons,
|
||||||
|
} from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -38,7 +44,6 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [error, setError] = useState<string>("");
|
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -76,8 +81,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={24} color="white" />
|
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||||
|
<Text className="ml-2 text-purple-600">Change server</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
@@ -94,9 +101,9 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
setError(error.message);
|
Alert.alert("Connection failed", error.message);
|
||||||
} else {
|
} else {
|
||||||
setError("An unexpected error occurred");
|
Alert.alert("Connection failed", "An unexpected error occurred");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -120,7 +127,7 @@ const Login: React.FC = () => {
|
|||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
||||||
* - Logs errors and timeout information to the console.
|
* - Logs errors and timeout information to the console.
|
||||||
*/
|
*/
|
||||||
async function checkUrl(url: string) {
|
const checkUrl = useCallback(async (url: string) => {
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -130,15 +137,18 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
|
|
||||||
setServerName(data.ServerName || "");
|
setServerName(data.ServerName || "");
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
* Handles the connection attempt to a Jellyfin server.
|
||||||
@@ -156,7 +166,7 @@ const Login: React.FC = () => {
|
|||||||
* - Sets the server address using `setServer` if the connection is successful.
|
* - Sets the server address using `setServer` if the connection is successful.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const handleConnect = async (url: string) => {
|
const handleConnect = useCallback(async (url: string) => {
|
||||||
url = url.trim();
|
url = url.trim();
|
||||||
|
|
||||||
const result = await checkUrl(url);
|
const result = await checkUrl(url);
|
||||||
@@ -170,7 +180,7 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: url });
|
setServer({ address: url });
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -187,128 +197,131 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (api?.basePath) {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
||||||
style={{ flex: 1, height: "100%" }}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
|
||||||
<View className="px-4 -mt-20 w-full">
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
|
||||||
Log in
|
|
||||||
<>
|
|
||||||
{serverName ? (
|
|
||||||
<>
|
|
||||||
{" to "}
|
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
|
||||||
<Input
|
|
||||||
placeholder="Username"
|
|
||||||
onChangeText={(text) =>
|
|
||||||
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>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
|
||||||
<Button
|
|
||||||
color="black"
|
|
||||||
onPress={handleQuickConnect}
|
|
||||||
className="w-full mb-2"
|
|
||||||
>
|
|
||||||
Use Quick Connect
|
|
||||||
</Button>
|
|
||||||
<Button onPress={handleLogin} loading={loading}>
|
|
||||||
Log in
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1, height: "100%" }}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
{api?.basePath ? (
|
||||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
<>
|
||||||
<Image
|
<View className="flex flex-col h-full relative items-center justify-center">
|
||||||
style={{
|
<View className="px-4 -mt-20 w-full">
|
||||||
width: 100,
|
<View className="flex flex-col space-y-2">
|
||||||
height: 100,
|
<Text className="text-2xl font-bold -mb-2">
|
||||||
marginLeft: -23,
|
Log in
|
||||||
marginBottom: -20,
|
<>
|
||||||
}}
|
{serverName ? (
|
||||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
<>
|
||||||
/>
|
{" to "}
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<Text className="text-purple-600">{serverName}</Text>
|
||||||
<Text className="text-neutral-500">
|
</>
|
||||||
Enter the URL to your Jellyfin server
|
) : null}
|
||||||
</Text>
|
</>
|
||||||
<Input
|
</Text>
|
||||||
placeholder="Server URL"
|
<Text className="text-xs text-neutral-400">
|
||||||
onChangeText={setServerURL}
|
{api.basePath}
|
||||||
value={serverURL}
|
</Text>
|
||||||
keyboardType="url"
|
<Input
|
||||||
returnKeyType="done"
|
placeholder="Username"
|
||||||
autoCapitalize="none"
|
onChangeText={(text) =>
|
||||||
textContentType="URL"
|
setCredentials({ ...credentials, username: text })
|
||||||
maxLength={500}
|
}
|
||||||
/>
|
value={credentials.username}
|
||||||
<Text className="text-xs text-neutral-500">
|
autoFocus
|
||||||
Make sure to include http or https
|
secureTextEntry={false}
|
||||||
</Text>
|
keyboardType="default"
|
||||||
</View>
|
returnKeyType="done"
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
autoCapitalize="none"
|
||||||
<Button
|
textContentType="username"
|
||||||
loading={loadingServerCheck}
|
clearButtonMode="while-editing"
|
||||||
disabled={loadingServerCheck}
|
maxLength={500}
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
/>
|
||||||
className="w-full grow"
|
|
||||||
>
|
<Input
|
||||||
Connect
|
placeholder="Password"
|
||||||
</Button>
|
onChangeText={(text) =>
|
||||||
</View>
|
setCredentials({ ...credentials, password: text })
|
||||||
</View>
|
}
|
||||||
|
value={credentials.password}
|
||||||
|
secureTextEntry
|
||||||
|
keyboardType="default"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="password"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
className="flex-1 mr-2"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="cellphone-lock"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-col h-full items-center justify-center w-full">
|
||||||
|
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
marginLeft: -23,
|
||||||
|
marginBottom: -20,
|
||||||
|
}}
|
||||||
|
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||||
|
/>
|
||||||
|
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
Enter the URL to your Jellyfin server
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
aria-label="Server URL"
|
||||||
|
placeholder="http(s)://your-server.com"
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
value={serverURL}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck}
|
||||||
|
onPress={async () => await handleConnect(serverURL)}
|
||||||
|
className="w-full grow"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={(s) => {
|
||||||
|
handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
assets/icons/heart.fill.png
Normal file
BIN
assets/icons/heart.fill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/heart.png
Normal file
BIN
assets/icons/heart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
118
assets/icons/jellyseerr-logo.svg
Normal file
118
assets/icons/jellyseerr-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/jellyseerr.PNG
Normal file
BIN
assets/images/jellyseerr.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -1,9 +1,9 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Number {
|
interface Number {
|
||||||
bytesToReadable(): string;
|
bytesToReadable(): string;
|
||||||
secondsToMilliseconds(): number
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,27 +11,27 @@ Number.prototype.bytesToReadable = function () {
|
|||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
const gb = bytes / 1e9;
|
const gb = bytes / 1e9;
|
||||||
|
|
||||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
if (gb >= 1) return `${gb.toFixed(0)} GB`;
|
||||||
|
|
||||||
const mb = bytes / 1024.0 / 1024.0;
|
const mb = bytes / 1024.0 / 1024.0;
|
||||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
if (mb >= 1) return `${mb.toFixed(0)} MB`;
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
const kb = bytes / 1024.0;
|
||||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
if (kb >= 1) return `${kb.toFixed(0)} KB`;
|
||||||
|
|
||||||
return `${bytes.toFixed(2)} B`;
|
return `${bytes.toFixed(2)} B`;
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
return this.valueOf() * 1000
|
return this.valueOf() * 1000;
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.minutesToMilliseconds = function () {
|
Number.prototype.minutesToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).secondsToMilliseconds()
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).minutesToMilliseconds()
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
}
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
114
components/AddToFavorites.tsx
Normal file
114
components/AddToFavorites.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
type: "item" | "series";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const isFavorite = useMemo(() => {
|
||||||
|
return item.UserData?.IsFavorite;
|
||||||
|
}, [item.UserData?.IsFavorite]);
|
||||||
|
|
||||||
|
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
||||||
|
queryClient.setQueryData<BaseItemDto | undefined>(
|
||||||
|
[type, item.Id],
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
...newData,
|
||||||
|
UserData: { ...old.UserData, ...newData.UserData },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markFavoriteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (api && user) {
|
||||||
|
await getUserLibraryApi(api).markFavoriteItem({
|
||||||
|
userId: user.Id,
|
||||||
|
itemId: item.Id!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||||
|
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||||
|
type,
|
||||||
|
item.Id,
|
||||||
|
]);
|
||||||
|
updateItemInQueries({ UserData: { IsFavorite: true } });
|
||||||
|
|
||||||
|
return { previousItem };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
if (context?.previousItem) {
|
||||||
|
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unmarkFavoriteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (api && user) {
|
||||||
|
await getUserLibraryApi(api).unmarkFavoriteItem({
|
||||||
|
userId: user.Id,
|
||||||
|
itemId: item.Id!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||||
|
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||||
|
type,
|
||||||
|
item.Id,
|
||||||
|
]);
|
||||||
|
updateItemInQueries({ UserData: { IsFavorite: false } });
|
||||||
|
|
||||||
|
return { previousItem };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
if (context?.previousItem) {
|
||||||
|
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<RoundButton
|
||||||
|
size="large"
|
||||||
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
|
fillColor={isFavorite ? "primary" : undefined}
|
||||||
|
onPress={() => {
|
||||||
|
if (isFavorite) {
|
||||||
|
unmarkFavoriteMutation.mutate();
|
||||||
|
} else {
|
||||||
|
markFavoriteMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
@@ -37,12 +37,14 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
case "red":
|
case "red":
|
||||||
return "bg-red-600";
|
return "bg-red-600";
|
||||||
case "black":
|
case "black":
|
||||||
return "bg-neutral-900 border border-neutral-800";
|
return "bg-neutral-900";
|
||||||
case "transparent":
|
case "transparent":
|
||||||
return "bg-transparent";
|
return "bg-transparent";
|
||||||
}
|
}
|
||||||
}, [color]);
|
}, [color]);
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className={`
|
className={`
|
||||||
@@ -54,14 +56,16 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!loading && !disabled && onPress) {
|
if (!loading && !disabled && onPress) {
|
||||||
onPress();
|
onPress();
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader />
|
<View className="p-0.5">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!discoveryManager) {
|
if (!discoveryManager) {
|
||||||
|
console.warn("DiscoveryManager is not initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
@@ -77,6 +79,7 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {View, ViewProps} from "react-native";
|
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -8,14 +8,15 @@ interface TagProps {
|
|||||||
textClass?: ViewProps["className"]
|
textClass?: ViewProps["className"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
|
||||||
text,
|
text,
|
||||||
textClass,
|
textClass,
|
||||||
|
textStyle,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||||
<Text className={textClass}>{text}</Text>
|
<Text className={textClass} style={textStyle}>{text}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -26,7 +27,7 @@ export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-x
|
|||||||
return (
|
return (
|
||||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<View>
|
<View key={idx}>
|
||||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { ItemHeader } from "./ItemHeader";
|
|||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -90,6 +91,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadSingleItem item={item} size="large" />
|
<DownloadSingleItem item={item} size="large" />
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
|
<AddToFavorites item={item} type="item" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
) as MediaStream;
|
) as MediaStream;
|
||||||
}, [source.MediaStreams]);
|
}, [source.MediaStreams]);
|
||||||
|
|
||||||
|
if (!videoStream) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
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"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col overflow-visible">
|
|
||||||
<Text className="font-bold ">{title}</Text>
|
|
||||||
{subTitle && (
|
|
||||||
<Text uiTextView selectable className="text-xs text-neutral-400">
|
|
||||||
{subTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{iconAfter}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -32,7 +32,7 @@ import Animated, {
|
|||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -64,6 +64,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
@@ -79,7 +80,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
|
|||||||
48
components/PreviousServersList.tsx
Normal file
48
components/PreviousServersList.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
|
import { ListGroup } from "./list/ListGroup";
|
||||||
|
import { ListItem } from "./list/ListItem";
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviousServersListProps {
|
||||||
|
onServerSelect: (server: Server) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||||
|
onServerSelect,
|
||||||
|
}) => {
|
||||||
|
const [_previousServers, setPreviousServers] =
|
||||||
|
useMMKVString("previousServers");
|
||||||
|
|
||||||
|
const previousServers = useMemo(() => {
|
||||||
|
return JSON.parse(_previousServers || "[]") as Server[];
|
||||||
|
}, [_previousServers]);
|
||||||
|
|
||||||
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title="previous servers" className="mt-4">
|
||||||
|
{previousServers.map((s) => (
|
||||||
|
<ListItem
|
||||||
|
key={s.address}
|
||||||
|
onPress={() => onServerSelect(s)}
|
||||||
|
title={s.address}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
setPreviousServers("[]");
|
||||||
|
}}
|
||||||
|
title={"Clear"}
|
||||||
|
textColor="red"
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,10 +6,10 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
onPress?: () => void,
|
onPress?: () => void;
|
||||||
icon?: keyof typeof Ionicons.glyphMap;
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
background?: boolean;
|
background?: boolean;
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
@@ -29,10 +29,11 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||||
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
if (hapticFeedback) {
|
if (hapticFeedback) {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}
|
}
|
||||||
onPress?.();
|
onPress?.();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
|
|||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
className="p-4 rounded-xl bg-neutral-900"
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
placeholderTextColor={"#9CA3AF"}
|
placeholderTextColor={"#9CA3AF"}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
import * as Haptics from "expo-haptics";
|
BaseItemDto,
|
||||||
|
BaseItemPerson,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren, useCallback } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
export const itemRouter = (
|
||||||
if (item.CollectionType === "livetv") {
|
item: BaseItemDto | BaseItemPerson,
|
||||||
|
from: string
|
||||||
|
) => {
|
||||||
|
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||||
return `/(auth)/(tabs)/${from}/livetv`;
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +38,7 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
|||||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "Person") {
|
if (item.Type === "Person" || item.Type === "Actor") {
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,85 +68,51 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const showActionSheet = useCallback(() => {
|
||||||
|
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
|
||||||
|
const cancelButtonIndex = 2;
|
||||||
|
|
||||||
|
showActionSheetWithOptions(
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
cancelButtonIndex,
|
||||||
|
},
|
||||||
|
async (selectedIndex) => {
|
||||||
|
if (selectedIndex === 0) {
|
||||||
|
await markAsPlayedStatus(true);
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
} else if (selectedIndex === 1) {
|
||||||
|
await markAsPlayedStatus(false);
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [showActionSheetWithOptions, markAsPlayedStatus]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
from === "(home)" ||
|
||||||
|
from === "(search)" ||
|
||||||
|
from === "(libraries)" ||
|
||||||
|
from === "(favorites)"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onLongPress={showActionSheet}
|
||||||
<TouchableOpacity
|
onPress={() => {
|
||||||
onPress={() => {
|
const url = itemRouter(item, from);
|
||||||
const url = itemRouter(item, from);
|
// @ts-expect-error
|
||||||
// @ts-ignore
|
router.push(url);
|
||||||
router.push(url);
|
}}
|
||||||
}}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
avoidCollisions
|
|
||||||
alignOffset={0}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={false}
|
|
||||||
key={"content"}
|
|
||||||
>
|
|
||||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
|
||||||
<ContextMenu.Item
|
|
||||||
key="item-1"
|
|
||||||
onSelect={() => {
|
|
||||||
markAsPlayedStatus(true);
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key="item-1-title">
|
|
||||||
Mark as watched
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "green", // Changed to green for "watched"
|
|
||||||
light: "green",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName="checkmark-circle"
|
|
||||||
></ContextMenu.ItemIcon>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
<ContextMenu.Item
|
|
||||||
key="item-2"
|
|
||||||
onSelect={() => {
|
|
||||||
markAsPlayedStatus(false);
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
destructive
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key="item-2-title">
|
|
||||||
Mark as not watched
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
|
||||||
pointSize: 18, // Adjusted for better visibility
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "red", // Changed to red for "not watched"
|
|
||||||
light: "red",
|
|
||||||
},
|
|
||||||
// Removed paletteColors as it's not necessary in this case
|
|
||||||
}}
|
|
||||||
androidIconName="eye-slash"
|
|
||||||
></ContextMenu.ItemIcon>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +26,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(item.Id!);
|
return storage.getString(item.Id!);
|
||||||
@@ -41,7 +42,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useDownloadedFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
openFile(item);
|
openFile(item);
|
||||||
@@ -43,7 +44,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id);
|
deleteFile(item.Id);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
119
components/home/Favorites.tsx
Normal file
119
components/home/Favorites.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ScrollingCollectionList } from "./ScrollingCollectionList";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
export const Favorites = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const fetchFavoritesByType = useCallback(
|
||||||
|
async (itemType: BaseItemKind) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id!,
|
||||||
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
filters: ["IsFavorite"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeLocationTypes: ["Virtual"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
limit: 20,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
[api, user]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchFavoriteSeries = useCallback(
|
||||||
|
() => fetchFavoritesByType("Series"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteMovies = useCallback(
|
||||||
|
() => fetchFavoritesByType("Movie"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteEpisodes = useCallback(
|
||||||
|
() => fetchFavoritesByType("Episode"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteVideos = useCallback(
|
||||||
|
() => fetchFavoritesByType("Video"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteBoxsets = useCallback(
|
||||||
|
() => fetchFavoritesByType("BoxSet"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoritePlaylists = useCallback(
|
||||||
|
() => fetchFavoritesByType("Playlist"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteMusicAlbum = useCallback(
|
||||||
|
() => fetchFavoritesByType("MusicAlbum"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteAudio = useCallback(
|
||||||
|
() => fetchFavoritesByType("Audio"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-co gap-y-4">
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteSeries}
|
||||||
|
queryKey={["home", "favorites", "series"]}
|
||||||
|
title="Series"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteMovies}
|
||||||
|
queryKey={["home", "favorites", "movies"]}
|
||||||
|
title="Movies"
|
||||||
|
hideIfEmpty
|
||||||
|
orientation="vertical"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteEpisodes}
|
||||||
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
|
title="Episodes"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteVideos}
|
||||||
|
queryKey={["home", "favorites", "videos"]}
|
||||||
|
title="Videos"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteBoxsets}
|
||||||
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
|
title="Boxsets"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoritePlaylists}
|
||||||
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
|
title="Playlists"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteMusicAlbum}
|
||||||
|
queryKey={["home", "favorites", "musicAlbums"]}
|
||||||
|
title="Music Albums"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteAudio}
|
||||||
|
queryKey={["home", "favorites", "audio"]}
|
||||||
|
title="Audio"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,7 +22,7 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
|||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -84,21 +84,27 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const width = Dimensions.get("screen").width;
|
const width = Dimensions.get("screen").width;
|
||||||
|
|
||||||
|
if (settings?.usePopularPlugin === false) return null;
|
||||||
if (l1 || l2) return null;
|
if (l1 || l2) return null;
|
||||||
if (!popularItems) return null;
|
if (!popularItems) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center" {...props}>
|
<View className="flex flex-col items-center mt-2" {...props}>
|
||||||
<Carousel
|
<Carousel
|
||||||
autoPlay={true}
|
|
||||||
autoPlayInterval={3000}
|
|
||||||
loop={true}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
autoPlay={false}
|
||||||
|
loop={true}
|
||||||
|
snapEnabled={true}
|
||||||
|
mode="parallax"
|
||||||
|
modeConfig={{
|
||||||
|
parallaxScrollingScale: 0.86,
|
||||||
|
parallaxScrollingOffset: 100,
|
||||||
|
}}
|
||||||
width={width}
|
width={width}
|
||||||
height={204}
|
height={204}
|
||||||
data={popularItems}
|
data={popularItems}
|
||||||
onProgressChange={progress}
|
onProgressChange={progress}
|
||||||
renderItem={({ item, index }) => <RenderItem item={item} />}
|
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
||||||
/>
|
/>
|
||||||
<Pagination.Basic
|
<Pagination.Basic
|
||||||
progress={progress}
|
progress={progress}
|
||||||
@@ -122,6 +128,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const uri = useMemo(() => {
|
const uri = useMemo(() => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -147,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const handleRoute = useCallback(() => {
|
const handleRoute = useCallback(() => {
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
const url = itemRouter(item, from);
|
const url = itemRouter(item, from);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (url) router.push(url);
|
if (url) router.push(url);
|
||||||
}, [item, from]);
|
}, [item, from]);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Props extends ViewProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
hideIfEmpty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -26,10 +27,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// console.log(queryKey);
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
@@ -41,8 +41,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
|
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="">
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -82,15 +84,13 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{data?.map((item, index) => (
|
{data?.map((item) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={index}
|
key={item.Id}
|
||||||
className={`
|
className={`mr-2
|
||||||
mr-2
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
|
`}
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
|
|||||||
39
components/jellyseerr/Cast.tsx
Normal file
39
components/jellyseerr/Cast.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import React from "react";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
||||||
|
|
||||||
|
const CastSlide: React.FC<
|
||||||
|
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||||
|
> = ({ details, ...props }) => {
|
||||||
|
return (
|
||||||
|
details?.credits?.cast?.length &&
|
||||||
|
details?.credits?.cast?.length > 0 && (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="text-lg font-bold mb-2 px-4">Cast</Text>
|
||||||
|
<FlashList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
data={details?.credits.cast}
|
||||||
|
ItemSeparatorComponent={() => <View className="w-2" />}
|
||||||
|
estimatedItemSize={15}
|
||||||
|
keyExtractor={(item) => item?.id?.toString()}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<PersonPoster
|
||||||
|
id={item.id.toString()}
|
||||||
|
posterPath={item.profilePath}
|
||||||
|
name={item.name}
|
||||||
|
subName={item.character}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CastSlide;
|
||||||
218
components/jellyseerr/DetailFacts.tsx
Normal file
218
components/jellyseerr/DetailFacts.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import CountryFlag from "react-native-country-flag";
|
||||||
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
|
|
||||||
|
interface Release {
|
||||||
|
certification: string;
|
||||||
|
iso_639_1?: string;
|
||||||
|
note?: string;
|
||||||
|
release_date: string;
|
||||||
|
type: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
};
|
||||||
|
|
||||||
|
const Facts: React.FC<
|
||||||
|
{ title: string; facts?: string[] | React.ReactNode[] } & ViewProps
|
||||||
|
> = ({ title, facts, ...props }) =>
|
||||||
|
facts &&
|
||||||
|
facts?.length > 0 && (
|
||||||
|
<View className="flex flex-row justify-between py-2" {...props}>
|
||||||
|
<Text className="font-bold">{title}</Text>
|
||||||
|
|
||||||
|
<View className="flex flex-col items-end">
|
||||||
|
{facts.map((f, idx) =>
|
||||||
|
typeof f === "string" ? <Text key={idx}>{f}</Text> : f
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||||
|
title,
|
||||||
|
fact,
|
||||||
|
...props
|
||||||
|
}) => fact && <Facts title={title} facts={[fact]} {...props} />;
|
||||||
|
|
||||||
|
const DetailFacts: React.FC<
|
||||||
|
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||||
|
> = ({ details, className, ...props }) => {
|
||||||
|
const { jellyseerrUser } = useJellyseerr();
|
||||||
|
|
||||||
|
const locale = useMemo(() => {
|
||||||
|
return jellyseerrUser?.settings?.locale || "en";
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const region = useMemo(
|
||||||
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
|
[jellyseerrUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
const releases = useMemo(
|
||||||
|
() =>
|
||||||
|
(details as MovieDetails)?.releases?.results.find(
|
||||||
|
(r: TmdbRelease) => r.iso_3166_1 === region
|
||||||
|
)?.release_dates as TmdbRelease["release_dates"],
|
||||||
|
[details]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Release date types:
|
||||||
|
// 1. Premiere
|
||||||
|
// 2. Theatrical (limited)
|
||||||
|
// 3. Theatrical
|
||||||
|
// 4. Digital
|
||||||
|
// 5. Physical
|
||||||
|
// 6. TV
|
||||||
|
const filteredReleases = useMemo(
|
||||||
|
() =>
|
||||||
|
uniqBy(
|
||||||
|
releases?.filter((r: Release) => r.type > 2 && r.type < 6),
|
||||||
|
"type"
|
||||||
|
),
|
||||||
|
[releases]
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstAirDate = useMemo(() => {
|
||||||
|
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||||
|
if (firstAirDate) {
|
||||||
|
return new Date(firstAirDate).toLocaleDateString(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
dateOpts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const nextAirDate = useMemo(() => {
|
||||||
|
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||||
|
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
|
||||||
|
if (nextAirDate && firstAirDate !== nextAirDate) {
|
||||||
|
return new Date(nextAirDate).toLocaleDateString(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
dateOpts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const revenue = useMemo(
|
||||||
|
() =>
|
||||||
|
(details as MovieDetails)?.revenue?.toLocaleString?.(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
{ style: "currency", currency: "USD" }
|
||||||
|
),
|
||||||
|
[details]
|
||||||
|
);
|
||||||
|
|
||||||
|
const budget = useMemo(
|
||||||
|
() =>
|
||||||
|
(details as MovieDetails)?.budget?.toLocaleString?.(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
{ style: "currency", currency: "USD" }
|
||||||
|
),
|
||||||
|
[details]
|
||||||
|
);
|
||||||
|
|
||||||
|
const streamingProviders = useMemo(
|
||||||
|
() =>
|
||||||
|
details?.watchProviders?.find(
|
||||||
|
(provider) => provider.iso_3166_1 === region
|
||||||
|
)?.flatrate,
|
||||||
|
[details]
|
||||||
|
);
|
||||||
|
|
||||||
|
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
||||||
|
|
||||||
|
const spokenLanguage = useMemo(
|
||||||
|
() =>
|
||||||
|
details?.spokenLanguages.find(
|
||||||
|
(lng) => lng.iso_639_1 === details.originalLanguage
|
||||||
|
)?.name,
|
||||||
|
[details]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
details && (
|
||||||
|
<View className="p-4">
|
||||||
|
<Text className="text-lg font-bold">Details</Text>
|
||||||
|
<View
|
||||||
|
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Fact title="Status" fact={details?.status} />
|
||||||
|
<Fact
|
||||||
|
title="Original Title"
|
||||||
|
fact={(details as TvDetails)?.originalName}
|
||||||
|
/>
|
||||||
|
{details.keywords.some(
|
||||||
|
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
) && <Fact title="Series Type" fact="Anime" />}
|
||||||
|
<Facts
|
||||||
|
title="Release Dates"
|
||||||
|
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||||
|
<View key={idx} className="flex flex-row space-x-2 items-center">
|
||||||
|
{r.type === 3 ? (
|
||||||
|
// Theatrical
|
||||||
|
<Ionicons name="ticket" size={16} color="white" />
|
||||||
|
) : r.type === 4 ? (
|
||||||
|
// Digital
|
||||||
|
<Ionicons name="cloud" size={16} color="white" />
|
||||||
|
) : (
|
||||||
|
// Physical
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="record-circle-outline"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text>
|
||||||
|
{new Date(r.release_date).toLocaleDateString(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
dateOpts
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
<Fact title="First Air Date" fact={firstAirDate} />
|
||||||
|
<Fact title="Next Air Date" fact={nextAirDate} />
|
||||||
|
<Fact title="Revenue" fact={revenue} />
|
||||||
|
<Fact title="Budget" fact={budget} />
|
||||||
|
<Fact title="Original Language" fact={spokenLanguage} />
|
||||||
|
<Facts
|
||||||
|
title="Production Country"
|
||||||
|
facts={details?.productionCountries?.map((n, idx) => (
|
||||||
|
<View key={idx} className="flex flex-row items-center space-x-2">
|
||||||
|
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||||
|
<Text>{n.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
<Facts
|
||||||
|
title="Studios"
|
||||||
|
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
||||||
|
(n) => n.name
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Facts title="Network" facts={networks?.map((n) => n.name)} />
|
||||||
|
<Facts
|
||||||
|
title="Currently Streaming on"
|
||||||
|
facts={streamingProviders?.map((s) => s.name)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailFacts;
|
||||||
@@ -71,11 +71,14 @@ const DiscoverSlide: React.FC<Props> = ({ slide }) => {
|
|||||||
flatData &&
|
flatData &&
|
||||||
flatData?.length > 0 && (
|
flatData?.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<Text className="font-bold text-lg mb-2">
|
<Text className="font-bold text-lg mb-2 px-4">
|
||||||
{DiscoverSliderType[slide.type].toString().toTitle()}
|
{DiscoverSliderType[slide.type].toString().toTitle()}
|
||||||
</Text>
|
</Text>
|
||||||
<FlashList
|
<FlashList
|
||||||
horizontal
|
horizontal
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: 16,
|
||||||
|
}}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
keyExtractor={(item) => item!!.id.toString()}
|
keyExtractor={(item) => item!!.id.toString()}
|
||||||
estimatedItemSize={250}
|
estimatedItemSize={250}
|
||||||
|
|||||||
37
components/jellyseerr/JellyseerrMediaIcon.tsx
Normal file
37
components/jellyseerr/JellyseerrMediaIcon.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {useMemo} from "react";
|
||||||
|
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import {Feather, MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
|
import {View, ViewProps} from "react-native";
|
||||||
|
|
||||||
|
const JellyseerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
||||||
|
mediaType,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const style = useMemo(
|
||||||
|
() => mediaType === MediaType.MOVIE
|
||||||
|
? 'bg-blue-600/90 border-blue-400/40'
|
||||||
|
: 'bg-purple-600/90 border-purple-400/40',
|
||||||
|
[mediaType]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
mediaType &&
|
||||||
|
<View className={`${className} border ${style} rounded-full p-1`} {...props}>
|
||||||
|
{mediaType === MediaType.MOVIE ? (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="movie-open"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Feather
|
||||||
|
size={16}
|
||||||
|
name="tv"
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JellyseerrMediaIcon;
|
||||||
@@ -2,7 +2,6 @@ import {useEffect, useState} from "react";
|
|||||||
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mediaStatus?: MediaStatus;
|
mediaStatus?: MediaStatus;
|
||||||
@@ -10,7 +9,7 @@ interface Props {
|
|||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
showRequestIcon,
|
showRequestIcon,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -69,4 +68,4 @@ const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default JellyseerrIconStatus;
|
export default JellyseerrStatusIcon;
|
||||||
42
components/jellyseerr/PersonPoster.tsx
Normal file
42
components/jellyseerr/PersonPoster.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||||
|
import React from "react";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import {useRouter, useSegments} from "expo-router";
|
||||||
|
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
posterPath?: string
|
||||||
|
name: string
|
||||||
|
subName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||||
|
id,
|
||||||
|
posterPath,
|
||||||
|
name,
|
||||||
|
subName,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const {jellyseerrApi} = useJellyseerr();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = segments[2];
|
||||||
|
|
||||||
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}>
|
||||||
|
<View className="flex flex-col w-28" {...props}>
|
||||||
|
<Poster
|
||||||
|
id={id}
|
||||||
|
url={jellyseerrApi?.imageProxy(posterPath, 'w600_and_h900_bestv2')}
|
||||||
|
/>
|
||||||
|
<Text className="mt-2">{name}</Text>
|
||||||
|
{subName && <Text className="text-xs opacity-50">{subName}</Text>}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PersonPoster;
|
||||||
@@ -5,6 +5,7 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
CollectionType,
|
CollectionType,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -50,18 +51,52 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
[library]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const itemType = useMemo(() => {
|
||||||
|
let _itemType: BaseItemKind | undefined;
|
||||||
|
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
_itemType = "Movie";
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
_itemType = "Series";
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
_itemType = "BoxSet";
|
||||||
|
} else if (library.CollectionType === "music") {
|
||||||
|
_itemType = "MusicAlbum";
|
||||||
|
}
|
||||||
|
|
||||||
|
return _itemType;
|
||||||
|
}, [library.CollectionType]);
|
||||||
|
|
||||||
|
const itemTypeName = useMemo(() => {
|
||||||
|
let nameStr: string;
|
||||||
|
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
nameStr = "movies";
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
nameStr = "series";
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
nameStr = "box sets";
|
||||||
|
} else if (library.CollectionType === "music") {
|
||||||
|
nameStr = "albums";
|
||||||
|
} else {
|
||||||
|
nameStr = "items";
|
||||||
|
}
|
||||||
|
|
||||||
|
return nameStr;
|
||||||
|
}, [library.CollectionType]);
|
||||||
|
|
||||||
const { data: itemsCount } = useQuery({
|
const { data: itemsCount } = useQuery({
|
||||||
queryKey: ["library-count", library.Id],
|
queryKey: ["library-count", library.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
const response = await getItemsApi(api!).getItems({
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: library.Id,
|
parentId: library.Id,
|
||||||
|
recursive: true,
|
||||||
limit: 0,
|
limit: 0,
|
||||||
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
});
|
});
|
||||||
return response.data.TotalRecordCount;
|
return response.data.TotalRecordCount;
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
@@ -80,7 +115,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
||||||
{itemsCount} items
|
{itemsCount} {itemTypeName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -109,6 +144,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -128,7 +164,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
)}
|
)}
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-start px-4">
|
<Text className="font-bold text-xs text-start px-4">
|
||||||
{itemsCount} items
|
{itemsCount} {itemTypeName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -145,7 +181,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
||||||
{itemsCount} items
|
{itemsCount} {itemTypeName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
58
components/list/ListGroup.tsx
Normal file
58
components/list/ListGroup.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
PropsWithChildren,
|
||||||
|
Children,
|
||||||
|
isValidElement,
|
||||||
|
cloneElement,
|
||||||
|
ReactElement,
|
||||||
|
} from "react";
|
||||||
|
import { StyleSheet, View, ViewProps, ViewStyle } from "react-native";
|
||||||
|
import { ListItem } from "./ListItem";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
description?: ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const childrenArray = Children.toArray(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="ml-4 mb-1 uppercase text-[#8E8D91] text-xs">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[]}
|
||||||
|
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900"
|
||||||
|
>
|
||||||
|
{Children.map(childrenArray, (child, index) => {
|
||||||
|
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||||
|
return cloneElement(child as any, {
|
||||||
|
style: StyleSheet.compose(
|
||||||
|
child.props.style,
|
||||||
|
index < childrenArray.length - 1
|
||||||
|
? styles.borderBottom
|
||||||
|
: undefined
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
{description && <View className="pl-4 mt-1">{description}</View>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
borderBottom: {
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: "#3D3C40",
|
||||||
|
},
|
||||||
|
});
|
||||||
124
components/list/ListItem.tsx
Normal file
124
components/list/ListItem.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps, ViewProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
value?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
showArrow?: boolean;
|
||||||
|
textColor?: "default" | "blue" | "red";
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
iconAfter,
|
||||||
|
children,
|
||||||
|
showArrow = false,
|
||||||
|
icon,
|
||||||
|
textColor = "default",
|
||||||
|
onPress,
|
||||||
|
disabled = false,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (onPress)
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
||||||
|
disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ListItemContent
|
||||||
|
title={title}
|
||||||
|
value={value}
|
||||||
|
icon={icon}
|
||||||
|
textColor={textColor}
|
||||||
|
showArrow={showArrow}
|
||||||
|
iconAfter={iconAfter}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ListItemContent>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
||||||
|
disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ListItemContent
|
||||||
|
title={title}
|
||||||
|
value={value}
|
||||||
|
icon={icon}
|
||||||
|
textColor={textColor}
|
||||||
|
showArrow={showArrow}
|
||||||
|
iconAfter={iconAfter}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ListItemContent>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListItemContent = ({
|
||||||
|
title,
|
||||||
|
textColor,
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
showArrow,
|
||||||
|
iconAfter,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row items-center w-full">
|
||||||
|
{icon && (
|
||||||
|
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
|
||||||
|
<Ionicons name="person-circle-outline" size={18} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
textColor === "blue"
|
||||||
|
? "text-[#0584FE]"
|
||||||
|
: textColor === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: "text-white"
|
||||||
|
}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{value && (
|
||||||
|
<View className="ml-auto items-end">
|
||||||
|
<Text selectable className=" text-[#9899A1]" numberOfLines={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{children && <View className="ml-auto">{children}</View>}
|
||||||
|
{showArrow && (
|
||||||
|
<View className={children ? "ml-1" : "ml-auto"}>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#5A5960" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{iconAfter}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -61,7 +61,7 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
{collection.Name}
|
{collection.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<InfiniteHorizontalScroll
|
<InfiniteHorizontalScroll
|
||||||
|
|||||||
@@ -1,55 +1,47 @@
|
|||||||
import {View, ViewProps} from "react-native";
|
import { View, ViewProps } from "react-native";
|
||||||
import {Image} from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
import { Text } from "@/components/common/Text";
|
||||||
import {Text} from "@/components/common/Text";
|
import { useMemo } from "react";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
import {
|
||||||
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
|
MediaStatus,
|
||||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
MediaType,
|
||||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
|
||||||
|
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||||
|
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||||
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: MovieResult | TvResult;
|
item: MovieResult | TvResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyseerrPoster: React.FC<Props> = ({
|
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
||||||
item,
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
|
|
||||||
// const imageSource =
|
|
||||||
|
|
||||||
const imageSrc = useMemo(() =>
|
const imageSrc = useMemo(
|
||||||
item.posterPath ?
|
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
|
||||||
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
|
|
||||||
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
|
|
||||||
[item, jellyseerrApi]
|
[item, jellyseerrApi]
|
||||||
)
|
);
|
||||||
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
|
const title = useMemo(
|
||||||
const releaseYear = useMemo(() =>
|
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
|
||||||
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
|
|
||||||
[item]
|
[item]
|
||||||
)
|
);
|
||||||
|
const releaseYear = useMemo(
|
||||||
|
() =>
|
||||||
|
new Date(
|
||||||
|
item.mediaType === MediaType.MOVIE
|
||||||
|
? item.releaseDate
|
||||||
|
: item.firstAirDate
|
||||||
|
).getFullYear(),
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
const showRequestButton = useMemo(() =>
|
const canRequest = useJellyseerrCanRequest(item);
|
||||||
jellyseerrUser && hasPermission(
|
|
||||||
[
|
|
||||||
Permission.REQUEST,
|
|
||||||
item.mediaType === 'movie'
|
|
||||||
? Permission.REQUEST_MOVIE
|
|
||||||
: Permission.REQUEST_TV,
|
|
||||||
],
|
|
||||||
jellyseerrUser.permissions,
|
|
||||||
{type: 'or'}
|
|
||||||
),
|
|
||||||
[item, jellyseerrUser]
|
|
||||||
)
|
|
||||||
|
|
||||||
const canRequest = useMemo(() => {
|
|
||||||
const status = item?.mediaInfo?.status
|
|
||||||
return showRequestButton && !status || status === MediaStatus.UNKNOWN
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableJellyseerrRouter
|
<TouchableJellyseerrRouter
|
||||||
@@ -57,14 +49,14 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
mediaTitle={title}
|
mediaTitle={title}
|
||||||
releaseYear={releaseYear}
|
releaseYear={releaseYear}
|
||||||
canRequest={canRequest}
|
canRequest={canRequest}
|
||||||
posterSrc={imageSrc}
|
posterSrc={imageSrc!!}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col w-28 mr-2">
|
<View className="flex flex-col w-28 mr-2">
|
||||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||||
<Image
|
<Image
|
||||||
key={item.id}
|
key={item.id}
|
||||||
id={item.id.toString()}
|
id={item.id.toString()}
|
||||||
source={{uri: imageSrc}}
|
source={{ uri: imageSrc }}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={{
|
style={{
|
||||||
@@ -72,21 +64,24 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<JellyseerrIconStatus
|
<JellyseerrStatusIcon
|
||||||
className="absolute bottom-1 right-1"
|
className="absolute bottom-1 right-1"
|
||||||
showRequestIcon={canRequest}
|
showRequestIcon={canRequest}
|
||||||
mediaStatus={item?.mediaInfo?.status}
|
mediaStatus={item?.mediaInfo?.status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<JellyseerrMediaIcon
|
||||||
|
className="absolute top-1 left-1"
|
||||||
|
mediaType={item?.mediaType}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="mt-2 flex flex-col">
|
<View className="mt-2 flex flex-col">
|
||||||
<Text numberOfLines={2}>{title}</Text>
|
<Text numberOfLines={2}>{title}</Text>
|
||||||
<Text className="text-xs opacity-50">{releaseYear}</Text>
|
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableJellyseerrRouter>
|
</TouchableJellyseerrRouter>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default JellyseerrPoster;
|
||||||
export default JellyseerrPoster;
|
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemPerson,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
type PosterProps = {
|
type PosterProps = {
|
||||||
item?: BaseItemDto | BaseItemPerson | null;
|
id?: string | null;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
showProgress?: boolean;
|
showProgress?: boolean;
|
||||||
blurhash?: string | null;
|
blurhash?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
|
||||||
if (!item)
|
if (!id && !url)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="border border-neutral-900"
|
className="border border-neutral-900"
|
||||||
@@ -33,8 +29,8 @@ const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
key={item.Id}
|
key={id}
|
||||||
id={item.Id}
|
id={id!!}
|
||||||
source={
|
source={
|
||||||
url
|
url
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemPerson,
|
BaseItemPerson,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router } from "expo-router";
|
import { router, useSegments } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../posters/Poster";
|
import Poster from "../posters/Poster";
|
||||||
|
import { itemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -19,6 +20,8 @@ interface Props extends ViewProps {
|
|||||||
|
|
||||||
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = segments[2];
|
||||||
|
|
||||||
const destinctPeople = useMemo(() => {
|
const destinctPeople = useMemo(() => {
|
||||||
const people: BaseItemPerson[] = [];
|
const people: BaseItemPerson[] = [];
|
||||||
@@ -33,6 +36,8 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
return people;
|
return people;
|
||||||
}, [item?.People]);
|
}, [item?.People]);
|
||||||
|
|
||||||
|
if (!from) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="flex flex-col">
|
<View {...props} className="flex flex-col">
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
@@ -44,11 +49,13 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/actors/${i.Id}`);
|
const url = itemRouter(i, from);
|
||||||
|
// @ts-ignore
|
||||||
|
router.push(url);
|
||||||
}}
|
}}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
|
<Poster id={i.id} url={getPrimaryImageUrl({ api, item: i })} />
|
||||||
<Text className="mt-2">{i.Name}</Text>
|
<Text className="mt-2">{i.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{i.Role}</Text>
|
<Text className="text-xs opacity-50">{i.Role}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
className="flex flex-col space-y-2 w-28"
|
className="flex flex-col space-y-2 w-28"
|
||||||
>
|
>
|
||||||
<Poster
|
<Poster
|
||||||
item={item}
|
id={item.id}
|
||||||
url={getPrimaryImageUrlById({ api, id: item.ParentId })}
|
url={getPrimaryImageUrlById({ api, id: item.ParentId })}
|
||||||
/>
|
/>
|
||||||
<Text>{item.SeriesName}</Text>
|
<Text>{item.SeriesName}</Text>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { orderBy } from "lodash";
|
import { orderBy } from "lodash";
|
||||||
import { Tags } from "@/components/GenreTags";
|
import { Tags } from "@/components/GenreTags";
|
||||||
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||||
import Season from "@/utils/jellyseerr/server/entity/Season";
|
import Season from "@/utils/jellyseerr/server/entity/Season";
|
||||||
import {
|
import {
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -15,11 +15,12 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { RoundButton } from "@/components/RoundButton";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import {QueryObserverResult, RefetchOptions, useQuery} from "@tanstack/react-query";
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
|
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
|
||||||
const JellyseerrSeasonEpisodes: React.FC<{
|
const JellyseerrSeasonEpisodes: React.FC<{
|
||||||
details: TvDetails;
|
details: TvDetails;
|
||||||
@@ -60,7 +61,7 @@ const RenderItem = ({ item, index }: any) => {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
id={item.id}
|
id={item.id}
|
||||||
source={{
|
source={{
|
||||||
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
|
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||||
}}
|
}}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
@@ -100,7 +101,8 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
result?: TvResult;
|
result?: TvResult;
|
||||||
details?: TvDetails;
|
details?: TvDetails;
|
||||||
}> = ({ isLoading, result, details }) => {
|
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
|
||||||
|
}> = ({ isLoading, result, details, refetch }) => {
|
||||||
if (!details) return null;
|
if (!details) return null;
|
||||||
|
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
@@ -168,6 +170,21 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
[requestAll]
|
[requestAll]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
|
||||||
|
if (canRequest) {
|
||||||
|
requestMedia(
|
||||||
|
`${result?.name!!}, Season ${seasonNumber}`,
|
||||||
|
{
|
||||||
|
mediaId: details.id,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
|
seasons: [seasonNumber],
|
||||||
|
},
|
||||||
|
refetch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [requestMedia]);
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -229,24 +246,9 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
||||||
?.status === MediaStatus.UNKNOWN;
|
?.status === MediaStatus.UNKNOWN;
|
||||||
return (
|
return (
|
||||||
<JellyseerrIconStatus
|
<JellyseerrStatusIcon
|
||||||
key={0}
|
key={0}
|
||||||
onPress={
|
onPress={() => requestSeason(canRequest, season.seasonNumber)}
|
||||||
canRequest
|
|
||||||
? () =>
|
|
||||||
requestMedia(
|
|
||||||
`${result?.name!!}, Season ${
|
|
||||||
season.seasonNumber
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
mediaId: details.id,
|
|
||||||
mediaType: MediaType.TV,
|
|
||||||
tvdbId: details.externalIds?.tvdbId,
|
|
||||||
seasons: [season.seasonNumber],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||||
mediaStatus={
|
mediaStatus={
|
||||||
seasons?.find(
|
seasons?.find(
|
||||||
|
|||||||
@@ -1,24 +1,45 @@
|
|||||||
|
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
Linking,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto | MovieDetails | TvDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemActions = ({ item, ...props }: Props) => {
|
export const ItemActions = ({ item, ...props }: Props) => {
|
||||||
const router = useRouter();
|
const trailerLink = useMemo(() => {
|
||||||
|
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
|
||||||
|
return item.RemoteTrailers[0].Url;
|
||||||
|
}
|
||||||
|
|
||||||
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
|
if ("relatedVideos" in item) {
|
||||||
|
return item.relatedVideos?.find((v) => v.type === "Trailer")?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
const openTrailer = useCallback(async () => {
|
const openTrailer = useCallback(async () => {
|
||||||
if (!trailerLink) return;
|
if (!trailerLink) {
|
||||||
|
Alert.alert("No trailer available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const encodedTrailerLink = encodeURIComponent(trailerLink);
|
try {
|
||||||
router.push(`/trailer/page?url=${encodedTrailerLink}`);
|
await Linking.openURL(trailerLink);
|
||||||
}, [router, trailerLink]);
|
} catch (err) {
|
||||||
|
console.error("Failed to open trailer link:", err);
|
||||||
|
}
|
||||||
|
}, [trailerLink]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="" {...props}>
|
<View className="" {...props}>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -14,26 +17,35 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props}>
|
||||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
<ListGroup
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
title={"Audio"}
|
||||||
<View
|
description={
|
||||||
className={`
|
<Text className="text-[#8E8D91] text-xs">
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
Choose a default audio language.
|
||||||
`}
|
</Text>
|
||||||
>
|
}
|
||||||
<View className="flex flex-col shrink">
|
>
|
||||||
<Text className="font-semibold">Audio language</Text>
|
<ListItem title={"Set Audio Track From Previous Item"}>
|
||||||
<Text className="text-xs opacity-50">
|
<Switch
|
||||||
Choose a default audio language.
|
value={settings.rememberAudioSelections}
|
||||||
</Text>
|
onValueChange={(value) =>
|
||||||
</View>
|
updateSettings({ rememberAudioSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem title="Audio language">
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
|
||||||
<Text>
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
{settings?.defaultAudioLanguage?.DisplayName || "None"}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -72,43 +84,8 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</ListItem>
|
||||||
<View className="flex flex-col">
|
</ListGroup>
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">Use Default Audio</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Play default audio track regardless of language.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.playDefaultAudioTrack}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ playDefaultAudioTrack: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
Set Audio Track From Previous Item
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs opacity-50 min max-w-[85%]">
|
|
||||||
Try to set the audio track to the closest match to the last
|
|
||||||
video.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.rememberAudioSelections}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ rememberAudioSelections: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
111
components/settings/DownloadSettings.tsx
Normal file
111
components/settings/DownloadSettings.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { Switch, TouchableOpacity, View } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const DownloadSettings: React.FC = ({ ...props }) => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const { setProcesses } = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props} className="mb-4">
|
||||||
|
<ListGroup title="Downloads">
|
||||||
|
<ListItem title="Download method">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
|
{settings.downloadMethod === "remux"
|
||||||
|
? "Default"
|
||||||
|
: "Optimized"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Methods</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ downloadMethod: "remux" });
|
||||||
|
setProcesses([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ downloadMethod: "optimized" });
|
||||||
|
setProcesses([]);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title="Remux max download"
|
||||||
|
disabled={settings.downloadMethod !== "remux"}
|
||||||
|
>
|
||||||
|
<Stepper
|
||||||
|
value={settings.remuxConcurrentLimit}
|
||||||
|
step={1}
|
||||||
|
min={1}
|
||||||
|
max={4}
|
||||||
|
onUpdate={(value) =>
|
||||||
|
updateSettings({
|
||||||
|
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title="Auto download"
|
||||||
|
disabled={settings.downloadMethod !== "optimized"}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
disabled={settings.downloadMethod !== "optimized"}
|
||||||
|
value={settings.autoDownload}
|
||||||
|
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
disabled={settings.downloadMethod !== "optimized"}
|
||||||
|
onPress={() => router.push("/settings/optimized-server/page")}
|
||||||
|
showArrow
|
||||||
|
title="Optimized Versions Server"
|
||||||
|
></ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { View } from "react-native";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { Input } from "../common/Input";
|
|
||||||
import { ListItem } from "../ListItem";
|
|
||||||
import { Loader } from "../Loader";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Button } from "../Button";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const JellyseerrSettings = () => {
|
export const JellyseerrSettings = () => {
|
||||||
const {
|
const {
|
||||||
@@ -83,41 +83,43 @@ export const JellyseerrSettings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mt-4">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
|
|
||||||
<View>
|
<View>
|
||||||
{jellyseerrUser ? (
|
{jellyseerrUser ? (
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
|
<>
|
||||||
<ListItem
|
<ListGroup title={"Jellyseerr"}>
|
||||||
title="Total media requests"
|
<ListItem
|
||||||
subTitle={jellyseerrUser?.requestCount?.toString()}
|
title="Total media requests"
|
||||||
/>
|
value={jellyseerrUser?.requestCount?.toString()}
|
||||||
<ListItem
|
/>
|
||||||
title="Movie quota limit"
|
<ListItem
|
||||||
subTitle={
|
title="Movie quota limit"
|
||||||
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
value={
|
||||||
}
|
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
||||||
/>
|
}
|
||||||
<ListItem
|
/>
|
||||||
title="Movie quota days"
|
<ListItem
|
||||||
subTitle={
|
title="Movie quota days"
|
||||||
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
value={
|
||||||
}
|
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
||||||
/>
|
}
|
||||||
<ListItem
|
/>
|
||||||
title="TV quota limit"
|
<ListItem
|
||||||
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
title="TV quota limit"
|
||||||
/>
|
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
||||||
<ListItem
|
/>
|
||||||
title="TV quota days"
|
<ListItem
|
||||||
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
title="TV quota days"
|
||||||
/>
|
value={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<Button color="red" onPress={clearData}>
|
<Button color="red" onPress={clearData}>
|
||||||
Reset Jellyseerr config
|
Reset Jellyseerr config
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
||||||
<Text className="text-xs text-red-600 mb-2">
|
<Text className="text-xs text-red-600 mb-2">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import React from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
@@ -9,86 +12,61 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
const renderSkipControl = (
|
||||||
<View>
|
value: number,
|
||||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
onDecrease: () => void,
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
onIncrease: () => void
|
||||||
<View
|
) => (
|
||||||
className={`
|
<View className="flex flex-row items-center">
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
<TouchableOpacity
|
||||||
`}
|
onPress={onDecrease}
|
||||||
>
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
<View className="flex flex-col shrink">
|
>
|
||||||
<Text className="font-semibold">Forward skip length</Text>
|
<Text>-</Text>
|
||||||
<Text className="text-xs opacity-50">
|
</TouchableOpacity>
|
||||||
Choose length in seconds when skipping in video playback.
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
</Text>
|
{value}s
|
||||||
</View>
|
</Text>
|
||||||
<View className="flex flex-row items-center">
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
onPress={() =>
|
onPress={onIncrease}
|
||||||
updateSettings({
|
>
|
||||||
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
<Text>+</Text>
|
||||||
})
|
</TouchableOpacity>
|
||||||
}
|
</View>
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
);
|
||||||
>
|
|
||||||
<Text>-</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
|
||||||
{settings.forwardSkipTime}s
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
|
||||||
onPress={() =>
|
|
||||||
updateSettings({
|
|
||||||
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
return (
|
||||||
className={`
|
<View {...props}>
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
<ListGroup title="Media Controls">
|
||||||
`}
|
<ListItem title="Forward Skip Length">
|
||||||
>
|
{renderSkipControl(
|
||||||
<View className="flex flex-col shrink">
|
settings.forwardSkipTime,
|
||||||
<Text className="font-semibold">Rewind length</Text>
|
() =>
|
||||||
<Text className="text-xs opacity-50">
|
updateSettings({
|
||||||
Choose length in seconds when skipping in video playback.
|
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
||||||
</Text>
|
}),
|
||||||
</View>
|
() =>
|
||||||
<View className="flex flex-row items-center">
|
updateSettings({
|
||||||
<TouchableOpacity
|
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
||||||
onPress={() =>
|
})
|
||||||
updateSettings({
|
)}
|
||||||
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
</ListItem>
|
||||||
})
|
|
||||||
}
|
<ListItem title="Rewind Length">
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
{renderSkipControl(
|
||||||
>
|
settings.rewindSkipTime,
|
||||||
<Text>-</Text>
|
() =>
|
||||||
</TouchableOpacity>
|
updateSettings({
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
||||||
{settings.rewindSkipTime}s
|
}),
|
||||||
</Text>
|
() =>
|
||||||
<TouchableOpacity
|
updateSettings({
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
||||||
onPress={() =>
|
})
|
||||||
updateSettings({
|
)}
|
||||||
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
</ListItem>
|
||||||
})
|
</ListGroup>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
43
components/settings/OptimizedServerForm.tsx
Normal file
43
components/settings/OptimizedServerForm.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { TextInput, View, Linking } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChangeValue: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OptimizedServerForm: React.FC<Props> = ({
|
||||||
|
value,
|
||||||
|
onChangeValue,
|
||||||
|
}) => {
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
||||||
|
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
|
||||||
|
<Text className="mr-4">URL</Text>
|
||||||
|
<TextInput
|
||||||
|
className="text-white"
|
||||||
|
placeholder="http(s)://domain.org:port"
|
||||||
|
value={value}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={(text) => onChangeValue(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
|
Enter the URL for the optimize server. The URL should include http or
|
||||||
|
https and optionally the port.{" "}
|
||||||
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
|
Read more about the optimize server.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
198
components/settings/OtherSettings.tsx
Normal file
198
components/settings/OtherSettings.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
BACKGROUND_FETCH_TASK,
|
||||||
|
registerBackgroundFetchAsync,
|
||||||
|
unregisterBackgroundFetchAsync,
|
||||||
|
} from "@/utils/background-tasks";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import * as BackgroundFetch from "expo-background-fetch";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import * as TaskManager from "expo-task-manager";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const OtherSettings: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
/********************
|
||||||
|
* Background task
|
||||||
|
*******************/
|
||||||
|
const checkStatusAsync = async () => {
|
||||||
|
await BackgroundFetch.getStatusAsync();
|
||||||
|
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const registered = await checkStatusAsync();
|
||||||
|
|
||||||
|
if (settings?.autoDownload === true && !registered) {
|
||||||
|
registerBackgroundFetchAsync();
|
||||||
|
toast.success("Background downloads enabled");
|
||||||
|
} else if (settings?.autoDownload === false && registered) {
|
||||||
|
unregisterBackgroundFetchAsync();
|
||||||
|
toast.info("Background downloads disabled");
|
||||||
|
} else if (settings?.autoDownload === true && registered) {
|
||||||
|
// Don't to anything
|
||||||
|
} else if (settings?.autoDownload === false && !registered) {
|
||||||
|
// Don't to anything
|
||||||
|
} else {
|
||||||
|
updateSettings({ autoDownload: false });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [settings?.autoDownload]);
|
||||||
|
/**********************
|
||||||
|
*********************/
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListGroup title="Other" className="">
|
||||||
|
<ListItem title="Auto rotate">
|
||||||
|
<Switch
|
||||||
|
value={settings.autoRotate}
|
||||||
|
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem title="Video orientation" disabled={settings.autoRotate}>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
|
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{
|
||||||
|
ScreenOrientationEnum[
|
||||||
|
ScreenOrientation.OrientationLock.DEFAULT
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{
|
||||||
|
ScreenOrientationEnum[
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="3"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{
|
||||||
|
ScreenOrientationEnum[
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="4"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultVideoOrientation:
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{
|
||||||
|
ScreenOrientationEnum[
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem title="Safe area in controls">
|
||||||
|
<Switch
|
||||||
|
value={settings.safeAreaInControlsEnabled}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem
|
||||||
|
title="Show Custom Menu Links"
|
||||||
|
onPress={() =>
|
||||||
|
Linking.openURL(
|
||||||
|
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.showCustomMenuLinks}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ showCustomMenuLinks: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/hide-libraries/page")}
|
||||||
|
title="Hide Libraries"
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
<ListItem title="Disable Haptic Feedback">
|
||||||
|
<Switch
|
||||||
|
value={settings.disableHapticFeedback}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ disableHapticFeedback: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
35
components/settings/PluginSettings.tsx
Normal file
35
components/settings/PluginSettings.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const PluginSettings = () => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title="Plugins">
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/jellyseerr/page")}
|
||||||
|
title={"Jellyseerr"}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/marlin-search/page")}
|
||||||
|
title="Marlin Search"
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/popular-lists/page")}
|
||||||
|
title="Popular Lists"
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
components/settings/QuickConnect.tsx
Normal file
115
components/settings/QuickConnect.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
import { Alert, View, ViewProps } from "react-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const authorizeQuickConnect = useCallback(async () => {
|
||||||
|
if (quickConnectCode) {
|
||||||
|
try {
|
||||||
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
|
code: quickConnectCode,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
if (res.status === 200) {
|
||||||
|
successHapticFeedback();
|
||||||
|
Alert.alert("Success", "Quick connect authorized");
|
||||||
|
setQuickConnectCode(undefined);
|
||||||
|
bottomSheetModalRef?.current?.close();
|
||||||
|
} else {
|
||||||
|
errorHapticFeedback();
|
||||||
|
Alert.alert("Error", "Invalid code");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
Alert.alert("Error", "Invalid code");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [api, user, quickConnectCode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<ListGroup title={"Quick Connect"}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
|
title="Authorize Quick Connect"
|
||||||
|
textColor="blue"
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||||
|
<View>
|
||||||
|
<Text className="font-bold text-2xl text-neutral-100">
|
||||||
|
Quick Connect
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||||
|
<BottomSheetTextInput
|
||||||
|
style={{ color: "white" }}
|
||||||
|
clearButtonMode="always"
|
||||||
|
placeholder="Enter the quick connect code..."
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={quickConnectCode}
|
||||||
|
onChangeText={setQuickConnectCode}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
className="mt-auto"
|
||||||
|
onPress={authorizeQuickConnect}
|
||||||
|
color="purple"
|
||||||
|
>
|
||||||
|
Authorize
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,643 +0,0 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import {
|
|
||||||
apiAtom,
|
|
||||||
getOrSetDeviceId,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
ScreenOrientationEnum,
|
|
||||||
Settings,
|
|
||||||
useSettings,
|
|
||||||
} from "@/utils/atoms/settings";
|
|
||||||
import {
|
|
||||||
BACKGROUND_FETCH_TASK,
|
|
||||||
registerBackgroundFetchAsync,
|
|
||||||
unregisterBackgroundFetchAsync,
|
|
||||||
} from "@/utils/background-tasks";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Linking,
|
|
||||||
Switch,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Input } from "../common/Input";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { Loader } from "../Loader";
|
|
||||||
import { MediaToggles } from "./MediaToggles";
|
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import { MediaProvider } from "./MediaContext";
|
|
||||||
import { SubtitleToggles } from "./SubtitleToggles";
|
|
||||||
import { AudioToggles } from "./AudioToggles";
|
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { ListItem } from "@/components/ListItem";
|
|
||||||
import { JellyseerrSettings } from "./Jellyseerr";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const { setProcesses } = useDownload();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
/********************
|
|
||||||
* Background task
|
|
||||||
*******************/
|
|
||||||
const checkStatusAsync = async () => {
|
|
||||||
await BackgroundFetch.getStatusAsync();
|
|
||||||
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const registered = await checkStatusAsync();
|
|
||||||
|
|
||||||
if (settings?.autoDownload === true && !registered) {
|
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
toast.success("Background downloads enabled");
|
|
||||||
} else if (settings?.autoDownload === false && registered) {
|
|
||||||
unregisterBackgroundFetchAsync();
|
|
||||||
toast.info("Background downloads disabled");
|
|
||||||
} else if (settings?.autoDownload === true && registered) {
|
|
||||||
// Don't to anything
|
|
||||||
} else if (settings?.autoDownload === false && !registered) {
|
|
||||||
// Don't to anything
|
|
||||||
} else {
|
|
||||||
updateSettings({ autoDownload: false });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [settings?.autoDownload]);
|
|
||||||
/**********************
|
|
||||||
*********************/
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: mediaListCollections,
|
|
||||||
isLoading: isLoadingMediaListCollections,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
tags: ["sf_promoted"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["Tags"],
|
|
||||||
includeItemTypes: ["BoxSet"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
{/* <View>
|
|
||||||
<Text className="text-lg font-bold mb-2">Look and feel</Text>
|
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="shrink">
|
|
||||||
<Text className="font-semibold">Coming soon</Text>
|
|
||||||
<Text className="text-xs opacity-50 max-w-[90%]">
|
|
||||||
Options for changing the look and feel of the app.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch disabled />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View> */}
|
|
||||||
|
|
||||||
<MediaProvider>
|
|
||||||
<MediaToggles />
|
|
||||||
<AudioToggles />
|
|
||||||
<SubtitleToggles />
|
|
||||||
</MediaProvider>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden 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
|
|
||||||
pointerEvents={settings.autoRotate ? "none" : "auto"}
|
|
||||||
className={`
|
|
||||||
${
|
|
||||||
settings.autoRotate
|
|
||||||
? "opacity-50 pointer-events-none"
|
|
||||||
: "opacity-100"
|
|
||||||
}
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Video orientation</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Set the full screen video player orientation.
|
|
||||||
</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>
|
|
||||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.DEFAULT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.DEFAULT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="3"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="4"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="shrink">
|
|
||||||
<Text className="font-semibold">Safe area in controls</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Enable safe area in video player controls
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.safeAreaInControlsEnabled}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<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>
|
|
||||||
{settings.usePopularPlugin && (
|
|
||||||
<View className="flex flex-col py-2 bg-neutral-900">
|
|
||||||
{mediaListCollections?.map((mlc) => (
|
|
||||||
<View
|
|
||||||
key={mlc.Id}
|
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
|
|
||||||
>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="font-semibold">{mlc.Name}</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (!settings.mediaListCollectionIds) {
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds: [mlc.Id!],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
mediaListCollectionIds:
|
|
||||||
settings.mediaListCollectionIds.includes(mlc.Id!)
|
|
||||||
? settings.mediaListCollectionIds.filter(
|
|
||||||
(id) => id !== mlc.Id
|
|
||||||
)
|
|
||||||
: [...settings.mediaListCollectionIds, mlc.Id!],
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{isLoadingMediaListCollections && (
|
|
||||||
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{mediaListCollections?.length === 0 && (
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
No collections found. Add some in Jellyfin.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Search engine</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose the search engine you want to use.
|
|
||||||
</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.searchEngine}</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({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ searchEngine: "Marlin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
{settings.searchEngine === "Marlin" && (
|
|
||||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
|
||||||
<View className="flex flex-row items-center space-x-2">
|
|
||||||
<View className="grow">
|
|
||||||
<Input
|
|
||||||
placeholder="Marlin Server URL..."
|
|
||||||
defaultValue={settings.marlinServerUrl}
|
|
||||||
value={marlinUrl}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
onChangeText={(text) => setMarlinUrl(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
className="shrink w-16 h-12"
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({
|
|
||||||
marlinServerUrl: marlinUrl.endsWith("/")
|
|
||||||
? marlinUrl
|
|
||||||
: marlinUrl + "/",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{settings.marlinServerUrl && (
|
|
||||||
<Text className="text-neutral-500 mt-2">
|
|
||||||
Current: {settings.marlinServerUrl}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="shrink">
|
|
||||||
<Text className="font-semibold">Show Custom Menu Links</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Show custom menu links defined inside your Jellyfin web
|
|
||||||
config.json file
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
Linking.openURL(
|
|
||||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text className="text-xs text-purple-600">More info</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.showCustomMenuLinks}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ showCustomMenuLinks: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="mt-4">
|
|
||||||
<Text className="text-lg font-bold mb-2">Downloads</Text>
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Download method</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose the download method to use. Optimized requires the
|
|
||||||
optimized server.
|
|
||||||
</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.downloadMethod === "remux"
|
|
||||||
? "Default"
|
|
||||||
: "Optimized"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Methods</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: "remux" });
|
|
||||||
setProcesses([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: "optimized" });
|
|
||||||
setProcesses([]);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
pointerEvents={
|
|
||||||
settings.downloadMethod === "remux" ? "auto" : "none"
|
|
||||||
}
|
|
||||||
className={`
|
|
||||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
|
||||||
${
|
|
||||||
settings.downloadMethod === "remux"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Remux max download</Text>
|
|
||||||
<Text className="text-xs opacity-50 shrink">
|
|
||||||
This is the total media you want to be able to download at the
|
|
||||||
same time.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Stepper
|
|
||||||
value={settings.remuxConcurrentLimit}
|
|
||||||
step={1}
|
|
||||||
min={1}
|
|
||||||
max={4}
|
|
||||||
onUpdate={(value) =>
|
|
||||||
updateSettings({
|
|
||||||
remuxConcurrentLimit:
|
|
||||||
value as Settings["remuxConcurrentLimit"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
pointerEvents={
|
|
||||||
settings.downloadMethod === "optimized" ? "auto" : "none"
|
|
||||||
}
|
|
||||||
className={`
|
|
||||||
flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4
|
|
||||||
${
|
|
||||||
settings.downloadMethod === "optimized"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Auto download</Text>
|
|
||||||
<Text className="text-xs opacity-50 shrink">
|
|
||||||
This will automatically download the media file when it's
|
|
||||||
finished optimizing on the server.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.autoDownload}
|
|
||||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
pointerEvents={
|
|
||||||
settings.downloadMethod === "optimized" ? "auto" : "none"
|
|
||||||
}
|
|
||||||
className={`
|
|
||||||
${
|
|
||||||
settings.downloadMethod === "optimized"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col bg-neutral-900 px-4 py-4">
|
|
||||||
<View className="flex flex-col shrink mb-2">
|
|
||||||
<View className="flex flex-row justify-between items-center">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
Optimized versions server
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Set the URL for the optimized versions server for downloads.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View></View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Input
|
|
||||||
placeholder="Optimized versions server URL..."
|
|
||||||
value={optimizedVersionsServerUrl}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
className="h-12 mt-2"
|
|
||||||
onPress={async () => {
|
|
||||||
updateSettings({
|
|
||||||
optimizedVersionsServerUrl:
|
|
||||||
optimizedVersionsServerUrl.length === 0
|
|
||||||
? null
|
|
||||||
: optimizedVersionsServerUrl.endsWith("/")
|
|
||||||
? optimizedVersionsServerUrl
|
|
||||||
: optimizedVersionsServerUrl + "/",
|
|
||||||
});
|
|
||||||
const res = await getStatistics({
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: await getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
if (res) {
|
|
||||||
toast.success("Connected");
|
|
||||||
} else toast.error("Could not connect");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<JellyseerrSettings />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
111
components/settings/StorageSettings.tsx
Normal file
111
components/settings/StorageSettings.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { clearLogs } from "@/utils/log";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import * as Progress from "react-native-progress";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const StorageSettings = () => {
|
||||||
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
|
const { data: size, isLoading: appSizeLoading } = useQuery({
|
||||||
|
queryKey: ["appSize", appSizeUsage],
|
||||||
|
queryFn: async () => {
|
||||||
|
const app = await appSizeUsage;
|
||||||
|
|
||||||
|
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
||||||
|
const total = await FileSystem.getTotalDiskCapacityAsync();
|
||||||
|
|
||||||
|
return { app, remaining, total, used: (total - remaining) / total };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDeleteClicked = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAllFiles();
|
||||||
|
successHapticFeedback();
|
||||||
|
} catch (e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
toast.error("Error deleting files");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
return ((value / total) * 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className="flex flex-col gap-y-1">
|
||||||
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
<Text className="">Storage</Text>
|
||||||
|
{size && (
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
{Number(size.total - size.remaining).bytesToReadable()} of{" "}
|
||||||
|
{size.total?.bytesToReadable()} used
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="h-3 w-full bg-gray-100/10 rounded-md overflow-hidden flex flex-row">
|
||||||
|
{size && (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${(size.app / size.total) * 100}%`,
|
||||||
|
backgroundColor: "rgb(147 51 234)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
((size.total - size.remaining - size.app) / size.total) *
|
||||||
|
100
|
||||||
|
}%`,
|
||||||
|
backgroundColor: "rgb(192 132 252)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row gap-x-2">
|
||||||
|
{size && (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
|
||||||
|
<Text className="text-white text-xs">
|
||||||
|
App {calculatePercentage(size.app, size.total)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
|
||||||
|
<Text className="text-white text-xs">
|
||||||
|
Phone{" "}
|
||||||
|
{calculatePercentage(
|
||||||
|
size.total - size.remaining - size.app,
|
||||||
|
size.total
|
||||||
|
)}
|
||||||
|
%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ListGroup>
|
||||||
|
<ListItem
|
||||||
|
textColor="red"
|
||||||
|
onPress={onDeleteClicked}
|
||||||
|
title="Delete All Downloaded Files"
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
@@ -11,6 +14,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const subtitleModes = [
|
const subtitleModes = [
|
||||||
@@ -22,26 +26,27 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props}>
|
||||||
<Text className="text-lg font-bold mb-2">Subtitle</Text>
|
<ListGroup
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
title={"Subtitles"}
|
||||||
<View
|
description={
|
||||||
className={`
|
<Text className="text-[#8E8D91] text-xs">
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
Configure subtitle preferences.
|
||||||
`}
|
</Text>
|
||||||
>
|
}
|
||||||
<View className="flex flex-col shrink">
|
>
|
||||||
<Text className="font-semibold">Subtitle language</Text>
|
<ListItem title="Subtitle language">
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose a default subtitle language.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text>
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -80,25 +85,20 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</ListItem>
|
||||||
|
|
||||||
<View
|
<ListItem title="Subtitle Mode">
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Subtitle Mode</Text>
|
|
||||||
<Text className="text-xs opacity-50 mr-2">
|
|
||||||
Subtitles are loaded based on the default and forced flags in the
|
|
||||||
embedded metadata. Language preferences are considered when
|
|
||||||
multiple options are available.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
|
{settings?.subtitleMode || "Loading"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -125,40 +125,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</ListItem>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<ListItem title="Set Subtitle Track From Previous Item">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<Switch
|
||||||
<View className="flex flex-col">
|
value={settings.rememberSubtitleSelections}
|
||||||
<Text className="font-semibold">
|
onValueChange={(value) =>
|
||||||
Set Subtitle Track From Previous Item
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
</Text>
|
}
|
||||||
<Text className="text-xs opacity-50 min max-w-[85%]">
|
/>
|
||||||
Try to set the subtitle track to the closest match to the last
|
</ListItem>
|
||||||
video.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.rememberSubtitleSelections}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ rememberSubtitleSelections: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
<ListItem title="Subtitle Size">
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Subtitle Size</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose a default subtitle size for direct play (only works for
|
|
||||||
some subtitle formats).
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center">
|
<View className="flex flex-row items-center">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
@@ -170,7 +148,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
>
|
>
|
||||||
<Text>-</Text>
|
<Text>-</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
|
||||||
{settings.subtitleSize}
|
{settings.subtitleSize}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -184,8 +162,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<Text>+</Text>
|
<Text>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ListItem>
|
||||||
</View>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
32
components/settings/UserInfo.tsx
Normal file
32
components/settings/UserInfo.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
import Application from "expo-application";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const version =
|
||||||
|
Application?.nativeApplicationVersion ||
|
||||||
|
Application?.nativeBuildVersion ||
|
||||||
|
"N/A";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<ListGroup title={"User Info"}>
|
||||||
|
<ListItem title="User" value={user?.Name} />
|
||||||
|
<ListItem title="Server" value={api?.basePath} />
|
||||||
|
<ListItem title="Token" value={api?.accessToken} />
|
||||||
|
<ListItem title="App version" value={version} />
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -157,10 +157,12 @@ export const Controls: React.FC<Props> = ({
|
|||||||
isVlc
|
isVlc
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
const goToPreviousItem = useCallback(() => {
|
||||||
if (!previousItem || !settings) return;
|
if (!previousItem || !settings) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
@@ -198,7 +200,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
if (!nextItem || !settings) return;
|
if (!nextItem || !settings) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
@@ -326,7 +328,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const handleSkipBackward = useCallback(async () => {
|
const handleSkipBackward = useCallback(async () => {
|
||||||
if (!settings?.rewindSkipTime) return;
|
if (!settings?.rewindSkipTime) return;
|
||||||
wasPlayingRef.current = isPlaying;
|
wasPlayingRef.current = isPlaying;
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
@@ -344,7 +346,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const handleSkipForward = useCallback(async () => {
|
const handleSkipForward = useCallback(async () => {
|
||||||
if (!settings?.forwardSkipTime) return;
|
if (!settings?.forwardSkipTime) return;
|
||||||
wasPlayingRef.current = isPlaying;
|
wasPlayingRef.current = isPlaying;
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
try {
|
try {
|
||||||
const curr = progress.value;
|
const curr = progress.value;
|
||||||
if (curr !== undefined) {
|
if (curr !== undefined) {
|
||||||
@@ -361,7 +363,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const memoizedRenderBubble = useCallback(() => {
|
const memoizedRenderBubble = useCallback(() => {
|
||||||
@@ -440,7 +442,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const gotoItem = await getItemById(api, itemId);
|
const gotoItem = await getItemById(api, itemId);
|
||||||
if (!settings || !gotoItem) return;
|
if (!settings || !gotoItem) return;
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
lightHapticFeedback();
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes: previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
@@ -497,33 +499,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<VideoProvider
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
|
||||||
left: settings?.safeAreaInControlsEnabled ? insets.left : 0,
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
|
||||||
>
|
|
||||||
{!mediaSource?.TranscodingUrl ? (
|
|
||||||
<DropdownViewDirect showControls={showControls} />
|
|
||||||
) : (
|
|
||||||
<DropdownViewTranscoding showControls={showControls} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</VideoProvider>
|
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPressIn={() => {
|
onPressIn={() => {
|
||||||
toggleControls();
|
toggleControls();
|
||||||
@@ -532,6 +507,8 @@ export const Controls: React.FC<Props> = ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: Dimensions.get("window").width,
|
width: Dimensions.get("window").width,
|
||||||
height: Dimensions.get("window").height,
|
height: Dimensions.get("window").height,
|
||||||
|
backgroundColor: "black",
|
||||||
|
opacity: showControls ? 0.5 : 0,
|
||||||
}}
|
}}
|
||||||
></Pressable>
|
></Pressable>
|
||||||
|
|
||||||
@@ -541,61 +518,82 @@ export const Controls: React.FC<Props> = ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
top: settings?.safeAreaInControlsEnabled ? insets.top : 0,
|
||||||
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
right: settings?.safeAreaInControlsEnabled ? insets.right : 0,
|
||||||
|
width: settings?.safeAreaInControlsEnabled
|
||||||
|
? Dimensions.get("window").width - insets.left - insets.right
|
||||||
|
: Dimensions.get("window").width,
|
||||||
opacity: showControls ? 1 : 0,
|
opacity: showControls ? 1 : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
className={`flex flex-row w-full p-4 `}
|
||||||
>
|
>
|
||||||
{item?.Type === "Episode" && !offline && (
|
<View className="mr-auto">
|
||||||
|
<VideoProvider
|
||||||
|
getAudioTracks={getAudioTracks}
|
||||||
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
|
setAudioTrack={setAudioTrack}
|
||||||
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
|
setSubtitleURL={setSubtitleURL}
|
||||||
|
>
|
||||||
|
{!mediaSource?.TranscodingUrl ? (
|
||||||
|
<DropdownViewDirect showControls={showControls} />
|
||||||
|
) : (
|
||||||
|
<DropdownViewTranscoding showControls={showControls} />
|
||||||
|
)}
|
||||||
|
</VideoProvider>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row items-center space-x-2 ">
|
||||||
|
{item?.Type === "Episode" && !offline && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
switchOnEpisodeMode();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="list" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{previousItem && !offline && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={goToPreviousItem}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextItem && !offline && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={goToNextItem}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mediaSource?.TranscodingUrl && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={toggleIgnoreSafeAreas}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
switchOnEpisodeMode();
|
lightHapticFeedback();
|
||||||
|
router.back();
|
||||||
}}
|
}}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons name="list" size={24} color="white" />
|
<Ionicons name="close" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
</View>
|
||||||
{previousItem && !offline && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={goToPreviousItem}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{nextItem && !offline && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={goToNextItem}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mediaSource?.TranscodingUrl && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={toggleIgnoreSafeAreas}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
|
||||||
size={24}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={async () => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -118,14 +118,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View>
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
zIndex: 1000,
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
|
||||||
className="p-4"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.23.0",
|
"channel": "0.24.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.23.0",
|
"channel": "0.24.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user