forked from Ninjalama/streamyfin_mirror
Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc02db6463 | ||
|
|
c168d79377 | ||
|
|
f756a663fe | ||
|
|
2baf57156e | ||
|
|
a97610a51d | ||
|
|
79b87b3d72 | ||
|
|
d52f025873 | ||
|
|
30348dc28f | ||
|
|
faf39a6de2 | ||
|
|
4641ff726c | ||
|
|
8eac2f39a8 | ||
|
|
309345c834 | ||
|
|
0d07f7216c | ||
|
|
b550d6302f | ||
|
|
55ba3daf86 | ||
|
|
e0afb68f0c | ||
|
|
91ed109a04 | ||
|
|
2565bf7353 | ||
|
|
bbc6f63089 | ||
|
|
e6d4414fd6 | ||
|
|
3047367ba6 | ||
|
|
07c5c21599 | ||
|
|
aa60e320c5 | ||
|
|
c12b58e5cb | ||
|
|
d962507749 | ||
|
|
a351c8d220 | ||
|
|
969e68901a | ||
|
|
c0f4587501 | ||
|
|
e8944528e4 | ||
|
|
9b2185d29e | ||
|
|
67af14dced | ||
|
|
7324fe826e | ||
|
|
75f3f483eb | ||
|
|
57cac96df5 | ||
|
|
7792b8a675 | ||
|
|
55df3991f5 | ||
|
|
26057ed196 | ||
|
|
30658ff067 | ||
|
|
8d327e8835 | ||
|
|
b1726962c1 | ||
|
|
25e6f655f3 | ||
|
|
275923dbdd | ||
|
|
36f1ea384d | ||
|
|
c100c2e0c4 | ||
|
|
f9a5841f88 | ||
|
|
42f4631143 | ||
|
|
638e8851c1 | ||
|
|
5c95730715 | ||
|
|
ec5aab99b8 | ||
|
|
70d0ec4780 | ||
|
|
a89d9c1f67 | ||
|
|
725ba1ccaf | ||
|
|
d330dd8db4 | ||
|
|
20739e6e2c | ||
|
|
ec50a90a32 | ||
|
|
6f6b46c14a | ||
|
|
7fcdfe9452 | ||
|
|
f9af493dc8 | ||
|
|
e8dc9e759a | ||
|
|
06877f4339 | ||
|
|
c496b1036b | ||
|
|
4cca6f0e8c | ||
|
|
7bf5fb9a01 | ||
|
|
bbf926e752 | ||
|
|
9b2a0487d2 | ||
|
|
a73488614c | ||
|
|
03fdf31b4b | ||
|
|
2e563bcbe0 | ||
|
|
b1062628d9 | ||
|
|
e216c8392f | ||
|
|
1a47ade4dc | ||
|
|
a54da1c3dc | ||
|
|
874364fcde | ||
|
|
9059f33538 | ||
|
|
27785e7d18 | ||
|
|
9a621cab4e | ||
|
|
a37ac74e9f | ||
|
|
4d4bb0f6a4 | ||
|
|
6bd60b2ec6 | ||
|
|
696a2a4780 | ||
|
|
13ac9b0443 | ||
|
|
83407674be | ||
|
|
53bb1751c2 | ||
|
|
fa31ff8f2b | ||
|
|
f863c95f70 | ||
|
|
f227565dbf | ||
|
|
7abc629a10 | ||
|
|
70a250df5b | ||
|
|
dbdf56b990 | ||
|
|
8b2204896a | ||
|
|
89729a95cd | ||
|
|
57dd3b8446 | ||
|
|
8d2a0378ca | ||
|
|
cbe01a0012 | ||
|
|
eed4df6a8a | ||
|
|
5e081751a4 | ||
|
|
09f953ebba | ||
|
|
4873aaf3df | ||
|
|
9bbab4f46f | ||
|
|
469e8b3f01 | ||
|
|
1c31458dd4 | ||
|
|
4c097c557f | ||
|
|
c23ca905c8 | ||
|
|
ed3170af76 | ||
|
|
e22dd759c7 | ||
|
|
aa44caa161 | ||
|
|
27260faea8 | ||
|
|
ec7e5f869d | ||
|
|
8e1a07e819 | ||
|
|
250c1968f3 | ||
|
|
caeedfbc52 | ||
|
|
66ce6b2cfa | ||
|
|
388480adef | ||
|
|
e911f99b26 | ||
|
|
73ff0aa66a | ||
|
|
29ae6747c4 | ||
|
|
44444e3b37 | ||
|
|
0e3f289d43 | ||
|
|
a66648c67c | ||
|
|
6dc9538483 | ||
|
|
cb7c018cf4 | ||
|
|
a01217b8ac | ||
|
|
21c1221138 | ||
|
|
6a8a155547 | ||
|
|
dbb7c6c9a5 | ||
|
|
30280e8b3a | ||
|
|
5281cba284 | ||
|
|
da666d3991 | ||
|
|
817a758b8a | ||
|
|
f04a29b757 | ||
|
|
550fc39faa | ||
|
|
d56bb79ac2 | ||
|
|
30781a6dfe | ||
|
|
ba6c2d5409 | ||
|
|
73b266adb4 | ||
|
|
e0ca83ae1f | ||
|
|
4a17a00f81 | ||
|
|
6bfc0c72d1 | ||
|
|
26050f7179 | ||
|
|
d39e3aeb80 | ||
|
|
d15d02d61d | ||
|
|
349fc1f21e | ||
|
|
83131cabe1 | ||
|
|
4aec9d350d | ||
|
|
a493f0cad2 | ||
|
|
329072a7a8 | ||
|
|
d2ff29a370 | ||
|
|
8f4cb55050 | ||
|
|
2c0df550f6 | ||
|
|
a7533bbee0 | ||
|
|
9ec37e26bf | ||
|
|
648df75f39 | ||
|
|
84d7983df7 | ||
|
|
1f0ff1594b | ||
|
|
d0baf56fd8 | ||
|
|
e9182dc4ca | ||
|
|
9817938d44 | ||
|
|
42ce8f15af | ||
|
|
aa0fbd6fad | ||
|
|
92a260b6f5 | ||
|
|
ea95cae685 | ||
|
|
17641bb529 | ||
|
|
a8fef13aa8 | ||
|
|
f91a37ddc2 | ||
|
|
c67b0ac6cd |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
*.apk
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
|
||||
61
README.md
61
README.md
@@ -1,5 +1,7 @@
|
||||
# 📺 Streamyfin
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 5px">
|
||||
@@ -26,18 +28,46 @@ Streamyfin includes some exciting experimental features like media downloading a
|
||||
|
||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
### Chromecast
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||
|
||||
## Plugins
|
||||
|
||||
In Streamyfin we have build in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
|
||||
|
||||
### Collection rows
|
||||
|
||||
Jellyfin collections can be shown as rows or carousel on the home screen.
|
||||
The following tags can be added to an collection to provide this functionality.
|
||||
|
||||
Avaiable tags:
|
||||
|
||||
- sf_promoted: Wil make the collection an row on home
|
||||
- sf_carousel: Wil make the collection an carousel on home.
|
||||
|
||||
A plugin exists to create collections based on external sources like mdblist. This makes managing collections like trending, most watched etc an automatic process.
|
||||
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
|
||||
|
||||
### Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## Roadmap for V1
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||
|
||||
## Get it now
|
||||
|
||||
<div style="display:flex;">
|
||||
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
|
||||
<img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
|
||||
</a>
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
|
||||
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
|
||||
</a>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
</div>
|
||||
|
||||
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
||||
|
||||
### Beta testing
|
||||
|
||||
Get the latest updates by using the TestFlight version of the app.
|
||||
@@ -46,8 +76,6 @@ Get the latest updates by using the TestFlight version of the app.
|
||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
||||
</a>
|
||||
|
||||
Or download the APKs here on GitHub for Android.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -61,6 +89,12 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
||||
|
||||
### Development info
|
||||
|
||||
1. Use node `20`
|
||||
2. Install deps `bun i`
|
||||
3. `Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
||||
|
||||
## Extended chromecast controls
|
||||
|
||||
Add this to AppDelegate.mm:
|
||||
|
||||
```
|
||||
@@ -102,17 +136,12 @@ Key points of the MPL-2.0:
|
||||
|
||||
## 🌐 Connect with Us
|
||||
|
||||
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/zyGKHJZvv4)
|
||||
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
|
||||
|
||||
If you have questions or need support, feel free to reach out:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
-
|
||||
|
||||
## Support
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
|
||||
19
app.json
19
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.5.1",
|
||||
"version": "0.10.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -22,20 +22,18 @@
|
||||
"UIBackgroundModes": ["audio"],
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true,
|
||||
"NSExceptionDomains": {
|
||||
"*": {
|
||||
"NSExceptionAllowsInsecureHTTPLoads": true
|
||||
}
|
||||
}
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 16,
|
||||
"versionCode": 30,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
@@ -80,6 +78,11 @@
|
||||
"deploymentTarget": "14.0"
|
||||
},
|
||||
"android": {
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "34.0.0"
|
||||
},
|
||||
"minSdkVersion": 24,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, View } from "react-native";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
@@ -14,7 +15,9 @@ export default function IndexLayout() {
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Home",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
@@ -43,6 +46,21 @@ export default function IndexLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads"
|
||||
options={{
|
||||
title: "Downloads",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const [process, setProcess] = useAtom(runningProcesses);
|
||||
@@ -27,14 +23,14 @@ const downloads: React.FC = () => {
|
||||
queryKey: ["downloaded_files", process?.item.Id],
|
||||
queryFn: async () =>
|
||||
JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
) as BaseItemDto[],
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||
[downloadedFiles],
|
||||
[downloadedFiles]
|
||||
);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
@@ -58,24 +54,35 @@ const downloads: React.FC = () => {
|
||||
return formatNumber(timeLeft / 10000);
|
||||
}, [process]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<View className="px-4 py-4">
|
||||
<View
|
||||
className="px-4 py-4"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className="mb-4 flex flex-col space-y-4">
|
||||
<View>
|
||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{queue.map((q) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/items/${q.item.Id}/page`)}
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
>
|
||||
<View>
|
||||
@@ -103,7 +110,7 @@ const downloads: React.FC = () => {
|
||||
{process?.item ? (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/${process.item.Id}/page`)
|
||||
router.push(`/(auth)/items/page?id=${process.item.Id}`)
|
||||
}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
>
|
||||
335
app/(auth)/(tabs)/(home)/index.tsx
Normal file
335
app/(auth)/(tabs)/(home)/index.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type BaseSection = {
|
||||
title: string;
|
||||
queryKey: (string | undefined)[];
|
||||
};
|
||||
|
||||
type ScrollingCollectionListSection = BaseSection & {
|
||||
type: "ScrollingCollectionList";
|
||||
queryFn: () => Promise<BaseItemDto[]>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
type MediaListSection = BaseSection & {
|
||||
type: "MediaListSection";
|
||||
queryFn: () => Promise<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||
|
||||
export default function index() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settings, _] = useSettings();
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
if (state.isConnected == false || state.isInternetReachable === false)
|
||||
setIsConnected(false);
|
||||
else setIsConnected(true);
|
||||
});
|
||||
|
||||
NetInfo.fetch().then((state) => {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: userViews,
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
queryKey: ["userViews", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: mediaListCollections,
|
||||
isError: e2,
|
||||
isLoading: l2,
|
||||
} = 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: 60 * 1000,
|
||||
});
|
||||
|
||||
const movieCollectionId = useMemo(() => {
|
||||
return userViews?.find((c) => c.CollectionType === "movies")?.Id;
|
||||
}, [userViews]);
|
||||
|
||||
const tvShowCollectionId = useMemo(() => {
|
||||
return userViews?.find((c) => c.CollectionType === "tvshows")?.Id;
|
||||
}, [userViews]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await queryClient.refetchQueries({ queryKey: ["userViews"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["sf_promoted"],
|
||||
});
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["sf_carousel"],
|
||||
});
|
||||
setLoading(false);
|
||||
}, [queryClient, user?.Id]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: "Continue Watching",
|
||||
queryKey: ["resumeItems", user.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
{
|
||||
title: "Next Up",
|
||||
queryKey: ["nextUp-all", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
...(mediaListCollections?.map(
|
||||
(ml) =>
|
||||
({
|
||||
title: ml.Name || "",
|
||||
queryKey: ["mediaList", ml.Id],
|
||||
queryFn: async () => ml,
|
||||
type: "MediaListSection",
|
||||
} as MediaListSection)
|
||||
) || []),
|
||||
{
|
||||
title: "Recently Added in Movies",
|
||||
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 50,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
parentId: movieCollectionId,
|
||||
})
|
||||
).data || [],
|
||||
type: "ScrollingCollectionList",
|
||||
},
|
||||
{
|
||||
title: "Recently Added in TV-Shows",
|
||||
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 50,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
parentId: tvShowCollectionId,
|
||||
})
|
||||
).data || [],
|
||||
type: "ScrollingCollectionList",
|
||||
},
|
||||
{
|
||||
title: "Suggested Movies",
|
||||
queryKey: ["suggestedMovies", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
},
|
||||
{
|
||||
title: "Suggested Episodes",
|
||||
queryKey: ["suggestedEpisodes", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Episode"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
movieCollectionId,
|
||||
tvShowCollectionId,
|
||||
mediaListCollections,
|
||||
]);
|
||||
|
||||
// if (isConnected === false) {
|
||||
// return (
|
||||
// <View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
// <Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
// <Text className="text-center opacity-70">
|
||||
// No worries, you can still watch{"\n"}downloaded content.
|
||||
// </Text>
|
||||
// <View className="mt-4">
|
||||
// <Button
|
||||
// color="purple"
|
||||
// onPress={() => router.push("/(auth)/downloads")}
|
||||
// justify="center"
|
||||
// iconRight={
|
||||
// <Ionicons name="arrow-forward" size={20} color="white" />
|
||||
// }
|
||||
// >
|
||||
// Go to downloads
|
||||
// </Button>
|
||||
// </View>
|
||||
// </View>
|
||||
// );
|
||||
// }
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (e1 || e2)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
Something went wrong.{"\n"}Please log out and in again.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (l1 || l2)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
className="flex flex-col pt-4 pb-24 gap-y-4"
|
||||
>
|
||||
<LargeMovieCarousel />
|
||||
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
/>
|
||||
);
|
||||
} else if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs, readFromLog } from "@/utils/log";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
@@ -23,9 +24,18 @@ export default function settings() {
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<View className="p-4 flex flex-col gap-y-4 pb-12">
|
||||
<View
|
||||
className="p-4 flex flex-col gap-y-4 pb-12"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<Text className="font-bold text-2xl">Information</Text>
|
||||
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||
@@ -44,7 +54,7 @@ export default function settings() {
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -55,7 +65,7 @@ export default function settings() {
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
140
app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx
Normal file
140
app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { actorId } = local as { actorId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", actorId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: actorId,
|
||||
}),
|
||||
enabled: !!actorId && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !user?.Id) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
personIds: [actorId],
|
||||
startIndex: pageParam,
|
||||
limit: 8,
|
||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
recursive: true,
|
||||
fields: [
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
],
|
||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||
collapseBoxSetItems: false,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[api, user?.Id, actorId]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item?.Id || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col space-y-4 my-4">
|
||||
<View className="px-4 mb-4">
|
||||
<MoviesTitleHeader item={item} className="mb-4" />
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
Appeared In
|
||||
</Text>
|
||||
<InfiniteHorizontalScroll
|
||||
height={247}
|
||||
renderItem={(i, idx) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
item={i}
|
||||
className={`flex flex-col
|
||||
${"w-28"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={i} />
|
||||
<ItemCardText item={i} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["actor", "movies", actorId]}
|
||||
/>
|
||||
<View className="h-12"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
128
app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx
Normal file
128
app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { SongsList } from "@/components/music/SongsList";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId, artistId, albumId } = searchParams as {
|
||||
collectionId: string;
|
||||
artistId: string;
|
||||
albumId: string;
|
||||
};
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Chromecast />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const { data: album } = useQuery({
|
||||
queryKey: ["album", albumId, artistId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [albumId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!albumId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: songs,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<{
|
||||
Items: BaseItemDto[];
|
||||
TotalRecordCount: number;
|
||||
}>({
|
||||
queryKey: ["songs", artistId, albumId],
|
||||
queryFn: async () => {
|
||||
if (!api)
|
||||
return {
|
||||
Items: [],
|
||||
TotalRecordCount: 0,
|
||||
};
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: albumId,
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!album) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<ItemImage
|
||||
variant={"Primary"}
|
||||
item={album}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="px-4 mb-8">
|
||||
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{songs?.TotalRecordCount} songs
|
||||
</Text>
|
||||
</View>
|
||||
<View className="px-4">
|
||||
<SongsList
|
||||
albumId={albumId}
|
||||
songs={songs?.Items}
|
||||
collectionId={collectionId}
|
||||
artistId={artistId}
|
||||
/>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
130
app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx
Normal file
130
app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { artistId } = searchParams as {
|
||||
artistId: string;
|
||||
};
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [startIndex, setStartIndex] = useState<number>(0);
|
||||
|
||||
const { data: artist } = useQuery({
|
||||
queryKey: ["album", artistId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [artistId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!artistId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: albums,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<{
|
||||
Items: BaseItemDto[];
|
||||
TotalRecordCount: number;
|
||||
}>({
|
||||
queryKey: ["albums", artistId, startIndex],
|
||||
queryFn: async () => {
|
||||
if (!api)
|
||||
return {
|
||||
Items: [],
|
||||
TotalRecordCount: 0,
|
||||
};
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: artistId,
|
||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
recursive: true,
|
||||
fields: [
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
"ParentId",
|
||||
"PrimaryImageAspectRatio",
|
||||
],
|
||||
collapseBoxSetItems: false,
|
||||
albumArtistIds: [artistId],
|
||||
startIndex,
|
||||
limit: 100,
|
||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!artist || !albums) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<ItemImage
|
||||
variant={"Primary"}
|
||||
item={artist}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="px-4 mb-8">
|
||||
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{albums.TotalRecordCount} albums
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row flex-wrap justify-between px-4">
|
||||
{albums.Items.map((item, idx) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
style={{ width: "30%", marginBottom: 20 }}
|
||||
key={idx}
|
||||
>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<ArtistPoster item={item} />
|
||||
<Text numberOfLines={2}>{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
117
app/(auth)/(tabs)/(home,libraries,search)/artists/index.tsx
Normal file
117
app/(auth)/(tabs)/(home,libraries,search)/artists/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [collectionId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [startIndex, setStartIndex] = useState<number>(0);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<{
|
||||
Items: BaseItemDto[];
|
||||
TotalRecordCount: number;
|
||||
}>({
|
||||
queryKey: ["collection-items", collection?.Id, startIndex],
|
||||
queryFn: async () => {
|
||||
if (!api || !collectionId)
|
||||
return {
|
||||
Items: [],
|
||||
TotalRecordCount: 0,
|
||||
};
|
||||
|
||||
const response = await getArtistsApi(api).getArtists({
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
parentId: collectionId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
},
|
||||
enabled: !!collection?.Id && !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
return data?.TotalRecordCount;
|
||||
}, [data]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: 140,
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<View className="mb-4">
|
||||
<Text className="font-bold text-3xl mb-2">Artists</Text>
|
||||
</View>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
data={data.Items}
|
||||
numColumns={3}
|
||||
columnWrapperStyle={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
renderItem={({ item, index }) => (
|
||||
<TouchableItemRouter
|
||||
style={{
|
||||
maxWidth: "30%",
|
||||
width: "100%",
|
||||
}}
|
||||
key={index}
|
||||
item={item}
|
||||
>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
{collection?.CollectionType === "movies" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{collection?.CollectionType === "music" && (
|
||||
<ArtistPoster item={item} />
|
||||
)}
|
||||
<Text>{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([
|
||||
{
|
||||
key: "PremiereDate",
|
||||
value: "Premiere Date",
|
||||
},
|
||||
]);
|
||||
setSortOrder([
|
||||
{
|
||||
key: "Ascending",
|
||||
value: "Ascending",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: collectionId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
const data = response.data;
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!collectionId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
}, [navigation, collection]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !collection) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 18,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<MemoizedTouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % 3 === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % 3 === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</MemoizedTouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
collectionId,
|
||||
api,
|
||||
user?.Id,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedYears,
|
||||
setSelectedYears,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
);
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
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"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
13
app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
Normal file
13
app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
|
||||
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
|
||||
|
||||
return memoizedContent;
|
||||
};
|
||||
|
||||
export default React.memo(Page);
|
||||
@@ -20,11 +20,7 @@ const page: React.FC = () => {
|
||||
seasonIndex: string;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (seriesId) {
|
||||
console.log("seasonIndex", seasonIndex);
|
||||
}
|
||||
}, [seriesId]);
|
||||
console.log("seasonIndex", seasonIndex);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -38,7 +34,7 @@ const page: React.FC = () => {
|
||||
itemId: seriesId,
|
||||
}),
|
||||
enabled: !!seriesId && !!api,
|
||||
staleTime: 60,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
@@ -49,7 +45,7 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
@@ -58,13 +54,14 @@ const page: React.FC = () => {
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerHeight={400}
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
@@ -93,7 +90,7 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-24">
|
||||
<View className="flex flex-col pt-4">
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
@@ -101,7 +98,7 @@ const page: React.FC = () => {
|
||||
<View className="mb-4">
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
<SeasonPicker item={item} />
|
||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
447
app/(auth)/(tabs)/(libraries)/[libraryId].tsx
Normal file
447
app/(auth)/(tabs)/(libraries)/[libraryId].tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
getItemsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { libraryId } = searchParams as { libraryId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
const getNumberOfColumns = useCallback(() => {
|
||||
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
|
||||
if (screenWidth < 600) return 5;
|
||||
if (screenWidth < 960) return 6;
|
||||
if (screenWidth < 1280) return 7;
|
||||
return 6;
|
||||
}, [screenWidth]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSortBy([
|
||||
{
|
||||
key: "SortName",
|
||||
value: "Name",
|
||||
},
|
||||
]);
|
||||
setSortOrder([
|
||||
{
|
||||
key: "Ascending",
|
||||
value: "Ascending",
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: library, isLoading: isLibraryLoading } = useQuery({
|
||||
queryKey: ["library", libraryId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: libraryId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!libraryId,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !library) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
limit: 36,
|
||||
startIndex: pageParam,
|
||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0].key],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
recursive: false,
|
||||
imageTypeLimit: 1,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
libraryId,
|
||||
library,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [
|
||||
"library-items",
|
||||
libraryId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!library,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<MemoizedTouchableItemRouter
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
? index % 3 === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % 3 === 0
|
||||
? "flex-start"
|
||||
: "center"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</MemoizedTouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title="Genres"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title="Years"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title="Tags"
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title="Sort By"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title="Sort Order"
|
||||
renderItemLabel={(item) => item.value}
|
||||
searchFilter={(item, search) =>
|
||||
item.value.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[
|
||||
libraryId,
|
||||
api,
|
||||
user?.Id,
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
selectedYears,
|
||||
setSelectedYears,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading || isLibraryLoading)
|
||||
return (
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (flatData.length === 0)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">No items found</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
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"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={244}
|
||||
numColumns={getNumberOfColumns()}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={1}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Page);
|
||||
200
app/(auth)/(tabs)/(libraries)/_layout.tsx
Normal file
200
app/(auth)/(tabs)/(libraries)/_layout.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Library",
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Ionicons
|
||||
name="ellipsis-horizontal-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
align={"end"}
|
||||
alignOffset={-10}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
side={"bottom"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
||||
<DropdownMenu.Group key="display-group">
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
Display
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-1"
|
||||
value={settings.libraryOptions.display === "row"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-1">
|
||||
Row
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-2"
|
||||
value={settings.libraryOptions.display === "list"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-2">
|
||||
List
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
Image style
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="poster-option"
|
||||
value={settings.libraryOptions.imageStyle === "poster"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="poster-title">
|
||||
Poster
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="cover-option"
|
||||
value={settings.libraryOptions.imageStyle === "cover"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="cover-title">
|
||||
Cover
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Group key="show-titles-group">
|
||||
<DropdownMenu.CheckboxItem
|
||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||
key="show-titles-option"
|
||||
value={settings.libraryOptions.showTitles}
|
||||
onValueChange={(newValue) => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
return;
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||
Show titles
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="show-stats-option"
|
||||
value={settings.libraryOptions.showStats}
|
||||
onValueChange={(newValue) => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||
Show stats
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Group>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[libraryId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
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>
|
||||
);
|
||||
}
|
||||
103
app/(auth)/(tabs)/(libraries)/index.tsx
Normal file
103
app/(auth)/(tabs)/(libraries)/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function index() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000 * 60,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
for (const item of data || []) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["library", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!item.Id || !user?.Id || !api) return null;
|
||||
const response = await getUserLibraryApi(api).getItem({
|
||||
itemId: item.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
extraData={settings}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||
paddingBottom: 150,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
data={data}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
ItemSeparatorComponent={() =>
|
||||
settings?.libraryOptions?.display === "row" ? (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className="bg-neutral-800 mx-2 my-4"
|
||||
></View>
|
||||
) : (
|
||||
<View className="h-4" />
|
||||
)
|
||||
}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
app/(auth)/(tabs)/(search)/_layout.tsx
Normal file
24
app/(auth)/(tabs)/(search)/_layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function SearchLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Search",
|
||||
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>
|
||||
);
|
||||
}
|
||||
490
app/(auth)/(tabs)/(search)/index.tsx
Normal file
490
app/(auth)/(tabs)/(search)/index.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
"Avengers",
|
||||
"Game of Thrones",
|
||||
"Breaking Bad",
|
||||
"Stranger Things",
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (q && q.length > 0) setSearch(q);
|
||||
}, [q]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
async ({
|
||||
types,
|
||||
query,
|
||||
}: {
|
||||
types: BaseItemKind[];
|
||||
query: string;
|
||||
}): Promise<BaseItemDto[]> => {
|
||||
if (!api) return [];
|
||||
|
||||
if (searchEngine === "Jellyfin") {
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints as BaseItemDto[];
|
||||
} else {
|
||||
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
|
||||
query
|
||||
)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) return [];
|
||||
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return response2.data.Items as BaseItemDto[];
|
||||
}
|
||||
},
|
||||
[api, settings]
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useLayoutEffect(() => {
|
||||
if (Platform.OS === "ios")
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search...",
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: true,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const { data: movies, isFetching: l1 } = useQuery({
|
||||
queryKey: ["search", "movies", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: series, isFetching: l2 } = useQuery({
|
||||
queryKey: ["search", "series", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: episodes, isFetching: l3 } = useQuery({
|
||||
queryKey: ["search", "episodes", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: collections, isFetching: l7 } = useQuery({
|
||||
queryKey: ["search", "collections", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["BoxSet"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: actors, isFetching: l8 } = useQuery({
|
||||
queryKey: ["search", "actors", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Person"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: artists, isFetching: l4 } = useQuery({
|
||||
queryKey: ["search", "artists", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicArtist"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: albums, isFetching: l5 } = useQuery({
|
||||
queryKey: ["search", "albums", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicAlbum"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: songs, isFetching: l6 } = useQuery({
|
||||
queryKey: ["search", "songs", debouncedSearch],
|
||||
queryFn: () =>
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Audio"],
|
||||
}),
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const noResults = useMemo(() => {
|
||||
return !(
|
||||
artists?.length ||
|
||||
albums?.length ||
|
||||
songs?.length ||
|
||||
movies?.length ||
|
||||
episodes?.length ||
|
||||
series?.length ||
|
||||
collections?.length ||
|
||||
actors?.length
|
||||
);
|
||||
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
||||
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
keyboardDismissMode="on-drag"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-32">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
keyboardType="web-search"
|
||||
placeholder="Search here..."
|
||||
value={search}
|
||||
onChangeText={(text) => setSearch(text)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{!!q && (
|
||||
<View className="px-4 flex flex-col space-y-2">
|
||||
<Text className="text-neutral-500 ">
|
||||
Results for <Text className="text-purple-600">{q}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/series/${item.Id}`)}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header="Collections"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
onPress={() => router.push(`/collections/${item.Id}`)}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header="Actors"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={albums?.map((m) => m.Id!)}
|
||||
header="Albums"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={songs?.map((m) => m.Id!)}
|
||||
header="Songs"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{loading ? (
|
||||
<View className="mt-4 flex justify-center items-center">
|
||||
<Loader />
|
||||
</View>
|
||||
) : noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className="text-center text-lg font-bold mt-4">
|
||||
No results found for
|
||||
</Text>
|
||||
<Text className="text-xs text-purple-600 text-center">
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSearch(e)}
|
||||
key={e}
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-purple-600">{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
ids?: string[] | null;
|
||||
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
||||
header?: string;
|
||||
};
|
||||
|
||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data, isLoading: l1 } = useQuery({
|
||||
queryKey: ["items", ids],
|
||||
queryFn: async () => {
|
||||
if (!user?.Id || !api || !ids || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemPromises = ids.map((id) =>
|
||||
getUserItemData({
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: id,
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(itemPromises);
|
||||
|
||||
// Filter out null items
|
||||
return results.filter(
|
||||
(item) => item !== null
|
||||
) as unknown as BaseItemDto[];
|
||||
},
|
||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
|
||||
{renderItem(data)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import { router, Tabs } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { BlurView } from "expo-blur";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { Tabs } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet } from "react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
useEffect(() => {
|
||||
@@ -17,13 +16,38 @@ export default function TabLayout() {
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
initialRouteName="home"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors.tabIconSelected,
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
position: "absolute",
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderTopWidth: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
||||
height: Platform.OS === "android" ? 58 : 74,
|
||||
},
|
||||
tabBarBackground: () =>
|
||||
Platform.OS === "ios" ? (
|
||||
<BlurView
|
||||
experimentalBlurMethod="dimezisBlurView"
|
||||
intensity={95}
|
||||
style={{
|
||||
...StyleSheet.absoluteFillObject,
|
||||
overflow: "hidden",
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
/>
|
||||
) : undefined,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen redirect name="index" />
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
name="(home)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Home",
|
||||
@@ -36,7 +60,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="search"
|
||||
name="(search)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Search",
|
||||
@@ -45,6 +69,19 @@ export default function TabLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="(libraries)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Library",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon
|
||||
name={focused ? "apps" : "apps-outline"}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
5
app/(auth)/(tabs)/index.tsx
Normal file
5
app/(auth)/(tabs)/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const Index = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Index;
|
||||
@@ -1,366 +0,0 @@
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
BaseItemDto,
|
||||
ItemFields,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getChannelsApi,
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
|
||||
export default function index() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["resumeItems", user?.Id],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user?.Id,
|
||||
})
|
||||
).data.Items) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: _nextUpData } = useQuery({
|
||||
queryKey: ["nextUp-all", user?.Id],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
})
|
||||
).data.Items) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const nextUpData = useMemo(() => {
|
||||
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
|
||||
}, [_nextUpData]);
|
||||
|
||||
const { data: collections } = useQuery({
|
||||
queryKey: ["collections", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (
|
||||
await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
})
|
||||
).data;
|
||||
|
||||
const order = ["boxsets", "tvshows", "movies"];
|
||||
|
||||
const cs = data.Items?.sort((a, b) => {
|
||||
if (
|
||||
order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!)
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
});
|
||||
|
||||
return data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const movieCollectionId = useMemo(() => {
|
||||
return collections?.find((c) => c.CollectionType === "movies")?.Id;
|
||||
}, [collections]);
|
||||
|
||||
const tvShowCollectionId = useMemo(() => {
|
||||
return collections?.find((c) => c.CollectionType === "tvshows")?.Id;
|
||||
}, [collections]);
|
||||
|
||||
const { data: recentlyAddedInMovies } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 50,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
parentId: movieCollectionId,
|
||||
})
|
||||
).data) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id && !!movieCollectionId,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: recentlyAddedInTVShows } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 50,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
parentId: tvShowCollectionId,
|
||||
})
|
||||
).data) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id && !!tvShowCollectionId,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: suggestions } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["suggestions", user?.Id],
|
||||
queryFn: async () =>
|
||||
(api &&
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 5,
|
||||
mediaType: ["Video"],
|
||||
})
|
||||
).data.Items) ||
|
||||
[],
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
|
||||
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
|
||||
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
|
||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
||||
setLoading(false);
|
||||
}, [queryClient, user?.Id]);
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
if (state.isConnected == false || state.isInternetReachable === false)
|
||||
setIsConnected(false);
|
||||
else setIsConnected(true);
|
||||
});
|
||||
|
||||
NetInfo.fetch().then((state) => {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
No worries, you can still watch{"\n"}downloaded content.
|
||||
</Text>
|
||||
<View className="mt-4">
|
||||
<Button
|
||||
color="purple"
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify="center"
|
||||
iconRight={
|
||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||
}
|
||||
>
|
||||
Go to downloads
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
Something went wrong.{"\n"}Please log out and in again.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">
|
||||
Continue Watching
|
||||
</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
onPress={() => router.push(`/items/${item.Id}/page`)}
|
||||
className="flex flex-col w-48"
|
||||
>
|
||||
<View>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">Next Up</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={nextUpData}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
onPress={() => router.push(`/items/${item.Id}/page`)}
|
||||
className="flex flex-col w-48"
|
||||
>
|
||||
<View>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">
|
||||
Recently Added in Movies
|
||||
</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={recentlyAddedInMovies}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
onPress={() => router.push(`/items/${item.Id}/page`)}
|
||||
className="flex flex-col w-32"
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">
|
||||
Recently Added in TV-Shows
|
||||
</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={recentlyAddedInTVShows}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
onPress={() => router.push(`/series/${item.Id}/page`)}
|
||||
className="flex flex-col w-32"
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">Collections</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={collections}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
onPress={() => router.push(`/collections/${item.Id}/page`)}
|
||||
className="flex flex-col w-48"
|
||||
>
|
||||
<View>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">Suggestions</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={suggestions}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
onPress={() => router.push(`/items/${item.Id}/page`)}
|
||||
className="flex flex-col w-48"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function SearchLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="search"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: "Search",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
import Poster from "@/components/Poster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useLayoutEffect, useState } from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function search() {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useLayoutEffect(() => {
|
||||
if (Platform.OS === "ios")
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search...",
|
||||
onChangeText: (e: any) => setSearch(e.nativeEvent.text),
|
||||
hideWhenScrolling: false,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const { data: movies } = useQuery({
|
||||
queryKey: ["search-movies", search],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || search.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: search,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Movie"],
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: series } = useQuery({
|
||||
queryKey: ["search-series", search],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || search.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: search,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Series"],
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
});
|
||||
const { data: episodes } = useQuery({
|
||||
queryKey: ["search-episodes", search],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || search.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: search,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Episode"],
|
||||
});
|
||||
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
keyboardDismissMode="on-drag"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
<View className="flex flex-col pt-2 pb-20">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
keyboardType="web-search"
|
||||
placeholder="Search here..."
|
||||
value={search}
|
||||
onChangeText={(text) => setSearch(text)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
|
||||
<SearchItemWrapper
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
onPress={() => router.push(`/items/${item.Id}/page`)}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text className="mt-2">{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/series/${item.Id}/page`)}
|
||||
className="flex flex-col w-32"
|
||||
>
|
||||
<Poster
|
||||
item={item}
|
||||
key={item.Id}
|
||||
url={getPrimaryImageUrl({ api, item })}
|
||||
/>
|
||||
<Text className="mt-2">{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
renderItem={(item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/${item.Id}/page`)}
|
||||
className="flex flex-col w-48"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
ids?: string[] | null;
|
||||
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
||||
};
|
||||
|
||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data, isLoading: l1 } = useQuery({
|
||||
queryKey: ["items", ids],
|
||||
queryFn: async () => {
|
||||
if (!user?.Id || !api || !ids || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemPromises = ids.map((id) =>
|
||||
getUserItemData({
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: id,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(itemPromises);
|
||||
|
||||
// Filter out null items
|
||||
return results.filter(
|
||||
(item) => item !== null,
|
||||
) as unknown as BaseItemDto[];
|
||||
},
|
||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (!data) return <Text className="opacity-50 text-xs px-4">No results</Text>;
|
||||
|
||||
return renderItem(data);
|
||||
};
|
||||
@@ -1,241 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loading } from "@/components/Loading";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
ItemSortBy,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { collection: collectionId } = searchParams as { collection: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("CollectionId", collectionId);
|
||||
}, [collectionId]);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
ids: [collectionId],
|
||||
});
|
||||
const data = response.data.Items?.[0];
|
||||
return data;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [startIndex, setStartIndex] = useState<number>(0);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<{
|
||||
Items: BaseItemDto[];
|
||||
TotalRecordCount: number;
|
||||
}>({
|
||||
queryKey: ["collection-items", collectionId, startIndex],
|
||||
queryFn: async () => {
|
||||
if (!api || !collectionId)
|
||||
return {
|
||||
Items: [],
|
||||
TotalRecordCount: 0,
|
||||
};
|
||||
|
||||
const sortBy: ItemSortBy[] = [];
|
||||
|
||||
switch (collection?.CollectionType) {
|
||||
case "movies":
|
||||
sortBy.push("SortName", "ProductionYear");
|
||||
break;
|
||||
case "boxsets":
|
||||
sortBy.push("IsFolder", "SortName");
|
||||
break;
|
||||
default:
|
||||
sortBy.push("SortName");
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 100,
|
||||
startIndex,
|
||||
sortBy,
|
||||
sortOrder: ["Ascending"],
|
||||
});
|
||||
|
||||
const data = response.data.Items;
|
||||
|
||||
return {
|
||||
Items: data || [],
|
||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
||||
};
|
||||
},
|
||||
enabled: !!collectionId && !!api,
|
||||
});
|
||||
// const { data, isLoading, isError } = useQuery<{
|
||||
// Items: BaseItemDto[];
|
||||
// TotalRecordCount: number;
|
||||
// }>({
|
||||
// queryKey: ["collection-items", collectionId, startIndex],
|
||||
// queryFn: async () => {
|
||||
// if (!api) return [];
|
||||
|
||||
// const response = await api.axiosInstance.get(
|
||||
// `${api.basePath}/Users/${user?.Id}/Items`,
|
||||
// {
|
||||
// params: {
|
||||
// SortBy:
|
||||
// collection?.CollectionType === "movies"
|
||||
// ? "SortName,ProductionYear"
|
||||
// : "SortName",
|
||||
// SortOrder: "Ascending",
|
||||
// IncludeItemTypes:
|
||||
// collection?.CollectionType === "movies" ? "Movie" : "Series",
|
||||
// Recursive: true,
|
||||
// Fields:
|
||||
// collection?.CollectionType === "movies"
|
||||
// ? "PrimaryImageAspectRatio,MediaSourceCount"
|
||||
// : "PrimaryImageAspectRatio",
|
||||
// ImageTypeLimit: 1,
|
||||
// EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||
// ParentId: collectionId,
|
||||
// Limit: 100,
|
||||
// StartIndex: startIndex,
|
||||
// },
|
||||
// headers: {
|
||||
// Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
|
||||
// return response.data || [];
|
||||
// },
|
||||
// enabled: !!collection && !!api,
|
||||
// });
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
return data?.TotalRecordCount;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<View>
|
||||
<View className="px-4 mb-4">
|
||||
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
|
||||
{totalItems}
|
||||
</Text>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setStartIndex((prev) => Math.max(prev - 100, 0));
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="arrow-back-circle-outline"
|
||||
size={32}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setStartIndex((prev) => prev + 100);
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="arrow-forward-circle-outline"
|
||||
size={32}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<View className="my-12">
|
||||
<ActivityIndicator color={"white"} />
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex flex-row flex-wrap">
|
||||
{data?.Items?.map((item: BaseItemDto, index: number) => (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
maxWidth: "33%",
|
||||
width: "100%",
|
||||
padding: 10,
|
||||
}}
|
||||
key={index}
|
||||
onPress={() => {
|
||||
if (item?.Type === "Series") {
|
||||
router.push(`/series/${item.Id}/page`);
|
||||
} else if (item.IsFolder) {
|
||||
router.push(`/collections/${item?.Id}/page`);
|
||||
} else {
|
||||
router.push(`/items/${item.Id}/page`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<MoviePoster item={item} />
|
||||
<Text>{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!isLoading && (
|
||||
<View className="flex flex-row items-center space-x-2 justify-center mt-4 mb-12">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setStartIndex((prev) => Math.max(prev - 100, 0));
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="arrow-back-circle-outline"
|
||||
size={32}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setStartIndex((prev) => prev + 100);
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="arrow-forward-circle-outline"
|
||||
size={32}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -1,304 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||
import { ParallaxScrollView } from "../../../../components/ParallaxPage";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios12 from "@/utils/profiles/ios12";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
triggerPlayAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { Ratings } from "@/components/Ratings";
|
||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { id } = local as { id: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: id,
|
||||
}),
|
||||
enabled: !!id && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||
[item],
|
||||
);
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", item?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: playbackUrl } = useQuery({
|
||||
queryKey: [
|
||||
"playbackUrl",
|
||||
item?.Id,
|
||||
maxBitrate,
|
||||
castDevice,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sessionData) return null;
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
});
|
||||
|
||||
console.log("Transcode URL: ", url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const client = useRemoteMediaClient();
|
||||
const [, setPlayTrigger] = useAtom(triggerPlayAtom);
|
||||
|
||||
const onPressPlay = useCallback(
|
||||
async (type: "device" | "cast" = "device") => {
|
||||
if (!playbackUrl || !item) return;
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackUrl,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
|
||||
// Use this trigger to initiate playback in another component (CurrentlyPlayingBar)
|
||||
setPlayTrigger((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
[playbackUrl, item],
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item?.Id || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerImage={
|
||||
<Image
|
||||
source={{
|
||||
uri: backdropUrl,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col px-4 pt-4">
|
||||
<View className="flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<SeriesTitleHeader item={item} />
|
||||
) : (
|
||||
<>
|
||||
<MoviesTitleHeader item={item} />
|
||||
</>
|
||||
)}
|
||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||
<Ratings item={item} />
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
)}
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
<View className="flex flex-col p-4 w-full">
|
||||
<View className="flex flex-row items-center space-x-2 w-full">
|
||||
<BitrateSelector
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
item={item}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={chromecastReady}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal className="flex px-4 mb-4">
|
||||
<View className="flex flex-row space-x-2 ">
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-sm opacity-70">Video</Text>
|
||||
<Text className="text-sm opacity-70">Audio</Text>
|
||||
<Text className="text-sm opacity-70">Subtitles</Text>
|
||||
</View>
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-sm opacity-70">
|
||||
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
|
||||
</Text>
|
||||
<Text className="text-sm opacity-70">
|
||||
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
|
||||
</Text>
|
||||
<Text className="text-sm opacity-70">
|
||||
{
|
||||
item.MediaStreams?.find((i) => i.Type === "Subtitle")
|
||||
?.DisplayTitle
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<CastAndCrew item={item} />
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<View className="mb-4">
|
||||
<CurrentSeries item={item} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
|
||||
<View className="h-12"></View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { OfflineVideoPlayer } from "@/components/OfflineVideoPlayer";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const { itemId, url } = searchParams as { itemId: string; url: string };
|
||||
|
||||
const fileUrl = useMemo(() => {
|
||||
const u = FileSystem.documentDirectory + url;
|
||||
return u;
|
||||
}, [url]);
|
||||
|
||||
if (!fileUrl) return null;
|
||||
|
||||
return (
|
||||
<View className="h-screen w-screen items-center justify-center">
|
||||
{url && <OfflineVideoPlayer url={fileUrl} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { Link, Stack, usePathname } from "expo-router";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||
<Link href={"/"} style={styles.link}>
|
||||
<Link href={"/home"} style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
|
||||
144
app/_layout.tsx
144
app/_layout.tsx
@@ -1,28 +1,26 @@
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useFonts } from "expo-font";
|
||||
import { Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "react-native-reanimated";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import * as Linking from "expo-linking";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: "(auth)/(tabs)/",
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||
@@ -54,14 +52,14 @@ function Layout() {
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60,
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,77 +67,49 @@ function Layout() {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
else
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
const url = Linking.useURL();
|
||||
const router = useRouter();
|
||||
|
||||
if (url) {
|
||||
const { hostname, path, queryParams } = Linking.parse(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JobQueueProvider>
|
||||
<ActionSheetProvider>
|
||||
<JellyfinProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Home",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/downloads"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collection]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</JellyfinProvider>
|
||||
</ActionSheetProvider>
|
||||
</JobQueueProvider>
|
||||
</QueryClientProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JobQueueProvider>
|
||||
<ActionSheetProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaybackProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</PlaybackProvider>
|
||||
</JellyfinProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ActionSheetProvider>
|
||||
</JobQueueProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
274
app/login.tsx
274
app/login.tsx
@@ -3,10 +3,16 @@ import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { AxiosError } from "axios";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { KeyboardAvoidingView, Platform, View } from "react-native";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -15,19 +21,44 @@ const CredentialsSchema = z.object({
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { setServer, login, removeServer } = useJellyfin();
|
||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||
useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const [serverURL, setServerURL] = useState<string>("");
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
username: _username,
|
||||
password: _password,
|
||||
} = params as { apiUrl: string; username: string; password: string };
|
||||
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
}>({
|
||||
username: "",
|
||||
password: "",
|
||||
username: _username,
|
||||
password: _password,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (_apiUrl) {
|
||||
setServer({
|
||||
address: _apiUrl,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (_username && _password) {
|
||||
setCredentials({ username: _username, password: _password });
|
||||
login(_username, _password);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
@@ -38,110 +69,165 @@ const Login: React.FC = () => {
|
||||
await login(credentials.username, credentials.password);
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as AxiosError;
|
||||
setError(e.message);
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = (url: string) => {
|
||||
if (!url.startsWith("http")) {
|
||||
Alert.alert("Error", "URL needs to start with http or https.");
|
||||
return;
|
||||
}
|
||||
setServer({ address: url.trim() });
|
||||
};
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
try {
|
||||
const code = await initiateQuickConnect();
|
||||
if (code) {
|
||||
Alert.alert("Quick Connect", `Enter code ${code} to login`, [
|
||||
{
|
||||
text: "Got It",
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to initiate Quick Connect");
|
||||
}
|
||||
};
|
||||
|
||||
if (api?.basePath) {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
|
||||
<View>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
setServerURL("");
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
|
||||
}
|
||||
>
|
||||
Change server
|
||||
</Button>
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
|
||||
<View></View>
|
||||
<View>
|
||||
<View className="mb-4">
|
||||
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
|
||||
<Text className="text-neutral-500 mb-2">
|
||||
Server: {api.basePath}
|
||||
</Text>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
setServerURL("");
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="arrow-back-outline"
|
||||
size={18}
|
||||
color={"white"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Change server
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Log in to any user account
|
||||
</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="mt-auto mb-2">
|
||||
<Button
|
||||
color="black"
|
||||
onPress={handleQuickConnect}
|
||||
className="mb-2"
|
||||
>
|
||||
Use Quick Connect
|
||||
</Button>
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="text-red-600 mb-2">{error}</Text>
|
||||
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-center h-full">
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="opacity-50">Enter a server adress</Text>
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="http(s)://..."
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-between h-full">
|
||||
<View></View>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Connect to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="opacity-30">
|
||||
Server URL requires http or https
|
||||
</Text>
|
||||
</View>
|
||||
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
40
assets/Download_on_the_App_Store_Badge_DE_RGB_blk_092917.svg
Normal file
40
assets/Download_on_the_App_Store_Badge_DE_RGB_blk_092917.svg
Normal file
@@ -0,0 +1,40 @@
|
||||
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
|
||||
<title>Download_on_the_App_Store_Badge_DE_RGB_blk_092917</title>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
|
||||
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
|
||||
</g>
|
||||
<g id="_Group_" data-name="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
|
||||
<path id="_Path_2" data-name="<Path>" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
|
||||
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
|
||||
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
|
||||
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
|
||||
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="_Group_4" data-name="<Group>">
|
||||
<g>
|
||||
<path d="M39.3926,14.69775H35.67092V8.731h.92676V13.8457H39.3926Z" style="fill: #fff"/>
|
||||
<path d="M40.32912,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,40.32912,13.42432Zm2.89453-.38477v-.37646L42.124,12.7334c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,43.22365,13.03955Z" style="fill: #fff"/>
|
||||
<path d="M45.27639,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074H48.6299v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C46,14.772,45.27639,13.87061,45.27639,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C46.64943,10.91846,46.19436,11.49707,46.19436,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M54.74709,13.48193a1.828,1.828,0,0,1-1.95117,1.30273,2.04531,2.04531,0,0,1-2.08008-2.32422A2.07685,2.07685,0,0,1,52.792,10.10791c1.25293,0,2.00879.856,2.00879,2.27V12.688H51.62111v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,51.62111,12.03076Z" style="fill: #fff"/>
|
||||
<path d="M55.99416,10.19482h.85547v.71533H56.916a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M63.51955,8.86328a.57572.57572,0,1,1,.5752.5415A.54735.54735,0,0,1,63.51955,8.86328Zm.13281,1.33154h.88477v4.50293h-.88477Z" style="fill: #fff"/>
|
||||
<path d="M65.97121,10.19482h.85547v.72363h.06641a1.36385,1.36385,0,0,1,2.49316,0h.07031a1.46325,1.46325,0,0,1,1.36914-.81055,1.33821,1.33821,0,0,1,1.43848,1.48828v3.10156h-.88867V11.82813c0-.60791-.29-.90576-.873-.90576a.91167.91167,0,0,0-.9502.94287v2.83252h-.873V11.74121a.78468.78468,0,0,0-.86816-.81885.96854.96854,0,0,0-.95117,1.02148v2.75391h-.88867Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
2
assets/Google_Play_Store_badge_EN.svg
Normal file
2
assets/Google_Play_Store_badge_EN.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.1 KiB |
@@ -1,7 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ["nativewind/babel"],
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,52 +2,57 @@ import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
source: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected: number;
|
||||
}
|
||||
|
||||
export const AudioTrackSelector: React.FC<Props> = ({
|
||||
item,
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const audioStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[item],
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected],
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
|
||||
const index = source.DefaultAudioStreamIndex;
|
||||
if (index !== undefined && index !== null) onChange(index);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{tc(selectedAudioSteam?.DisplayTitle, 13)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="" numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
const BITRATES: Bitrate[] = [
|
||||
@@ -16,63 +17,84 @@ const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
height: 720,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
height: 480,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
height: 480,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected: Bitrate;
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
export const BitrateSelector: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.sort(
|
||||
(a, b) => (a.value || Infinity) - (b.value || Infinity)
|
||||
);
|
||||
return BITRATES.sort(
|
||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text style={{}} className="" numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
loop={false}
|
||||
side="bottom"
|
||||
align="start"
|
||||
align="center"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{BITRATES?.map((b, index: number) => (
|
||||
{sorted.map((b) => (
|
||||
<DropdownMenu.Item
|
||||
key={index.toString()}
|
||||
key={b.key}
|
||||
onSelect={() => {
|
||||
onChange(b);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPress?: () => void;
|
||||
@@ -9,7 +10,7 @@ interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
disabled?: boolean;
|
||||
children?: string | ReactNode;
|
||||
loading?: boolean;
|
||||
color?: "purple" | "red" | "black";
|
||||
color?: "purple" | "red" | "black" | "transparent";
|
||||
iconRight?: ReactNode;
|
||||
iconLeft?: ReactNode;
|
||||
justify?: "center" | "between";
|
||||
@@ -36,6 +37,8 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
return "bg-red-600";
|
||||
case "black":
|
||||
return "bg-neutral-900 border border-neutral-800";
|
||||
case "transparent":
|
||||
return "bg-transparent";
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
@@ -57,7 +60,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={"white"} size={24} />
|
||||
<Loader />
|
||||
) : (
|
||||
<View
|
||||
className={`
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
import { View, ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
useCastDevice,
|
||||
useDevices,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import GoogleCast from "react-native-google-cast";
|
||||
|
||||
type Props = {
|
||||
interface Props extends ViewProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
background?: "blur" | "transparent";
|
||||
}
|
||||
|
||||
export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
|
||||
export const Chromecast: React.FC<Props> = ({
|
||||
width = 48,
|
||||
height = 48,
|
||||
background = "transparent",
|
||||
...props
|
||||
}) => {
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
@@ -31,9 +36,23 @@ export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<View
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
||||
{...props}
|
||||
>
|
||||
<CastButton style={{ tintColor: "white", height, width }} />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<BlurView
|
||||
intensity={100}
|
||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
<CastButton style={{ tintColor: "white", height, width }} />
|
||||
</View>
|
||||
</BlurView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,39 +5,63 @@ import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
width?: number;
|
||||
useEpisodePoster?: boolean;
|
||||
};
|
||||
|
||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
item,
|
||||
width = 176,
|
||||
useEpisodePoster = false,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(
|
||||
() =>
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 70,
|
||||
width: 300,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
/**
|
||||
* Get horrizontal poster for movie and episode, with failover to primary.
|
||||
*/
|
||||
const url = useMemo(() => {
|
||||
if (!api) return;
|
||||
if (item.Type === "Episode" && useEpisodePoster) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag)
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
else
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
if (item.Type === "Movie") {
|
||||
if (item.ImageTags?.["Thumb"])
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||
else
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0,
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
if (!url)
|
||||
return (
|
||||
<View className="w-48 aspect-video border border-neutral-800"></View>
|
||||
<View
|
||||
className="aspect-video border border-neutral-800"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
></View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
<View
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
|
||||
>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
|
||||
@@ -1,62 +1,39 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
Ref,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
|
||||
export const currentlyPlayingItemAtom = atom<{
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
} | null>(null);
|
||||
|
||||
export const triggerPlayAtom = atom(0);
|
||||
import Video from "react-native-video";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
export const CurrentlyPlayingBar: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const segments = useSegments();
|
||||
const [settings] = useSettings();
|
||||
const {
|
||||
currentlyPlaying,
|
||||
pauseVideo,
|
||||
playVideo,
|
||||
stopPlayback,
|
||||
setVolume,
|
||||
setIsPlaying,
|
||||
isPlaying,
|
||||
videoRef,
|
||||
presentFullscreenPlayer,
|
||||
onProgress,
|
||||
} = usePlayback();
|
||||
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const aBottom = useSharedValue(0);
|
||||
const aPadding = useSharedValue(0);
|
||||
@@ -86,6 +63,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
};
|
||||
});
|
||||
|
||||
const from = useMemo(() => segments[2], [segments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (segments.find((s) => s.includes("tabs"))) {
|
||||
// Tab screen - i.e. home
|
||||
@@ -104,127 +83,52 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
}
|
||||
}, [segments]);
|
||||
|
||||
// TODO: Fix this
|
||||
// useEffect(() => {
|
||||
// if (settings?.forceLandscapeInVideoPlayer === true && fullScreen)
|
||||
// ScreenOrientation.lockAsync(
|
||||
// ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||
// );
|
||||
// }, [settings, fullScreen]);
|
||||
|
||||
const { data: item } = useQuery({
|
||||
queryKey: ["item", cp?.item.Id],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: cp?.item.Id,
|
||||
}),
|
||||
enabled: !!cp?.item.Id && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", cp?.item.Id],
|
||||
queryFn: async () => {
|
||||
if (!cp?.item.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: cp?.item.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!cp?.item.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onProgress = useCallback(
|
||||
({ currentTime }: OnProgressData) => {
|
||||
if (!currentTime || !sessionData?.PlaySessionId || paused) return;
|
||||
const newProgress = currentTime * 10000000;
|
||||
setProgress(newProgress);
|
||||
reportPlaybackProgress({
|
||||
api,
|
||||
itemId: cp?.item.Id,
|
||||
positionTicks: newProgress,
|
||||
sessionId: sessionData.PlaySessionId,
|
||||
});
|
||||
},
|
||||
[sessionData?.PlaySessionId, item, api, paused],
|
||||
);
|
||||
|
||||
const play = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.resume();
|
||||
setPaused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
setPaused(true);
|
||||
|
||||
if (progress > 0)
|
||||
reportPlaybackStopped({
|
||||
api,
|
||||
itemId: item?.Id,
|
||||
positionTicks: progress,
|
||||
sessionId: sessionData?.PlaySessionId,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp", item?.SeriesId],
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
refetchType: "all",
|
||||
});
|
||||
}, [api, item, progress, sessionData, queryClient]);
|
||||
|
||||
const startPosition = useMemo(
|
||||
() =>
|
||||
item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(
|
||||
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
||||
)
|
||||
: 0,
|
||||
[item],
|
||||
[currentlyPlaying?.item]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
const poster = useMemo(() => {
|
||||
if (currentlyPlaying?.item.Type === "Audio")
|
||||
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
|
||||
else
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
item: currentlyPlaying?.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
});
|
||||
}, [currentlyPlaying?.item.Id, api]);
|
||||
|
||||
/**
|
||||
* These two useEffects are used to start playing the
|
||||
* video when the playbackUrl is available.
|
||||
*
|
||||
* The trigger playback is triggered from the button component.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (cp?.playbackUrl) {
|
||||
play();
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
}
|
||||
}
|
||||
}, [cp?.playbackUrl]);
|
||||
const videoSource = useMemo(() => {
|
||||
if (!api || !currentlyPlaying || !poster) return null;
|
||||
return {
|
||||
uri: currentlyPlaying.url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: currentlyPlaying.item?.AlbumArtist
|
||||
? currentlyPlaying.item?.AlbumArtist
|
||||
: undefined,
|
||||
title: currentlyPlaying.item?.Name || "Unknown",
|
||||
description: currentlyPlaying.item?.Overview
|
||||
? currentlyPlaying.item?.Overview
|
||||
: undefined,
|
||||
imageUri: poster,
|
||||
subtitle: currentlyPlaying.item?.Album
|
||||
? currentlyPlaying.item?.Album
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}, [currentlyPlaying, startPosition, api, poster]);
|
||||
|
||||
const [triggerPlay] = useAtom(triggerPlayAtom);
|
||||
useEffect(() => {
|
||||
play();
|
||||
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
}
|
||||
}, [triggerPlay]);
|
||||
|
||||
if (!cp || !api) return null;
|
||||
if (!api || !currentlyPlaying) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -234,7 +138,9 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
<BlurView
|
||||
intensity={Platform.OS === "android" ? 60 : 100}
|
||||
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
|
||||
className={`h-full w-full rounded-xl overflow-hidden ${Platform.OS === "android" && "bg-black"}`}
|
||||
className={`h-full w-full rounded-xl overflow-hidden ${
|
||||
Platform.OS === "android" && "bg-black"
|
||||
}`}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
@@ -249,10 +155,14 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
}}
|
||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
|
||||
${
|
||||
currentlyPlaying.item?.Type === "Audio"
|
||||
? "aspect-square"
|
||||
: "aspect-video"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{cp.playbackUrl && (
|
||||
{videoSource && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
allowsExternalPlayback
|
||||
@@ -264,55 +174,51 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
controls={false}
|
||||
pictureInPicture={true}
|
||||
poster={
|
||||
backdropUrl && item?.Type === "Audio"
|
||||
? backdropUrl
|
||||
poster && currentlyPlaying.item?.Type === "Audio"
|
||||
? poster
|
||||
: undefined
|
||||
}
|
||||
debug={{
|
||||
enable: true,
|
||||
thread: true,
|
||||
}}
|
||||
paused={paused}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
subtitleStyle={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
source={{
|
||||
uri: cp.playbackUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
}}
|
||||
onBuffer={(e) =>
|
||||
e.isBuffering ? console.log("Buffering...") : null
|
||||
}
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
setFullScreen(false);
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
setFullScreen(true);
|
||||
source={videoSource}
|
||||
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||
setTimeout(() => {
|
||||
presentFullscreenPlayer();
|
||||
}, 300);
|
||||
}}
|
||||
onFullscreenPlayerDidDismiss={() => {}}
|
||||
onFullscreenPlayerDidPresent={() => {}}
|
||||
onPlaybackStateChanged={(e) => {
|
||||
if (e.isPlaying) {
|
||||
setPaused(false);
|
||||
} else if (e.isSeeking) {
|
||||
return;
|
||||
} else {
|
||||
pause();
|
||||
if (e.isPlaying === true) {
|
||||
playVideo(false);
|
||||
} else if (e.isPlaying === false) {
|
||||
pauseVideo(false);
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={1000}
|
||||
onVolumeChange={(e) => {
|
||||
setVolume(e.volume);
|
||||
}}
|
||||
progressUpdateInterval={4000}
|
||||
onError={(e) => {
|
||||
console.log(e);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Video playback error: " + JSON.stringify(e),
|
||||
"Video playback error: " + JSON.stringify(e)
|
||||
);
|
||||
Alert.alert("Error", "Cannot play this video file.");
|
||||
setIsPlaying(false);
|
||||
// setCurrentlyPlaying(null);
|
||||
}}
|
||||
renderLoader={
|
||||
item?.Type !== "Audio" && (
|
||||
currentlyPlaying.item?.Type !== "Audio" && (
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -322,46 +228,71 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
<View className="shrink text-xs">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${item?.Id}/page`);
|
||||
if (currentlyPlaying.item?.Type === "Audio") {
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
|
||||
);
|
||||
} else {
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>{item?.Name}</Text>
|
||||
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||
</TouchableOpacity>
|
||||
{item?.SeriesName ? (
|
||||
{currentlyPlaying.item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/series/${item.SeriesId}/page`);
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
|
||||
);
|
||||
}}
|
||||
className="text-xs opacity-50"
|
||||
>
|
||||
<Text>{item.SeriesName}</Text>
|
||||
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
)}
|
||||
{currentlyPlaying.item?.Type === "Movie" && (
|
||||
<View>
|
||||
<Text className="text-xs opacity-50">
|
||||
{item?.ProductionYear}
|
||||
{currentlyPlaying.item?.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{currentlyPlaying.item?.Type === "Audio" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||
}}
|
||||
>
|
||||
<Text className="text-xs opacity-50">
|
||||
{currentlyPlaying.item?.Album}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (paused) play();
|
||||
else pause();
|
||||
if (isPlaying) pauseVideo();
|
||||
else playVideo();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
{paused ? (
|
||||
<Ionicons name="play" size={24} color="white" />
|
||||
) : (
|
||||
{isPlaying ? (
|
||||
<Ionicons name="pause" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="play" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setCp(null);
|
||||
stopPlayback();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
|
||||
@@ -2,44 +2,180 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
type DownloadProps = {
|
||||
interface DownloadProps extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
item,
|
||||
playbackUrl,
|
||||
}) => {
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [process] = useAtom(runningProcesses);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||
|
||||
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
||||
|
||||
const { data: playbackInfo, isLoading } = useQuery({
|
||||
queryKey: ["playbackInfo", item.Id],
|
||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
||||
const [selectedMediaSource, setSelectedMediaSource] =
|
||||
useState<MediaSourceInfo | null>(null);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
|
||||
/**
|
||||
* Bottom sheet
|
||||
*/
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["50%"], []);
|
||||
|
||||
const handlePresentModalPress = useCallback(() => {
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
console.log("handleSheetChanges", index);
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Start download
|
||||
*/
|
||||
const initiateDownload = useCallback(async () => {
|
||||
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
);
|
||||
}
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
|
||||
if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
}
|
||||
|
||||
const response = await api.axiosInstance.post(
|
||||
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
|
||||
{
|
||||
DeviceProfile: deviceProfile,
|
||||
UserId: user.Id,
|
||||
MaxStreamingBitrate: maxBitrate.value,
|
||||
StartTimeTicks: 0,
|
||||
EnableTranscoding: maxBitrate.value ? true : undefined,
|
||||
AutoOpenLiveStream: true,
|
||||
AllowVideoStreamCopy: maxBitrate.value ? false : true,
|
||||
MediaSourceId: selectedMediaSource?.Id,
|
||||
AudioStreamIndex: selectedAudioStream,
|
||||
SubtitleStreamIndex: selectedSubtitleStream,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let url: string | undefined = undefined;
|
||||
|
||||
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
||||
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
||||
);
|
||||
|
||||
if (!mediaSource) {
|
||||
throw new Error("No media source");
|
||||
}
|
||||
|
||||
if (mediaSource.SupportsDirectPlay) {
|
||||
if (item.MediaType === "Video") {
|
||||
console.log("Using direct stream for video!");
|
||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
console.log("Using direct stream for audio!");
|
||||
const searchParams = new URLSearchParams({
|
||||
UserId: user.Id,
|
||||
DeviceId: api.deviceInfo.id,
|
||||
MaxStreamingBitrate: "140000000",
|
||||
Container:
|
||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||
TranscodingContainer: "mp4",
|
||||
TranscodingProtocol: "hls",
|
||||
AudioCodec: "aac",
|
||||
api_key: api.accessToken,
|
||||
StartTimeTicks: "0",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
});
|
||||
url = `${api.basePath}/Audio/${
|
||||
item.Id
|
||||
}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
} else {
|
||||
throw new Error("No transcoding url");
|
||||
}
|
||||
|
||||
return await startRemuxing(url);
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
startRemuxing,
|
||||
user?.Id,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
maxBitrate,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if item is downloaded
|
||||
*/
|
||||
const { data: downloaded, isFetching } = useQuery({
|
||||
queryKey: ["downloaded", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!item.Id) return false;
|
||||
|
||||
const data: BaseItemDto[] = JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
);
|
||||
|
||||
return data.some((d) => d.Id === item.Id);
|
||||
@@ -47,32 +183,32 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
enabled: !!item.Id,
|
||||
});
|
||||
|
||||
if (isLoading || isLoadingDownloaded) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (process && process?.item.Id === item.Id) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
return (
|
||||
<View
|
||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader />
|
||||
) : process && process?.item.Id === item.Id ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
{process.progress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
@@ -84,54 +220,90 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (queue.some((i) => i.id === item.Id)) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||
</TouchableOpacity>
|
||||
) : queue.some((i) => i.id === item.Id) ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (downloaded) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
</TouchableOpacity>
|
||||
) : downloaded ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id!,
|
||||
execute: async () => {
|
||||
await startRemuxing();
|
||||
},
|
||||
item,
|
||||
});
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handlePresentModalPress}>
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<Text className="font-bold text-2xl text-neutral-10">
|
||||
Download options
|
||||
</Text>
|
||||
<View className="flex flex-col space-y-2 w-full items-start">
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
item={item}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<View className="flex flex-col space-y-2">
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={() => {
|
||||
closeModal();
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id!,
|
||||
execute: async () => {
|
||||
await initiateDownload();
|
||||
},
|
||||
item,
|
||||
});
|
||||
}}
|
||||
color="purple"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,41 +2,28 @@ import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
|
||||
type ItemCardProps = {
|
||||
item: BaseItemDto;
|
||||
};
|
||||
|
||||
function seasonNameToIndex(seasonName: string | null | undefined) {
|
||||
if (!seasonName) return -1;
|
||||
if (seasonName.startsWith("Season")) {
|
||||
return parseInt(seasonName.replace("Season ", ""));
|
||||
}
|
||||
if (seasonName.startsWith("Specials")) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
return (
|
||||
<View className="mt-2 flex flex-col grow-0">
|
||||
<View className="mt-2 flex flex-col h-12">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text className="">{item.SeriesName}</Text>
|
||||
<Text
|
||||
style={{ flexWrap: "wrap" }}
|
||||
className="flex text-xs opacity-50 break-all"
|
||||
>
|
||||
{`S${seasonNameToIndex(
|
||||
item?.SeasonName,
|
||||
)}:E${item.IndexNumber?.toString()}`}{" "}
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||
{item.Name}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{item.Name}</Text>
|
||||
<Text numberOfLines={2}>{item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
363
components/ItemContent.tsx
Normal file
363
components/ItemContent.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import { Loader } from "./Loader";
|
||||
import { set } from "lodash";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
const castDevice = useCastDevice();
|
||||
const navigation = useNavigation();
|
||||
const [settings] = useSettings();
|
||||
const [selectedMediaSource, setSelectedMediaSource] =
|
||||
useState<MediaSourceInfo | null>(null);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const [loadingImage, setLoadingImage] = useState(true);
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const fadeIn = () => {
|
||||
opacity.value = withTiming(1, { duration: 300 });
|
||||
};
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const headerHeightRef = useRef(0);
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
const res = await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: id,
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
enabled: !!id && !!api,
|
||||
staleTime: 60 * 1000 * 5,
|
||||
});
|
||||
|
||||
const [localItem, setLocalItem] = useState(item);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
if (localItem) {
|
||||
// Fade out current item
|
||||
fadeOut(() => {
|
||||
// Update local item after fade out
|
||||
setLocalItem(item);
|
||||
// Then fade in
|
||||
fadeIn();
|
||||
});
|
||||
} else {
|
||||
// If there's no current item, just set and fade in
|
||||
setLocalItem(item);
|
||||
fadeIn();
|
||||
}
|
||||
} else {
|
||||
// If item is null, fade out and clear local item
|
||||
fadeOut(() => setLocalItem(null));
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast background="blur" width={22} height={22} />
|
||||
<DownloadItem item={item} />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||
headerHeightRef.current = 230;
|
||||
return;
|
||||
}
|
||||
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
||||
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
||||
}, [item]);
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", item?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: playbackUrl } = useQuery({
|
||||
queryKey: [
|
||||
"playbackUrl",
|
||||
item?.Id,
|
||||
maxBitrate,
|
||||
castDevice,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
|
||||
return null;
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
|
||||
if (castDevice?.deviceId) {
|
||||
deviceProfile = chromecastProfile;
|
||||
} else if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
}
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
forceDirectPlay: settings?.forceDirectPlay,
|
||||
height: maxBitrate.height,
|
||||
mediaSourceId: selectedMediaSource.Id,
|
||||
});
|
||||
|
||||
console.info("Stream URL:", url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(
|
||||
isLoading || isFetching || loadingImage || (logoUrl && loadingLogo)
|
||||
);
|
||||
}, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
headerHeight={headerHeightRef.current}
|
||||
headerImage={
|
||||
<>
|
||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||
{localItem && (
|
||||
<ItemImage
|
||||
variant={
|
||||
localItem.Type === "Movie" && logoUrl
|
||||
? "Backdrop"
|
||||
: "Primary"
|
||||
}
|
||||
item={localItem}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
onLoad={() => setLoadingImage(false)}
|
||||
onError={() => setLoadingImage(false)}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col bg-transparent shrink">
|
||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||
<ItemHeader item={localItem} className="mb-4" />
|
||||
{localItem ? (
|
||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||
<BitrateSelector
|
||||
className="mr-1"
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className="mr-1"
|
||||
item={localItem}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<>
|
||||
<AudioTrackSelector
|
||||
className="mr-1"
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View className="h-16">
|
||||
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
|
||||
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||
</View>
|
||||
|
||||
{item?.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<OverviewText text={item?.Overview} className="px-4 mb-4" />
|
||||
|
||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||
|
||||
{item?.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
<SimilarItems itemId={item?.Id} />
|
||||
|
||||
<View className="h-16"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
38
components/ItemHeader.tsx
Normal file
38
components/ItemHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item)
|
||||
return (
|
||||
<View
|
||||
className="flex flex-col space-y-1.5 w-full items-start h-24"
|
||||
{...props}
|
||||
>
|
||||
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
||||
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
|
||||
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
|
||||
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
minHeight: 96,
|
||||
}}
|
||||
className="flex flex-col"
|
||||
{...props}
|
||||
>
|
||||
<Ratings item={item} className="mb-2" />
|
||||
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
|
||||
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
18
components/Loader.tsx
Normal file
18
components/Loader.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ActivityIndicatorProps,
|
||||
Platform,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
interface Props extends ActivityIndicatorProps {}
|
||||
|
||||
export const Loader: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
size={"small"}
|
||||
color={Platform.OS === "ios" ? "white" : "#9333ea"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
|
||||
export const Loading: React.FC = () => {
|
||||
return (
|
||||
<View>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
79
components/MediaSourceSelector.tsx
Normal file
79
components/MediaSourceSelector.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
onChange: (value: MediaSourceInfo) => void;
|
||||
selected: MediaSourceInfo | null;
|
||||
}
|
||||
|
||||
export const MediaSourceSelector: React.FC<Props> = ({
|
||||
item,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const mediaSources = useMemo(() => {
|
||||
return item.MediaSources;
|
||||
}, [item]);
|
||||
|
||||
const selectedMediaSource = useMemo(
|
||||
() =>
|
||||
mediaSources
|
||||
?.find((x) => x.Id === selected?.Id)
|
||||
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
|
||||
[mediaSources, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaSources?.length) onChange(mediaSources[0]);
|
||||
}, [mediaSources]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
|
||||
<Text numberOfLines={1}>{selectedMediaSource}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||
{mediaSources?.map((source, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
onChange(source);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{source.Name}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
PixelRatio,
|
||||
StyleSheet,
|
||||
View,
|
||||
Button,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
|
||||
const videoSource =
|
||||
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||
|
||||
interface Props {
|
||||
videoSource: string;
|
||||
}
|
||||
|
||||
export const NewVideoPlayer: React.FC<Props> = ({ videoSource }) => {
|
||||
const ref = useRef<VideoView | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const player = useVideoPlayer(videoSource, (player) => {
|
||||
player.loop = true;
|
||||
player.play();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = player.addListener("playingChange", (isPlaying) => {
|
||||
setIsPlaying(isPlaying);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
ref.current?.enterFullscreen();
|
||||
}}
|
||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||
`}
|
||||
>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
player={player}
|
||||
allowsFullscreen
|
||||
allowsPictureInPicture
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,273 +0,0 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import ios12 from "../utils/profiles/ios12";
|
||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
itemId: string;
|
||||
onChangePlaybackURL: (url: string | null) => void;
|
||||
};
|
||||
|
||||
export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
itemId,
|
||||
onChangePlaybackURL,
|
||||
}) => {
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
const client = useRemoteMediaClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: item } = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId,
|
||||
}),
|
||||
enabled: !!itemId && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", itemId],
|
||||
queryFn: async () => {
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!itemId && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: playbackURL } = useQuery({
|
||||
queryKey: ["playbackUrl", itemId, maxBitrate, castDevice],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sessionData) return null;
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
|
||||
});
|
||||
|
||||
onChangePlaybackURL(url);
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onProgress = useCallback(
|
||||
({ currentTime }: OnProgressData) => {
|
||||
if (!currentTime || !sessionData?.PlaySessionId || paused) return;
|
||||
const newProgress = currentTime * 10000000;
|
||||
setProgress(newProgress);
|
||||
reportPlaybackProgress({
|
||||
api,
|
||||
itemId,
|
||||
positionTicks: newProgress,
|
||||
sessionId: sessionData.PlaySessionId,
|
||||
});
|
||||
},
|
||||
[sessionData?.PlaySessionId, item, api, paused],
|
||||
);
|
||||
|
||||
const play = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.resume();
|
||||
setPaused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
setPaused(true);
|
||||
|
||||
if (progress > 0)
|
||||
reportPlaybackStopped({
|
||||
api,
|
||||
itemId: item?.Id,
|
||||
positionTicks: progress,
|
||||
sessionId: sessionData?.PlaySessionId,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp", item?.SeriesId],
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
refetchType: "all",
|
||||
});
|
||||
}, [api, item, progress, sessionData, queryClient]);
|
||||
|
||||
const startPosition = useMemo(
|
||||
() =>
|
||||
item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0,
|
||||
[item],
|
||||
);
|
||||
|
||||
const enableVideo = useMemo(
|
||||
() => !!(playbackURL && item && startPosition !== null && sessionData),
|
||||
[playbackURL, item, startPosition, sessionData],
|
||||
);
|
||||
|
||||
const chromecastReady = useMemo(
|
||||
() => !!(castDevice?.deviceId && item),
|
||||
[castDevice, item],
|
||||
);
|
||||
|
||||
const cast = useCallback(() => {
|
||||
if (!client || !playbackURL || !item) return;
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackURL,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
streamDuration: Math.floor((item.RunTimeTicks || 0) / 10000),
|
||||
},
|
||||
startTime: Math.floor(
|
||||
(item.UserData?.PlaybackPositionTicks || 0) / 10000,
|
||||
),
|
||||
});
|
||||
}, [item, client, playbackURL]);
|
||||
|
||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
useEffect(() => {
|
||||
videoRef.current?.pause();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{enableVideo === true && startPosition !== null && !!playbackURL ? (
|
||||
<Video
|
||||
style={{ width: 0, height: 0 }}
|
||||
source={{
|
||||
uri: playbackURL,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
}}
|
||||
ref={videoRef}
|
||||
onBuffer={(e) => (e.isBuffering ? console.log("Buffering...") : null)}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
pause();
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
play();
|
||||
}}
|
||||
paused={paused}
|
||||
ignoreSilentSwitch="ignore"
|
||||
/>
|
||||
) : null}
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === maxBitrate)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{BITRATES?.map((b: any, index: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={index.toString()}
|
||||
onSelect={() => {
|
||||
setMaxbitrate(b.value);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col w-full">
|
||||
<Button
|
||||
disabled={!enableVideo}
|
||||
onPress={() => {
|
||||
// if (chromecastReady) {
|
||||
// cast();
|
||||
// } else {
|
||||
// setTimeout(() => {
|
||||
// if (!videoRef.current) return;
|
||||
// videoRef.current.presentFullscreenPlayer();
|
||||
// }, 1000);
|
||||
// }
|
||||
if (item) setCp(item);
|
||||
}}
|
||||
iconRight={
|
||||
chromecastReady ? (
|
||||
<Feather name="cast" size={20} color="white" />
|
||||
) : (
|
||||
<Ionicons name="play-circle" size={24} color="white" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -5,34 +5,37 @@ import { useState } from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
text?: string | null;
|
||||
characterLimit?: number;
|
||||
}
|
||||
|
||||
const LIMIT = 140;
|
||||
|
||||
export const OverviewText: React.FC<Props> = ({ text, ...props }) => {
|
||||
const [limit, setLimit] = useState(LIMIT);
|
||||
export const OverviewText: React.FC<Props> = ({
|
||||
text,
|
||||
characterLimit = 100,
|
||||
...props
|
||||
}) => {
|
||||
const [limit, setLimit] = useState(characterLimit);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
if (text.length > LIMIT)
|
||||
return (
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="text-xl font-bold mb-2">Overview</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setLimit((prev) => (prev === LIMIT ? text.length : LIMIT))
|
||||
setLimit((prev) =>
|
||||
prev === characterLimit ? text.length : characterLimit
|
||||
)
|
||||
}
|
||||
>
|
||||
<View {...props} className="">
|
||||
<View>
|
||||
<Text>{tc(text, limit)}</Text>
|
||||
<Text className="text-purple-600 mt-1">
|
||||
{limit === LIMIT ? "Show more" : "Show less"}
|
||||
</Text>
|
||||
{text.length > characterLimit && (
|
||||
<Text className="text-purple-600 mt-1">
|
||||
{limit === characterLimit ? "Show more" : "Show less"}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { type PropsWithChildren, type ReactElement } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollViewOffset,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
|
||||
const HEADER_HEIGHT = 400;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
interface Props extends ViewProps {
|
||||
headerImage: ReactElement;
|
||||
logo?: ReactElement;
|
||||
}>;
|
||||
episodePoster?: ReactElement;
|
||||
headerHeight?: number;
|
||||
}
|
||||
|
||||
export const ParallaxScrollView: React.FC<Props> = ({
|
||||
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
children,
|
||||
headerImage,
|
||||
episodePoster,
|
||||
headerHeight = 400,
|
||||
logo,
|
||||
...props
|
||||
}: Props) => {
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||
@@ -32,25 +32,23 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
[-headerHeight, 0, headerHeight],
|
||||
[-headerHeight / 2, 0, headerHeight * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[2, 1, 1],
|
||||
[-headerHeight, 0, headerHeight],
|
||||
[2, 1, 1]
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const inset = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<View className="flex-1" {...props}>
|
||||
<Animated.ScrollView
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -58,32 +56,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
ref={scrollRef}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
|
||||
style={{
|
||||
top: inset.top + 17,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
className="drop-shadow-2xl"
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color="#077DF2"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View
|
||||
className="absolute right-4 z-50 bg-black rounded-full p-0.5"
|
||||
style={{
|
||||
top: inset.top + 17,
|
||||
}}
|
||||
>
|
||||
<Chromecast width={22} height={22} />
|
||||
</View>
|
||||
|
||||
{logo && (
|
||||
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
|
||||
<View
|
||||
style={{
|
||||
top: headerHeight - 200,
|
||||
height: 130,
|
||||
}}
|
||||
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
|
||||
>
|
||||
{logo}
|
||||
</View>
|
||||
)}
|
||||
@@ -91,7 +71,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: HEADER_HEIGHT,
|
||||
height: headerHeight,
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerAnimatedStyle,
|
||||
@@ -99,7 +79,35 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<View className="flex-1 overflow-hidden bg-black pb-24">
|
||||
|
||||
<View
|
||||
style={{
|
||||
top: -50,
|
||||
}}
|
||||
className="relative flex-1 bg-transparent pb-24"
|
||||
>
|
||||
<LinearGradient
|
||||
// Background Linear Gradient
|
||||
colors={["transparent", "rgba(0,0,0,1)"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: -150,
|
||||
height: 200,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
// Background Linear Gradient
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 50,
|
||||
height: "100%",
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
|
||||
@@ -1,27 +1,85 @@
|
||||
import { Button } from "./Button";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { View } from "react-native";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { useAtom } from "jotai";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
interpolateColor,
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
onPress: (type?: "cast" | "device") => void;
|
||||
chromecastReady: boolean;
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
onPress,
|
||||
chromecastReady,
|
||||
...props
|
||||
}) => {
|
||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const _onPress = () => {
|
||||
if (!chromecastReady) {
|
||||
onPress("device");
|
||||
const [color] = useAtom(itemThemeColorAtom);
|
||||
|
||||
// Create a shared value for animation progress
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
// Create shared values for start and end colors
|
||||
const startColor = useSharedValue(color);
|
||||
const endColor = useSharedValue(color);
|
||||
|
||||
useEffect(() => {
|
||||
// When color changes, update end color and animate progress
|
||||
endColor.value = color;
|
||||
progress.value = 0; // Reset progress
|
||||
progress.value = withTiming(1, { duration: 300 }); // Animate to 1 over 500ms
|
||||
}, [color]);
|
||||
|
||||
// Animated style for primary color
|
||||
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||
backgroundColor: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[startColor.value.average, endColor.value.average]
|
||||
),
|
||||
}));
|
||||
|
||||
// Animated style for text color
|
||||
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||
color: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[startColor.value.text, endColor.value.text]
|
||||
),
|
||||
}));
|
||||
|
||||
// Update start color after animation completes
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
startColor.value = color;
|
||||
}, 500); // Should match the duration in withTiming
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [color]);
|
||||
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
|
||||
if (!client) {
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,33 +91,88 @@ export const PlayButton: React.FC<Props> = ({
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
(selectedIndex: number | undefined) => {
|
||||
async (selectedIndex: number | undefined) => {
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
onPress("cast");
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: url,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
onPress("device");
|
||||
setCurrentlyPlayingState({ item, url });
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
console.log("calcel");
|
||||
break;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const playbackPercent = useMemo(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (!userData) return 0;
|
||||
const PlaybackPositionTicks = userData.PlaybackPositionTicks;
|
||||
if (!PlaybackPositionTicks) return 0;
|
||||
return (PlaybackPositionTicks / item.RunTimeTicks) * 100;
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={_onPress}
|
||||
iconRight={
|
||||
<TouchableOpacity onPress={onPress} className="relative" {...props}>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
{
|
||||
width:
|
||||
playbackPercent === 0
|
||||
? "100%"
|
||||
: `${Math.max(playbackPercent, 15)}%`,
|
||||
height: "100%",
|
||||
},
|
||||
]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl z-10"
|
||||
/>
|
||||
<Animated.View
|
||||
style={[animatedPrimaryStyle]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl "
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: color.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Ionicons name="play-circle" size={24} color="white" />
|
||||
{chromecastReady && <Feather name="cast" size={22} color="white" />}
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Button>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,24 +6,28 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
|
||||
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = useCallback(() => {
|
||||
const invalidateQueries = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
queryKey: ["item"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems", user?.Id],
|
||||
queryKey: ["resumeItems"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp", item.SeriesId],
|
||||
queryKey: ["nextUp"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
@@ -31,10 +35,16 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["seasons"],
|
||||
});
|
||||
}, [api, item.Id, queryClient, user?.Id]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{item.UserData?.Played ? (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
@@ -48,7 +58,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle" size={30} color="white" />
|
||||
<Ionicons name="checkmark-circle" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
@@ -64,7 +74,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
|
||||
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const Ratings: React.FC<Props> = ({ item }) => {
|
||||
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item) return null;
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-center mt-2 space-x-2">
|
||||
<View className="flex flex-row items-center mt-2 space-x-2" {...props}>
|
||||
{item.OfficialRating && (
|
||||
<Badge text={item.OfficialRating} variant="gray" />
|
||||
)}
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import ContinueWatchingPoster from "./ContinueWatchingPoster";
|
||||
import { ItemCardText } from "./ItemCardText";
|
||||
import { Text } from "./common/Text";
|
||||
import MoviePoster from "./MoviePoster";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { ItemCardText } from "./ItemCardText";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
type SimilarItemsProps = {
|
||||
itemId: string;
|
||||
};
|
||||
interface SimilarItemsProps extends ViewProps {
|
||||
itemId?: string | null;
|
||||
}
|
||||
|
||||
export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
||||
export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
itemId,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["similarItems", itemId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
if (!api || !user?.Id || !itemId) return [];
|
||||
const response = await getLibraryApi(api).getSimilarItems({
|
||||
itemId,
|
||||
userId: user.Id,
|
||||
@@ -46,11 +44,11 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
|
||||
<View {...props}>
|
||||
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
||||
{isLoading ? (
|
||||
<View className="my-12">
|
||||
<ActivityIndicator />
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView horizontal>
|
||||
@@ -58,7 +56,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
||||
{movies.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/items/${item.Id}/page`)}
|
||||
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||
className="flex flex-col w-32"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
@@ -68,7 +66,9 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
{movies.length === 0 && <Text className="px-4">No similar items</Text>}
|
||||
{movies.length === 0 && (
|
||||
<Text className="px-4 text-neutral-500">No similar items</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,64 +2,66 @@ import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
source: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected: number;
|
||||
}
|
||||
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
item,
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) ?? [],
|
||||
[item],
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||
[source]
|
||||
);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected],
|
||||
[subtitleStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
|
||||
const index = source.DefaultSubtitleStreamIndex;
|
||||
if (index !== undefined && index !== null) {
|
||||
onChange(index);
|
||||
} else {
|
||||
// Get first subtitle stream
|
||||
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
|
||||
if (firstSubtitle?.Index !== undefined) {
|
||||
onChange(firstSubtitle.Index);
|
||||
}
|
||||
onChange(-1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<View
|
||||
className="flex col shrink justify-start place-self-start items-start"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex flex-col " {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className=" ">
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
@@ -71,7 +73,15 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
|
||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"-1"}
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
|
||||
42
components/common/ColumnItem.tsx
Normal file
42
components/common/ColumnItem.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from "react";
|
||||
import { StyleSheet, View, ViewProps } from "react-native";
|
||||
|
||||
const getItemStyle = (index: number, numColumns: number) => {
|
||||
const alignItems = (() => {
|
||||
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
|
||||
if ((index + 1) % numColumns === 0) return "flex-end";
|
||||
|
||||
return "center";
|
||||
})();
|
||||
|
||||
return {
|
||||
padding: 20,
|
||||
alignItems,
|
||||
width: "100%",
|
||||
} as const;
|
||||
};
|
||||
|
||||
type ColumnItemProps = ViewProps & {
|
||||
children: React.ReactNode;
|
||||
index: number;
|
||||
numColumns: number;
|
||||
};
|
||||
|
||||
export const ColumnItem = ({
|
||||
children,
|
||||
index,
|
||||
numColumns,
|
||||
...rest
|
||||
}: ColumnItemProps) => {
|
||||
return (
|
||||
<View className="flex flex-col mb-2 p-4" style={{ width: "33.3%" }}>
|
||||
<View
|
||||
className={`
|
||||
`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
59
components/common/HeaderBackButton.tsx
Normal file
59
components/common/HeaderBackButton.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView, BlurViewProps } from "expo-blur";
|
||||
|
||||
interface Props extends BlurViewProps {
|
||||
background?: "blur" | "transparent";
|
||||
touchableOpacityProps?: TouchableOpacityProps;
|
||||
}
|
||||
|
||||
export const HeaderBackButton: React.FC<Props> = ({
|
||||
background = "transparent",
|
||||
touchableOpacityProps,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<BlurView
|
||||
{...props}
|
||||
intensity={100}
|
||||
className="overflow-hidden rounded-full p-2"
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Ionicons
|
||||
className="drop-shadow-2xl"
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</BlurView>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className=" bg-neutral-800/80 rounded-full p-2"
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Ionicons
|
||||
className="drop-shadow-2xl"
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color="#077DF2"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +1,101 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
View,
|
||||
ViewStyle,
|
||||
ActivityIndicator,
|
||||
ScrollViewProps,
|
||||
} from "react-native";
|
||||
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { View, ViewStyle } from "react-native";
|
||||
import { Text } from "./Text";
|
||||
|
||||
interface HorizontalScrollProps<T> extends ScrollViewProps {
|
||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
|
||||
export interface HorizontalScrollRef {
|
||||
scrollToIndex: (index: number, viewOffset: number) => void;
|
||||
}
|
||||
|
||||
interface HorizontalScrollProps<T>
|
||||
extends PartialExcept<
|
||||
Omit<FlashListProps<T>, "renderItem">,
|
||||
"estimatedItemSize"
|
||||
> {
|
||||
data?: T[] | null;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
containerStyle?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
loadingContainerStyle?: ViewStyle;
|
||||
height?: number;
|
||||
loading?: boolean;
|
||||
extraData?: any;
|
||||
}
|
||||
|
||||
export function HorizontalScroll<T>({
|
||||
data,
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loadingContainerStyle,
|
||||
...props
|
||||
}: HorizontalScrollProps<T>): React.ReactElement {
|
||||
if (!data) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{ flex: 1, justifyContent: "center", alignItems: "center" },
|
||||
loadingContainerStyle,
|
||||
]}
|
||||
>
|
||||
<ActivityIndicator size="small" />
|
||||
export const HorizontalScroll = forwardRef<
|
||||
HorizontalScrollRef,
|
||||
HorizontalScrollProps<any>
|
||||
>(
|
||||
<T,>(
|
||||
{
|
||||
data = [],
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loadingContainerStyle,
|
||||
loading = false,
|
||||
height = 164,
|
||||
extraData,
|
||||
...props
|
||||
}: HorizontalScrollProps<T>,
|
||||
ref: React.ForwardedRef<HorizontalScrollRef>
|
||||
) => {
|
||||
const flashListRef = useRef<FlashList<T>>(null);
|
||||
|
||||
useImperativeHandle(ref!, () => ({
|
||||
scrollToIndex: (index: number, viewOffset: number) => {
|
||||
flashListRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewPosition: 0,
|
||||
viewOffset,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const renderFlashListItem = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: T;
|
||||
index: number;
|
||||
}) => (
|
||||
<View className="mr-2">
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
style={containerStyle}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
{...props}
|
||||
>
|
||||
<View className="flex flex-row px-4">
|
||||
{data.map((item, index) => (
|
||||
<View className="mr-2" key={index}>
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<View className="px-4 mb-2">
|
||||
<View className="bg-neutral-950 h-24 w-full rounded-md mb-2"></View>
|
||||
<View className="bg-neutral-950 h-10 w-full rounded-md mb-1"></View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList<T>
|
||||
ref={flashListRef}
|
||||
data={data}
|
||||
extraData={extraData}
|
||||
renderItem={renderFlashListItem}
|
||||
horizontal
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
...contentContainerStyle,
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text className="text-center text-gray-500">No data available</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
146
components/common/InfiniteHorrizontalScroll.tsx
Normal file
146
components/common/InfiniteHorrizontalScroll.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { View, ViewStyle } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "./Text";
|
||||
|
||||
interface HorizontalScrollProps
|
||||
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
|
||||
queryFn: ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}) => Promise<BaseItemDtoQueryResult | null>;
|
||||
queryKey: string[];
|
||||
initialData?: BaseItemDto[];
|
||||
renderItem: (item: BaseItemDto, index: number) => React.ReactNode;
|
||||
containerStyle?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
loadingContainerStyle?: ViewStyle;
|
||||
height?: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function InfiniteHorizontalScroll({
|
||||
queryFn,
|
||||
queryKey,
|
||||
initialData = [],
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
loadingContainerStyle,
|
||||
loading = false,
|
||||
height = 164,
|
||||
...props
|
||||
}: HorizontalScrollProps): React.ReactElement {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const animatedOpacity = useSharedValue(0);
|
||||
const animatedStyle1 = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
|
||||
};
|
||||
});
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
|
||||
[]
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
animatedOpacity.value = 1;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (data === undefined || data === null || loading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingContainerStyle,
|
||||
]}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[containerStyle, animatedStyle1]}>
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={({ item, index }) => (
|
||||
<View className="mr-2">
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
)}
|
||||
estimatedItemSize={height}
|
||||
horizontal
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
...contentContainerStyle,
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text className="text-center text-gray-500">No data available</Text>
|
||||
</View>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { TextInputProps, TextProps } from "react-native";
|
||||
import { TextInput } from "react-native";
|
||||
import React from "react";
|
||||
import { TextInput, TextInputProps } from "react-native";
|
||||
export function Input(props: TextInputProps) {
|
||||
const { style, ...otherProps } = props;
|
||||
const inputRef = React.useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
@@ -17,6 +12,7 @@ export function Input(props: TextInputProps) {
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
99
components/common/ItemImage.tsx
Normal file
99
components/common/ItemImage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image, ImageProps, ImageSource } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
|
||||
interface Props extends ImageProps {
|
||||
item: BaseItemDto;
|
||||
variant?: "Backdrop" | "Primary" | "Thumb" | "Logo";
|
||||
quality?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const ItemImage: React.FC<Props> = ({
|
||||
item,
|
||||
variant,
|
||||
quality = 90,
|
||||
width = 1000,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
let tag: string | null | undefined;
|
||||
let blurhash: string | null | undefined;
|
||||
let src: ImageSource | null = null;
|
||||
|
||||
switch (variant) {
|
||||
case "Backdrop":
|
||||
if (item.Type === "Episode") {
|
||||
tag = item.ParentBackdropImageTags?.[0];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
tag = item.BackdropImageTags?.[0];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
case "Primary":
|
||||
tag = item.ImageTags?.["Primary"];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Primary?.[tag];
|
||||
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
case "Thumb":
|
||||
tag = item.ImageTags?.["Thumb"];
|
||||
if (!tag) break;
|
||||
blurhash = item.ImageBlurHashes?.Thumb?.[tag];
|
||||
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`,
|
||||
blurhash,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
tag = item.ImageTags?.["Primary"];
|
||||
src = {
|
||||
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return src;
|
||||
}, [item.ImageTags]);
|
||||
|
||||
useImageColors(source?.uri);
|
||||
|
||||
return (
|
||||
<Image
|
||||
transition={300}
|
||||
placeholder={{
|
||||
blurhash: source?.blurhash,
|
||||
}}
|
||||
source={{
|
||||
uri: source?.uri,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
80
components/common/TouchableItemRouter.tsx
Normal file
80
components/common/TouchableItemRouter.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Alert, TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
if (item.Type === "Series") {
|
||||
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "MusicAlbum") {
|
||||
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "Audio") {
|
||||
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "MusicArtist") {
|
||||
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "Person") {
|
||||
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "BoxSet") {
|
||||
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "UserView") {
|
||||
Alert.alert("Not implemented");
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "CollectionFolder") {
|
||||
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same as default
|
||||
// if (item.Type === "Episode") {
|
||||
// router.push(`/items/${item.Id}`);
|
||||
// return;
|
||||
// }
|
||||
|
||||
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
29
components/common/VerticalSkeleton.tsx
Normal file
29
components/common/VerticalSkeleton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
width: "32%",
|
||||
}}
|
||||
className="flex flex-col"
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
className="w-full bg-neutral-800 mb-2 rounded-lg"
|
||||
></View>
|
||||
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
|
||||
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
|
||||
<View className="h-2 bg-neutral-800 rounded-full mb-2 w-1/2"></View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +1,84 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useCallback } from "react";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useCallback } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
|
||||
|
||||
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
import { Text } from "../common/Text";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
|
||||
interface EpisodeCardProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* EpisodeCard component displays an episode with context menu options.
|
||||
* @param {EpisodeCardProps} props - The component props.
|
||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
||||
*/
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
// const fetchFileSize = async () => {
|
||||
// try {
|
||||
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
// const info = await FileSystem.getInfoAsync(filePath);
|
||||
// return info.exists ? info.size : null;
|
||||
// } catch (e) {
|
||||
// console.log(e);
|
||||
// return null;
|
||||
// }
|
||||
// };
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
// const { data: fileSize } = useQuery({
|
||||
// queryKey: ["fileSize", item?.Id],
|
||||
// queryFn: fetchFileSize,
|
||||
// });
|
||||
|
||||
const openFile = useCallback(() => {
|
||||
setCp({
|
||||
const handleOpenFile = useCallback(async () => {
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
}, [item]);
|
||||
}, [item, setCurrentlyPlayingState]);
|
||||
|
||||
const options = [
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
const contextMenuOptions = [
|
||||
{
|
||||
label: "Delete",
|
||||
onSelect: (id: string) => {
|
||||
deleteFile(id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
},
|
||||
onSelect: handleDeleteFile,
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={openFile}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||
>
|
||||
<Text className=" font-bold">{item.Name}</Text>
|
||||
<Text className=" text-xs opacity-50">
|
||||
Episode {item.IndexNumber}
|
||||
</Text>
|
||||
{/* <Text className=" text-xs opacity-50">
|
||||
Size:{" "}
|
||||
{fileSize
|
||||
? `${(fileSize / 1000000).toFixed(0)} MB`
|
||||
: "Calculating..."}{" "}
|
||||
</Text> */}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
alignOffset={0}
|
||||
avoidCollisions
|
||||
collisionPadding={10}
|
||||
loop={false}
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||
>
|
||||
{options.map((i) => (
|
||||
<ContextMenu.Item
|
||||
onSelect={() => {
|
||||
i.onSelect(item.Id!);
|
||||
}}
|
||||
key={i.label}
|
||||
destructive={i.destructive}
|
||||
>
|
||||
<ContextMenu.ItemTitle
|
||||
style={{
|
||||
color: "red",
|
||||
}}
|
||||
>
|
||||
{i.label}
|
||||
</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
alignOffset={0}
|
||||
avoidCollisions
|
||||
collisionPadding={10}
|
||||
loop={false}
|
||||
>
|
||||
{contextMenuOptions.map((option) => (
|
||||
<ContextMenu.Item
|
||||
key={option.label}
|
||||
onSelect={option.onSelect}
|
||||
destructive={option.destructive}
|
||||
>
|
||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
||||
{option.label}
|
||||
</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,97 +1,92 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useCallback } from "react";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
import { Text } from "../common/Text";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
|
||||
interface MovieCardProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* MovieCard component displays a movie with context menu options.
|
||||
* @param {MovieCardProps} props - The component props.
|
||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
||||
*/
|
||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
// const fetchFileSize = async () => {
|
||||
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
// const info = await FileSystem.getInfoAsync(filePath);
|
||||
// return info.exists ? info.size : null;
|
||||
// };
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
// const { data: fileSize } = useQuery({
|
||||
// queryKey: ["fileSize", item?.Id],
|
||||
// queryFn: fetchFileSize,
|
||||
// });
|
||||
|
||||
const openFile = useCallback(() => {
|
||||
setCp({
|
||||
const handleOpenFile = useCallback(() => {
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
}, [item]);
|
||||
}, [item, setCurrentlyPlayingState]);
|
||||
|
||||
const options = [
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
const contextMenuOptions = [
|
||||
{
|
||||
label: "Delete",
|
||||
onSelect: (id: string) => {
|
||||
deleteFile(id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
},
|
||||
onSelect: handleDeleteFile,
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={openFile}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||
>
|
||||
<Text className=" font-bold">{item.Name}</Text>
|
||||
<View className="flex flex-col">
|
||||
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
<Text className=" text-xs opacity-50">
|
||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
||||
</Text>
|
||||
{/* <Text className=" text-xs opacity-50">
|
||||
Size:{" "}
|
||||
{fileSize
|
||||
? `${(fileSize / 1000000).toFixed(0)} MB`
|
||||
: "Calculating..."}{" "}
|
||||
</Text>*/}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
alignOffset={0}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||
>
|
||||
{options.map((i) => (
|
||||
<ContextMenu.Item
|
||||
onSelect={() => {
|
||||
i.onSelect(item.Id!);
|
||||
}}
|
||||
key={i.label}
|
||||
destructive={i.destructive}
|
||||
>
|
||||
<ContextMenu.ItemTitle
|
||||
style={{
|
||||
color: "red",
|
||||
}}
|
||||
>
|
||||
{i.label}
|
||||
</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
</>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
loop={false}
|
||||
alignOffset={0}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
>
|
||||
{contextMenuOptions.map((option) => (
|
||||
<ContextMenu.Item
|
||||
key={option.label}
|
||||
onSelect={option.onSelect}
|
||||
destructive={option.destructive}
|
||||
>
|
||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
||||
{option.label}
|
||||
</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
99
components/filters/FilterButton.tsx
Normal file
99
components/filters/FilterButton.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
|
||||
interface FilterButtonProps<T> extends ViewProps {
|
||||
collectionId: string;
|
||||
showSearch?: boolean;
|
||||
queryKey: string;
|
||||
values: T[];
|
||||
title: string;
|
||||
set: (value: T[]) => void;
|
||||
queryFn: (params: any) => Promise<any>;
|
||||
searchFilter: (item: T, query: string) => boolean;
|
||||
renderItemLabel: (item: T) => React.ReactNode;
|
||||
icon?: "filter" | "sort";
|
||||
}
|
||||
|
||||
export const FilterButton = <T,>({
|
||||
collectionId,
|
||||
queryFn,
|
||||
queryKey,
|
||||
set,
|
||||
values,
|
||||
title,
|
||||
renderItemLabel,
|
||||
searchFilter,
|
||||
showSearch = true,
|
||||
icon = "filter",
|
||||
...props
|
||||
}: FilterButtonProps<T>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: filters } = useQuery<T[]>({
|
||||
queryKey: ["filters", title, queryKey, collectionId],
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
enabled: !!collectionId && !!queryFn && !!queryKey,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
filters?.length && setOpen(true);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`
|
||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||
${
|
||||
values.length > 0
|
||||
? "bg-purple-600 border border-purple-700"
|
||||
: "bg-neutral-900 border border-neutral-900"
|
||||
}
|
||||
${filters?.length === 0 && "opacity-50"}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<Text
|
||||
className={`
|
||||
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
||||
text-xs font-semibold`}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{icon === "filter" ? (
|
||||
<Ionicons
|
||||
name="filter"
|
||||
size={14}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesome
|
||||
name="sort"
|
||||
size={14}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<FilterSheet<T>
|
||||
title={title}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
data={filters}
|
||||
values={values}
|
||||
set={set}
|
||||
renderItemLabel={renderItemLabel}
|
||||
searchFilter={searchFilter}
|
||||
showSearch={showSearch}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
217
components/filters/FilterSheet.tsx
Normal file
217
components/filters/FilterSheet.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetFlatList,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
|
||||
interface Props<T> extends ViewProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
data?: T[] | null;
|
||||
values: T[];
|
||||
set: (value: T[]) => void;
|
||||
title: string;
|
||||
searchFilter: (item: T, query: string) => boolean;
|
||||
renderItemLabel: (item: T) => React.ReactNode;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
const LIMIT = 100;
|
||||
|
||||
/**
|
||||
* FilterSheet Component
|
||||
*
|
||||
* This component creates a bottom sheet modal for filtering and selecting items from a list.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*
|
||||
* @param {Object} props - The component props
|
||||
* @param {boolean} props.open - Whether the bottom sheet is open
|
||||
* @param {function} props.setOpen - Function to set the open state
|
||||
* @param {T[] | null} [props.data] - The full list of items to filter from
|
||||
* @param {T[]} props.values - The currently selected items
|
||||
* @param {function} props.set - Function to update the selected items
|
||||
* @param {string} props.title - The title of the bottom sheet
|
||||
* @param {function} props.searchFilter - Function to filter items based on search query
|
||||
* @param {function} props.renderItemLabel - Function to render the label for each item
|
||||
* @param {boolean} [props.showSearch=true] - Whether to show the search input
|
||||
*
|
||||
* @returns {React.ReactElement} The FilterSheet component
|
||||
*
|
||||
* Features:
|
||||
* - Displays a list of items in a bottom sheet
|
||||
* - Allows searching and filtering of items
|
||||
* - Supports single selection of items
|
||||
* - Loads items in batches for performance optimization
|
||||
* - Customizable item rendering
|
||||
*/
|
||||
|
||||
export const FilterSheet = <T,>({
|
||||
values,
|
||||
data: _data,
|
||||
open,
|
||||
set,
|
||||
setOpen,
|
||||
title,
|
||||
searchFilter,
|
||||
renderItemLabel,
|
||||
showSearch = true,
|
||||
...props
|
||||
}: Props<T>) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["80%"], []);
|
||||
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!search) return _data;
|
||||
const results = [];
|
||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||
if (_data && searchFilter(_data[i], search)) {
|
||||
results.push(_data[i]);
|
||||
}
|
||||
}
|
||||
return results.slice(0, 100);
|
||||
}, [search, _data, searchFilter]);
|
||||
|
||||
// Loads data in batches of LIMIT size, starting from offset,
|
||||
// to implement efficient "load more" functionality
|
||||
useEffect(() => {
|
||||
if (!_data || _data.length === 0) return;
|
||||
const tmp = new Set(data);
|
||||
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
||||
tmp.add(_data[i]);
|
||||
}
|
||||
setData(Array.from(tmp));
|
||||
}, [offset, _data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderData = useMemo(() => {
|
||||
if (search.length > 0 && showSearch) return filteredData;
|
||||
return data;
|
||||
}, [search, filteredData, data]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
style={{}}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<View className="px-4 mt-2 mb-8">
|
||||
<Text className="font-bold text-2xl">{title}</Text>
|
||||
<Text className="mb-2 text-neutral-500">{_data?.length} items</Text>
|
||||
{showSearch && (
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
className="my-2"
|
||||
value={search}
|
||||
onChangeText={(text) => {
|
||||
setSearch(text);
|
||||
}}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className="mb-4 flex flex-col rounded-xl overflow-hidden"
|
||||
>
|
||||
{renderData?.map((item, index) => (
|
||||
<View key={index}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!values.includes(item)) {
|
||||
set([item]);
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 250);
|
||||
}
|
||||
}}
|
||||
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
||||
>
|
||||
<Text>{renderItemLabel(item)}</Text>
|
||||
{values.includes(item) ? (
|
||||
<Ionicons name="radio-button-on" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="radio-button-off" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className="h-1 divide-neutral-700 "
|
||||
></View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length < (_data?.length || 0) && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
setOffset(offset + 100);
|
||||
}}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
38
components/filters/ResetFiltersButton.tsx
Normal file
38
components/filters/ResetFiltersButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
genreFilterAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
|
||||
interface Props extends TouchableOpacityProps {}
|
||||
|
||||
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
|
||||
if (
|
||||
selectedGenres.length === 0 &&
|
||||
selectedTags.length === 0 &&
|
||||
selectedYears.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedTags([]);
|
||||
setSelectedYears([]);
|
||||
}}
|
||||
className="bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1"
|
||||
{...props}
|
||||
>
|
||||
<Ionicons name="close" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
177
components/home/LargeMovieCarousel.tsx
Normal file
177
components/home/LargeMovieCarousel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo } from "react";
|
||||
import { Dimensions, View, ViewProps } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Carousel, {
|
||||
ICarouselInstance,
|
||||
Pagination,
|
||||
} from "react-native-reanimated-carousel";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const ref = React.useRef<ICarouselInstance>(null);
|
||||
const progress = useSharedValue<number>(0);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: sf_carousel, isFetching: l1 } = useQuery({
|
||||
queryKey: ["sf_carousel", user?.Id, settings?.mediaListCollectionIds],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
tags: ["sf_carousel"],
|
||||
recursive: true,
|
||||
fields: ["Tags"],
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
return response.data.Items?.[0].Id || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const onPressPagination = (index: number) => {
|
||||
ref.current?.scrollTo({
|
||||
/**
|
||||
* Calculate the difference between the current index and the target index
|
||||
* to ensure that the carousel scrolls to the nearest index
|
||||
*/
|
||||
count: index - progress.value,
|
||||
animated: true,
|
||||
});
|
||||
};
|
||||
|
||||
const { data: popularItems, isFetching: l2 } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["popular", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sf_carousel) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
parentId: sf_carousel,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!sf_carousel,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const width = Dimensions.get("screen").width;
|
||||
|
||||
if (l1 || l2)
|
||||
return (
|
||||
<View className="h-[242px] flex items-center justify-center">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!popularItems) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col items-center" {...props}>
|
||||
<Carousel
|
||||
autoPlay={true}
|
||||
autoPlayInterval={3000}
|
||||
loop={true}
|
||||
ref={ref}
|
||||
width={width}
|
||||
height={204}
|
||||
data={popularItems}
|
||||
onProgressChange={progress}
|
||||
renderItem={({ item, index }) => <RenderItem item={item} />}
|
||||
/>
|
||||
<Pagination.Basic
|
||||
progress={progress}
|
||||
data={popularItems}
|
||||
dotStyle={{
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 50,
|
||||
}}
|
||||
activeDotStyle={{
|
||||
backgroundColor: "rgba(255,255,255,0.8)",
|
||||
borderRadius: 50,
|
||||
}}
|
||||
containerStyle={{ gap: 5, marginTop: 12 }}
|
||||
onPress={onPressPagination}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
|
||||
const uri = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 70,
|
||||
width: Math.floor(screenWidth * 0.8 * 2),
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
const logoUri = useMemo(() => {
|
||||
if (!api) return null;
|
||||
return getLogoImageUrlById({ api, item, height: 100 });
|
||||
}, [item]);
|
||||
|
||||
if (!uri || !logoUri) return null;
|
||||
|
||||
return (
|
||||
<TouchableItemRouter item={item}>
|
||||
<View className="px-4">
|
||||
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
||||
<Image
|
||||
source={{
|
||||
uri,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
<View className="absolute bottom-0 left-0 w-full h-24 p-4 flex items-center">
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUri,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
};
|
||||
82
components/home/ScrollingCollectionList.tsx
Normal file
82
components/home/ScrollingCollectionList.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import {
|
||||
type QueryKey,
|
||||
useQuery,
|
||||
type QueryFunction,
|
||||
} from "@tanstack/react-query";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
import { EpisodePoster } from "../posters/EpisodePoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
height?: "small" | "large";
|
||||
disabled?: boolean;
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
}
|
||||
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
title,
|
||||
orientation = "vertical",
|
||||
height = "small",
|
||||
disabled = false,
|
||||
queryFn,
|
||||
queryKey,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !disabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (disabled || !title) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
{title}
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={data}
|
||||
height={orientation === "vertical" ? 247 : 164}
|
||||
loading={isLoading}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableItemRouter
|
||||
key={index}
|
||||
item={item}
|
||||
className={`flex flex-col
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
)}
|
||||
{item.Type === "Movie" && orientation === "vertical" && (
|
||||
<MoviePoster item={item} />
|
||||
)}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
209
components/library/LibraryItemCard.tsx
Normal file
209
components/library/LibraryItemCard.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
CollectionType,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { getColors, ImageColorsResult } from "react-native-image-colors";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { sortBy } from "lodash";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
library: BaseItemDto;
|
||||
}
|
||||
|
||||
type LibraryColor = {
|
||||
dominantColor: string;
|
||||
averageColor: string;
|
||||
secondary: string;
|
||||
};
|
||||
|
||||
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||
|
||||
const icons: Record<CollectionType, IconName> = {
|
||||
movies: "film",
|
||||
tvshows: "tv",
|
||||
music: "musical-notes",
|
||||
books: "book",
|
||||
homevideos: "videocam",
|
||||
boxsets: "albums",
|
||||
playlists: "list",
|
||||
folders: "folder",
|
||||
livetv: "tv",
|
||||
musicvideos: "musical-notes",
|
||||
photos: "images",
|
||||
trailers: "videocam",
|
||||
unknown: "help-circle",
|
||||
} as const;
|
||||
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const [imageInfo, setImageInfo] = useState<LibraryColor>({
|
||||
dominantColor: "#fff",
|
||||
averageColor: "#fff",
|
||||
secondary: "#fff",
|
||||
});
|
||||
|
||||
const url = useMemo(
|
||||
() =>
|
||||
getPrimaryImageUrl({
|
||||
api,
|
||||
item: library,
|
||||
}),
|
||||
[library]
|
||||
);
|
||||
|
||||
const { data: itemsCount } = useQuery({
|
||||
queryKey: ["library-count", library.Id],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: library.Id,
|
||||
limit: 0,
|
||||
});
|
||||
return response.data.TotalRecordCount;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
getColors(url, {
|
||||
fallback: "#fff",
|
||||
cache: true,
|
||||
key: url,
|
||||
})
|
||||
.then((colors) => {
|
||||
let dominantColor: string = "#fff";
|
||||
let averageColor: string = "#fff";
|
||||
let secondary: string = "#fff";
|
||||
|
||||
if (colors.platform === "android") {
|
||||
dominantColor = colors.dominant;
|
||||
averageColor = colors.average;
|
||||
secondary = colors.muted;
|
||||
} else if (colors.platform === "ios") {
|
||||
dominantColor = colors.primary;
|
||||
averageColor = colors.background;
|
||||
secondary = colors.detail;
|
||||
}
|
||||
|
||||
setImageInfo({
|
||||
dominantColor,
|
||||
averageColor,
|
||||
secondary,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
if (settings?.libraryOptions?.display === "row") {
|
||||
return (
|
||||
<TouchableItemRouter item={library} className="w-full px-4">
|
||||
<View className="flex flex-row items-center w-full relative ">
|
||||
<Ionicons
|
||||
name={icons[library.CollectionType!] || "folder"}
|
||||
size={22}
|
||||
color={"#e5e5e5"}
|
||||
/>
|
||||
<Text className="text-start px-4 text-neutral-200">
|
||||
{library.Name}
|
||||
</Text>
|
||||
{settings?.libraryOptions?.showStats && (
|
||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
|
||||
{itemsCount} items
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
}
|
||||
|
||||
if (settings?.libraryOptions?.imageStyle === "cover") {
|
||||
return (
|
||||
<TouchableItemRouter item={library} className="w-full">
|
||||
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: 8,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.3)", // Adjust the alpha value (0.3) to control darkness
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{settings?.libraryOptions?.showTitles && (
|
||||
<Text className="font-bold text-lg text-start px-4">
|
||||
{library.Name}
|
||||
</Text>
|
||||
)}
|
||||
{settings?.libraryOptions?.showStats && (
|
||||
<Text className="font-bold text-xs text-start px-4">
|
||||
{itemsCount} items
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableItemRouter item={library} {...props}>
|
||||
<View className="flex flex-row items-center justify-between rounded-xl w-full relative border bg-neutral-900 border-neutral-900 h-20">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-bold text-lg text-start px-4">
|
||||
{library.Name}
|
||||
</Text>
|
||||
{settings?.libraryOptions?.showStats && (
|
||||
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
||||
{itemsCount} items
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="p-2">
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
className="h-full aspect-[2/1] object-cover rounded-lg overflow-hidden"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
};
|
||||
88
components/medialists/MediaListSection.tsx
Normal file
88
components/medialists/MediaListSection.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import MoviePoster from "../posters/MoviePoster";
|
||||
import {
|
||||
type QueryKey,
|
||||
type QueryFunction,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
}
|
||||
|
||||
export const MediaListSection: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: collection, isLoading } = useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
}: {
|
||||
pageParam: number;
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !user?.Id || !collection) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
parentId: collection.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[api, user?.Id, collection?.Id]
|
||||
);
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
{collection.Name}
|
||||
</Text>
|
||||
<InfiniteHorizontalScroll
|
||||
height={247}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableItemRouter
|
||||
key={index}
|
||||
item={item}
|
||||
className={`flex flex-col
|
||||
${"w-28"}
|
||||
`}
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["media-list", collection.Id!]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,16 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<View className="flex flex-row items-center self-center px-4">
|
||||
<Text className="text-center font-bold text-2xl mr-2">
|
||||
{item?.Name}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
<View {...props}>
|
||||
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
|
||||
<Text className=" opacity-50">{item?.ProductionYear}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
35
components/music/SongsList.tsx
Normal file
35
components/music/SongsList.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { SongsListItem } from "./SongsListItem";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
songs?: BaseItemDto[] | null;
|
||||
collectionId: string;
|
||||
artistId: string;
|
||||
albumId: string;
|
||||
}
|
||||
|
||||
export const SongsList: React.FC<Props> = ({
|
||||
collectionId,
|
||||
artistId,
|
||||
albumId,
|
||||
songs = [],
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<View className="flex flex-col space-y-2" {...props}>
|
||||
{songs?.map((item: BaseItemDto, index: number) => (
|
||||
<SongsListItem
|
||||
key={item.Id}
|
||||
item={item}
|
||||
index={index}
|
||||
collectionId={collectionId}
|
||||
artistId={artistId}
|
||||
albumId={albumId}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
147
components/music/SongsListItem.tsx
Normal file
147
components/music/SongsListItem.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
collectionId: string;
|
||||
artistId: string;
|
||||
albumId: string;
|
||||
item: BaseItemDto;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const SongsListItem: React.FC<Props> = ({
|
||||
collectionId,
|
||||
artistId,
|
||||
albumId,
|
||||
item,
|
||||
index,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const client = useRemoteMediaClient();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const openSelect = () => {
|
||||
if (!castDevice?.deviceId) {
|
||||
play("device");
|
||||
return;
|
||||
}
|
||||
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
(selectedIndex: number | undefined) => {
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
play("cast");
|
||||
break;
|
||||
case 1:
|
||||
play("device");
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const play = async (type: "device" | "cast") => {
|
||||
if (!user?.Id || !api || !item.Id) {
|
||||
console.warn("No user, api or item", user, api, item.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
const sessionData = response.data;
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||
mediaSourceId: item.Id,
|
||||
});
|
||||
|
||||
if (!url || !item) {
|
||||
console.warn("No url or item", url, item.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: url,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log("Playing on device", url, item.Id);
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
url,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
openSelect();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-4 bg-neutral-900 border-neutral-800 px-4 py-4 rounded-xl">
|
||||
<Text className="opacity-50">{index + 1}</Text>
|
||||
<View>
|
||||
<Text className="mb-0.5 font-semibold">{item.Name}</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
|
||||
import { type ComponentProps } from 'react';
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { type IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||
import { type ComponentProps } from "react";
|
||||
|
||||
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
|
||||
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
export function TabBarIcon({
|
||||
style,
|
||||
...rest
|
||||
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
}
|
||||
|
||||
82
components/posters/AlbumCover.tsx
Normal file
82
components/posters/AlbumCover.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
type ArtistPosterProps = {
|
||||
item?: BaseItemDto | null;
|
||||
id?: string | null;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(() => {
|
||||
const u = getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
});
|
||||
return u;
|
||||
}, [item]);
|
||||
|
||||
const url2 = useMemo(() => {
|
||||
const u = getPrimaryImageUrlById({
|
||||
api,
|
||||
id,
|
||||
quality: 85,
|
||||
width: 300,
|
||||
});
|
||||
return u;
|
||||
}, [item]);
|
||||
|
||||
if (!item && id)
|
||||
return (
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
source={
|
||||
url2
|
||||
? {
|
||||
uri: url2,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (item)
|
||||
return (
|
||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumCover;
|
||||
@@ -1,18 +1,17 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
type MoviePosterProps = {
|
||||
type ArtistPosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
const ArtistPoster: React.FC<ArtistPosterProps> = ({
|
||||
item,
|
||||
showProgress = false,
|
||||
}) => {
|
||||
@@ -24,19 +23,15 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0,
|
||||
[item]
|
||||
);
|
||||
|
||||
if (!url)
|
||||
return (
|
||||
<View
|
||||
className="rounded-md overflow-hidden border border-neutral-900"
|
||||
className="rounded-lg overflow-hidden border border-neutral-900"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
></View>
|
||||
);
|
||||
@@ -52,15 +47,11 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
aspectRatio: "1/1",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className="h-1 bg-red-600 w-full"></View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoviePoster;
|
||||
export default ArtistPoster;
|
||||
64
components/posters/EpisodePoster.tsx
Normal file
64
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||
item,
|
||||
showProgress = false,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (item.Type === "Episode") {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.["Primary"] as string;
|
||||
return item.ImageBlurHashes?.["Primary"]?.[key];
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
placeholder={{
|
||||
blurhash,
|
||||
}}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className="h-1 bg-red-600 w-full"></View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
69
components/posters/MoviePoster.tsx
Normal file
69
components/posters/MoviePoster.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
item,
|
||||
showProgress = false,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(() => {
|
||||
return getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: 300,
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.["Primary"] as string;
|
||||
return item.ImageBlurHashes?.["Primary"]?.[key];
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
placeholder={{
|
||||
blurhash,
|
||||
}}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className="h-1 bg-red-600 w-full"></View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoviePoster;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
@@ -29,7 +28,7 @@ const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
||||
<View className="rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
@@ -9,10 +9,11 @@ type PosterProps = {
|
||||
item?: BaseItemDto | BaseItemPerson | null;
|
||||
url?: string | null;
|
||||
showProgress?: boolean;
|
||||
blurhash?: string | null;
|
||||
};
|
||||
|
||||
const Poster: React.FC<PosterProps> = ({ item, url }) => {
|
||||
if (!url || !item)
|
||||
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
||||
if (!item)
|
||||
return (
|
||||
<View
|
||||
className="border border-neutral-900"
|
||||
@@ -23,13 +24,24 @@ const Poster: React.FC<PosterProps> = ({ item, url }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
||||
<View className="rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
placeholder={
|
||||
blurhash
|
||||
? {
|
||||
blurhash,
|
||||
}
|
||||
: null
|
||||
}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
60
components/posters/SeriesPoster.tsx
Normal file
60
components/posters/SeriesPoster.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (item.Type === "Episode") {
|
||||
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=389&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||
}
|
||||
return getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: 300,
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.["Primary"] as string;
|
||||
return item.ImageBlurHashes?.["Primary"]?.[key];
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
placeholder={{
|
||||
blurhash,
|
||||
}}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeriesPoster;
|
||||
@@ -1,29 +1,35 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { Linking, TouchableOpacity, View } from "react-native";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import Poster from "../Poster";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { router } from "expo-router";
|
||||
import Poster from "../posters/Poster";
|
||||
|
||||
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View {...props} className="flex flex-col">
|
||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||
<HorizontalScroll<NonNullable<BaseItemPerson>>
|
||||
data={item.People}
|
||||
<HorizontalScroll
|
||||
loading={loading}
|
||||
data={item?.People || []}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// TODO: Navigate to person
|
||||
router.push(`/actors/${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
|
||||
@@ -3,24 +3,28 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Poster from "../Poster";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import Poster from "../posters/Poster";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
|
||||
export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View {...props}>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={[item]}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => router.push(`/series/${item.SeriesId}/page`)}
|
||||
onPress={() => router.push(`/series/${item.SeriesId}`)}
|
||||
className="flex flex-col space-y-2 w-32"
|
||||
>
|
||||
<Poster
|
||||
|
||||
34
components/series/EpisodeTitleHeader.tsx
Normal file
34
components/series/EpisodeTitleHeader.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="font-bold text-2xl">{item?.Name}</Text>
|
||||
<View className="flex flex-row items-center mb-1">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(
|
||||
// @ts-ignore
|
||||
`/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className="opacity-50">{item?.SeasonName}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="opacity-50 mx-2">{"—"}</Text>
|
||||
<Text className="opacity-50">{`Episode ${item.IndexNumber}`}</Text>
|
||||
</View>
|
||||
<Text className="opacity-50">{item?.ProductionYear}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -23,40 +23,6 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
// const { data: seasons } = useQuery({
|
||||
// queryKey: ["seasons", item.SeriesId],
|
||||
// queryFn: async () => {
|
||||
// if (
|
||||
// !api ||
|
||||
// !user?.Id ||
|
||||
// !item?.Id ||
|
||||
// !item?.SeriesId ||
|
||||
// !item?.IndexNumber
|
||||
// )
|
||||
// return [];
|
||||
|
||||
// const response = await getItemsApi(api).getItems({
|
||||
// parentId: item?.SeriesId,
|
||||
// });
|
||||
|
||||
// console.log("seasons ~", type, response.data);
|
||||
|
||||
// return (response.data.Items as BaseItemDto[]) ?? [];
|
||||
// },
|
||||
// enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||
// });
|
||||
|
||||
// const nextSeason = useMemo(() => {
|
||||
// if (!seasons) return null;
|
||||
// const currentSeasonIndex = seasons.findIndex(
|
||||
// (season) => season.Id === item.SeasonId,
|
||||
// );
|
||||
|
||||
// if (currentSeasonIndex === seasons.length - 1) return null;
|
||||
|
||||
// return seasons[currentSeasonIndex + 1];
|
||||
// }, [seasons]);
|
||||
|
||||
const { data: nextEpisode } = useQuery({
|
||||
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
||||
queryFn: async () => {
|
||||
@@ -75,8 +41,6 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
startIndex: type === "next" ? item.IndexNumber : item.IndexNumber - 2,
|
||||
});
|
||||
|
||||
console.log("NextEpisode ~", type, response.data);
|
||||
|
||||
return (response.data.Items?.[0] as BaseItemDto) || null;
|
||||
},
|
||||
enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||
@@ -92,7 +56,7 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={() => router.replace(`/items/${nextEpisode?.Id}/page`)}
|
||||
onPress={() => router.setParams({ id: nextEpisode?.Id })}
|
||||
className={`h-12 aspect-square`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import Poster from "../Poster";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { nextUp } from "@/utils/jellyfin/tvshows/nextUp";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -26,6 +24,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
userId: user?.Id,
|
||||
seriesId,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items;
|
||||
},
|
||||
@@ -35,7 +34,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
|
||||
if (!items?.length)
|
||||
return (
|
||||
<View>
|
||||
<View className="px-4">
|
||||
<Text className="text-lg font-bold mb-2">Next up</Text>
|
||||
<Text className="opacity-50">No items to display</Text>
|
||||
</View>
|
||||
@@ -44,17 +43,17 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${item.Id}/page`);
|
||||
router.push(`/(auth)/items/page?id=${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
className="flex flex-col w-44"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ContinueWatchingPoster item={item} useEpisodePoster />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
143
components/series/SeasonEpisodesCarousel.tsx
Normal file
143
components/series/SeasonEpisodesCarousel.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
HorizontalScrollRef,
|
||||
} from "../common/HorrizontalScroll";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
item,
|
||||
loading,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollRef.current?.scrollToIndex(index, 16);
|
||||
};
|
||||
|
||||
const seasonId = useMemo(() => {
|
||||
return item?.SeasonId;
|
||||
}, [item]);
|
||||
|
||||
const {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: ["episodes", seasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item?.Id}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
userId: user?.Id,
|
||||
seasonId,
|
||||
Fields:
|
||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!seasonId,
|
||||
});
|
||||
|
||||
/**
|
||||
* Prefetch previous and next episode
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousId = episodes?.find(
|
||||
(ep) => ep.IndexNumber === item.IndexNumber! - 1
|
||||
)?.Id;
|
||||
if (previousId) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["item", previousId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: previousId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
const nextId = episodes?.find(
|
||||
(ep) => ep.IndexNumber === item.IndexNumber! + 1
|
||||
)?.Id;
|
||||
if (nextId) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["item", nextId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: nextId,
|
||||
}),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
}, [episodes, api, user?.Id, item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item?.Type === "Episode" && item.Id) {
|
||||
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||
if (index !== undefined && index !== -1) {
|
||||
setTimeout(() => {
|
||||
scrollToIndex(index);
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
}, [episodes, item]);
|
||||
|
||||
return (
|
||||
<HorizontalScroll
|
||||
ref={scrollRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
loading={loading || isLoading || isFetching}
|
||||
renderItem={(_item, idx) => (
|
||||
<TouchableOpacity
|
||||
key={_item.Id}
|
||||
onPress={() => {
|
||||
router.setParams({ id: _item.Id });
|
||||
}}
|
||||
className={`flex flex-col w-44
|
||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||
`}
|
||||
>
|
||||
<ContinueWatchingPoster item={_item} useEpisodePoster />
|
||||
<ItemCardText item={_item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +1,38 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { DownloadItem } from "../DownloadItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "../common/Text";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Image } from "expo-image";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
initialSeasonIndex?: number;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<number>(1);
|
||||
type SeasonIndexState = {
|
||||
[seriesId: string]: number;
|
||||
};
|
||||
|
||||
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
|
||||
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -40,7 +52,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
@@ -48,38 +60,93 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||
let initialIndex: number | undefined;
|
||||
|
||||
if (initialSeasonIndex !== undefined) {
|
||||
// Use the provided initialSeasonIndex if it exists in the seasons
|
||||
const seasonExists = seasons.some(
|
||||
(season: any) => season.IndexNumber === initialSeasonIndex
|
||||
);
|
||||
if (seasonExists) {
|
||||
initialIndex = initialSeasonIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (initialIndex === undefined) {
|
||||
// Fall back to the previous logic if initialIndex is not set
|
||||
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
|
||||
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
|
||||
const firstSeason = season1 || season0 || seasons[0];
|
||||
initialIndex = firstSeason.IndexNumber;
|
||||
}
|
||||
|
||||
if (initialIndex !== undefined) {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: initialIndex,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
[seasons, seasonIndex],
|
||||
[seasons, seasonIndex]
|
||||
);
|
||||
|
||||
const { data: episodes } = useQuery({
|
||||
const { data: episodes, isFetching } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item.Id}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
userId: user?.Id,
|
||||
seasonId: selectedSeasonId,
|
||||
Fields:
|
||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.Id,
|
||||
userId: user.Id,
|
||||
seasonId: selectedSeasonId,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
return res.data.Items;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
for (let e of episodes || []) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["item", e.Id],
|
||||
queryFn: async () => {
|
||||
if (!e.Id) return;
|
||||
const res = await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: e.Id,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
staleTime: 60 * 5 * 1000,
|
||||
});
|
||||
}
|
||||
}, [episodes]);
|
||||
|
||||
// Used for height calculation
|
||||
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
|
||||
useEffect(() => {
|
||||
if (episodes && episodes.length > 0) {
|
||||
setNrOfEpisodes(episodes.length);
|
||||
}
|
||||
}, [episodes]);
|
||||
|
||||
return (
|
||||
<View className="mb-2">
|
||||
<View
|
||||
style={{
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row px-4">
|
||||
@@ -102,7 +169,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
<DropdownMenu.Item
|
||||
key={season.Name}
|
||||
onSelect={() => {
|
||||
setSeasonIndex(season.IndexNumber);
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
||||
@@ -110,25 +180,59 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{episodes && (
|
||||
<View className="mt-4">
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={episodes}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.Id}
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${item.Id}/page`);
|
||||
}}
|
||||
className="flex flex-col w-48"
|
||||
<View className="px-4 flex flex-col my-4">
|
||||
{isFetching ? (
|
||||
<View
|
||||
style={{
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
episodes?.map((e: BaseItemDto) => (
|
||||
<TouchableOpacity
|
||||
key={e.Id}
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/page?id=${e.Id}`);
|
||||
}}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
<View className="flex flex-row items-center mb-2">
|
||||
<View className="w-32 aspect-video overflow-hidden mr-2">
|
||||
<ContinueWatchingPoster
|
||||
item={e}
|
||||
width={128}
|
||||
useEpisodePoster
|
||||
/>
|
||||
</View>
|
||||
<View className="shrink">
|
||||
<Text numberOfLines={2} className="">
|
||||
{e.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
{`S${e.ParentIndexNumber?.toString()}:E${e.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start ml-auto">
|
||||
<DownloadItem item={e} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
numberOfLines={3}
|
||||
className="text-xs text-neutral-500 shrink"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{e.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const SeriesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/series/${item.SeriesId}/page`)}
|
||||
>
|
||||
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
<View className="flex flex-row items-center self-center px-4">
|
||||
<Text className="text-center font-bold text-2xl mr-2">
|
||||
{item?.Name}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<View className="flex flex-row items-center self-center">
|
||||
<TouchableOpacity onPress={() => {}}>
|
||||
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
|
||||
<Text className="text-center opacity-50">
|
||||
{`Episode ${item.IndexNumber}`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,111 @@
|
||||
import { Switch, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import { Input } from "../common/Input";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../Button";
|
||||
|
||||
export const SettingToggles: React.FC = () => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<Text>Auto rotate</Text>
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Auto rotate</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Important on android since the video player orientation is locked to
|
||||
the app orientation.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.autoRotate}
|
||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||
/>
|
||||
</View>
|
||||
{/* <View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Download quality</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the download quality.
|
||||
</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?.downloadQuality?.label}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Quality</DropdownMenu.Label>
|
||||
{DownloadOptions.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadQuality: option });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View> */}
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<Text>Start videos in fullscreen</Text>
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Clicking a video will start it in fullscreen mode, instead of
|
||||
inline.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openFullScreenVideoPlayerByDefault}
|
||||
onValueChange={(value) =>
|
||||
@@ -24,6 +113,246 @@ export const SettingToggles: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
Open all videos in VLC instead of the default player. This requries
|
||||
VLC to be installed on the phone.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: 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-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Force direct play</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
This will always request direct play. This is good if you want to
|
||||
try to stream movies you think the device supports.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.forceDirectPlay}
|
||||
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Device profile</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
A profile used for deciding what audio and video codecs the device
|
||||
supports.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.deviceProfile}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Expo" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Native" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="3"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Old" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View 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 });
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Text className="text-neutral-500 mt-2">
|
||||
{settings?.marlinServerUrl}
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
24
components/stacks/NestedTabPageStack.tsx
Normal file
24
components/stacks/NestedTabPageStack.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Chromecast } from "../Chromecast";
|
||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||
|
||||
const commonScreenOptions = {
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
};
|
||||
|
||||
const routes = [
|
||||
"actors/[actorId]",
|
||||
"albums/[albumId]",
|
||||
"artists/index",
|
||||
"artists/[artistId]",
|
||||
"collections/[collectionId]",
|
||||
"items/page",
|
||||
"series/[id]",
|
||||
];
|
||||
|
||||
export const nestedTabPageScreenOptions: { [key: string]: any } =
|
||||
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.5.1",
|
||||
"channel": "0.10.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.5.1",
|
||||
"channel": "0.10.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -26,11 +26,12 @@ export const useFiles = () => {
|
||||
fileNames.map((item) =>
|
||||
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
|
||||
idempotent: true,
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
await AsyncStorage.removeItem("downloaded_files");
|
||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all files:", error);
|
||||
}
|
||||
@@ -49,7 +50,7 @@ export const useFiles = () => {
|
||||
try {
|
||||
await FileSystem.deleteAsync(
|
||||
`${FileSystem.documentDirectory}/${id}.mp4`,
|
||||
{ idempotent: true },
|
||||
{ idempotent: true }
|
||||
);
|
||||
|
||||
const currentFiles = await getDownloadedFiles();
|
||||
@@ -57,7 +58,7 @@ export const useFiles = () => {
|
||||
|
||||
await AsyncStorage.setItem(
|
||||
"downloaded_files",
|
||||
JSON.stringify(updatedFiles),
|
||||
JSON.stringify(updatedFiles)
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||
|
||||
42
hooks/useImageColors.ts
Normal file
42
hooks/useImageColors.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
export const useImageColors = (uri: string | undefined | null) => {
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (uri) {
|
||||
getColors(uri, {
|
||||
fallback: "#fff",
|
||||
cache: true,
|
||||
key: uri,
|
||||
})
|
||||
.then((colors) => {
|
||||
let primary: string = "#fff";
|
||||
let average: string = "#fff";
|
||||
let secondary: string = "#fff";
|
||||
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
average = colors.average;
|
||||
secondary = colors.muted;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.primary;
|
||||
secondary = colors.detail;
|
||||
average = colors.background;
|
||||
}
|
||||
|
||||
setPrimaryColor({
|
||||
primary,
|
||||
secondary,
|
||||
average,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [uri, setPrimaryColor]);
|
||||
};
|
||||
19
hooks/useInterval.ts
Normal file
19
hooks/useInterval.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useInterval(callback: () => void, delay: number | null) {
|
||||
const savedCallback = useRef<() => void>();
|
||||
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
useEffect(() => {
|
||||
function tick() {
|
||||
savedCallback.current?.();
|
||||
}
|
||||
if (delay !== null) {
|
||||
const id = setInterval(tick, delay);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
}, [delay]);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
@@ -14,8 +15,9 @@ import { writeToLog } from "@/utils/log";
|
||||
* @param item - The BaseItemDto object representing the media item
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
const [_, setProgress] = useAtom(runningProcesses);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!item.Id || !item.Name) {
|
||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||
@@ -23,87 +25,94 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
}
|
||||
|
||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
const startRemuxing = useCallback(async () => {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
|
||||
);
|
||||
const startRemuxing = useCallback(
|
||||
async (url: string) => {
|
||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
try {
|
||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
setProgress((prev) =>
|
||||
prev?.item.Id === item.Id!
|
||||
? { ...prev, progress: percentage, speed }
|
||||
: prev,
|
||||
);
|
||||
});
|
||||
|
||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpegKit.executeAsync(command, async (session) => {
|
||||
try {
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
await updateDownloadedFiles(item);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
||||
);
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
);
|
||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
|
||||
);
|
||||
setProgress(null);
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
}, [output, item, command, setProgress]);
|
||||
|
||||
try {
|
||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
setProgress((prev) =>
|
||||
prev?.item.Id === item.Id!
|
||||
? { ...prev, progress: percentage, speed }
|
||||
: prev
|
||||
);
|
||||
});
|
||||
|
||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpegKit.executeAsync(command, async (session) => {
|
||||
try {
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
await updateDownloadedFiles(item);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
|
||||
);
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
setProgress(null);
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[output, item, setProgress]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
setProgress(null);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
|
||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
|
||||
);
|
||||
}, [item.Name, setProgress]);
|
||||
|
||||
@@ -118,7 +127,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
||||
try {
|
||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
);
|
||||
const updatedFiles = [
|
||||
...currentFiles.filter((i) => i.Id !== item.Id),
|
||||
@@ -126,13 +135,13 @@ async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
||||
];
|
||||
await AsyncStorage.setItem(
|
||||
"downloaded_files",
|
||||
JSON.stringify(updatedFiles),
|
||||
JSON.stringify(updatedFiles)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error updating downloaded files:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`Failed to update downloaded files for item: ${item.Name}`,
|
||||
`Failed to update downloaded files for item: ${item.Name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
22
package.json
22
package.json
@@ -18,37 +18,42 @@
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@gorhom/bottom-sheet": "^4",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.3.1",
|
||||
"@react-native-menu/menu": "^1.1.2",
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/react-query": "^5.51.16",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.7.3",
|
||||
"expo": "~51.0.27",
|
||||
"expo": "~51.0.31",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.22",
|
||||
"expo-dev-client": "~4.0.25",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "~12.0.9",
|
||||
"expo-haptics": "~13.0.1",
|
||||
"expo-image": "~1.12.13",
|
||||
"expo-image": "~1.12.15",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-router": "~3.5.21",
|
||||
"expo-router": "~3.5.23",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "~3.0.7",
|
||||
"expo-updates": "~0.25.22",
|
||||
"expo-updates": "~0.25.24",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"jotai": "^2.9.1",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -58,17 +63,20 @@
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.2",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^2.5.1",
|
||||
"react-native-ios-utilities": "^4.4.5",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.4.3",
|
||||
"react-native-video": "^6.4.5",
|
||||
"react-native-web": "~0.19.10",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.3",
|
||||
"uuid": "^10.0.0",
|
||||
"zeego": "^1.10.0",
|
||||
"zod": "^3.23.8"
|
||||
@@ -79,7 +87,7 @@
|
||||
"@types/react": "~18.2.45",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~51.0.3",
|
||||
"jest-expo": "~51.0.4",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "~5.3.3"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user