mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-17 22:42:03 +01:00
Compare commits
48 Commits
v0.6.0
...
feat/vlc-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29e6a3815 | ||
|
|
92b847a447 | ||
|
|
e7fcf806b3 | ||
|
|
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 |
34
README.md
34
README.md
@@ -26,18 +26,31 @@ 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.
|
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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## 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
|
## Get it now
|
||||||
|
|
||||||
<div style="display:flex;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
|
<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>
|
||||||
<img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
</a>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
Get the latest updates by using the TestFlight version of the app.
|
Get the latest updates by using the TestFlight version of the app.
|
||||||
@@ -46,8 +59,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"/>
|
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
Or download the APKs here on GitHub for Android.
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -102,13 +113,12 @@ Key points of the MPL-2.0:
|
|||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 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:
|
If you have questions or need support, feel free to reach out:
|
||||||
|
|
||||||
- GitHub Issues: Report bugs or request features here.
|
- GitHub Issues: Report bugs or request features here.
|
||||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||||
-
|
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
|
|||||||
13
app.json
13
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.6.0",
|
"version": "0.6.2",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -96,6 +96,17 @@
|
|||||||
{
|
{
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"react-native-vlc-media-player",
|
||||||
|
{
|
||||||
|
"ios": {
|
||||||
|
"includeVLCKit": false // should be true if react-native version < 0.61
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"legacyJetifier": false // should be true if react-native version < 0.71
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export default function TabLayout() {
|
|||||||
borderTopRightRadius: 0,
|
borderTopRightRadius: 0,
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
|
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
||||||
|
height: Platform.OS === "android" ? 58 : 74,
|
||||||
},
|
},
|
||||||
tabBarBackground: () =>
|
tabBarBackground: () =>
|
||||||
Platform.OS === "ios" ? (
|
Platform.OS === "ios" ? (
|
||||||
@@ -70,6 +72,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>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,26 @@
|
|||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
|
||||||
ItemFields,
|
|
||||||
ItemFilter,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getChannelsApi,
|
|
||||||
getItemsApi,
|
getItemsApi,
|
||||||
getSuggestionsApi,
|
getSuggestionsApi,
|
||||||
getTvShowsApi,
|
getTvShowsApi,
|
||||||
getUserApi,
|
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -44,6 +32,24 @@ export default function index() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [settings, _] = useSettings();
|
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, isLoading, isError } = useQuery<BaseItemDto[]>({
|
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
|
||||||
queryKey: ["resumeItems", user?.Id],
|
queryKey: ["resumeItems", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
@@ -77,35 +83,21 @@ export default function index() {
|
|||||||
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
|
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
|
||||||
}, [_nextUpData]);
|
}, [_nextUpData]);
|
||||||
|
|
||||||
const { data: collections, isLoading: isLoadingCollections } = useQuery({
|
const { data: collections } = useQuery({
|
||||||
queryKey: ["collections", user?.Id],
|
queryKey: ["collectinos", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) {
|
if (!api || !user?.Id) {
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
await getItemsApi(api).getItems({
|
userId: user.Id,
|
||||||
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 || [];
|
return response.data.Items || null;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const movieCollectionId = useMemo(() => {
|
const movieCollectionId = useMemo(() => {
|
||||||
@@ -178,10 +170,14 @@ export default function index() {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: mediaListCollection } = useQuery<string | null>({
|
const { data: mediaListCollections } = useQuery({
|
||||||
queryKey: ["mediaListCollection", user?.Id],
|
queryKey: [
|
||||||
|
"mediaListCollections-home",
|
||||||
|
user?.Id,
|
||||||
|
settings?.mediaListCollectionIds,
|
||||||
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) return null;
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
@@ -191,58 +187,32 @@ export default function index() {
|
|||||||
includeItemTypes: ["BoxSet"],
|
includeItemTypes: ["BoxSet"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data.Items?.[0].Id || null;
|
const ids =
|
||||||
|
response.data.Items?.filter(
|
||||||
|
(c) =>
|
||||||
|
c.Name !== "cf_carousel" &&
|
||||||
|
settings?.mediaListCollectionIds?.includes(c.Id!)
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
return ids;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 0,
|
||||||
});
|
|
||||||
|
|
||||||
const { data: popularItems, isLoading: isLoadingPopular } = useQuery<
|
|
||||||
BaseItemDto[]
|
|
||||||
>({
|
|
||||||
queryKey: ["popular", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !mediaListCollection) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
parentId: mediaListCollection,
|
|
||||||
limit: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!mediaListCollection,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
|
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
||||||
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
|
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
||||||
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: ["mediaListCollections-home"],
|
||||||
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [queryClient, user?.Id]);
|
}, [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) {
|
if (isConnected === false) {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||||
@@ -279,7 +249,7 @@ export default function index() {
|
|||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
<ActivityIndicator />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -292,6 +262,8 @@ export default function index() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
||||||
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
title="Continue Watching"
|
title="Continue Watching"
|
||||||
data={data}
|
data={data}
|
||||||
@@ -299,13 +271,6 @@ export default function index() {
|
|||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Popular"
|
|
||||||
data={popularItems}
|
|
||||||
loading={isLoadingPopular}
|
|
||||||
disabled={!mediaListCollection}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
title="Next Up"
|
title="Next Up"
|
||||||
data={nextUpData}
|
data={nextUpData}
|
||||||
@@ -313,6 +278,10 @@ export default function index() {
|
|||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{mediaListCollections?.map((ml) => (
|
||||||
|
<MediaListSection key={ml.Id} collection={ml} />
|
||||||
|
))}
|
||||||
|
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
title="Recently Added in Movies"
|
title="Recently Added in Movies"
|
||||||
data={recentlyAddedInMovies}
|
data={recentlyAddedInMovies}
|
||||||
@@ -325,13 +294,6 @@ export default function index() {
|
|||||||
loading={isLoadingRecentlyAddedTVShows}
|
loading={isLoadingRecentlyAddedTVShows}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Collections"
|
|
||||||
data={collections}
|
|
||||||
loading={isLoadingCollections}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
title="Suggestions"
|
title="Suggestions"
|
||||||
data={suggestions}
|
data={suggestions}
|
||||||
|
|||||||
339
app/(auth)/(tabs)/libraries/[libraryId].tsx
Normal file
339
app/(auth)/(tabs)/libraries/[libraryId].tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
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 { Loader } from "@/components/Loader";
|
||||||
|
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 {
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getFilterApi,
|
||||||
|
getItemsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
||||||
|
|
||||||
|
const isCloseToBottom = ({
|
||||||
|
layoutMeasurement,
|
||||||
|
contentOffset,
|
||||||
|
contentSize,
|
||||||
|
}: NativeScrollEvent) => {
|
||||||
|
const paddingToBottom = 200;
|
||||||
|
return (
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - paddingToBottom
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const page: React.FC = () => {
|
||||||
|
const searchParams = useLocalSearchParams();
|
||||||
|
const { libraryId } = searchParams as { libraryId: string };
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
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 { data: library } = useQuery({
|
||||||
|
queryKey: ["library", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: libraryId,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
const data = response.data;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!libraryId,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async ({
|
||||||
|
pageParam,
|
||||||
|
}: {
|
||||||
|
pageParam: number;
|
||||||
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
|
if (!api || !library) return null;
|
||||||
|
|
||||||
|
const includeItemTypes: BaseItemKind[] = [];
|
||||||
|
|
||||||
|
switch (library?.CollectionType) {
|
||||||
|
case "movies":
|
||||||
|
includeItemTypes.push("Movie");
|
||||||
|
break;
|
||||||
|
case "boxsets":
|
||||||
|
includeItemTypes.push("BoxSet");
|
||||||
|
break;
|
||||||
|
case "tvshows":
|
||||||
|
includeItemTypes.push("Series");
|
||||||
|
break;
|
||||||
|
case "music":
|
||||||
|
includeItemTypes.push("MusicAlbum");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
limit: 66,
|
||||||
|
startIndex: pageParam,
|
||||||
|
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||||
|
sortOrder: [sortOrder[0].key],
|
||||||
|
includeItemTypes,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
recursive: true,
|
||||||
|
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 } = useInfiniteQuery({
|
||||||
|
queryKey: [
|
||||||
|
"library-items",
|
||||||
|
library,
|
||||||
|
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 type = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!library || !library.CollectionType) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
onScroll={({ nativeEvent }) => {
|
||||||
|
if (isCloseToBottom(nativeEvent)) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={400}
|
||||||
|
>
|
||||||
|
<View className="mt-4 mb-24">
|
||||||
|
<View className="mb-4">
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className="flex flex-row space-x-1 px-3">
|
||||||
|
<ResetFiltersButton />
|
||||||
|
<FilterButton
|
||||||
|
collectionId={libraryId}
|
||||||
|
queryKey="genreFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
}}
|
||||||
|
set={setSelectedGenres}
|
||||||
|
values={selectedGenres}
|
||||||
|
title="Genres"
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
collectionId={libraryId}
|
||||||
|
queryKey="tagsFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
}}
|
||||||
|
set={setSelectedTags}
|
||||||
|
values={selectedTags}
|
||||||
|
title="Tags"
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
collectionId={libraryId}
|
||||||
|
queryKey="yearFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
response.data.Years?.sort((a, b) => b - a).map((y) =>
|
||||||
|
y.toString()
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
set={setSelectedYears}
|
||||||
|
values={selectedYears}
|
||||||
|
title="Years"
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
icon="sort"
|
||||||
|
collectionId={libraryId}
|
||||||
|
queryKey="sortByFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
return sortOptions;
|
||||||
|
}}
|
||||||
|
set={setSortBy}
|
||||||
|
values={sortBy}
|
||||||
|
title="Sort by"
|
||||||
|
renderItemLabel={(item) => item.value}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
icon="sort"
|
||||||
|
showSearch={false}
|
||||||
|
collectionId={libraryId}
|
||||||
|
queryKey="orderByFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
return sortOrderOptions;
|
||||||
|
}}
|
||||||
|
set={setSortOrder}
|
||||||
|
values={sortOrder}
|
||||||
|
title="Order by"
|
||||||
|
renderItemLabel={(item) => item.value}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
{!type && isFetching && (
|
||||||
|
<Loader
|
||||||
|
style={{
|
||||||
|
marginTop: 300,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
|
||||||
|
{flatData.map(
|
||||||
|
(item, index) =>
|
||||||
|
item && (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={`${item.Id}-${index}`}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
className={`
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{flatData.length % 3 !== 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "33%",
|
||||||
|
}}
|
||||||
|
></View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
30
app/(auth)/(tabs)/libraries/_layout.tsx
Normal file
30
app/(auth)/(tabs)/libraries/_layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function IndexLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: "Library",
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="[libraryId]"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
app/(auth)/(tabs)/libraries/index.tsx
Normal file
104
app/(auth)/(tabs)/libraries/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export default function index() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 17,
|
||||||
|
paddingHorizontal: 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
}}
|
||||||
|
data={data}
|
||||||
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-4" />}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
library: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LibraryItemCard: React.FC<Props> = ({ library }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() =>
|
||||||
|
getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item: library,
|
||||||
|
}),
|
||||||
|
[library]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/libraries/${library.Id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 8,
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text className="font-bold text-xl text-start px-4">
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,92 +1,214 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
import { Loader } from "@/components/Loader";
|
||||||
import Poster from "@/components/Poster";
|
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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { router, Stack, useNavigation } from "expo-router";
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Href,
|
||||||
|
router,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
usePathname,
|
||||||
|
} from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useLayoutEffect, useState } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
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() {
|
export default function search() {
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
|
||||||
|
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||||
|
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
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();
|
const navigation = useNavigation();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (Platform.OS === "ios")
|
if (Platform.OS === "ios")
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerSearchBarOptions: {
|
headerSearchBarOptions: {
|
||||||
placeholder: "Search...",
|
placeholder: "Search...",
|
||||||
onChangeText: (e: any) => setSearch(e.nativeEvent.text),
|
onChangeText: (e: any) => {
|
||||||
|
router.setParams({ q: "" });
|
||||||
|
setSearch(e.nativeEvent.text);
|
||||||
|
},
|
||||||
hideWhenScrolling: false,
|
hideWhenScrolling: false,
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
const { data: movies } = useQuery({
|
const { data: movies, isFetching: l1 } = useQuery({
|
||||||
queryKey: ["search-movies", search],
|
queryKey: ["search", "movies", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if (!api || !user || search.length === 0) return [];
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
types: ["Movie"],
|
||||||
searchTerm: search,
|
}),
|
||||||
limit: 10,
|
enabled: debouncedSearch.length > 0,
|
||||||
includeItemTypes: ["Movie"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return searchApi.data.SearchHints;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: series } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search-series", search],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if (!api || !user || search.length === 0) return [];
|
searchFn({
|
||||||
|
query: debouncedSearch,
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
types: ["Series"],
|
||||||
searchTerm: search,
|
}),
|
||||||
limit: 10,
|
enabled: debouncedSearch.length > 0,
|
||||||
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({
|
const { data: episodes, isFetching: l3 } = useQuery({
|
||||||
searchTerm: search,
|
queryKey: ["search", "episodes", debouncedSearch],
|
||||||
limit: 10,
|
queryFn: () =>
|
||||||
includeItemTypes: ["Episode"],
|
searchFn({
|
||||||
});
|
query: debouncedSearch,
|
||||||
|
types: ["Episode"],
|
||||||
return searchApi.data.SearchHints;
|
}),
|
||||||
},
|
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
|
||||||
|
);
|
||||||
|
}, [artists, episodes, albums, songs, movies, series]);
|
||||||
|
|
||||||
|
const loading = useMemo(() => {
|
||||||
|
return l1 || l2 || l3 || l4 || l5 || l6;
|
||||||
|
}, [l1, l2, l3, l4, l5, l6]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2 pb-20">
|
<View className="flex flex-col pt-4 pb-32">
|
||||||
{Platform.OS === "android" && (
|
{Platform.OS === "android" && (
|
||||||
<View className="mb-4 px-4">
|
<View className="mb-4 px-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -99,8 +221,15 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
|
{!!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
|
<SearchItemWrapper
|
||||||
|
header="Movies"
|
||||||
ids={movies?.map((m) => m.Id!)}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
@@ -108,11 +237,13 @@ export default function search() {
|
|||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-28"
|
||||||
onPress={() => router.push(`/items/${item.Id}`)}
|
onPress={() => router.push(`/items/${item.Id}`)}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text className="mt-2">{item.Name}</Text>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
<Text className="opacity-50 text-xs">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -121,9 +252,9 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
|
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={series?.map((m) => m.Id!)}
|
ids={series?.map((m) => m.Id!)}
|
||||||
|
header="Series"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
data={data}
|
data={data}
|
||||||
@@ -131,14 +262,12 @@ export default function search() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/series/${item.Id}`)}
|
onPress={() => router.push(`/series/${item.Id}`)}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<Poster
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
item={item}
|
<Text numberOfLines={2} className="mt-2">
|
||||||
key={item.Id}
|
{item.Name}
|
||||||
url={getPrimaryImageUrl({ api, item })}
|
</Text>
|
||||||
/>
|
|
||||||
<Text className="mt-2">{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -147,9 +276,9 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
|
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
|
header="Episodes"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
data={data}
|
data={data}
|
||||||
@@ -157,7 +286,7 @@ export default function search() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/items/${item.Id}`)}
|
onPress={() => router.push(`/items/${item.Id}`)}
|
||||||
className="flex flex-col w-48"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
@@ -166,6 +295,89 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
ids={artists?.map((m) => m.Id!)}
|
||||||
|
header="Artists"
|
||||||
|
renderItem={(data) => (
|
||||||
|
<HorizontalScroll<BaseItemDto>
|
||||||
|
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<BaseItemDto>
|
||||||
|
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<BaseItemDto>
|
||||||
|
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>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
@@ -175,9 +387,10 @@ export default function search() {
|
|||||||
type Props = {
|
type Props = {
|
||||||
ids?: string[] | null;
|
ids?: string[] | null;
|
||||||
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
||||||
|
header?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
|
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -193,21 +406,26 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
|
|||||||
api,
|
api,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
itemId: id,
|
itemId: id,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = await Promise.all(itemPromises);
|
const results = await Promise.all(itemPromises);
|
||||||
|
|
||||||
// Filter out null items
|
// Filter out null items
|
||||||
return results.filter(
|
return results.filter(
|
||||||
(item) => item !== null,
|
(item) => item !== null
|
||||||
) as unknown as BaseItemDto[];
|
) as unknown as BaseItemDto[];
|
||||||
},
|
},
|
||||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) return <Text className="opacity-50 text-xs px-4">No results</Text>;
|
if (!data) return null;
|
||||||
|
|
||||||
return renderItem(data);
|
return (
|
||||||
|
<>
|
||||||
|
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
|
||||||
|
{renderItem(data)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,15 @@
|
|||||||
import ArtistPoster from "@/components/ArtistPoster";
|
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loading } from "@/components/Loading";
|
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
|
||||||
import { SongsList } from "@/components/music/SongsList";
|
import { SongsList } from "@/components/music/SongsList";
|
||||||
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
ItemSortBy,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getArtistsApi,
|
|
||||||
getItemsApi,
|
|
||||||
getUserApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -40,8 +22,6 @@ export default function page() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -119,6 +99,21 @@ export default function page() {
|
|||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-bold text-3xl">{album?.Name}</Text>
|
<Text className="font-bold text-3xl">{album?.Name}</Text>
|
||||||
<Text className="">{album?.ProductionYear}</Text>
|
<Text className="">{album?.ProductionYear}</Text>
|
||||||
|
|
||||||
|
<View className="flex flex-row space-x-2 mt-1">
|
||||||
|
{album.AlbumArtists?.map((a) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={a.Id}
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/artists/${a.Id}/page`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="font-bold text-purple-600">
|
||||||
|
{album?.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<SongsList
|
<SongsList
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ArtistPoster from "@/components/ArtistPoster";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import ArtistPoster from "@/components/ArtistPoster";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loading } from "@/components/Loading";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
ItemSortBy,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
FlatList,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
|
|||||||
@@ -1,25 +1,46 @@
|
|||||||
import ArtistPoster from "@/components/ArtistPoster";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loading } from "@/components/Loading";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
genreFilterAtom,
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
import {
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
ItemSortBy,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} 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 {
|
import {
|
||||||
ActivityIndicator,
|
getFilterApi,
|
||||||
ScrollView,
|
getItemsApi,
|
||||||
TouchableOpacity,
|
getUserLibraryApi,
|
||||||
View,
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
} from "react-native";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
||||||
|
|
||||||
|
const isCloseToBottom = ({
|
||||||
|
layoutMeasurement,
|
||||||
|
contentOffset,
|
||||||
|
contentSize,
|
||||||
|
}: NativeScrollEvent) => {
|
||||||
|
const paddingToBottom = 200;
|
||||||
|
return (
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - paddingToBottom
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -27,200 +48,294 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSortBy([
|
||||||
|
{
|
||||||
|
key: "ProductionYear",
|
||||||
|
value: "Production Year",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSortOrder([
|
||||||
|
{
|
||||||
|
key: "Descending",
|
||||||
|
value: "Descending",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: collectionId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
ids: [collectionId],
|
|
||||||
});
|
});
|
||||||
const data = response.data.Items?.[0];
|
const data = response.data;
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!collectionId,
|
enabled: !!api && !!user?.Id && !!collectionId,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
useEffect(() => {
|
||||||
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
|
}, [navigation, collection]);
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<{
|
const fetchItems = useCallback(
|
||||||
Items: BaseItemDto[];
|
async ({
|
||||||
TotalRecordCount: number;
|
pageParam,
|
||||||
}>({
|
}: {
|
||||||
queryKey: ["collection-items", collection?.Id, startIndex],
|
pageParam: number;
|
||||||
queryFn: async () => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !collectionId)
|
if (!api || !collection) return null;
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortBy: ItemSortBy[] = [];
|
|
||||||
const includeItemTypes: BaseItemKind[] = [];
|
|
||||||
|
|
||||||
switch (collection?.CollectionType) {
|
|
||||||
case "movies":
|
|
||||||
sortBy.push("SortName", "ProductionYear");
|
|
||||||
break;
|
|
||||||
case "boxsets":
|
|
||||||
sortBy.push("IsFolder", "SortName");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
sortBy.push("SortName");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (collection?.CollectionType) {
|
|
||||||
case "movies":
|
|
||||||
includeItemTypes.push("Movie");
|
|
||||||
break;
|
|
||||||
case "boxsets":
|
|
||||||
includeItemTypes.push("BoxSet");
|
|
||||||
break;
|
|
||||||
case "tvshows":
|
|
||||||
includeItemTypes.push("Series");
|
|
||||||
break;
|
|
||||||
case "music":
|
|
||||||
includeItemTypes.push("MusicAlbum");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
limit: 100,
|
limit: 18,
|
||||||
startIndex,
|
startIndex: pageParam,
|
||||||
sortBy,
|
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: [sortOrder[0].key],
|
||||||
includeItemTypes,
|
fields: [
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
"ItemCounts",
|
||||||
recursive: true,
|
"PrimaryImageAspectRatio",
|
||||||
imageTypeLimit: 1,
|
"CanDelete",
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
"MediaSourceCount",
|
||||||
|
],
|
||||||
|
genres: selectedGenres,
|
||||||
|
tags: selectedTags,
|
||||||
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = response.data.Items;
|
return response.data || null;
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
enabled: !!collection?.Id && !!api && !!user?.Id,
|
[
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
collection,
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage } = 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 totalItems = useMemo(() => {
|
useEffect(() => {
|
||||||
return data?.TotalRecordCount;
|
console.log("Data: ", data);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const type = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ScrollView
|
||||||
<View>
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
<View className="px-4 mb-4">
|
onScroll={({ nativeEvent }) => {
|
||||||
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
|
if (isCloseToBottom(nativeEvent)) {
|
||||||
<View className="flex flex-row items-center justify-between">
|
fetchNextPage();
|
||||||
<Text>
|
}
|
||||||
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
|
}}
|
||||||
{totalItems}
|
scrollEventThrottle={400}
|
||||||
</Text>
|
>
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="mt-4 mb-24">
|
||||||
<TouchableOpacity
|
<View className="mb-4">
|
||||||
onPress={() => {
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
setStartIndex((prev) => Math.max(prev - 100, 0));
|
<View className="flex flex-row space-x-1 px-3">
|
||||||
|
<ResetFiltersButton />
|
||||||
|
<FilterButton
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="genreFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
}}
|
}}
|
||||||
>
|
set={setSelectedGenres}
|
||||||
<Ionicons
|
values={selectedGenres}
|
||||||
name="arrow-back-circle-outline"
|
title="Genres"
|
||||||
size={32}
|
renderItemLabel={(item) => item.toString()}
|
||||||
color="white"
|
searchFilter={(item, search) =>
|
||||||
/>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
</TouchableOpacity>
|
}
|
||||||
<TouchableOpacity
|
/>
|
||||||
onPress={() => {
|
<FilterButton
|
||||||
setStartIndex((prev) => prev + 100);
|
collectionId={collectionId}
|
||||||
|
queryKey="tagsFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
}}
|
}}
|
||||||
>
|
set={setSelectedTags}
|
||||||
<Ionicons
|
values={selectedTags}
|
||||||
name="arrow-forward-circle-outline"
|
title="Tags"
|
||||||
size={32}
|
renderItemLabel={(item) => item.toString()}
|
||||||
color="white"
|
searchFilter={(item, search) =>
|
||||||
/>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
</TouchableOpacity>
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="yearFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
response.data.Years?.sort((a, b) => b - a).map((y) =>
|
||||||
|
y.toString()
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
set={setSelectedYears}
|
||||||
|
values={selectedYears}
|
||||||
|
title="Years"
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
icon="sort"
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="sortByFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
return sortOptions;
|
||||||
|
}}
|
||||||
|
set={setSortBy}
|
||||||
|
values={sortBy}
|
||||||
|
title="Sort by"
|
||||||
|
renderItemLabel={(item) => item.value}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
icon="sort"
|
||||||
|
showSearch={false}
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="orderByFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
return sortOrderOptions;
|
||||||
|
}}
|
||||||
|
set={setSortOrder}
|
||||||
|
values={sortOrder}
|
||||||
|
title="Order by"
|
||||||
|
renderItemLabel={(item) => item.value}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
|
{!type && isFetching && (
|
||||||
|
<Loader
|
||||||
|
style={{
|
||||||
|
marginTop: 300,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
|
||||||
|
{flatData.map(
|
||||||
|
(item, index) =>
|
||||||
|
item && (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={`${item.Id}`}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
className={`
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{flatData.length % 3 !== 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "33%",
|
||||||
|
}}
|
||||||
|
></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}`);
|
|
||||||
} else if (item.IsFolder) {
|
|
||||||
router.push(`/collections/${item?.Id}`);
|
|
||||||
} else {
|
|
||||||
router.push(`/items/${item.Id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col gap-y-2">
|
|
||||||
{collection?.CollectionType === "movies" ? (
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
) : collection?.CollectionType === "music" ? (
|
|
||||||
<ArtistPoster item={item} />
|
|
||||||
) : (
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
)}
|
|
||||||
<Text>{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { router } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||||
import * as FileSystem from "expo-file-system";
|
import { useAtom } from "jotai";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { useMemo } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
const downloads: React.FC = () => {
|
||||||
const [process, setProcess] = useAtom(runningProcesses);
|
const [process, setProcess] = useAtom(runningProcesses);
|
||||||
@@ -27,14 +22,14 @@ const downloads: React.FC = () => {
|
|||||||
queryKey: ["downloaded_files", process?.item.Id],
|
queryKey: ["downloaded_files", process?.item.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||||
) as BaseItemDto[],
|
) as BaseItemDto[],
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const movies = useMemo(
|
const movies = useMemo(
|
||||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||||
[downloadedFiles],
|
[downloadedFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
const groupedBySeries = useMemo(() => {
|
||||||
@@ -61,7 +56,7 @@ const downloads: React.FC = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||||
<ActivityIndicator size="small" color="white" />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
import { DownloadItem } from "@/components/DownloadItem";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
@@ -19,29 +15,34 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
|
|||||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
currentlyPlayingItemAtom,
|
||||||
|
fullScreenAtom,
|
||||||
|
playingAtom,
|
||||||
|
showCurrentlyPlayingBarAtom,
|
||||||
|
} from "@/utils/atoms/playState";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
import ios12 from "@/utils/profiles/ios12";
|
import ios from "@/utils/profiles/ios";
|
||||||
|
import native from "@/utils/profiles/native";
|
||||||
|
import old from "@/utils/profiles/old";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import old from "@/utils/profiles/old";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -55,6 +56,7 @@ const page: React.FC = () => {
|
|||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
|
|
||||||
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
||||||
|
const [, setShowCurrentlyPlayingBar] = useAtom(showCurrentlyPlayingBarAtom);
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
const [, setPlaying] = useAtom(playingAtom);
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||||
|
|
||||||
@@ -168,12 +170,16 @@ const page: React.FC = () => {
|
|||||||
playbackUrl,
|
playbackUrl,
|
||||||
});
|
});
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
|
setShowCurrentlyPlayingBar(true);
|
||||||
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||||
setFullscreen(true);
|
setTimeout(() => {
|
||||||
|
setFullscreen(true);
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackUrl, item, settings],
|
[playbackUrl, item, settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
@@ -184,18 +190,18 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
<ActivityIndicator />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,7 +250,7 @@ const page: React.FC = () => {
|
|||||||
<Ratings item={item} />
|
<Ratings item={item} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
<View className="flex flex-row justify-between items-center mb-2">
|
||||||
{playbackUrl ? (
|
{playbackUrl ? (
|
||||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||||
) : (
|
) : (
|
||||||
@@ -274,38 +280,10 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center justify-between w-full">
|
<View className="flex flex-row items-center justify-between w-full">
|
||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||||
<PlayButton
|
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||||
item={item}
|
|
||||||
chromecastReady={chromecastReady}
|
|
||||||
onPress={onPressPlay}
|
|
||||||
className="grow"
|
|
||||||
/>
|
|
||||||
<NextEpisodeButton item={item} className="ml-2" />
|
<NextEpisodeButton item={item} className="ml-2" />
|
||||||
</View>
|
</View>
|
||||||
</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} />
|
<CastAndCrew item={item} />
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ScrollView, View } from "react-native";
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
|
import { WebSocketsTest } from "@/components/settings/WebsocketsText";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
@@ -44,7 +45,7 @@ export default function settings() {
|
|||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await deleteAllFiles();
|
await deleteAllFiles();
|
||||||
Haptics.notificationAsync(
|
Haptics.notificationAsync(
|
||||||
Haptics.NotificationFeedbackType.Success,
|
Haptics.NotificationFeedbackType.Success
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -55,7 +56,7 @@ export default function settings() {
|
|||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await clearLogs();
|
await clearLogs();
|
||||||
Haptics.notificationAsync(
|
Haptics.notificationAsync(
|
||||||
Haptics.NotificationFeedbackType.Success,
|
Haptics.NotificationFeedbackType.Success
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,37 +1,38 @@
|
|||||||
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
currentlyPlayingItemAtom,
|
||||||
|
playingAtom,
|
||||||
|
} from "@/components/CurrentlyPlayingBar";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
import { DownloadItem } from "@/components/DownloadItem";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
|
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
|
||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -84,12 +85,12 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
const { data: sessionData } = useQuery({
|
||||||
@@ -173,13 +174,13 @@ const page: React.FC = () => {
|
|||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackUrl, item],
|
[playbackUrl, item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
<ActivityIndicator />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import { useEffect } from "react";
|
|||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(`Navigated to ${pathname}`);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
|
|||||||
238
app/_layout.tsx
238
app/_layout.tsx
@@ -1,20 +1,22 @@
|
|||||||
|
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
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 { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useFonts } from "expo-font";
|
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 { useKeepAwake } from "expo-keep-awake";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { Stack } 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";
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
@@ -61,7 +63,7 @@ function Layout() {
|
|||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,111 +71,117 @@ function Layout() {
|
|||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||||
else
|
else
|
||||||
ScreenOrientation.lockAsync(
|
ScreenOrientation.lockAsync(
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
);
|
);
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<JobQueueProvider>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<ActionSheetProvider>
|
<JobQueueProvider>
|
||||||
<JellyfinProvider>
|
<ActionSheetProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<BottomSheetModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<JellyfinProvider>
|
||||||
<Stack initialRouteName="/home">
|
<PlaybackProvider>
|
||||||
<Stack.Screen
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
name="(auth)/(tabs)"
|
<ThemeProvider value={DarkTheme}>
|
||||||
options={{
|
<Stack initialRouteName="/home">
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
title: "",
|
name="(auth)/(tabs)"
|
||||||
}}
|
options={{
|
||||||
/>
|
headerShown: false,
|
||||||
<Stack.Screen
|
title: "",
|
||||||
name="(auth)/settings"
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: true,
|
<Stack.Screen
|
||||||
title: "Settings",
|
name="(auth)/settings"
|
||||||
headerStyle: { backgroundColor: "black" },
|
options={{
|
||||||
headerShadowVisible: false,
|
headerShown: true,
|
||||||
}}
|
title: "Settings",
|
||||||
/>
|
headerStyle: { backgroundColor: "black" },
|
||||||
<Stack.Screen
|
headerShadowVisible: false,
|
||||||
name="(auth)/downloads"
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: true,
|
<Stack.Screen
|
||||||
title: "Downloads",
|
name="(auth)/downloads"
|
||||||
headerStyle: { backgroundColor: "black" },
|
options={{
|
||||||
headerShadowVisible: false,
|
headerShown: true,
|
||||||
}}
|
title: "Downloads",
|
||||||
/>
|
headerStyle: { backgroundColor: "black" },
|
||||||
<Stack.Screen
|
headerShadowVisible: false,
|
||||||
name="(auth)/items/[id]"
|
}}
|
||||||
options={{
|
/>
|
||||||
title: "",
|
<Stack.Screen
|
||||||
headerShown: false,
|
name="(auth)/items/[id]"
|
||||||
}}
|
options={{
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen
|
headerShown: false,
|
||||||
name="(auth)/collections/[collectionId]"
|
}}
|
||||||
options={{
|
/>
|
||||||
title: "",
|
<Stack.Screen
|
||||||
headerShown: true,
|
name="(auth)/collections/[collectionId]"
|
||||||
headerStyle: { backgroundColor: "black" },
|
options={{
|
||||||
headerShadowVisible: false,
|
title: "",
|
||||||
}}
|
headerShown: true,
|
||||||
/>
|
headerStyle: { backgroundColor: "black" },
|
||||||
<Stack.Screen
|
headerShadowVisible: false,
|
||||||
name="(auth)/artists/page"
|
}}
|
||||||
options={{
|
/>
|
||||||
title: "",
|
<Stack.Screen
|
||||||
headerShown: true,
|
name="(auth)/artists/page"
|
||||||
headerStyle: { backgroundColor: "black" },
|
options={{
|
||||||
headerShadowVisible: false,
|
title: "",
|
||||||
}}
|
headerShown: true,
|
||||||
/>
|
headerStyle: { backgroundColor: "black" },
|
||||||
<Stack.Screen
|
headerShadowVisible: false,
|
||||||
name="(auth)/artists/[artistId]/page"
|
}}
|
||||||
options={{
|
/>
|
||||||
title: "",
|
<Stack.Screen
|
||||||
headerShown: true,
|
name="(auth)/artists/[artistId]/page"
|
||||||
headerStyle: { backgroundColor: "black" },
|
options={{
|
||||||
headerShadowVisible: false,
|
title: "",
|
||||||
}}
|
headerShown: true,
|
||||||
/>
|
headerStyle: { backgroundColor: "black" },
|
||||||
<Stack.Screen
|
headerShadowVisible: false,
|
||||||
name="(auth)/albums/[albumId]"
|
}}
|
||||||
options={{
|
/>
|
||||||
title: "",
|
<Stack.Screen
|
||||||
headerShown: true,
|
name="(auth)/albums/[albumId]"
|
||||||
headerStyle: { backgroundColor: "black" },
|
options={{
|
||||||
headerShadowVisible: false,
|
title: "",
|
||||||
}}
|
headerShown: true,
|
||||||
/>
|
headerStyle: { backgroundColor: "black" },
|
||||||
<Stack.Screen
|
headerShadowVisible: false,
|
||||||
name="(auth)/songs/[songId]"
|
}}
|
||||||
options={{
|
/>
|
||||||
title: "",
|
<Stack.Screen
|
||||||
headerShown: false,
|
name="(auth)/songs/[songId]"
|
||||||
}}
|
options={{
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen
|
headerShown: false,
|
||||||
name="(auth)/series/[id]"
|
}}
|
||||||
options={{
|
/>
|
||||||
title: "",
|
<Stack.Screen
|
||||||
headerShown: false,
|
name="(auth)/series/[id]"
|
||||||
}}
|
options={{
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen
|
headerShown: false,
|
||||||
name="login"
|
}}
|
||||||
options={{ headerShown: false, title: "Login" }}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen name="+not-found" />
|
name="login"
|
||||||
</Stack>
|
options={{ headerShown: false, title: "Login" }}
|
||||||
<CurrentlyPlayingBar />
|
/>
|
||||||
</ThemeProvider>
|
<Stack.Screen name="+not-found" />
|
||||||
</JellyfinProvider>
|
</Stack>
|
||||||
</ActionSheetProvider>
|
<CurrentlyPlayingBar />
|
||||||
</JobQueueProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</PlaybackProvider>
|
||||||
|
</JellyfinProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</ActionSheetProvider>
|
||||||
|
</JobQueueProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
204
app/login.tsx
204
app/login.tsx
@@ -6,7 +6,13 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { KeyboardAvoidingView, Platform, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
SafeAreaView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -46,102 +52,134 @@ const Login: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConnect = (url: string) => {
|
const handleConnect = (url: string) => {
|
||||||
|
if (!url.startsWith("http")) {
|
||||||
|
Alert.alert("Error", "URL needs to start with http or https.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setServer({ address: url.trim() });
|
setServer({ address: url.trim() });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (api?.basePath) {
|
if (api?.basePath) {
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
>
|
style={{ flex: 1, height: "100%" }}
|
||||||
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
|
>
|
||||||
<View>
|
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<View></View>
|
||||||
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
|
<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>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="black"
|
onPress={handleLogin}
|
||||||
onPress={() => {
|
loading={loading}
|
||||||
removeServer();
|
className="mt-auto mb-2"
|
||||||
setServerURL("");
|
|
||||||
}}
|
|
||||||
justify="between"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Change server
|
Log in
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col space-y-2">
|
</KeyboardAvoidingView>
|
||||||
<Text className="text-2xl font-bold">Log in</Text>
|
</SafeAreaView>
|
||||||
<Input
|
|
||||||
placeholder="Username"
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials({ ...credentials, username: text })
|
|
||||||
}
|
|
||||||
value={credentials.username}
|
|
||||||
autoFocus
|
|
||||||
secureTextEntry={false}
|
|
||||||
keyboardType="default"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="username"
|
|
||||||
clearButtonMode="while-editing"
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
className="mb-2"
|
|
||||||
placeholder="Password"
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials({ ...credentials, password: text })
|
|
||||||
}
|
|
||||||
value={credentials.password}
|
|
||||||
secureTextEntry
|
|
||||||
keyboardType="default"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="password"
|
|
||||||
clearButtonMode="while-editing"
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className="text-red-600 mb-2">{error}</Text>
|
|
||||||
|
|
||||||
<Button onPress={handleLogin} loading={loading}>
|
|
||||||
Log in
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
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">
|
<View className="flex flex-col px-4 justify-between h-full">
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<View></View>
|
||||||
<Text className="opacity-50">Enter a server adress</Text>
|
<View className="flex flex-col gap-y-2">
|
||||||
<Input
|
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||||
className="mb-2"
|
<Text className="text-neutral-500">
|
||||||
placeholder="http(s)://..."
|
Connect to your Jellyfin server
|
||||||
onChangeText={setServerURL}
|
</Text>
|
||||||
value={serverURL}
|
<Input
|
||||||
keyboardType="url"
|
placeholder="Server URL"
|
||||||
returnKeyType="done"
|
onChangeText={setServerURL}
|
||||||
autoCapitalize="none"
|
value={serverURL}
|
||||||
textContentType="URL"
|
keyboardType="url"
|
||||||
maxLength={500}
|
returnKeyType="done"
|
||||||
/>
|
autoCapitalize="none"
|
||||||
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
|
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>
|
||||||
</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) {
|
module.exports = function (api) {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ['babel-preset-expo'],
|
presets: ["babel-preset-expo"],
|
||||||
plugins: ["nativewind/babel"],
|
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 * 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> {
|
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -57,7 +58,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color={"white"} size={24} />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -21,23 +21,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
getPrimaryImageUrl({
|
getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
quality: 70,
|
quality: 90,
|
||||||
width: 300,
|
width: 176 * 2,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [progress, setProgress] = useState(
|
const [progress, setProgress] = useState(
|
||||||
item.UserData?.PlayedPercentage || 0,
|
item.UserData?.PlayedPercentage || 0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return (
|
return (
|
||||||
<View className="w-48 aspect-video border border-neutral-800"></View>
|
<View className="w-44 aspect-video border border-neutral-800"></View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
<View className="w-44 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||||
<Image
|
<Image
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
id={item.Id}
|
||||||
|
|||||||
@@ -1,56 +1,40 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
|
||||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import {
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
import Video from "react-native-video";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
export const currentlyPlayingItemAtom = atom<{
|
import { VLCPlayer, VlCPlayerView } from "react-native-vlc-media-player";
|
||||||
item: BaseItemDto;
|
|
||||||
playbackUrl: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
export const playingAtom = atom(false);
|
|
||||||
export const fullScreenAtom = atom(false);
|
|
||||||
|
|
||||||
export const CurrentlyPlayingBar: React.FC = () => {
|
export const CurrentlyPlayingBar: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
const {
|
||||||
|
currentlyPlaying,
|
||||||
|
pauseVideo,
|
||||||
|
playVideo,
|
||||||
|
setCurrentlyPlayingState,
|
||||||
|
stopPlayback,
|
||||||
|
setIsPlaying,
|
||||||
|
isPlaying,
|
||||||
|
videoRef,
|
||||||
|
onProgress,
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [playing, setPlaying] = useAtom(playingAtom);
|
|
||||||
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
);
|
|
||||||
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
|
|
||||||
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
|
|
||||||
const aBottom = useSharedValue(0);
|
const aBottom = useSharedValue(0);
|
||||||
const aPadding = useSharedValue(0);
|
const aPadding = useSharedValue(0);
|
||||||
@@ -98,109 +82,28 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [segments]);
|
}, [segments]);
|
||||||
|
|
||||||
const { data: item } = useQuery({
|
|
||||||
queryKey: ["item", currentlyPlaying?.item.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: currentlyPlaying?.item.Id,
|
|
||||||
}),
|
|
||||||
enabled: !!currentlyPlaying?.item.Id && !!api,
|
|
||||||
staleTime: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
|
||||||
queryKey: ["sessionData", currentlyPlaying?.item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!currentlyPlaying?.item.Id) return null;
|
|
||||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
|
||||||
itemId: currentlyPlaying?.item.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
return playbackData.data;
|
|
||||||
},
|
|
||||||
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
({ currentTime }: OnProgressData) => {
|
|
||||||
if (
|
|
||||||
!currentTime ||
|
|
||||||
!sessionData?.PlaySessionId ||
|
|
||||||
!playing ||
|
|
||||||
!api ||
|
|
||||||
!currentlyPlaying?.item.Id
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const newProgress = currentTime * 10000000;
|
|
||||||
setProgress(newProgress);
|
|
||||||
reportPlaybackProgress({
|
|
||||||
api,
|
|
||||||
itemId: currentlyPlaying?.item.Id,
|
|
||||||
positionTicks: newProgress,
|
|
||||||
sessionId: sessionData.PlaySessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!item || !api) return;
|
|
||||||
|
|
||||||
if (playing) {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
if (progress > 0 && sessionData?.PlaySessionId)
|
|
||||||
reportPlaybackStopped({
|
|
||||||
api,
|
|
||||||
itemId: item?.Id,
|
|
||||||
positionTicks: progress,
|
|
||||||
sessionId: sessionData?.PlaySessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["nextUp", item?.SeriesId],
|
|
||||||
refetchType: "all",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["episodes"],
|
|
||||||
refetchType: "all",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [playing, progress, item, sessionData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("Full screen changed", fullScreen);
|
|
||||||
if (fullScreen === true) {
|
|
||||||
videoRef.current?.presentFullscreenPlayer();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.dismissFullscreenPlayer();
|
|
||||||
}
|
|
||||||
}, [fullScreen]);
|
|
||||||
|
|
||||||
const startPosition = useMemo(
|
const startPosition = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item?.UserData?.PlaybackPositionTicks
|
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
? Math.round(
|
||||||
|
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
||||||
|
)
|
||||||
: 0,
|
: 0,
|
||||||
[item],
|
[currentlyPlaying?.item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getBackdropUrl({
|
getBackdropUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item: currentlyPlaying?.item,
|
||||||
quality: 70,
|
quality: 70,
|
||||||
width: 200,
|
width: 200,
|
||||||
}),
|
}),
|
||||||
[item],
|
[currentlyPlaying?.item, api]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentlyPlaying || !api) return null;
|
if (!api || !currentlyPlaying) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -227,115 +130,129 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
videoRef.current?.presentFullscreenPlayer();
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
}}
|
}}
|
||||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
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"
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{currentlyPlaying.playbackUrl && (
|
{currentlyPlaying?.url && (
|
||||||
<Video
|
// <Video
|
||||||
ref={videoRef}
|
// ref={videoRef}
|
||||||
allowsExternalPlayback
|
// allowsExternalPlayback
|
||||||
style={{ width: "100%", height: "100%" }}
|
// style={{ width: "100%", height: "100%" }}
|
||||||
playWhenInactive={true}
|
// playWhenInactive={true}
|
||||||
playInBackground={true}
|
// playInBackground={true}
|
||||||
showNotificationControls={true}
|
// showNotificationControls={true}
|
||||||
ignoreSilentSwitch="ignore"
|
// ignoreSilentSwitch="ignore"
|
||||||
controls={false}
|
// controls={false}
|
||||||
pictureInPicture={true}
|
// pictureInPicture={true}
|
||||||
poster={
|
// poster={
|
||||||
backdropUrl && item?.Type === "Audio"
|
// backdropUrl && currentlyPlaying.item?.Type === "Audio"
|
||||||
? backdropUrl
|
// ? backdropUrl
|
||||||
: undefined
|
// : undefined
|
||||||
}
|
// }
|
||||||
debug={{
|
// debug={{
|
||||||
enable: true,
|
// enable: true,
|
||||||
thread: true,
|
// thread: true,
|
||||||
}}
|
// }}
|
||||||
paused={!playing}
|
// paused={!isPlaying}
|
||||||
onProgress={(e) => onProgress(e)}
|
// onProgress={(e) => onProgress(e)}
|
||||||
subtitleStyle={{
|
// subtitleStyle={{
|
||||||
fontSize: 16,
|
// fontSize: 16,
|
||||||
|
// }}
|
||||||
|
// source={{
|
||||||
|
// uri: currentlyPlaying.url,
|
||||||
|
// isNetwork: true,
|
||||||
|
// startPosition,
|
||||||
|
// headers: getAuthHeaders(api),
|
||||||
|
// }}
|
||||||
|
// onBuffer={(e) =>
|
||||||
|
// e.isBuffering ? console.log("Buffering...") : null
|
||||||
|
// }
|
||||||
|
// onFullscreenPlayerDidDismiss={() => {}}
|
||||||
|
// onFullscreenPlayerDidPresent={() => {}}
|
||||||
|
// onPlaybackStateChanged={(e) => {
|
||||||
|
// if (e.isPlaying) {
|
||||||
|
// setIsPlaying(true);
|
||||||
|
// } else if (e.isSeeking) {
|
||||||
|
// return;
|
||||||
|
// } else {
|
||||||
|
// setIsPlaying(false);
|
||||||
|
// }
|
||||||
|
// }}
|
||||||
|
// progressUpdateInterval={2000}
|
||||||
|
// onError={(e) => {
|
||||||
|
// console.log(e);
|
||||||
|
// writeToLog(
|
||||||
|
// "ERROR",
|
||||||
|
// "Video playback error: " + JSON.stringify(e)
|
||||||
|
// );
|
||||||
|
// Alert.alert("Error", "Cannot play this video file.");
|
||||||
|
// setIsPlaying(false);
|
||||||
|
// // setCurrentlyPlaying(null);
|
||||||
|
// }}
|
||||||
|
// renderLoader={
|
||||||
|
// currentlyPlaying.item?.Type !== "Audio" && (
|
||||||
|
// <View className="flex flex-col items-center justify-center h-full">
|
||||||
|
// <Loader />
|
||||||
|
// </View>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// />
|
||||||
|
<VlCPlayerView
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
source={{
|
source={{
|
||||||
uri: currentlyPlaying.playbackUrl,
|
uri: encodeURIComponent(currentlyPlaying.url),
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
}}
|
}}
|
||||||
onBuffer={(e) =>
|
key={"1"}
|
||||||
e.isBuffering ? console.log("Buffering...") : null
|
autoAspectRatio={true}
|
||||||
}
|
resizeMode="cover"
|
||||||
onFullscreenPlayerDidDismiss={() => {
|
|
||||||
setFullScreen(false);
|
|
||||||
}}
|
|
||||||
onFullscreenPlayerDidPresent={() => {
|
|
||||||
setFullScreen(true);
|
|
||||||
}}
|
|
||||||
onPlaybackStateChanged={(e) => {
|
|
||||||
if (e.isPlaying) {
|
|
||||||
setPlaying(true);
|
|
||||||
} else if (e.isSeeking) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
setPlaying(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={1000}
|
|
||||||
onError={(e) => {
|
|
||||||
console.log(e);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
"Video playback error: " + JSON.stringify(e),
|
|
||||||
);
|
|
||||||
Alert.alert("Error", "Cannot play this video file.");
|
|
||||||
setPlaying(false);
|
|
||||||
setCurrentlyPlaying(null);
|
|
||||||
}}
|
|
||||||
renderLoader={
|
|
||||||
item?.Type !== "Audio" && (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View className="shrink text-xs">
|
<View className="shrink text-xs">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
console.log(JSON.stringify(item));
|
if (currentlyPlaying.item?.Type === "Audio")
|
||||||
if (item?.Type === "Audio")
|
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||||
router.push(`/albums/${item?.AlbumId}`);
|
else router.push(`/items/${currentlyPlaying.item?.Id}`);
|
||||||
else router.push(`/items/${item?.Id}`);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text>{item?.Name}</Text>
|
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{item?.Type === "Episode" && (
|
{currentlyPlaying.item?.Type === "Episode" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/(auth)/series/${item.SeriesId}`);
|
router.push(
|
||||||
|
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className="text-xs opacity-50"
|
className="text-xs opacity-50"
|
||||||
>
|
>
|
||||||
<Text>{item.SeriesName}</Text>
|
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{item?.Type === "Movie" && (
|
{currentlyPlaying.item?.Type === "Movie" && (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
{item?.ProductionYear}
|
{currentlyPlaying.item?.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{item?.Type === "Audio" && (
|
{currentlyPlaying.item?.Type === "Audio" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
console.log(JSON.stringify(item));
|
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||||
router.push(`/albums/${item?.AlbumId}`);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.Album}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -343,12 +260,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (playing) setPlaying(false);
|
if (isPlaying) pauseVideo();
|
||||||
else setPlaying(true);
|
else playVideo();
|
||||||
}}
|
}}
|
||||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
{playing ? (
|
{isPlaying ? (
|
||||||
<Ionicons name="pause" size={24} color="white" />
|
<Ionicons name="pause" size={24} color="white" />
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="play" size={24} color="white" />
|
<Ionicons name="play" size={24} color="white" />
|
||||||
@@ -356,7 +273,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setCurrentlyPlaying(null);
|
stopPlayback();
|
||||||
}}
|
}}
|
||||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
|
|
||||||
type DownloadProps = {
|
type DownloadProps = {
|
||||||
@@ -39,7 +40,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
|||||||
if (!item.Id) return false;
|
if (!item.Id) return false;
|
||||||
|
|
||||||
const data: BaseItemDto[] = JSON.parse(
|
const data: BaseItemDto[] = JSON.parse(
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||||
);
|
);
|
||||||
|
|
||||||
return data.some((d) => d.Id === item.Id);
|
return data.some((d) => d.Id === item.Id);
|
||||||
@@ -50,7 +51,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
|||||||
if (isLoading || isLoadingDownloaded) {
|
if (isLoading || isLoadingDownloaded) {
|
||||||
return (
|
return (
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
|||||||
>
|
>
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
{process.progress === 0 ? (
|
{process.progress === 0 ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<View className="-rotate-45">
|
<View className="-rotate-45">
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
|
|||||||
@@ -8,17 +8,6 @@ type ItemCardProps = {
|
|||||||
item: BaseItemDto;
|
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 }) => {
|
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||||
return (
|
return (
|
||||||
<View className="mt-2 flex flex-col h-12">
|
<View className="mt-2 flex flex-col h-12">
|
||||||
@@ -28,15 +17,13 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
|||||||
{item.SeriesName}
|
{item.SeriesName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
{`S${seasonNameToIndex(
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||||
item?.SeasonName,
|
|
||||||
)}:E${item.IndexNumber?.toString()}`}{" "}
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text>{item.Name}</Text>
|
<Text numberOfLines={2}>{item.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import CastContext, {
|
||||||
|
PlayServicesState,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item?: BaseItemDto | null;
|
||||||
onPress: (type?: "cast" | "device") => void;
|
url?: string | null;
|
||||||
chromecastReady: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||||
item,
|
|
||||||
onPress,
|
|
||||||
chromecastReady,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const client = useRemoteMediaClient();
|
||||||
|
const { currentlyPlaying, setCurrentlyPlayingState } = usePlayback();
|
||||||
|
|
||||||
const _onPress = () => {
|
const onPress = async () => {
|
||||||
if (!chromecastReady) {
|
if (!url || !item) return;
|
||||||
onPress("device");
|
|
||||||
|
if (!client) {
|
||||||
|
setCurrentlyPlayingState({ item, url });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,28 +36,45 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
options,
|
options,
|
||||||
cancelButtonIndex,
|
cancelButtonIndex,
|
||||||
},
|
},
|
||||||
(selectedIndex: number | undefined) => {
|
async (selectedIndex: number | undefined) => {
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
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;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
onPress("device");
|
setCurrentlyPlayingState({ item, url });
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onPress={_onPress}
|
onPress={onPress}
|
||||||
iconRight={
|
iconRight={
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Ionicons name="play-circle" size={24} color="white" />
|
<Ionicons name="play-circle" size={24} color="white" />
|
||||||
{chromecastReady && <Feather name="cast" size={22} color="white" />}
|
{client && <Feather name="cast" size={22} color="white" />}
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback } from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
@@ -15,15 +15,15 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = useCallback(() => {
|
const invalidateQueries = () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["item", item.Id],
|
queryKey: ["item"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["resumeItems", user?.Id],
|
queryKey: ["resumeItems"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["nextUp", item.SeriesId],
|
queryKey: ["nextUp"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["episodes"],
|
queryKey: ["episodes"],
|
||||||
@@ -31,7 +31,10 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["seasons"],
|
queryKey: ["seasons"],
|
||||||
});
|
});
|
||||||
}, [api, item.Id, queryClient, user?.Id]);
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["nextUp-all"],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
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 { useMemo } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { ItemCardText } from "./ItemCardText";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
type SimilarItemsProps = {
|
type SimilarItemsProps = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -42,7 +37,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
|||||||
|
|
||||||
const movies = useMemo(
|
const movies = useMemo(
|
||||||
() => similarItems?.filter((i) => i.Type === "Movie") || [],
|
() => similarItems?.filter((i) => i.Type === "Movie") || [],
|
||||||
[similarItems],
|
[similarItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +45,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
|||||||
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
|
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View className="my-12">
|
<View className="my-12">
|
||||||
<ActivityIndicator />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView horizontal>
|
<ScrollView horizontal>
|
||||||
|
|||||||
@@ -37,11 +37,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
if (index !== undefined && index !== null) {
|
if (index !== undefined && index !== null) {
|
||||||
onChange(index);
|
onChange(index);
|
||||||
} else {
|
} else {
|
||||||
// Get first subtitle stream
|
onChange(-1);
|
||||||
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
|
|
||||||
if (firstSubtitle?.Index !== undefined) {
|
|
||||||
onChange(firstSubtitle.Index);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -56,7 +52,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
<View className="flex flex-row">
|
<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">
|
<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="">
|
<Text className="">
|
||||||
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
|
{selectedSubtitleSteam
|
||||||
|
? tc(selectedSubtitleSteam?.DisplayTitle, 13)
|
||||||
|
: "None"}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -72,6 +70,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
|
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={"-1"}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(-1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={idx.toString()}
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import {
|
import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native";
|
||||||
ScrollView,
|
|
||||||
View,
|
|
||||||
ViewStyle,
|
|
||||||
ActivityIndicator,
|
|
||||||
ScrollViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
interface HorizontalScrollProps<T> extends ScrollViewProps {
|
interface HorizontalScrollProps<T> extends ScrollViewProps {
|
||||||
@@ -58,7 +53,7 @@ export function HorizontalScroll<T>({
|
|||||||
loadingContainerStyle,
|
loadingContainerStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ActivityIndicator size="small" />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
164
components/common/InfiniteHorrizontalScroll.tsx
Normal file
164
components/common/InfiniteHorrizontalScroll.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
NativeScrollEvent,
|
||||||
|
ScrollView,
|
||||||
|
ScrollViewProps,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
import { Text } from "./Text";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
interface HorizontalScrollProps extends ScrollViewProps {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCloseToBottom = ({
|
||||||
|
layoutMeasurement,
|
||||||
|
contentOffset,
|
||||||
|
contentSize,
|
||||||
|
}: NativeScrollEvent) => {
|
||||||
|
const paddingToBottom = 50;
|
||||||
|
return (
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - paddingToBottom
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 navigation = useNavigation();
|
||||||
|
|
||||||
|
const animatedOpacity = useSharedValue(0);
|
||||||
|
const animatedStyle1 = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage } = 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
onScroll={({ nativeEvent }) => {
|
||||||
|
if (isCloseToBottom(nativeEvent)) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={400}
|
||||||
|
style={containerStyle}
|
||||||
|
contentContainerStyle={contentContainerStyle}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
className={`
|
||||||
|
flex flex-row px-4
|
||||||
|
`}
|
||||||
|
style={[animatedStyle1]}
|
||||||
|
>
|
||||||
|
{data?.pages
|
||||||
|
.flatMap((page) => page?.Items)
|
||||||
|
.map(
|
||||||
|
(item, index) =>
|
||||||
|
item && (
|
||||||
|
<View className="mr-2" key={index}>
|
||||||
|
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{data?.pages.flatMap((page) => page?.Items).length === 0 && (
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<Text className="text-center text-gray-500">No data available</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
import { useFocusEffect } from "expo-router";
|
import React from "react";
|
||||||
import React, { useEffect } from "react";
|
import { TextInput, TextInputProps } from "react-native";
|
||||||
import { TextInputProps, TextProps } from "react-native";
|
|
||||||
import { TextInput } from "react-native";
|
|
||||||
export function Input(props: TextInputProps) {
|
export function Input(props: TextInputProps) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
const inputRef = React.useRef<TextInput>(null);
|
const inputRef = React.useRef<TextInput>(null);
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
61
components/common/TouchableItemRouter.tsx
Normal file
61
components/common/TouchableItemRouter.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
item,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
if (item.Type === "Series") {
|
||||||
|
router.push(`/series/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
router.push(`/items/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "MusicAlbum") {
|
||||||
|
router.push(`/albums/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "Audio") {
|
||||||
|
router.push(`/albums/${item.AlbumId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "MusicArtist") {
|
||||||
|
router.push(`/artists/${item.Id}/page`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "BoxSet") {
|
||||||
|
router.push(`/collections/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/items/${item.Id}`);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,12 +8,12 @@ import { useAtom } from "jotai";
|
|||||||
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
currentlyPlayingItemAtom,
|
currentlyPlayingItemAtom,
|
||||||
fullScreenAtom,
|
fullScreenAtom,
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "../CurrentlyPlayingBar";
|
} from "@/utils/atoms/playState";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
interface EpisodeCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { useAtom } from "jotai";
|
|||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
currentlyPlayingItemAtom,
|
currentlyPlayingItemAtom,
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "../CurrentlyPlayingBar";
|
fullScreenAtom,
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
} from "@/utils/atoms/playState";
|
||||||
|
|
||||||
interface MovieCardProps {
|
interface MovieCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -81,7 +82,12 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content
|
||||||
|
loop={false}
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={false}
|
||||||
|
collisionPadding={0}
|
||||||
|
>
|
||||||
{contextMenuOptions.map((option) => (
|
{contextMenuOptions.map((option) => (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key={option.label}
|
key={option.label}
|
||||||
|
|||||||
95
components/filters/FilterButton.tsx
Normal file
95
components/filters/FilterButton.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { 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: [queryKey, collectionId],
|
||||||
|
queryFn,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters?.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity onPress={() => 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"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
{...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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
190
components/filters/FilterSheet.tsx
Normal file
190
components/filters/FilterSheet.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
set(
|
||||||
|
values.includes(item)
|
||||||
|
? values.filter((i) => i !== item)
|
||||||
|
: [item]
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, 250);
|
||||||
|
}}
|
||||||
|
key={`${index}`}
|
||||||
|
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>
|
||||||
|
{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-8 h-8 flex items-center justify-center"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
93
components/filters/_SortButton.tsx
Normal file
93
components/filters/_SortButton.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import {
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SortButton: React.FC<Props> = ({ title, ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
px-3 py-2 rounded-full flex flex-row items-center space-x-2 bg-neutral-900
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text>Sort by</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="filter"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
{sortOptions?.map((g) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
value={sortBy.key === g.key ? "on" : "off"}
|
||||||
|
onValueChange={(next, previous) => {
|
||||||
|
if (next === "on") {
|
||||||
|
setSortBy(g);
|
||||||
|
} else {
|
||||||
|
setSortBy(sortOptions[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={g.key}
|
||||||
|
textValue={g.value}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
{sortOrderOptions.map((g) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
value={sortOrder.key === g.key ? "on" : "off"}
|
||||||
|
onValueChange={(next, previous) => {
|
||||||
|
if (next === "on") {
|
||||||
|
setSortOrder(g);
|
||||||
|
} else {
|
||||||
|
setSortOrder(sortOrderOptions[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={g.key}
|
||||||
|
textValue={g.value}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
components/home/LargeMovieCarousel.tsx
Normal file
176
components/home/LargeMovieCarousel.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
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 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: mediaListCollection, isLoading: l1 } = useQuery<string | null>({
|
||||||
|
queryKey: ["mediaListCollection", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return null;
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
tags: ["medialist", "promoted"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["Tags"],
|
||||||
|
includeItemTypes: ["BoxSet"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
|
||||||
|
return id || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: popularItems, isLoading: l2 } = useQuery<BaseItemDto[]>({
|
||||||
|
queryKey: ["popular", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !mediaListCollection) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
parentId: mediaListCollection,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!mediaListCollection,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
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={2000}
|
||||||
|
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 uri = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 1000,
|
||||||
|
});
|
||||||
|
}, [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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router } from "expo-router";
|
import { View, ViewProps } from "react-native";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import MoviePoster from "../MoviePoster";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -29,24 +29,19 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-2xl font-bold mb-2">{title}</Text>
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
data={data}
|
data={data}
|
||||||
height={orientation === "vertical" ? 247 : 164}
|
height={orientation === "vertical" ? 247 : 164}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
key={index}
|
key={index}
|
||||||
onPress={() => {
|
item={item}
|
||||||
if (item.Type === "Series") router.push(`/series/${item.Id}`);
|
|
||||||
else if (item.CollectionType === "music")
|
|
||||||
router.push(`/artists/page?collectionId=${item.Id}`);
|
|
||||||
else if (item.Type === "CollectionFolder")
|
|
||||||
router.push(`/collections/${item.Id}`);
|
|
||||||
else router.push(`/items/${item.Id}`);
|
|
||||||
}}
|
|
||||||
className={`flex flex-col
|
className={`flex flex-col
|
||||||
${orientation === "vertical" ? "w-32" : "w-48"}
|
${orientation === "vertical" ? "w-28" : "w-44"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
@@ -57,7 +52,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
74
components/medialists/MediaListSection.tsx
Normal file
74
components/medialists/MediaListSection.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { ScrollingCollectionList } from "../home/ScrollingCollectionList";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import MoviePoster from "../posters/MoviePoster";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
collection: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
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,
|
||||||
|
parentId: collection.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,9 +1,12 @@
|
|||||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
// 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 Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
|
import { type IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||||
import { type ComponentProps } from 'react';
|
import { type ComponentProps } from "react";
|
||||||
|
|
||||||
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
|
export function TabBarIcon({
|
||||||
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
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-md 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,11 +1,10 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
type ArtistPosterProps = {
|
type ArtistPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -24,7 +23,7 @@ const ArtistPoster: React.FC<ArtistPosterProps> = ({
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
type MoviePosterProps = {
|
type MoviePosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -24,35 +24,38 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [progress, setProgress] = useState(
|
const [progress, setProgress] = useState(
|
||||||
item.UserData?.PlayedPercentage || 0,
|
item.UserData?.PlayedPercentage || 0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!url)
|
const blurhash = useMemo(() => {
|
||||||
return (
|
const key = item.ImageTags?.["Primary"] as string;
|
||||||
<View
|
return item.ImageBlurHashes?.["Primary"]?.[key];
|
||||||
className="rounded-md overflow-hidden border border-neutral-900"
|
}, [item]);
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
id={item.Id}
|
||||||
source={{
|
source={
|
||||||
uri: url,
|
url
|
||||||
}}
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "10/15",
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<WatchedIndicator item={item} />
|
<WatchedIndicator item={item} />
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -9,10 +9,11 @@ type PosterProps = {
|
|||||||
item?: BaseItemDto | BaseItemPerson | null;
|
item?: BaseItemDto | BaseItemPerson | null;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
showProgress?: boolean;
|
showProgress?: boolean;
|
||||||
|
blurhash?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Poster: React.FC<PosterProps> = ({ item, url }) => {
|
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
||||||
if (!url || !item)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="border border-neutral-900"
|
className="border border-neutral-900"
|
||||||
@@ -25,11 +26,22 @@ const Poster: React.FC<PosterProps> = ({ item, url }) => {
|
|||||||
return (
|
return (
|
||||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
<View className="rounded-md overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
|
placeholder={
|
||||||
|
blurhash
|
||||||
|
? {
|
||||||
|
blurhash,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
id={item.Id}
|
||||||
source={{
|
source={
|
||||||
uri: url,
|
url
|
||||||
}}
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={{
|
style={{
|
||||||
58
components/posters/SeriesPoster.tsx
Normal file
58
components/posters/SeriesPoster.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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(
|
||||||
|
() =>
|
||||||
|
getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.["Primary"] as string;
|
||||||
|
return item.ImageBlurHashes?.["Primary"]?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="relative rounded-md 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;
|
||||||
@@ -6,15 +6,17 @@ import React from "react";
|
|||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../Poster";
|
import Poster from "../posters/Poster";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { router } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
|
|
||||||
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
@@ -23,7 +25,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
|||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// TODO: Navigate to person
|
router.push(`/search?q=${item.Name}&prev=${pathname}`);
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-32"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { router } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Poster from "../Poster";
|
import Poster from "../posters/Poster";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../Poster";
|
import Poster from "../posters/Poster";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
@@ -52,7 +52,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
router.push(`/(auth)/items/${item.Id}`);
|
router.push(`/(auth)/items/${item.Id}`);
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { 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 { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
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 = () => {
|
export const SettingToggles: React.FC = () => {
|
||||||
const [settings, updateSettings] = useSettings();
|
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: ["mediaListCollections", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
tags: ["medialist", "promoted"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["Tags"],
|
||||||
|
includeItemTypes: ["BoxSet"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids =
|
||||||
|
response.data.Items?.filter((c) => c.Name !== "sf_carousel") ?? [];
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
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-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
@@ -36,25 +76,76 @@ export const SettingToggles: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-col">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<Text className="font-semibold">Use popular lists plugin</Text>
|
<View className="flex flex-col">
|
||||||
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||||
<TouchableOpacity
|
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||||
onPress={() => {
|
<TouchableOpacity
|
||||||
Linking.openURL(
|
onPress={() => {
|
||||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists",
|
Linking.openURL(
|
||||||
);
|
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
||||||
}}
|
);
|
||||||
>
|
}}
|
||||||
<Text className="text-xs text-purple-600">More info</Text>
|
>
|
||||||
</TouchableOpacity>
|
<Text className="text-xs text-purple-600">More info</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings?.usePopularPlugin}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ usePopularPlugin: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
{settings?.usePopularPlugin && (
|
||||||
value={settings?.usePopularPlugin}
|
<View className="flex flex-col py-2 bg-neutral-900">
|
||||||
onValueChange={(value) => updateSettings({ usePopularPlugin: value })}
|
{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>
|
||||||
|
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Force direct play</Text>
|
<Text className="font-semibold">Force direct play</Text>
|
||||||
@@ -124,6 +215,89 @@ export const SettingToggles: React.FC = () => {
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Search engine</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose the search engine you want to use.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>{settings?.searchEngine}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ searchEngine: "Marlin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
{settings?.searchEngine === "Marlin" && (
|
||||||
|
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<View className="grow">
|
||||||
|
<Input
|
||||||
|
placeholder="Marlin Server URL..."
|
||||||
|
defaultValue={settings.marlinServerUrl}
|
||||||
|
value={marlinUrl}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={(text) => setMarlinUrl(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
className="shrink w-16 h-12"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ marlinServerUrl: marlinUrl });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-neutral-500 mt-2">
|
||||||
|
{settings?.marlinServerUrl}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.6.0",
|
"channel": "0.6.2",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.6.0",
|
"channel": "0.6.2",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -18,20 +18,23 @@
|
|||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
|
"@gorhom/bottom-sheet": "^4",
|
||||||
"@jellyfin/sdk": "^0.10.0",
|
"@jellyfin/sdk": "^0.10.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.2",
|
"@react-native-menu/menu": "^1.1.2",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
|
"@shopify/flash-list": "1.6.4",
|
||||||
"@tanstack/react-query": "^5.51.16",
|
"@tanstack/react-query": "^5.51.16",
|
||||||
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.7.3",
|
"axios": "^1.7.3",
|
||||||
"expo": "~51.0.27",
|
"expo": "~51.0.28",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-dev-client": "~4.0.22",
|
"expo-dev-client": "~4.0.23",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "~12.0.9",
|
"expo-font": "~12.0.9",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-haptics": "~13.0.1",
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
"expo-navigation-bar": "~3.0.7",
|
"expo-navigation-bar": "~3.0.7",
|
||||||
"expo-router": "~3.5.21",
|
"expo-router": "~3.5.23",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
"expo-screen-orientation": "~7.0.5",
|
||||||
"expo-sensors": "~13.0.9",
|
"expo-sensors": "~13.0.9",
|
||||||
"expo-splash-screen": "~0.27.5",
|
"expo-splash-screen": "~0.27.5",
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
"ffmpeg-kit-react-native": "^6.0.2",
|
||||||
"jotai": "^2.9.1",
|
"jotai": "^2.9.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
@@ -61,14 +65,17 @@
|
|||||||
"react-native-ios-context-menu": "^2.5.1",
|
"react-native-ios-context-menu": "^2.5.1",
|
||||||
"react-native-ios-utilities": "^4.4.5",
|
"react-native-ios-utilities": "^4.4.5",
|
||||||
"react-native-reanimated": "~3.10.1",
|
"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-safe-area-context": "4.10.5",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.2.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
"react-native-video": "^6.4.3",
|
"react-native-video": "^6.4.3",
|
||||||
|
"react-native-vlc-media-player": "^1.0.69",
|
||||||
"react-native-web": "~0.19.10",
|
"react-native-web": "~0.19.10",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
|
"use-debounce": "^10.0.3",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zeego": "^1.10.0",
|
"zeego": "^1.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import {
|
||||||
|
currentlyPlayingItemAtom,
|
||||||
|
playingAtom,
|
||||||
|
showCurrentlyPlayingBarAtom,
|
||||||
|
} from "@/utils/atoms/playState";
|
||||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { isLoaded } from "expo-font";
|
|
||||||
import { router, useSegments } from "expo-router";
|
import { router, useSegments } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -21,6 +25,7 @@ interface Server {
|
|||||||
|
|
||||||
export const apiAtom = atom<Api | null>(null);
|
export const apiAtom = atom<Api | null>(null);
|
||||||
export const userAtom = atom<UserDto | null>(null);
|
export const userAtom = atom<UserDto | null>(null);
|
||||||
|
export const wsAtom = atom<WebSocket | null>(null);
|
||||||
|
|
||||||
interface JellyfinContextValue {
|
interface JellyfinContextValue {
|
||||||
discoverServers: (url: string) => Promise<Server[]>;
|
discoverServers: (url: string) => Promise<Server[]>;
|
||||||
@@ -31,7 +36,7 @@ interface JellyfinContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||||
undefined,
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOrSetDeviceId = async () => {
|
const getOrSetDeviceId = async () => {
|
||||||
@@ -49,6 +54,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
||||||
|
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -56,10 +63,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.6.0" },
|
clientInfo: { name: "Streamyfin", version: "0.6.2" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
setDeviceId(id);
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -67,8 +75,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
|
||||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||||
const servers =
|
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||||
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
url
|
||||||
|
);
|
||||||
return servers?.map((server) => ({ address: server.address })) || [];
|
return servers?.map((server) => ({ address: server.address })) || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,7 +153,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const token = await AsyncStorage.getItem("token");
|
const token = await AsyncStorage.getItem("token");
|
||||||
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
||||||
const user = JSON.parse(
|
const user = JSON.parse(
|
||||||
(await AsyncStorage.getItem("user")) as string,
|
(await AsyncStorage.getItem("user")) as string
|
||||||
) as UserDto;
|
) as UserDto;
|
||||||
|
|
||||||
if (serverUrl && token && user.Id && jellyfin) {
|
if (serverUrl && token && user.Id && jellyfin) {
|
||||||
|
|||||||
269
providers/PlaybackProvider.tsx
Normal file
269
providers/PlaybackProvider.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||||
|
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
PlaybackInfoResponse,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||||
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
|
import { getDeviceId } from "@/utils/device";
|
||||||
|
|
||||||
|
type CurrentlyPlayingState = {
|
||||||
|
url: string;
|
||||||
|
item: BaseItemDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlaybackContextType {
|
||||||
|
sessionData: PlaybackInfoResponse | null | undefined;
|
||||||
|
currentlyPlaying: CurrentlyPlayingState | null;
|
||||||
|
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
progressTicks: number | null;
|
||||||
|
playVideo: () => void;
|
||||||
|
pauseVideo: () => void;
|
||||||
|
stopPlayback: () => void;
|
||||||
|
presentFullscreenPlayer: () => void;
|
||||||
|
dismissFullscreenPlayer: () => void;
|
||||||
|
setIsFullscreen: (isFullscreen: boolean) => void;
|
||||||
|
setIsPlaying: (isPlaying: boolean) => void;
|
||||||
|
onProgress: (data: OnProgressData) => void;
|
||||||
|
setCurrentlyPlayingState: (
|
||||||
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaybackContext = createContext<PlaybackContextType | null>(null);
|
||||||
|
|
||||||
|
export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
|
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||||
|
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||||
|
useState<CurrentlyPlayingState | null>(null);
|
||||||
|
|
||||||
|
// WS
|
||||||
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
const { data: sessionData } = useQuery({
|
||||||
|
queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!currentlyPlaying?.item.Id) return null;
|
||||||
|
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
|
itemId: currentlyPlaying?.item.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
return playbackData.data;
|
||||||
|
},
|
||||||
|
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: deviceId } = useQuery({
|
||||||
|
queryKey: ["deviceId", api],
|
||||||
|
queryFn: getDeviceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCurrentlyPlayingState = (state: CurrentlyPlayingState | null) => {
|
||||||
|
if (state) {
|
||||||
|
setCurrentlyPlaying(state);
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
if (settings?.openFullScreenVideoPlayerByDefault)
|
||||||
|
presentFullscreenPlayer();
|
||||||
|
} else {
|
||||||
|
setCurrentlyPlaying(null);
|
||||||
|
setIsFullscreen(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define control methods
|
||||||
|
const playVideo = useCallback(() => {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
setIsPlaying(true);
|
||||||
|
reportPlaybackProgress({
|
||||||
|
api,
|
||||||
|
itemId: currentlyPlaying?.item.Id,
|
||||||
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
sessionId: sessionData?.PlaySessionId,
|
||||||
|
IsPaused: true,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
currentlyPlaying?.item.Id,
|
||||||
|
sessionData?.PlaySessionId,
|
||||||
|
progressTicks,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pauseVideo = useCallback(() => {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
reportPlaybackProgress({
|
||||||
|
api,
|
||||||
|
itemId: currentlyPlaying?.item.Id,
|
||||||
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
sessionId: sessionData?.PlaySessionId,
|
||||||
|
IsPaused: false,
|
||||||
|
});
|
||||||
|
}, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
|
||||||
|
|
||||||
|
const stopPlayback = useCallback(async () => {
|
||||||
|
await reportPlaybackStopped({
|
||||||
|
api,
|
||||||
|
itemId: currentlyPlaying?.item?.Id,
|
||||||
|
sessionId: sessionData?.PlaySessionId,
|
||||||
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
|
});
|
||||||
|
setCurrentlyPlayingState(null);
|
||||||
|
}, [currentlyPlaying, sessionData, progressTicks]);
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
({ currentTime }: OnProgressData) => {
|
||||||
|
const ticks = currentTime * 10000000;
|
||||||
|
setProgressTicks(ticks);
|
||||||
|
reportPlaybackProgress({
|
||||||
|
api,
|
||||||
|
itemId: currentlyPlaying?.item.Id,
|
||||||
|
positionTicks: ticks,
|
||||||
|
sessionId: sessionData?.PlaySessionId,
|
||||||
|
IsPaused: !isPlaying,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
|
||||||
|
);
|
||||||
|
|
||||||
|
const presentFullscreenPlayer = useCallback(() => {
|
||||||
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
|
setIsFullscreen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissFullscreenPlayer = useCallback(() => {
|
||||||
|
videoRef.current?.dismissFullscreenPlayer();
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deviceId || !api) return;
|
||||||
|
|
||||||
|
const url = `wss://${api?.basePath
|
||||||
|
.replace("https://", "")
|
||||||
|
.replace("http://", "")}/socket?api_key=${
|
||||||
|
api?.accessToken
|
||||||
|
}&deviceId=${deviceId}`;
|
||||||
|
|
||||||
|
console.log("WS", url);
|
||||||
|
|
||||||
|
const newWebSocket = new WebSocket(url);
|
||||||
|
|
||||||
|
let keepAliveInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
newWebSocket.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
// Start sending "KeepAlive" message every 30 seconds
|
||||||
|
keepAliveInterval = setInterval(() => {
|
||||||
|
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||||
|
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||||
|
console.log("KeepAlive message sent");
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
};
|
||||||
|
|
||||||
|
newWebSocket.onerror = (e) => {
|
||||||
|
console.error("WebSocket error:", e);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
newWebSocket.onclose = (e) => {
|
||||||
|
console.log("WebSocket connection closed:", e.reason);
|
||||||
|
if (keepAliveInterval) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setWs(newWebSocket);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (keepAliveInterval) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
}
|
||||||
|
newWebSocket.close();
|
||||||
|
};
|
||||||
|
}, [api, deviceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ws) return;
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const json = JSON.parse(e.data);
|
||||||
|
const command = json?.Data?.Command;
|
||||||
|
|
||||||
|
// On PlayPause
|
||||||
|
if (command === "PlayPause") {
|
||||||
|
console.log("Command ~ PlayPause");
|
||||||
|
if (isPlaying) pauseVideo();
|
||||||
|
else playVideo();
|
||||||
|
} else if (command === "Stop") {
|
||||||
|
console.log("Command ~ Stop");
|
||||||
|
stopPlayback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [ws, stopPlayback, playVideo, pauseVideo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlaybackContext.Provider
|
||||||
|
value={{
|
||||||
|
onProgress,
|
||||||
|
progressTicks,
|
||||||
|
setIsPlaying,
|
||||||
|
setIsFullscreen,
|
||||||
|
isFullscreen,
|
||||||
|
isPlaying,
|
||||||
|
currentlyPlaying,
|
||||||
|
sessionData,
|
||||||
|
videoRef,
|
||||||
|
playVideo,
|
||||||
|
setCurrentlyPlayingState,
|
||||||
|
pauseVideo,
|
||||||
|
stopPlayback,
|
||||||
|
presentFullscreenPlayer,
|
||||||
|
dismissFullscreenPlayer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PlaybackContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlayback = () => {
|
||||||
|
const context = useContext(PlaybackContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("usePlayback must be used within a PlaybackProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
47
utils/atoms/filters.ts
Normal file
47
utils/atoms/filters.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
ItemFilter,
|
||||||
|
ItemSortBy,
|
||||||
|
NameGuidPair,
|
||||||
|
SortOrder,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
|
||||||
|
export const sortOptions: {
|
||||||
|
key: ItemSortBy;
|
||||||
|
value: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "SortName", value: "Name" },
|
||||||
|
{ key: "CommunityRating", value: "Community Rating" },
|
||||||
|
{ key: "CriticRating", value: "Critics Rating" },
|
||||||
|
{ key: "DateLastContentAdded", value: "Content Added" },
|
||||||
|
{ key: "DatePlayed", value: "Date Played" },
|
||||||
|
{ key: "PlayCount", value: "Play Count" },
|
||||||
|
{ key: "ProductionYear", value: "Production Year" },
|
||||||
|
{ key: "Runtime", value: "Runtime" },
|
||||||
|
{ key: "OfficialRating", value: "Official Rating" },
|
||||||
|
{ key: "PremiereDate", value: "Premiere Date" },
|
||||||
|
{ key: "StartDate", value: "Start Date" },
|
||||||
|
{ key: "IsUnplayed", value: "Is Unplayed" },
|
||||||
|
{ key: "IsPlayed", value: "Is Played" },
|
||||||
|
{ key: "VideoBitRate", value: "Video Bit Rate" },
|
||||||
|
{ key: "AirTime", value: "Air Time" },
|
||||||
|
{ key: "Studio", value: "Studio" },
|
||||||
|
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
|
||||||
|
{ key: "Random", value: "Random" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sortOrderOptions: {
|
||||||
|
key: SortOrder;
|
||||||
|
value: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "Ascending", value: "Ascending" },
|
||||||
|
{ key: "Descending", value: "Descending" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const genreFilterAtom = atom<string[]>([]);
|
||||||
|
export const tagsFilterAtom = atom<string[]>([]);
|
||||||
|
export const yearFilterAtom = atom<string[]>([]);
|
||||||
|
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
|
||||||
|
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
|
||||||
|
sortOrderOptions[0],
|
||||||
|
]);
|
||||||
10
utils/atoms/playState.ts
Normal file
10
utils/atoms/playState.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export const playingAtom = atom(false);
|
||||||
|
export const fullScreenAtom = atom(false);
|
||||||
|
export const showCurrentlyPlayingBarAtom = atom(false);
|
||||||
|
export const currentlyPlayingItemAtom = atom<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
playbackUrl: string;
|
||||||
|
} | null>(null);
|
||||||
@@ -46,7 +46,6 @@ export const useJobProcessor = () => {
|
|||||||
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
|
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.info("Queue changed", queue, isProcessing);
|
|
||||||
if (queue.length > 0 && !isProcessing) {
|
if (queue.length > 0 && !isProcessing) {
|
||||||
console.info("Processing queue", queue);
|
console.info("Processing queue", queue);
|
||||||
queueActions.processJob(queue, setQueue, setProcessing);
|
queueActions.processJob(queue, setQueue, setProcessing);
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ type Settings = {
|
|||||||
usePopularPlugin?: boolean;
|
usePopularPlugin?: boolean;
|
||||||
deviceProfile?: "Expo" | "Native" | "Old";
|
deviceProfile?: "Expo" | "Native" | "Old";
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
|
mediaListCollectionIds?: string[];
|
||||||
|
searchEngine: "Marlin" | "Jellyfin";
|
||||||
|
marlinServerUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +34,9 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
usePopularPlugin: false,
|
usePopularPlugin: false,
|
||||||
deviceProfile: "Expo",
|
deviceProfile: "Expo",
|
||||||
forceDirectPlay: false,
|
forceDirectPlay: false,
|
||||||
|
mediaListCollectionIds: [],
|
||||||
|
searchEngine: "Jellyfin",
|
||||||
|
marlinServerUrl: "",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
utils/device.ts
Normal file
19
utils/device.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import uuid from "react-native-uuid";
|
||||||
|
|
||||||
|
export const getOrSetDeviceId = async () => {
|
||||||
|
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
deviceId = uuid.v4() as string;
|
||||||
|
await AsyncStorage.setItem("deviceId", deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDeviceId = async () => {
|
||||||
|
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||||
|
|
||||||
|
return deviceId || null;
|
||||||
|
};
|
||||||
@@ -11,9 +11,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
export const getLogoImageUrlById = ({
|
export const getLogoImageUrlById = ({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
|
height = 130,
|
||||||
}: {
|
}: {
|
||||||
api?: Api | null;
|
api?: Api | null;
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
|
height?: number;
|
||||||
}) => {
|
}) => {
|
||||||
if (!api || !item) {
|
if (!api || !item) {
|
||||||
return null;
|
return null;
|
||||||
@@ -27,7 +29,7 @@ export const getLogoImageUrlById = ({
|
|||||||
|
|
||||||
params.append("tag", imageTags);
|
params.append("tag", imageTags);
|
||||||
params.append("quality", "90");
|
params.append("quality", "90");
|
||||||
params.append("fillHeight", "130");
|
params.append("fillHeight", height.toString());
|
||||||
|
|
||||||
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
|
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const getStreamUrl = async ({
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
|
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
|
||||||
@@ -69,7 +69,16 @@ export const getStreamUrl = async ({
|
|||||||
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
|
||||||
if (item.MediaType === "Video") {
|
if (item.MediaType === "Video") {
|
||||||
console.log("Using direct stream for video!");
|
console.log("Using direct stream for video!");
|
||||||
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
mediaSourceId: itemId,
|
||||||
|
Static: "true",
|
||||||
|
deviceId: api.deviceInfo.id,
|
||||||
|
api_key: api.accessToken,
|
||||||
|
Tag: item.MediaSources?.[0].ETag || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${api.basePath}/Videos/${itemId}/stream.mp4?${params.toString()}`;
|
||||||
} else if (item.MediaType === "Audio") {
|
} else if (item.MediaType === "Audio") {
|
||||||
console.log("Using direct stream for audio!");
|
console.log("Using direct stream for audio!");
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
@@ -87,7 +96,9 @@ export const getStreamUrl = async ({
|
|||||||
EnableRedirection: "true",
|
EnableRedirection: "true",
|
||||||
EnableRemoteMedia: "false",
|
EnableRemoteMedia: "false",
|
||||||
});
|
});
|
||||||
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
return `${
|
||||||
|
api.basePath
|
||||||
|
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { AxiosError } from "axios";
|
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
import { postCapabilities } from "../session/capabilities";
|
||||||
|
|
||||||
interface ReportPlaybackProgressParams {
|
interface ReportPlaybackProgressParams {
|
||||||
api: Api;
|
api?: Api | null;
|
||||||
sessionId: string;
|
sessionId?: string | null;
|
||||||
itemId: string;
|
itemId?: string | null;
|
||||||
positionTicks: number;
|
positionTicks?: number | null;
|
||||||
|
IsPaused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,25 +21,44 @@ export const reportPlaybackProgress = async ({
|
|||||||
sessionId,
|
sessionId,
|
||||||
itemId,
|
itemId,
|
||||||
positionTicks,
|
positionTicks,
|
||||||
|
IsPaused = false,
|
||||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||||
console.info(
|
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||||
"Reporting playback progress:",
|
console.error(
|
||||||
sessionId,
|
"Missing required parameter",
|
||||||
itemId,
|
sessionId,
|
||||||
positionTicks,
|
itemId,
|
||||||
);
|
positionTicks
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await postCapabilities({
|
||||||
|
api,
|
||||||
|
itemId,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to post capabilities.", error);
|
||||||
|
throw new Error("Failed to post capabilities.");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.axiosInstance.post(
|
await api.axiosInstance.post(
|
||||||
`${api.basePath}/Sessions/Playing/Progress`,
|
`${api.basePath}/Sessions/Playing/Progress`,
|
||||||
{
|
{
|
||||||
ItemId: itemId,
|
ItemId: itemId,
|
||||||
PlaySessionId: sessionId,
|
PlaySessionId: sessionId,
|
||||||
IsPaused: false,
|
IsPaused,
|
||||||
PositionTicks: Math.round(positionTicks),
|
PositionTicks: Math.round(positionTicks),
|
||||||
CanSeek: true,
|
CanSeek: true,
|
||||||
MediaSourceId: itemId,
|
MediaSourceId: itemId,
|
||||||
|
EventName: "timeupdate",
|
||||||
},
|
},
|
||||||
{ headers: getAuthHeaders(api) },
|
{ headers: getAuthHeaders(api) }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -41,12 +41,15 @@ export const reportPlaybackStopped = async ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("reportPlaybackStopped ~", { sessionId, itemId });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${api.basePath}/PlayingItems/${itemId}`;
|
const url = `${api.basePath}/PlayingItems/${itemId}`;
|
||||||
const params = {
|
const params = {
|
||||||
playSessionId: sessionId,
|
playSessionId: sessionId,
|
||||||
positionTicks: Math.round(positionTicks),
|
positionTicks: Math.round(positionTicks),
|
||||||
mediaSourceId: itemId,
|
MediaSourceId: itemId,
|
||||||
|
IsPaused: true,
|
||||||
};
|
};
|
||||||
const headers = getAuthHeaders(api);
|
const headers = getAuthHeaders(api);
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@ export const reportPlaybackStopped = async ({
|
|||||||
console.error(
|
console.error(
|
||||||
"Failed to report playback progress",
|
"Failed to report playback progress",
|
||||||
error.message,
|
error.message,
|
||||||
error.response?.data,
|
error.response?.data
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to report playback progress", error);
|
console.error("Failed to report playback progress", error);
|
||||||
|
|||||||
48
utils/jellyfin/session/capabilities.ts
Normal file
48
utils/jellyfin/session/capabilities.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
SessionApi,
|
||||||
|
SessionApiPostCapabilitiesRequest,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
|
interface PostCapabilitiesParams {
|
||||||
|
api: Api | null | undefined;
|
||||||
|
itemId: string | null | undefined;
|
||||||
|
sessionId: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a media item as not played for a specific user.
|
||||||
|
*
|
||||||
|
* @param params - The parameters for marking an item as not played
|
||||||
|
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export const postCapabilities = async ({
|
||||||
|
api,
|
||||||
|
itemId,
|
||||||
|
sessionId,
|
||||||
|
}: PostCapabilitiesParams): Promise<void> => {
|
||||||
|
if (!api || !itemId || !sessionId) {
|
||||||
|
throw new Error("Missing required parameters");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await api.axiosInstance.post(
|
||||||
|
api.basePath + "/Sessions/Capabilities/Full",
|
||||||
|
{
|
||||||
|
playableMediaTypes: ["Audio", "Video", "Audio"],
|
||||||
|
supportedCommands: ["PlayState", "Play"],
|
||||||
|
supportsMediaControl: true,
|
||||||
|
id: sessionId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any | AxiosError) {
|
||||||
|
console.log("Failed to mark as not played", error);
|
||||||
|
throw new Error("Failed to mark as not played");
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user