mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-03 19:22:32 +00:00
Compare commits
54 Commits
feat/on-de
...
v0.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3b492f5e | ||
|
|
78189c8246 | ||
|
|
dc02db6463 | ||
|
|
c168d79377 | ||
|
|
f756a663fe | ||
|
|
2baf57156e | ||
|
|
a97610a51d | ||
|
|
79b87b3d72 | ||
|
|
d52f025873 | ||
|
|
30348dc28f | ||
|
|
faf39a6de2 | ||
|
|
4641ff726c | ||
|
|
8eac2f39a8 | ||
|
|
309345c834 | ||
|
|
0d07f7216c | ||
|
|
b550d6302f | ||
|
|
55ba3daf86 | ||
|
|
e0afb68f0c | ||
|
|
91ed109a04 | ||
|
|
2565bf7353 | ||
|
|
bbc6f63089 | ||
|
|
e6d4414fd6 | ||
|
|
3047367ba6 | ||
|
|
07c5c21599 | ||
|
|
aa60e320c5 | ||
|
|
c12b58e5cb | ||
|
|
d962507749 | ||
|
|
a351c8d220 | ||
|
|
969e68901a | ||
|
|
c0f4587501 | ||
|
|
e8944528e4 | ||
|
|
9b2185d29e | ||
|
|
67af14dced | ||
|
|
7324fe826e | ||
|
|
75f3f483eb | ||
|
|
57cac96df5 | ||
|
|
7792b8a675 | ||
|
|
55df3991f5 | ||
|
|
26057ed196 | ||
|
|
30658ff067 | ||
|
|
8d327e8835 | ||
|
|
b1726962c1 | ||
|
|
25e6f655f3 | ||
|
|
275923dbdd | ||
|
|
36f1ea384d | ||
|
|
c100c2e0c4 | ||
|
|
f9a5841f88 | ||
|
|
42f4631143 | ||
|
|
638e8851c1 | ||
|
|
5c95730715 | ||
|
|
ec5aab99b8 | ||
|
|
70d0ec4780 | ||
|
|
a89d9c1f67 | ||
|
|
725ba1ccaf |
@@ -1,5 +1,7 @@
|
|||||||
# 📺 Streamyfin
|
# 📺 Streamyfin
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
|
||||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: row; gap: 5px">
|
<div style="display: flex; flex-direction: row; gap: 5px">
|
||||||
@@ -141,10 +143,6 @@ 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
|
|
||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
|
|||||||
12
app.json
12
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.8.2",
|
"version": "0.10.2",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -25,12 +25,15 @@
|
|||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config": {
|
||||||
|
"usesNonExemptEncryption": false
|
||||||
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 23,
|
"versionCode": 31,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon.png"
|
"foregroundImage": "./assets/images/icon.png"
|
||||||
},
|
},
|
||||||
@@ -75,6 +78,11 @@
|
|||||||
"deploymentTarget": "14.0"
|
"deploymentTarget": "14.0"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"android": {
|
||||||
|
"compileSdkVersion": 34,
|
||||||
|
"targetSdkVersion": 34,
|
||||||
|
"buildToolsVersion": "34.0.0"
|
||||||
|
},
|
||||||
"minSdkVersion": 24,
|
"minSdkVersion": 24,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { TouchableOpacity } from "react-native";
|
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -45,6 +46,21 @@ export default function IndexLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="downloads"
|
||||||
|
options={{
|
||||||
|
title: "Downloads",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: "Settings",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import { FFmpegKit } from "ffmpeg-kit-react-native";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
const downloads: React.FC = () => {
|
||||||
const [process, setProcess] = useAtom(runningProcesses);
|
const [process, setProcess] = useAtom(runningProcesses);
|
||||||
@@ -53,6 +54,8 @@ const downloads: React.FC = () => {
|
|||||||
return formatNumber(timeLeft / 10000);
|
return formatNumber(timeLeft / 10000);
|
||||||
}, [process]);
|
}, [process]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
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">
|
||||||
@@ -62,7 +65,13 @@ const downloads: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View className="px-4 py-4">
|
<View className="px-4 py-4">
|
||||||
<View className="mb-4 flex flex-col space-y-4">
|
<View className="mb-4 flex flex-col space-y-4">
|
||||||
<View>
|
<View>
|
||||||
@@ -70,7 +79,9 @@ const downloads: React.FC = () => {
|
|||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
{queue.map((q) => (
|
{queue.map((q) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push(`/(auth)/items/${q.item.Id}`)}
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||||
|
}
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
@@ -97,7 +108,9 @@ const downloads: React.FC = () => {
|
|||||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
||||||
{process?.item ? (
|
{process?.item ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/items/page?id=${process.item.Id}`)
|
||||||
|
}
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
335
app/(auth)/(tabs)/(home)/index.tsx
Normal file
335
app/(auth)/(tabs)/(home)/index.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getSuggestionsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type BaseSection = {
|
||||||
|
title: string;
|
||||||
|
queryKey: (string | undefined)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScrollingCollectionListSection = BaseSection & {
|
||||||
|
type: "ScrollingCollectionList";
|
||||||
|
queryFn: () => Promise<BaseItemDto[]>;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaListSection = BaseSection & {
|
||||||
|
type: "MediaListSection";
|
||||||
|
queryFn: () => Promise<BaseItemDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||||
|
|
||||||
|
export default function index() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [settings, _] = useSettings();
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||||
|
if (state.isConnected == false || state.isInternetReachable === false)
|
||||||
|
setIsConnected(false);
|
||||||
|
else setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
NetInfo.fetch().then((state) => {
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: userViews,
|
||||||
|
isError: e1,
|
||||||
|
isLoading: l1,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["userViews", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mediaListCollections,
|
||||||
|
isError: e2,
|
||||||
|
isLoading: l2,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
tags: ["sf_promoted"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["Tags"],
|
||||||
|
includeItemTypes: ["BoxSet"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const movieCollectionId = useMemo(() => {
|
||||||
|
return userViews?.find((c) => c.CollectionType === "movies")?.Id;
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
|
const tvShowCollectionId = useMemo(() => {
|
||||||
|
return userViews?.find((c) => c.CollectionType === "tvshows")?.Id;
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["userViews"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: ["sf_promoted"],
|
||||||
|
});
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: ["sf_carousel"],
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
}, [queryClient, user?.Id]);
|
||||||
|
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const ss: Section[] = [
|
||||||
|
{
|
||||||
|
title: "Continue Watching",
|
||||||
|
queryKey: ["resumeItems", user.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Next Up",
|
||||||
|
queryKey: ["nextUp-all", user?.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: 20,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
...(mediaListCollections?.map(
|
||||||
|
(ml) =>
|
||||||
|
({
|
||||||
|
title: ml.Name || "",
|
||||||
|
queryKey: ["mediaList", ml.Id],
|
||||||
|
queryFn: async () => ml,
|
||||||
|
type: "MediaListSection",
|
||||||
|
} as MediaListSection)
|
||||||
|
) || []),
|
||||||
|
{
|
||||||
|
title: "Recently Added in Movies",
|
||||||
|
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 50,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
parentId: movieCollectionId,
|
||||||
|
})
|
||||||
|
).data || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Recently Added in TV-Shows",
|
||||||
|
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 50,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
parentId: tvShowCollectionId,
|
||||||
|
})
|
||||||
|
).data || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Suggested Movies",
|
||||||
|
queryKey: ["suggestedMovies", user?.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Suggested Episodes",
|
||||||
|
queryKey: ["suggestedEpisodes", user?.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Episode"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return ss;
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
movieCollectionId,
|
||||||
|
tvShowCollectionId,
|
||||||
|
mediaListCollections,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// if (isConnected === false) {
|
||||||
|
// return (
|
||||||
|
// <View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||||
|
// <Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||||
|
// <Text className="text-center opacity-70">
|
||||||
|
// No worries, you can still watch{"\n"}downloaded content.
|
||||||
|
// </Text>
|
||||||
|
// <View className="mt-4">
|
||||||
|
// <Button
|
||||||
|
// color="purple"
|
||||||
|
// onPress={() => router.push("/(auth)/downloads")}
|
||||||
|
// justify="center"
|
||||||
|
// iconRight={
|
||||||
|
// <Ionicons name="arrow-forward" size={20} color="white" />
|
||||||
|
// }
|
||||||
|
// >
|
||||||
|
// Go to downloads
|
||||||
|
// </Button>
|
||||||
|
// </View>
|
||||||
|
// </View>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (e1 || e2)
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
|
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||||
|
<Text className="text-center opacity-70">
|
||||||
|
Something went wrong.{"\n"}Please log out and in again.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (l1 || l2)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
className="flex flex-col pt-4 pb-24 gap-y-4"
|
||||||
|
>
|
||||||
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
if (section.type === "ScrollingCollectionList") {
|
||||||
|
return (
|
||||||
|
<ScrollingCollectionList
|
||||||
|
key={index}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation={section.orientation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (section.type === "MediaListSection") {
|
||||||
|
return (
|
||||||
|
<MediaListSection
|
||||||
|
key={index}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import { clearLogs, readFromLog } from "@/utils/log";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
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();
|
||||||
@@ -24,9 +24,17 @@ export default function settings() {
|
|||||||
refetchInterval: 1000,
|
refetchInterval: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ScrollView
|
||||||
<View className="p-4 flex flex-col gap-y-4 pb-12">
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
<Text className="font-bold text-2xl">Information</Text>
|
<Text className="font-bold text-2xl">Information</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
@@ -1,34 +1,23 @@
|
|||||||
import { Bitrate } from "@/components/BitrateSelector";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { Ratings } from "@/components/Ratings";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
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 { 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 { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import ios from "@/utils/profiles/ios";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import old from "@/utils/profiles/old";
|
|
||||||
import { getItemsApi, 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 } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useCastDevice } from "react-native-google-cast";
|
|
||||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { SongsList } from "@/components/music/SongsList";
|
import { SongsList } from "@/components/music/SongsList";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
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";
|
||||||
@@ -10,6 +13,7 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -87,35 +91,31 @@ export default function page() {
|
|||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (!album) return null;
|
if (!album) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ParallaxScrollView
|
||||||
<View className="px-4 pb-24">
|
headerHeight={400}
|
||||||
<View className="flex flex-row space-x-4 items-start mb-4">
|
headerImage={
|
||||||
<View className="w-24">
|
<ItemImage
|
||||||
<ArtistPoster item={album} />
|
variant={"Primary"}
|
||||||
</View>
|
item={album}
|
||||||
<View className="flex flex-col shrink">
|
style={{
|
||||||
<Text className="font-bold text-3xl">{album?.Name}</Text>
|
width: "100%",
|
||||||
<Text className="">{album?.ProductionYear}</Text>
|
height: "100%",
|
||||||
|
}}
|
||||||
<View className="flex flex-row space-x-2 mt-1">
|
/>
|
||||||
{album.AlbumArtists?.map((a) => (
|
}
|
||||||
<TouchableOpacity
|
>
|
||||||
key={a.Id}
|
<View className="px-4 mb-8">
|
||||||
onPress={() => {
|
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
||||||
router.push(`/artists/${a.Id}/page`);
|
<Text className="text-neutral-500">
|
||||||
}}
|
{songs?.TotalRecordCount} songs
|
||||||
>
|
</Text>
|
||||||
<Text className="font-bold text-purple-600">
|
</View>
|
||||||
{album?.AlbumArtist}
|
<View className="px-4">
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<SongsList
|
<SongsList
|
||||||
albumId={albumId}
|
albumId={albumId}
|
||||||
songs={songs?.Items}
|
songs={songs?.Items}
|
||||||
@@ -123,6 +123,6 @@ export default function page() {
|
|||||||
artistId={artistId}
|
artistId={artistId}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,10 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -82,50 +86,45 @@ export default function page() {
|
|||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const insets = useSafeAreaInsets();
|
||||||
navigation.setOptions({
|
|
||||||
title: albums?.Items?.[0]?.AlbumArtist || "",
|
|
||||||
});
|
|
||||||
}, [albums]);
|
|
||||||
|
|
||||||
if (!artist || !albums) return null;
|
if (!artist || !albums) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<ParallaxScrollView
|
||||||
contentContainerStyle={{
|
headerHeight={400}
|
||||||
padding: 16,
|
headerImage={
|
||||||
paddingBottom: 140,
|
<ItemImage
|
||||||
}}
|
variant={"Primary"}
|
||||||
ListHeaderComponent={
|
item={artist}
|
||||||
<View className="mb-2">
|
style={{
|
||||||
<View className="w-32 mb-4">
|
width: "100%",
|
||||||
<ArtistPoster item={artist} />
|
height: "100%",
|
||||||
</View>
|
|
||||||
<Text className="font-bold text-2xl mb-4">Albums</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
nestedScrollEnabled
|
|
||||||
data={albums.Items}
|
|
||||||
numColumns={3}
|
|
||||||
columnWrapperStyle={{
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{ width: "30%" }}
|
|
||||||
key={index}
|
|
||||||
onPress={() => {
|
|
||||||
router.push(`/albums/${item.Id}`);
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<View className="flex flex-col gap-y-2">
|
}
|
||||||
<ArtistPoster item={item} />
|
>
|
||||||
<Text>{item.Name}</Text>
|
<View className="px-4 mb-8">
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
||||||
</View>
|
<Text className="text-neutral-500">
|
||||||
</TouchableOpacity>
|
{albums.TotalRecordCount} albums
|
||||||
)}
|
</Text>
|
||||||
keyExtractor={(item) => item.Id || ""}
|
</View>
|
||||||
/>
|
<View className="flex flex-row flex-wrap justify-between px-4">
|
||||||
|
{albums.Items.map((item, idx) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
style={{ width: "30%", marginBottom: 20 }}
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col gap-y-2">
|
||||||
|
<ArtistPoster item={item} />
|
||||||
|
<Text numberOfLines={2}>{item.Name}</Text>
|
||||||
|
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -90,15 +91,13 @@ export default function page() {
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "30%",
|
maxWidth: "30%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
key={index}
|
key={index}
|
||||||
onPress={() => {
|
item={item}
|
||||||
router.push(`/artists/${item.Id}/page`);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-col gap-y-2">
|
<View className="flex flex-col gap-y-2">
|
||||||
{collection?.CollectionType === "movies" && (
|
{collection?.CollectionType === "movies" && (
|
||||||
@@ -110,7 +109,7 @@ export default function page() {
|
|||||||
<Text>{item.Name}</Text>
|
<Text>{item.Name}</Text>
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
/>
|
/>
|
||||||
@@ -3,7 +3,6 @@ import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -27,7 +25,8 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -36,8 +35,7 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { FlatList, NativeScrollEvent, ScrollView, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
|
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
13
app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
Normal file
13
app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
|
const Page: React.FC = () => {
|
||||||
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
|
|
||||||
|
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
|
||||||
|
|
||||||
|
return memoizedContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(Page);
|
||||||
@@ -20,6 +20,8 @@ const page: React.FC = () => {
|
|||||||
seasonIndex: string;
|
seasonIndex: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("seasonIndex", seasonIndex);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
|
headerHeight={400}
|
||||||
headerImage={
|
headerImage={
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
@@ -95,7 +98,7 @@ const page: React.FC = () => {
|
|||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<NextUp seriesId={seriesId} />
|
<NextUp seriesId={seriesId} />
|
||||||
</View>
|
</View>
|
||||||
<SeasonPicker item={item} />
|
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
@@ -9,7 +9,12 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import {
|
||||||
|
FlatList,
|
||||||
|
RefreshControl,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -38,6 +43,8 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
@@ -48,6 +55,7 @@ const Page = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
@@ -59,6 +67,14 @@ const Page = () => {
|
|||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getNumberOfColumns = useCallback(() => {
|
||||||
|
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
|
||||||
|
if (screenWidth < 600) return 5;
|
||||||
|
if (screenWidth < 960) return 6;
|
||||||
|
if (screenWidth < 1280) return 7;
|
||||||
|
return 6;
|
||||||
|
}, [screenWidth]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setSortBy([
|
setSortBy([
|
||||||
{
|
{
|
||||||
@@ -90,7 +106,7 @@ const Page = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: library } = useQuery({
|
const { data: library, isLoading: isLibraryLoading } = useQuery({
|
||||||
queryKey: ["library", libraryId],
|
queryKey: ["library", libraryId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -101,7 +117,7 @@ const Page = () => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!libraryId,
|
enabled: !!api && !!user?.Id && !!libraryId,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
@@ -112,36 +128,15 @@ const Page = () => {
|
|||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !library) return null;
|
if (!api || !library) return null;
|
||||||
|
|
||||||
let includeItemTypes: BaseItemKind[] | undefined = [];
|
|
||||||
|
|
||||||
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:
|
|
||||||
includeItemTypes = undefined;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
limit: 20,
|
limit: 36,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||||
sortOrder: [sortOrder[0].key],
|
sortOrder: [sortOrder[0].key],
|
||||||
includeItemTypes,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
recursive: true,
|
recursive: false,
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
@@ -164,40 +159,41 @@ const Page = () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
queryKey: [
|
useInfiniteQuery({
|
||||||
"library-items",
|
queryKey: [
|
||||||
libraryId,
|
"library-items",
|
||||||
selectedGenres,
|
libraryId,
|
||||||
selectedYears,
|
selectedGenres,
|
||||||
selectedTags,
|
selectedYears,
|
||||||
sortBy,
|
selectedTags,
|
||||||
sortOrder,
|
sortBy,
|
||||||
],
|
sortOrder,
|
||||||
queryFn: fetchItems,
|
],
|
||||||
getNextPageParam: (lastPage, pages) => {
|
queryFn: fetchItems,
|
||||||
if (
|
getNextPageParam: (lastPage, pages) => {
|
||||||
!lastPage?.Items ||
|
if (
|
||||||
!lastPage?.TotalRecordCount ||
|
!lastPage?.Items ||
|
||||||
lastPage?.TotalRecordCount === 0
|
!lastPage?.TotalRecordCount ||
|
||||||
)
|
lastPage?.TotalRecordCount === 0
|
||||||
return undefined;
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
const totalItems = lastPage.TotalRecordCount;
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
const accumulatedItems = pages.reduce(
|
const accumulatedItems = pages.reduce(
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
if (accumulatedItems < totalItems) {
|
||||||
return lastPage?.Items?.length * pages.length;
|
return lastPage?.Items?.length * pages.length;
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
enabled: !!api && !!user?.Id && !!library,
|
enabled: !!api && !!user?.Id && !!library,
|
||||||
});
|
});
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -212,18 +208,19 @@ const Page = () => {
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
marginBottom:
|
marginBottom: 4,
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
|
||||||
}}
|
}}
|
||||||
item={item}
|
item={item}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf:
|
alignSelf:
|
||||||
index % 3 === 0
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
? "flex-end"
|
? index % 3 === 0
|
||||||
: (index + 1) % 3 === 0
|
? "flex-end"
|
||||||
? "flex-start"
|
: (index + 1) % 3 === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center"
|
||||||
: "center",
|
: "center",
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
@@ -394,7 +391,21 @@ const Page = () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!library) return null;
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (isLoading || isLibraryLoading)
|
||||||
|
return (
|
||||||
|
<View className="w-full h-full flex items-center justify-center">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (flatData.length === 0)
|
||||||
|
return (
|
||||||
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
|
<Text className="text-lg text-neutral-500">No items found</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
@@ -407,18 +418,20 @@ const Page = () => {
|
|||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={255}
|
estimatedItemSize={244}
|
||||||
numColumns={
|
numColumns={getNumberOfColumns()}
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={1}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
ItemSeparatorComponent={() => (
|
ItemSeparatorComponent={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
200
app/(auth)/(tabs)/(libraries)/_layout.tsx
Normal file
200
app/(auth)/(tabs)/(libraries)/_layout.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
|
||||||
|
export default function IndexLayout() {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: "Library",
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerRight: () => (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<Ionicons
|
||||||
|
name="ellipsis-horizontal-outline"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
align={"end"}
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={false}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={false}
|
||||||
|
side={"bottom"}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Group key="display-group">
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
|
Display
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="display-option-1"
|
||||||
|
value={settings.libraryOptions.display === "row"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "row",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="display-title-1">
|
||||||
|
Row
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="display-option-2"
|
||||||
|
value={settings.libraryOptions.display === "list"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "list",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="display-title-2">
|
||||||
|
List
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
|
Image style
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="poster-option"
|
||||||
|
value={settings.libraryOptions.imageStyle === "poster"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="poster-title">
|
||||||
|
Poster
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="cover-option"
|
||||||
|
value={settings.libraryOptions.imageStyle === "cover"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="cover-title">
|
||||||
|
Cover
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Group key="show-titles-group">
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||||
|
key="show-titles-option"
|
||||||
|
value={settings.libraryOptions.showTitles}
|
||||||
|
onValueChange={(newValue) => {
|
||||||
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
|
return;
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: newValue === "on" ? true : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||||
|
Show titles
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="show-stats-option"
|
||||||
|
value={settings.libraryOptions.showStats}
|
||||||
|
onValueChange={(newValue) => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showStats: newValue === "on" ? true : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||||
|
Show stats
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="[libraryId]"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
app/(auth)/(tabs)/(libraries)/index.tsx
Normal file
103
app/(auth)/(tabs)/(libraries)/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function index() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const item of data || []) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["library", item.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!item.Id || !user?.Id || !api) return null;
|
||||||
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: item.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
return (
|
||||||
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
|
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
extraData={settings}
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 17,
|
||||||
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
data={data}
|
||||||
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
ItemSeparatorComponent={() =>
|
||||||
|
settings?.libraryOptions?.display === "row" ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className="bg-neutral-800 mx-2 my-4"
|
||||||
|
></View>
|
||||||
|
) : (
|
||||||
|
<View className="h-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
@@ -15,6 +16,9 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -12,8 +11,6 @@ import SeriesPoster from "@/components/posters/SeriesPoster";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
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 { Api } from "@jellyfin/sdk";
|
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
@@ -21,13 +18,7 @@ import {
|
|||||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {
|
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
Href,
|
|
||||||
router,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useNavigation,
|
|
||||||
usePathname,
|
|
||||||
} from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -37,6 +28,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
const exampleSearches = [
|
const exampleSearches = [
|
||||||
@@ -50,6 +42,7 @@ const exampleSearches = [
|
|||||||
|
|
||||||
export default function search() {
|
export default function search() {
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||||
|
|
||||||
@@ -229,6 +222,10 @@ export default function search() {
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4 pb-32">
|
<View className="flex flex-col pt-4 pb-32">
|
||||||
{Platform.OS === "android" && (
|
{Platform.OS === "android" && (
|
||||||
@@ -254,13 +251,13 @@ export default function search() {
|
|||||||
header="Movies"
|
header="Movies"
|
||||||
ids={movies?.map((m) => m.Id!)}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
onPress={() => router.push(`/items/${item.Id}`)}
|
item={item}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text numberOfLines={2} className="mt-2">
|
<Text numberOfLines={2} className="mt-2">
|
||||||
@@ -269,7 +266,7 @@ export default function search() {
|
|||||||
<Text className="opacity-50 text-xs">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -278,7 +275,7 @@ export default function search() {
|
|||||||
ids={series?.map((m) => m.Id!)}
|
ids={series?.map((m) => m.Id!)}
|
||||||
header="Series"
|
header="Series"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -302,12 +299,12 @@ export default function search() {
|
|||||||
ids={episodes?.map((m) => m.Id!)}
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
header="Episodes"
|
header="Episodes"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/items/${item.Id}`)}
|
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||||
className="flex flex-col w-44"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
@@ -321,7 +318,7 @@ export default function search() {
|
|||||||
ids={collections?.map((m) => m.Id!)}
|
ids={collections?.map((m) => m.Id!)}
|
||||||
header="Collections"
|
header="Collections"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -342,7 +339,7 @@ export default function search() {
|
|||||||
ids={actors?.map((m) => m.Id!)}
|
ids={actors?.map((m) => m.Id!)}
|
||||||
header="Actors"
|
header="Actors"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -361,7 +358,7 @@ export default function search() {
|
|||||||
ids={artists?.map((m) => m.Id!)}
|
ids={artists?.map((m) => m.Id!)}
|
||||||
header="Artists"
|
header="Artists"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -380,7 +377,7 @@ export default function search() {
|
|||||||
ids={albums?.map((m) => m.Id!)}
|
ids={albums?.map((m) => m.Id!)}
|
||||||
header="Albums"
|
header="Albums"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -399,7 +396,7 @@ export default function search() {
|
|||||||
ids={songs?.map((m) => m.Id!)}
|
ids={songs?.map((m) => m.Id!)}
|
||||||
header="Songs"
|
header="Songs"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import { router, Tabs } from "expo-router";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import * as NavigationBar from "expo-navigation-bar";
|
|
||||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Feather } from "@expo/vector-icons";
|
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { StyleSheet } from "react-native";
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
import { Tabs } from "expo-router";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Platform, StyleSheet } from "react-native";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,7 +47,7 @@ export default function TabLayout() {
|
|||||||
>
|
>
|
||||||
<Tabs.Screen redirect name="index" />
|
<Tabs.Screen redirect name="index" />
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="home"
|
name="(home)"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "Home",
|
title: "Home",
|
||||||
@@ -63,7 +60,7 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="search"
|
name="(search)"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "Search",
|
title: "Search",
|
||||||
@@ -73,7 +70,7 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="libraries"
|
name="(libraries)"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "Library",
|
title: "Library",
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
|
||||||
|
|
||||||
export default function index() {
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [settings, _] = useSettings();
|
|
||||||
|
|
||||||
const [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[]>({
|
|
||||||
queryKey: ["resumeItems", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(api &&
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
})
|
|
||||||
).data.Items) ||
|
|
||||||
[],
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: _nextUpData, isLoading: isLoadingNextUp } = useQuery({
|
|
||||||
queryKey: ["nextUp-all", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(api &&
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: 20,
|
|
||||||
})
|
|
||||||
).data.Items) ||
|
|
||||||
[],
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextUpData = useMemo(() => {
|
|
||||||
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
|
|
||||||
}, [_nextUpData]);
|
|
||||||
|
|
||||||
const { data: collections } = useQuery({
|
|
||||||
queryKey: ["collectinos", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const movieCollectionId = useMemo(() => {
|
|
||||||
return collections?.find((c) => c.CollectionType === "movies")?.Id;
|
|
||||||
}, [collections]);
|
|
||||||
|
|
||||||
const tvShowCollectionId = useMemo(() => {
|
|
||||||
return collections?.find((c) => c.CollectionType === "tvshows")?.Id;
|
|
||||||
}, [collections]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: recentlyAddedInMovies,
|
|
||||||
isLoading: isLoadingRecentlyAddedMovies,
|
|
||||||
} = useQuery<BaseItemDto[]>({
|
|
||||||
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
|
|
||||||
queryFn: async () =>
|
|
||||||
(api &&
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 50,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
parentId: movieCollectionId,
|
|
||||||
})
|
|
||||||
).data) ||
|
|
||||||
[],
|
|
||||||
enabled: !!api && !!user?.Id && !!movieCollectionId,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: recentlyAddedInTVShows,
|
|
||||||
isLoading: isLoadingRecentlyAddedTVShows,
|
|
||||||
} = useQuery<BaseItemDto[]>({
|
|
||||||
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
|
|
||||||
queryFn: async () =>
|
|
||||||
(api &&
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 50,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
parentId: tvShowCollectionId,
|
|
||||||
})
|
|
||||||
).data) ||
|
|
||||||
[],
|
|
||||||
enabled: !!api && !!user?.Id && !!tvShowCollectionId,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: suggestions, isLoading: isLoadingSuggestions } = useQuery<
|
|
||||||
BaseItemDto[]
|
|
||||||
>({
|
|
||||||
queryKey: ["suggestions", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(api &&
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 5,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
})
|
|
||||||
).data.Items) ||
|
|
||||||
[],
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: mediaListCollections } = useQuery({
|
|
||||||
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
tags: ["sf_promoted"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["Tags"],
|
|
||||||
includeItemTypes: ["BoxSet"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
|
||||||
await queryClient.refetchQueries({
|
|
||||||
queryKey: ["sf_promoted"],
|
|
||||||
});
|
|
||||||
await queryClient.refetchQueries({
|
|
||||||
queryKey: ["sf_carousel"],
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
}, [queryClient, user?.Id]);
|
|
||||||
|
|
||||||
if (isConnected === false) {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
|
||||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
No worries, you can still watch{"\n"}downloaded content.
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Go to downloads
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError)
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
|
||||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
Something went wrong.{"\n"}Please log out and in again.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
|
||||||
<LargeMovieCarousel />
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Continue Watching"
|
|
||||||
data={data}
|
|
||||||
loading={isLoading}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Next Up"
|
|
||||||
data={nextUpData}
|
|
||||||
loading={isLoadingNextUp}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{mediaListCollections?.map((ml) => (
|
|
||||||
<MediaListSection key={ml.Id} collection={ml} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Recently Added in Movies"
|
|
||||||
data={recentlyAddedInMovies}
|
|
||||||
loading={isLoadingRecentlyAddedMovies}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Recently Added in TV-Shows"
|
|
||||||
data={recentlyAddedInTVShows}
|
|
||||||
loading={isLoadingRecentlyAddedTVShows}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Suggestions"
|
|
||||||
data={suggestions}
|
|
||||||
loading={isLoadingSuggestions}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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,245 +0,0 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
|
||||||
import { Ratings } from "@/components/Ratings";
|
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
|
||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
|
||||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
|
||||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
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 native from "@/utils/profiles/native";
|
|
||||||
import old from "@/utils/profiles/old";
|
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useCastDevice } from "react-native-google-cast";
|
|
||||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const { id } = local as { id: string };
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
|
||||||
useState<number>(0);
|
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
|
||||||
key: "Max",
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: item, isLoading: l1 } = useQuery({
|
|
||||||
queryKey: ["item", id],
|
|
||||||
queryFn: async () =>
|
|
||||||
await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: id,
|
|
||||||
}),
|
|
||||||
enabled: !!id && !!api,
|
|
||||||
staleTime: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
|
||||||
queryKey: ["sessionData", item?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !item?.Id) return null;
|
|
||||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
|
||||||
itemId: item?.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return playbackData.data;
|
|
||||||
},
|
|
||||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: playbackUrl } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"playbackUrl",
|
|
||||||
item?.Id,
|
|
||||||
maxBitrate,
|
|
||||||
castDevice,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
settings,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !sessionData) return null;
|
|
||||||
|
|
||||||
let deviceProfile: any = ios;
|
|
||||||
|
|
||||||
if (castDevice?.deviceId) {
|
|
||||||
deviceProfile = chromecastProfile;
|
|
||||||
} else if (settings?.deviceProfile === "Native") {
|
|
||||||
deviceProfile = native;
|
|
||||||
} else if (settings?.deviceProfile === "Old") {
|
|
||||||
deviceProfile = old;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
sessionData,
|
|
||||||
deviceProfile,
|
|
||||||
audioStreamIndex: selectedAudioStream,
|
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
forceDirectPlay: settings?.forceDirectPlay,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Transcode URL: ", url);
|
|
||||||
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
|
||||||
() =>
|
|
||||||
getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 1000,
|
|
||||||
}),
|
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
|
||||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!item?.Id || !backdropUrl) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerImage={
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: backdropUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
logo={
|
|
||||||
<>
|
|
||||||
{logoUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: logoUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: 130,
|
|
||||||
width: "100%",
|
|
||||||
resizeMode: "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col px-4 pt-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
{item.Type === "Episode" ? (
|
|
||||||
<SeriesTitleHeader item={item} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MoviesTitleHeader item={item} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
|
||||||
<Ratings item={item} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center mb-2">
|
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
<PlayedStatus item={item} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<OverviewText text={item.Overview} />
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col p-4 w-full">
|
|
||||||
<View className="flex flex-row items-center space-x-2 w-full">
|
|
||||||
<BitrateSelector
|
|
||||||
onChange={(val) => setMaxBitrate(val)}
|
|
||||||
selected={maxBitrate}
|
|
||||||
/>
|
|
||||||
<AudioTrackSelector
|
|
||||||
item={item}
|
|
||||||
onChange={setSelectedAudioStream}
|
|
||||||
selected={selectedAudioStream}
|
|
||||||
/>
|
|
||||||
<SubtitleTrackSelector
|
|
||||||
item={item}
|
|
||||||
onChange={setSelectedSubtitleStream}
|
|
||||||
selected={selectedSubtitleStream}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center justify-between w-full">
|
|
||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
|
||||||
<PlayButton item={item} url={playbackUrl} className="grow" />
|
|
||||||
<NextEpisodeButton item={item} className="ml-2" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<CastAndCrew item={item} />
|
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<CurrentSeries item={item} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
|
||||||
|
|
||||||
<View className="h-12"></View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default page;
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
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 { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
|
||||||
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 { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { ScrollView, View } from "react-native";
|
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const { songId: id } = local as { songId: string };
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
|
||||||
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
|
||||||
useState<number>(0);
|
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
|
||||||
key: "Max",
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: item, isLoading: l1 } = useQuery({
|
|
||||||
queryKey: ["item", id],
|
|
||||||
queryFn: async () =>
|
|
||||||
await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: id,
|
|
||||||
}),
|
|
||||||
enabled: !!id && !!api,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
|
||||||
() =>
|
|
||||||
getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 1000,
|
|
||||||
}),
|
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
|
||||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
|
||||||
queryKey: ["sessionData", item?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !item?.Id) return null;
|
|
||||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
|
||||||
itemId: item?.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return playbackData.data;
|
|
||||||
},
|
|
||||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: playbackUrl } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"playbackUrl",
|
|
||||||
item?.Id,
|
|
||||||
maxBitrate,
|
|
||||||
castDevice,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !sessionData) return null;
|
|
||||||
|
|
||||||
const url = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
sessionData,
|
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
|
||||||
audioStreamIndex: selectedAudioStream,
|
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Transcode URL: ", url);
|
|
||||||
|
|
||||||
return url;
|
|
||||||
},
|
|
||||||
enabled: !!sessionData,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
|
|
||||||
const onPressPlay = useCallback(
|
|
||||||
async (type: "device" | "cast" = "device") => {
|
|
||||||
if (!playbackUrl || !item) return;
|
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
|
||||||
else {
|
|
||||||
client.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: playbackUrl,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCurrentlyPlayingState({
|
|
||||||
item,
|
|
||||||
url: playbackUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[playbackUrl, item]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!item?.Id || !backdropUrl) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerImage={
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: backdropUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
logo={
|
|
||||||
<>
|
|
||||||
{logoUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: logoUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: 130,
|
|
||||||
width: "100%",
|
|
||||||
resizeMode: "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col px-4 pt-4">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<MoviesTitleHeader item={item} />
|
|
||||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col p-4 w-full">
|
|
||||||
<View className="flex flex-row items-center space-x-2 w-full">
|
|
||||||
<BitrateSelector
|
|
||||||
onChange={(val) => setMaxBitrate(val)}
|
|
||||||
selected={maxBitrate}
|
|
||||||
/>
|
|
||||||
<AudioTrackSelector
|
|
||||||
item={item}
|
|
||||||
onChange={setSelectedAudioStream}
|
|
||||||
selected={selectedAudioStream}
|
|
||||||
/>
|
|
||||||
<SubtitleTrackSelector
|
|
||||||
item={item}
|
|
||||||
onChange={setSelectedSubtitleStream}
|
|
||||||
selected={selectedSubtitleStream}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row items-center justify-between w-full">
|
|
||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
|
||||||
<PlayButton item={item} className="grow" />
|
|
||||||
<NextEpisodeButton item={item} className="ml-2" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal className="flex px-4 mb-4">
|
|
||||||
<View className="flex flex-row space-x-2 ">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-sm opacity-70">Audio</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-sm opacity-70">
|
|
||||||
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
|
||||||
|
|
||||||
<View className="h-12"></View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default page;
|
|
||||||
@@ -9,7 +9,7 @@ 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 { useKeepAwake } from "expo-keep-awake";
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import { Stack } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
@@ -17,14 +17,10 @@ import { Provider as JotaiProvider } from "jotai";
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
export const unstable_settings = {
|
|
||||||
initialRouteName: "/index",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
@@ -75,6 +71,13 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
const url = Linking.useURL();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
const { hostname, path, queryParams } = Linking.parse(url);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
@@ -93,88 +96,6 @@ function Layout() {
|
|||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/settings"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "Settings",
|
|
||||||
headerStyle: { backgroundColor: "black" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/downloads"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "Downloads",
|
|
||||||
headerStyle: { backgroundColor: "black" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/items/[id]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/actors/[actorId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/collections/[collectionId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerStyle: { backgroundColor: "black" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/artists/page"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerStyle: { backgroundColor: "black" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/artists/[artistId]/page"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerStyle: { backgroundColor: "black" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/albums/[albumId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerStyle: { backgroundColor: "black" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/songs/[songId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/series/[id]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{ headerShown: false, title: "Login" }}
|
options={{ headerShown: false, title: "Login" }}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { AxiosError } from "axios";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -21,19 +21,44 @@ const CredentialsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
const { setServer, login, removeServer } = useJellyfin();
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
|
useJellyfin();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
|
||||||
const [serverURL, setServerURL] = useState<string>("");
|
const {
|
||||||
|
apiUrl: _apiUrl,
|
||||||
|
username: _username,
|
||||||
|
password: _password,
|
||||||
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}>({
|
}>({
|
||||||
username: "",
|
username: _username,
|
||||||
password: "",
|
password: _password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (_apiUrl) {
|
||||||
|
setServer({
|
||||||
|
address: _apiUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_username && _password) {
|
||||||
|
setCredentials({ username: _username, password: _password });
|
||||||
|
login(_username, _password);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
@@ -62,6 +87,21 @@ const Login: React.FC = () => {
|
|||||||
setServer({ address: url.trim() });
|
setServer({ address: url.trim() });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuickConnect = async () => {
|
||||||
|
try {
|
||||||
|
const code = await initiateQuickConnect();
|
||||||
|
if (code) {
|
||||||
|
Alert.alert("Quick Connect", `Enter code ${code} to login`, [
|
||||||
|
{
|
||||||
|
text: "Got It",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert("Error", "Failed to initiate Quick Connect");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (api?.basePath) {
|
if (api?.basePath) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
@@ -137,13 +177,18 @@ const Login: React.FC = () => {
|
|||||||
<Text className="text-red-600 mb-2">{error}</Text>
|
<Text className="text-red-600 mb-2">{error}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<View className="mt-auto mb-2">
|
||||||
onPress={handleLogin}
|
<Button
|
||||||
loading={loading}
|
color="black"
|
||||||
className="mt-auto mb-2"
|
onPress={handleQuickConnect}
|
||||||
>
|
className="mb-2"
|
||||||
Log in
|
>
|
||||||
</Button>
|
Use Quick Connect
|
||||||
|
</Button>
|
||||||
|
<Button onPress={handleLogin} loading={loading}>
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
@@ -2,27 +2,29 @@ import { 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 { Text } from "./common/Text";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
source: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected: number;
|
selected: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AudioTrackSelector: React.FC<Props> = ({
|
export const AudioTrackSelector: React.FC<Props> = ({
|
||||||
item,
|
source,
|
||||||
onChange,
|
onChange,
|
||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() =>
|
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
[source]
|
||||||
[item]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedAudioSteam = useMemo(
|
const selectedAudioSteam = useMemo(
|
||||||
@@ -31,23 +33,26 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
|
const index = source.DefaultAudioStreamIndex;
|
||||||
if (index !== undefined && index !== null) onChange(index);
|
if (index !== undefined && index !== null) onChange(index);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between" {...props}>
|
<View
|
||||||
|
className="flex shrink"
|
||||||
|
style={{
|
||||||
|
minWidth: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col mb-2">
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
||||||
<View className="flex flex-row">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<Text className="" numberOfLines={1}>
|
||||||
<Text className="">
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
{tc(selectedAudioSteam?.DisplayTitle, 13)}
|
</Text>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { 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 { Text } from "./common/Text";
|
||||||
import { atom, useAtom } from "jotai";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
value: number | undefined;
|
value: number | undefined;
|
||||||
|
height?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BITRATES: Bitrate[] = [
|
const BITRATES: Bitrate[] = [
|
||||||
@@ -16,63 +17,84 @@ const BITRATES: Bitrate[] = [
|
|||||||
{
|
{
|
||||||
key: "8 Mb/s",
|
key: "8 Mb/s",
|
||||||
value: 8000000,
|
value: 8000000,
|
||||||
|
height: 1080,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "4 Mb/s",
|
key: "4 Mb/s",
|
||||||
value: 4000000,
|
value: 4000000,
|
||||||
|
height: 1080,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "2 Mb/s",
|
key: "2 Mb/s",
|
||||||
value: 2000000,
|
value: 2000000,
|
||||||
|
height: 720,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "500 Kb/s",
|
key: "500 Kb/s",
|
||||||
value: 500000,
|
value: 500000,
|
||||||
|
height: 480,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "250 Kb/s",
|
key: "250 Kb/s",
|
||||||
value: 250000,
|
value: 250000,
|
||||||
|
height: 480,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
onChange: (value: Bitrate) => void;
|
onChange: (value: Bitrate) => void;
|
||||||
selected: Bitrate;
|
selected: Bitrate;
|
||||||
|
inverted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BitrateSelector: React.FC<Props> = ({
|
export const BitrateSelector: React.FC<Props> = ({
|
||||||
onChange,
|
onChange,
|
||||||
selected,
|
selected,
|
||||||
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
if (inverted)
|
||||||
|
return BITRATES.sort(
|
||||||
|
(a, b) => (a.value || Infinity) - (b.value || Infinity)
|
||||||
|
);
|
||||||
|
return BITRATES.sort(
|
||||||
|
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between" {...props}>
|
<View
|
||||||
|
className="flex shrink"
|
||||||
|
style={{
|
||||||
|
minWidth: 60,
|
||||||
|
maxWidth: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col mb-2">
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
||||||
<View className="flex flex-row">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
<Text>
|
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
</Text>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={true}
|
loop={false}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
align="center"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={8}
|
collisionPadding={0}
|
||||||
sideOffset={8}
|
sideOffset={0}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||||
{BITRATES?.map((b, index: number) => (
|
{sorted.map((b) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={index.toString()}
|
key={b.key}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onChange(b);
|
onChange(b);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: string | ReactNode;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black";
|
color?: "purple" | "red" | "black" | "transparent";
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
iconLeft?: ReactNode;
|
iconLeft?: ReactNode;
|
||||||
justify?: "center" | "between";
|
justify?: "center" | "between";
|
||||||
@@ -37,6 +37,8 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
return "bg-red-600";
|
return "bg-red-600";
|
||||||
case "black":
|
case "black":
|
||||||
return "bg-neutral-900 border border-neutral-800";
|
return "bg-neutral-900 border border-neutral-800";
|
||||||
|
case "transparent":
|
||||||
|
return "bg-transparent";
|
||||||
}
|
}
|
||||||
}, [color]);
|
}, [color]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { View } from "react-native";
|
import { Platform, View, ViewProps } from "react-native";
|
||||||
import {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useDevices,
|
useDevices,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import GoogleCast from "react-native-google-cast";
|
|
||||||
|
|
||||||
type Props = {
|
interface Props extends ViewProps {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
};
|
background?: "blur" | "transparent";
|
||||||
|
}
|
||||||
|
|
||||||
export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
|
export const Chromecast: React.FC<Props> = ({
|
||||||
|
width = 48,
|
||||||
|
height = 48,
|
||||||
|
background = "transparent",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const devices = useDevices();
|
const devices = useDevices();
|
||||||
@@ -31,9 +36,33 @@ export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
|
|||||||
})();
|
})();
|
||||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
|
|
||||||
|
if (background === "transparent")
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CastButton style={{ tintColor: "white", height, width }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.OS === "android")
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CastButton style={{ tintColor: "white", height, width }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
<BlurView
|
||||||
|
intensity={100}
|
||||||
|
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<CastButton style={{ tintColor: "white", height, width }} />
|
<CastButton style={{ tintColor: "white", height, width }} />
|
||||||
</View>
|
</BlurView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,29 +5,41 @@ 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 "./WatchedIndicator";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
useEpisodePoster?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
width = 176,
|
width = 176,
|
||||||
|
useEpisodePoster = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const url = useMemo(
|
/**
|
||||||
() =>
|
* Get horrizontal poster for movie and episode, with failover to primary.
|
||||||
getPrimaryImageUrl({
|
*/
|
||||||
api,
|
const url = useMemo(() => {
|
||||||
item,
|
if (!api) return;
|
||||||
quality: 80,
|
if (item.Type === "Episode" && useEpisodePoster) {
|
||||||
width: 300,
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}),
|
}
|
||||||
[item]
|
if (item.Type === "Episode") {
|
||||||
);
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag)
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
else
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie") {
|
||||||
|
if (item.ImageTags?.["Thumb"])
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
||||||
|
else
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
const [progress, setProgress] = useState(
|
const [progress, setProgress] = useState(
|
||||||
item.UserData?.PlayedPercentage || 0
|
item.UserData?.PlayedPercentage || 0
|
||||||
|
|||||||
@@ -24,16 +24,16 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
currentlyPlaying,
|
currentlyPlaying,
|
||||||
pauseVideo,
|
pauseVideo,
|
||||||
playVideo,
|
playVideo,
|
||||||
setCurrentlyPlayingState,
|
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
|
setVolume,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
videoRef,
|
videoRef,
|
||||||
|
presentFullscreenPlayer,
|
||||||
onProgress,
|
onProgress,
|
||||||
} = usePlayback();
|
} = usePlayback();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const aBottom = useSharedValue(0);
|
const aBottom = useSharedValue(0);
|
||||||
const aPadding = useSharedValue(0);
|
const aPadding = useSharedValue(0);
|
||||||
@@ -63,6 +63,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const from = useMemo(() => segments[2], [segments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (segments.find((s) => s.includes("tabs"))) {
|
if (segments.find((s) => s.includes("tabs"))) {
|
||||||
// Tab screen - i.e. home
|
// Tab screen - i.e. home
|
||||||
@@ -91,16 +93,40 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
[currentlyPlaying?.item]
|
[currentlyPlaying?.item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const poster = useMemo(() => {
|
||||||
() =>
|
if (currentlyPlaying?.item.Type === "Audio")
|
||||||
getBackdropUrl({
|
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
|
||||||
|
else
|
||||||
|
return getBackdropUrl({
|
||||||
api,
|
api,
|
||||||
item: currentlyPlaying?.item,
|
item: currentlyPlaying?.item,
|
||||||
quality: 70,
|
quality: 70,
|
||||||
width: 200,
|
width: 200,
|
||||||
}),
|
});
|
||||||
[currentlyPlaying?.item, api]
|
}, [currentlyPlaying?.item.Id, api]);
|
||||||
);
|
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!api || !currentlyPlaying || !poster) return null;
|
||||||
|
return {
|
||||||
|
uri: currentlyPlaying.url,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: currentlyPlaying.item?.AlbumArtist
|
||||||
|
? currentlyPlaying.item?.AlbumArtist
|
||||||
|
: undefined,
|
||||||
|
title: currentlyPlaying.item?.Name || "Unknown",
|
||||||
|
description: currentlyPlaying.item?.Overview
|
||||||
|
? currentlyPlaying.item?.Overview
|
||||||
|
: undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: currentlyPlaying.item?.Album
|
||||||
|
? currentlyPlaying.item?.Album
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [currentlyPlaying, startPosition, api, poster]);
|
||||||
|
|
||||||
if (!api || !currentlyPlaying) return null;
|
if (!api || !currentlyPlaying) return null;
|
||||||
|
|
||||||
@@ -136,7 +162,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{currentlyPlaying?.url && (
|
{videoSource && (
|
||||||
<Video
|
<Video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
allowsExternalPlayback
|
allowsExternalPlayback
|
||||||
@@ -148,40 +174,37 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
controls={false}
|
controls={false}
|
||||||
pictureInPicture={true}
|
pictureInPicture={true}
|
||||||
poster={
|
poster={
|
||||||
backdropUrl && currentlyPlaying.item?.Type === "Audio"
|
poster && currentlyPlaying.item?.Type === "Audio"
|
||||||
? backdropUrl
|
? poster
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
debug={{
|
debug={{
|
||||||
enable: true,
|
enable: true,
|
||||||
thread: true,
|
thread: true,
|
||||||
}}
|
}}
|
||||||
paused={!isPlaying}
|
|
||||||
onProgress={(e) => onProgress(e)}
|
onProgress={(e) => onProgress(e)}
|
||||||
subtitleStyle={{
|
subtitleStyle={{
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
}}
|
}}
|
||||||
source={{
|
source={videoSource}
|
||||||
uri: currentlyPlaying.url,
|
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||||
isNetwork: true,
|
setTimeout(() => {
|
||||||
startPosition,
|
presentFullscreenPlayer();
|
||||||
headers: getAuthHeaders(api),
|
}, 300);
|
||||||
}}
|
}}
|
||||||
onBuffer={(e) =>
|
|
||||||
e.isBuffering ? console.log("Buffering...") : null
|
|
||||||
}
|
|
||||||
onFullscreenPlayerDidDismiss={() => {}}
|
onFullscreenPlayerDidDismiss={() => {}}
|
||||||
onFullscreenPlayerDidPresent={() => {}}
|
onFullscreenPlayerDidPresent={() => {}}
|
||||||
onPlaybackStateChanged={(e) => {
|
onPlaybackStateChanged={(e) => {
|
||||||
if (e.isPlaying) {
|
if (e.isPlaying === true) {
|
||||||
setIsPlaying(true);
|
playVideo(false);
|
||||||
} else if (e.isSeeking) {
|
} else if (e.isPlaying === false) {
|
||||||
return;
|
pauseVideo(false);
|
||||||
} else {
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
progressUpdateInterval={2000}
|
onVolumeChange={(e) => {
|
||||||
|
setVolume(e.volume);
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={4000}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
writeToLog(
|
writeToLog(
|
||||||
@@ -205,9 +228,17 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
<View className="shrink text-xs">
|
<View className="shrink text-xs">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (currentlyPlaying.item?.Type === "Audio")
|
if (currentlyPlaying.item?.Type === "Audio") {
|
||||||
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
router.push(
|
||||||
else router.push(`/items/${currentlyPlaying.item?.Id}`);
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text>{currentlyPlaying.item?.Name}</Text>
|
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||||
@@ -216,7 +247,8 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="text-xs opacity-50"
|
className="text-xs opacity-50"
|
||||||
|
|||||||
@@ -2,8 +2,17 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
|
import native from "@/utils/profiles/native";
|
||||||
|
import old from "@/utils/profiles/old";
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
@@ -12,21 +21,18 @@ 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 {
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
TouchableOpacity,
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
TouchableOpacityProps,
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
View,
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
ViewProps,
|
import { Button } from "./Button";
|
||||||
} from "react-native";
|
import { Text } from "./common/Text";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { DownloadQuality, useSettings } from "@/utils/atoms/settings";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
import { useCallback } from "react";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import old from "@/utils/profiles/old";
|
|
||||||
|
|
||||||
interface DownloadProps extends TouchableOpacityProps {
|
interface DownloadProps extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,100 +41,132 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [process] = useAtom(runningProcesses);
|
const [process] = useAtom(runningProcesses);
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||||
|
|
||||||
const initiateDownload = useCallback(
|
const [selectedMediaSource, setSelectedMediaSource] =
|
||||||
async (qualitySetting: DownloadQuality) => {
|
useState<MediaSourceInfo | null>(null);
|
||||||
if (!api || !user?.Id || !item.Id) {
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
throw new Error(
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
useState<number>(0);
|
||||||
);
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
}
|
key: "Max",
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
let deviceProfile: any = ios;
|
/**
|
||||||
|
* Bottom sheet
|
||||||
|
*/
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const snapPoints = useMemo(() => ["50%"], []);
|
||||||
|
|
||||||
if (settings?.deviceProfile === "Native") {
|
const handlePresentModalPress = useCallback(() => {
|
||||||
deviceProfile = native;
|
bottomSheetModalRef.current?.present();
|
||||||
} else if (settings?.deviceProfile === "Old") {
|
}, []);
|
||||||
deviceProfile = old;
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxStreamingBitrate: number | undefined = undefined;
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
|
console.log("handleSheetChanges", index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (qualitySetting === "high") {
|
const closeModal = useCallback(() => {
|
||||||
maxStreamingBitrate = 8000000;
|
bottomSheetModalRef.current?.dismiss();
|
||||||
} else if (qualitySetting === "low") {
|
}, []);
|
||||||
maxStreamingBitrate = 2000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.axiosInstance.post(
|
/**
|
||||||
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
|
* Start download
|
||||||
{
|
*/
|
||||||
DeviceProfile: deviceProfile,
|
const initiateDownload = useCallback(async () => {
|
||||||
UserId: user.Id,
|
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
|
||||||
MaxStreamingBitrate: maxStreamingBitrate,
|
throw new Error(
|
||||||
StartTimeTicks: 0,
|
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||||
EnableTranscoding: maxStreamingBitrate ? true : undefined,
|
|
||||||
AutoOpenLiveStream: true,
|
|
||||||
MediaSourceId: item.Id,
|
|
||||||
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let url: string | undefined = undefined;
|
let deviceProfile: any = ios;
|
||||||
|
|
||||||
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
|
if (settings?.deviceProfile === "Native") {
|
||||||
|
deviceProfile = native;
|
||||||
|
} else if (settings?.deviceProfile === "Old") {
|
||||||
|
deviceProfile = old;
|
||||||
|
}
|
||||||
|
|
||||||
if (!mediaSource) {
|
const response = await api.axiosInstance.post(
|
||||||
throw new Error("No media source");
|
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
|
||||||
|
{
|
||||||
|
DeviceProfile: deviceProfile,
|
||||||
|
UserId: user.Id,
|
||||||
|
MaxStreamingBitrate: maxBitrate.value,
|
||||||
|
StartTimeTicks: 0,
|
||||||
|
EnableTranscoding: maxBitrate.value ? true : undefined,
|
||||||
|
AutoOpenLiveStream: true,
|
||||||
|
AllowVideoStreamCopy: maxBitrate.value ? false : true,
|
||||||
|
MediaSourceId: selectedMediaSource?.Id,
|
||||||
|
AudioStreamIndex: selectedAudioStream,
|
||||||
|
SubtitleStreamIndex: selectedSubtitleStream,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay) {
|
let url: string | undefined = undefined;
|
||||||
if (item.MediaType === "Video") {
|
|
||||||
console.log("Using direct stream for video!");
|
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
||||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true`;
|
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
||||||
} else if (item.MediaType === "Audio") {
|
);
|
||||||
console.log("Using direct stream for audio!");
|
|
||||||
const searchParams = new URLSearchParams({
|
if (!mediaSource) {
|
||||||
UserId: user.Id,
|
throw new Error("No media source");
|
||||||
DeviceId: api.deviceInfo.id,
|
}
|
||||||
MaxStreamingBitrate: "140000000",
|
|
||||||
Container:
|
if (mediaSource.SupportsDirectPlay) {
|
||||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
if (item.MediaType === "Video") {
|
||||||
TranscodingContainer: "mp4",
|
console.log("Using direct stream for video!");
|
||||||
TranscodingProtocol: "hls",
|
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
AudioCodec: "aac",
|
} else if (item.MediaType === "Audio") {
|
||||||
api_key: api.accessToken,
|
console.log("Using direct stream for audio!");
|
||||||
StartTimeTicks: "0",
|
const searchParams = new URLSearchParams({
|
||||||
EnableRedirection: "true",
|
UserId: user.Id,
|
||||||
EnableRemoteMedia: "false",
|
DeviceId: api.deviceInfo.id,
|
||||||
});
|
MaxStreamingBitrate: "140000000",
|
||||||
url = `${api.basePath}/Audio/${
|
Container:
|
||||||
item.Id
|
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||||
}/universal?${searchParams.toString()}`;
|
TranscodingContainer: "mp4",
|
||||||
}
|
TranscodingProtocol: "hls",
|
||||||
|
AudioCodec: "aac",
|
||||||
|
api_key: api.accessToken,
|
||||||
|
StartTimeTicks: "0",
|
||||||
|
EnableRedirection: "true",
|
||||||
|
EnableRemoteMedia: "false",
|
||||||
|
});
|
||||||
|
url = `${api.basePath}/Audio/${
|
||||||
|
item.Id
|
||||||
|
}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
|
console.log("Using transcoded stream!");
|
||||||
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (mediaSource.TranscodingUrl) {
|
if (!url) throw new Error("No url");
|
||||||
console.log("Using transcoded stream!");
|
|
||||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
|
||||||
} else {
|
|
||||||
throw new Error("No transcoding url");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await startRemuxing(url);
|
return await startRemuxing(url);
|
||||||
},
|
}, [
|
||||||
[api, item, startRemuxing, user?.Id]
|
api,
|
||||||
);
|
item,
|
||||||
|
startRemuxing,
|
||||||
|
user?.Id,
|
||||||
|
selectedMediaSource,
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
maxBitrate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if item is downloaded
|
||||||
|
*/
|
||||||
const { data: downloaded, isFetching } = useQuery({
|
const { data: downloaded, isFetching } = useQuery({
|
||||||
queryKey: ["downloaded", item.Id],
|
queryKey: ["downloaded", item.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -143,23 +181,30 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
enabled: !!item.Id,
|
enabled: !!item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isFetching) {
|
const renderBackdrop = useCallback(
|
||||||
return (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
<BottomSheetBackdrop
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process && process?.item.Id === item.Id) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
disappearsOnIndex={-1}
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<Loader />
|
||||||
|
) : process && process?.item.Id === item.Id ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/downloads");
|
||||||
|
}}
|
||||||
|
>
|
||||||
{process.progress === 0 ? (
|
{process.progress === 0 ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
@@ -173,61 +218,90 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
) : queue.some((i) => i.id === item.Id) ? (
|
||||||
);
|
<TouchableOpacity
|
||||||
}
|
onPress={() => {
|
||||||
|
router.push("/downloads");
|
||||||
if (queue.some((i) => i.id === item.Id)) {
|
}}
|
||||||
return (
|
>
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
|
||||||
<Ionicons name="hourglass" size={24} color="white" />
|
<Ionicons name="hourglass" size={24} color="white" />
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
) : downloaded ? (
|
||||||
);
|
<TouchableOpacity
|
||||||
}
|
onPress={() => {
|
||||||
|
router.push("/downloads");
|
||||||
if (downloaded) {
|
}}
|
||||||
return (
|
>
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
) : (
|
||||||
);
|
<TouchableOpacity onPress={handlePresentModalPress}>
|
||||||
} else {
|
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||||
return (
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
)}
|
||||||
onPress={() => {
|
<BottomSheetModal
|
||||||
queueActions.enqueue(queue, setQueue, {
|
ref={bottomSheetModalRef}
|
||||||
id: item.Id!,
|
enableDynamicSizing
|
||||||
execute: async () => {
|
handleIndicatorStyle={{
|
||||||
// await startRemuxing(playbackUrl);
|
backgroundColor: "white",
|
||||||
if (!settings?.downloadQuality?.value) {
|
|
||||||
throw new Error("No download quality selected");
|
|
||||||
}
|
|
||||||
await initiateDownload(settings?.downloadQuality?.value);
|
|
||||||
},
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
>
|
>
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
<BottomSheetView>
|
||||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||||
</View>
|
<Text className="font-bold text-2xl text-neutral-10">
|
||||||
</TouchableOpacity>
|
Download options
|
||||||
);
|
</Text>
|
||||||
}
|
<View className="flex flex-col space-y-2 w-full items-start">
|
||||||
|
<BitrateSelector
|
||||||
|
inverted
|
||||||
|
onChange={(val) => setMaxBitrate(val)}
|
||||||
|
selected={maxBitrate}
|
||||||
|
/>
|
||||||
|
<MediaSourceSelector
|
||||||
|
item={item}
|
||||||
|
onChange={setSelectedMediaSource}
|
||||||
|
selected={selectedMediaSource}
|
||||||
|
/>
|
||||||
|
{selectedMediaSource && (
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
<AudioTrackSelector
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedAudioStream}
|
||||||
|
selected={selectedAudioStream}
|
||||||
|
/>
|
||||||
|
<SubtitleTrackSelector
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedSubtitleStream}
|
||||||
|
selected={selectedSubtitleStream}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
className="mt-auto"
|
||||||
|
onPress={() => {
|
||||||
|
closeModal();
|
||||||
|
queueActions.enqueue(queue, setQueue, {
|
||||||
|
id: item.Id!,
|
||||||
|
execute: async () => {
|
||||||
|
await initiateDownload();
|
||||||
|
},
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
color="purple"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
363
components/ItemContent.tsx
Normal file
363
components/ItemContent.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
|
import { DownloadItem } from "@/components/DownloadItem";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
|
import native from "@/utils/profiles/native";
|
||||||
|
import old from "@/utils/profiles/old";
|
||||||
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { useCastDevice } from "react-native-google-cast";
|
||||||
|
import { Chromecast } from "./Chromecast";
|
||||||
|
import { ItemHeader } from "./ItemHeader";
|
||||||
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
runOnJS,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
import { set } from "lodash";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const [selectedMediaSource, setSelectedMediaSource] =
|
||||||
|
useState<MediaSourceInfo | null>(null);
|
||||||
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
|
useState<number>(0);
|
||||||
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
|
key: "Max",
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loadingImage, setLoadingImage] = useState(true);
|
||||||
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
|
|
||||||
|
const [orientation, setOrientation] = useState(
|
||||||
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||||
|
(event) => {
|
||||||
|
setOrientation(event.orientationInfo.orientation);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||||
|
setOrientation(initialOrientation);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: opacity.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fadeIn = () => {
|
||||||
|
opacity.value = withTiming(1, { duration: 300 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const fadeOut = (callback: any) => {
|
||||||
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(callback)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerHeightRef = useRef(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: item,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["item", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
enabled: !!id && !!api,
|
||||||
|
staleTime: 60 * 1000 * 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [localItem, setLocalItem] = useState(item);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
if (localItem) {
|
||||||
|
// Fade out current item
|
||||||
|
fadeOut(() => {
|
||||||
|
// Update local item after fade out
|
||||||
|
setLocalItem(item);
|
||||||
|
// Then fade in
|
||||||
|
fadeIn();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If there's no current item, just set and fade in
|
||||||
|
setLocalItem(item);
|
||||||
|
fadeIn();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If item is null, fade out and clear local item
|
||||||
|
fadeOut(() => setLocalItem(null));
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () =>
|
||||||
|
item && (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Chromecast background="blur" width={22} height={22} />
|
||||||
|
<DownloadItem item={item} />
|
||||||
|
<PlayedStatus item={item} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||||
|
headerHeightRef.current = 230;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
||||||
|
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const { data: sessionData } = useQuery({
|
||||||
|
queryKey: ["sessionData", item?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item?.Id) return null;
|
||||||
|
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
|
itemId: item?.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return playbackData.data;
|
||||||
|
},
|
||||||
|
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: playbackUrl } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"playbackUrl",
|
||||||
|
item?.Id,
|
||||||
|
maxBitrate,
|
||||||
|
castDevice,
|
||||||
|
selectedMediaSource,
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
settings,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let deviceProfile: any = ios;
|
||||||
|
|
||||||
|
if (castDevice?.deviceId) {
|
||||||
|
deviceProfile = chromecastProfile;
|
||||||
|
} else if (settings?.deviceProfile === "Native") {
|
||||||
|
deviceProfile = native;
|
||||||
|
} else if (settings?.deviceProfile === "Old") {
|
||||||
|
deviceProfile = old;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
userId: user.Id,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
|
sessionData,
|
||||||
|
deviceProfile,
|
||||||
|
audioStreamIndex: selectedAudioStream,
|
||||||
|
subtitleStreamIndex: selectedSubtitleStream,
|
||||||
|
forceDirectPlay: settings?.forceDirectPlay,
|
||||||
|
height: maxBitrate.height,
|
||||||
|
mediaSourceId: selectedMediaSource.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info("Stream URL:", url);
|
||||||
|
|
||||||
|
return url;
|
||||||
|
},
|
||||||
|
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||||
|
|
||||||
|
const loading = useMemo(() => {
|
||||||
|
return Boolean(
|
||||||
|
isLoading || isFetching || loadingImage || (logoUrl && loadingLogo)
|
||||||
|
);
|
||||||
|
}, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 relative"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ParallaxScrollView
|
||||||
|
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||||
|
headerHeight={headerHeightRef.current}
|
||||||
|
headerImage={
|
||||||
|
<>
|
||||||
|
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||||
|
{localItem && (
|
||||||
|
<ItemImage
|
||||||
|
variant={
|
||||||
|
localItem.Type === "Movie" && logoUrl
|
||||||
|
? "Backdrop"
|
||||||
|
: "Primary"
|
||||||
|
}
|
||||||
|
item={localItem}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
onLoad={() => setLoadingImage(false)}
|
||||||
|
onError={() => setLoadingImage(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
logo={
|
||||||
|
<>
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
onLoad={() => setLoadingLogo(false)}
|
||||||
|
onError={() => setLoadingLogo(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col bg-transparent shrink">
|
||||||
|
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||||
|
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||||
|
<ItemHeader item={localItem} className="mb-4" />
|
||||||
|
{localItem ? (
|
||||||
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
|
<BitrateSelector
|
||||||
|
className="mr-1"
|
||||||
|
onChange={(val) => setMaxBitrate(val)}
|
||||||
|
selected={maxBitrate}
|
||||||
|
/>
|
||||||
|
<MediaSourceSelector
|
||||||
|
className="mr-1"
|
||||||
|
item={localItem}
|
||||||
|
onChange={setSelectedMediaSource}
|
||||||
|
selected={selectedMediaSource}
|
||||||
|
/>
|
||||||
|
{selectedMediaSource && (
|
||||||
|
<>
|
||||||
|
<AudioTrackSelector
|
||||||
|
className="mr-1"
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedAudioStream}
|
||||||
|
selected={selectedAudioStream}
|
||||||
|
/>
|
||||||
|
<SubtitleTrackSelector
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedSubtitleStream}
|
||||||
|
selected={selectedSubtitleStream}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="h-16">
|
||||||
|
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
|
||||||
|
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{item?.Type === "Episode" && (
|
||||||
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OverviewText text={item?.Overview} className="px-4 mb-4" />
|
||||||
|
|
||||||
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
|
|
||||||
|
{item?.Type === "Episode" && (
|
||||||
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
|
)}
|
||||||
|
<SimilarItems itemId={item?.Id} />
|
||||||
|
|
||||||
|
<View className="h-16"></View>
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
38
components/ItemHeader.tsx
Normal file
38
components/ItemHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
|
import { Ratings } from "./Ratings";
|
||||||
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
|
if (!item)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex flex-col space-y-1.5 w-full items-start h-24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
||||||
|
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
|
||||||
|
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
|
||||||
|
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
minHeight: 96,
|
||||||
|
}}
|
||||||
|
className="flex flex-col"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Ratings item={item} className="mb-2" />
|
||||||
|
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
|
||||||
|
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
89
components/MediaSourceSelector.tsx
Normal file
89
components/MediaSourceSelector.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { tc } from "@/utils/textTools";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
onChange: (value: MediaSourceInfo) => void;
|
||||||
|
selected: MediaSourceInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaSourceSelector: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const mediaSources = useMemo(() => {
|
||||||
|
return item.MediaSources;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const selectedMediaSource = useMemo(
|
||||||
|
() =>
|
||||||
|
mediaSources
|
||||||
|
?.find((x) => x.Id === selected?.Id)
|
||||||
|
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
|
||||||
|
[mediaSources, selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaSources?.length) onChange(mediaSources[0]);
|
||||||
|
}, [mediaSources]);
|
||||||
|
|
||||||
|
const name = (name?: string | null) => {
|
||||||
|
if (name && name.length > 40)
|
||||||
|
return (
|
||||||
|
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex shrink"
|
||||||
|
style={{
|
||||||
|
minWidth: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<View className="flex flex-col" {...props}>
|
||||||
|
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
||||||
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
|
||||||
|
<Text numberOfLines={1}>{selectedMediaSource}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||||
|
{mediaSources?.map((source, idx: number) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={idx.toString()}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(source);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{name(source.Name)}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,35 +10,32 @@ interface Props extends ViewProps {
|
|||||||
|
|
||||||
export const OverviewText: React.FC<Props> = ({
|
export const OverviewText: React.FC<Props> = ({
|
||||||
text,
|
text,
|
||||||
characterLimit = 140,
|
characterLimit = 100,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [limit, setLimit] = useState(characterLimit);
|
const [limit, setLimit] = useState(characterLimit);
|
||||||
|
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
if (text.length > characterLimit)
|
return (
|
||||||
return (
|
<View className="flex flex-col" {...props}>
|
||||||
|
<Text className="text-xl font-bold mb-2">Overview</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setLimit((prev) =>
|
setLimit((prev) =>
|
||||||
prev === characterLimit ? text.length : characterLimit
|
prev === characterLimit ? text.length : characterLimit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<View {...props} className="">
|
<View>
|
||||||
<Text>{tc(text, limit)}</Text>
|
<Text>{tc(text, limit)}</Text>
|
||||||
<Text className="text-purple-600 mt-1">
|
{text.length > characterLimit && (
|
||||||
{limit === characterLimit ? "Show more" : "Show less"}
|
<Text className="text-purple-600 mt-1">
|
||||||
</Text>
|
{limit === characterLimit ? "Show more" : "Show less"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<Text>{text}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { router } from "expo-router";
|
import { type PropsWithChildren, type ReactElement } from "react";
|
||||||
import type { PropsWithChildren, ReactElement } from "react";
|
import { View, ViewProps } from "react-native";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useScrollViewOffset,
|
useScrollViewOffset,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Chromecast } from "./Chromecast";
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 400;
|
interface Props extends ViewProps {
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
|
||||||
headerImage: ReactElement;
|
headerImage: ReactElement;
|
||||||
logo?: ReactElement;
|
logo?: ReactElement;
|
||||||
}>;
|
episodePoster?: ReactElement;
|
||||||
|
headerHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const ParallaxScrollView: React.FC<Props> = ({
|
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||||
children,
|
children,
|
||||||
headerImage,
|
headerImage,
|
||||||
|
episodePoster,
|
||||||
|
headerHeight = 400,
|
||||||
logo,
|
logo,
|
||||||
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
const scrollOffset = useScrollViewOffset(scrollRef);
|
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||||
@@ -32,25 +32,23 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
{
|
{
|
||||||
translateY: interpolate(
|
translateY: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
[-headerHeight, 0, headerHeight],
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
[-headerHeight / 2, 0, headerHeight * 0.75]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scale: interpolate(
|
scale: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
[-headerHeight, 0, headerHeight],
|
||||||
[2, 1, 1],
|
[2, 1, 1]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const inset = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1">
|
<View className="flex-1" {...props}>
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -58,32 +56,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.back()}
|
|
||||||
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
|
|
||||||
style={{
|
|
||||||
top: inset.top + 17,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
className="drop-shadow-2xl"
|
|
||||||
name="arrow-back"
|
|
||||||
size={24}
|
|
||||||
color="#077DF2"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View
|
|
||||||
className="absolute right-4 z-50 bg-black rounded-full p-0.5"
|
|
||||||
style={{
|
|
||||||
top: inset.top + 17,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Chromecast width={22} height={22} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{logo && (
|
{logo && (
|
||||||
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
|
<View
|
||||||
|
style={{
|
||||||
|
top: headerHeight - 200,
|
||||||
|
height: 130,
|
||||||
|
}}
|
||||||
|
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center"
|
||||||
|
>
|
||||||
{logo}
|
{logo}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -91,7 +71,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: HEADER_HEIGHT,
|
height: headerHeight,
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
},
|
},
|
||||||
headerAnimatedStyle,
|
headerAnimatedStyle,
|
||||||
@@ -99,7 +79,35 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
{headerImage}
|
{headerImage}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<View className="flex-1 overflow-hidden bg-black pb-24">
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
top: -50,
|
||||||
|
}}
|
||||||
|
className="relative flex-1 bg-transparent pb-24"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
// Background Linear Gradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,1)"]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: -150,
|
||||||
|
height: 200,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
// Background Linear Gradient
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 50,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "black",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
|
|||||||
@@ -3,12 +3,24 @@ 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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
interpolateColor,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedReaction,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -20,6 +32,49 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const { setCurrentlyPlayingState } = usePlayback();
|
const { setCurrentlyPlayingState } = usePlayback();
|
||||||
|
|
||||||
|
const [color] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
|
// Create a shared value for animation progress
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
|
||||||
|
// Create shared values for start and end colors
|
||||||
|
const startColor = useSharedValue(color);
|
||||||
|
const endColor = useSharedValue(color);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// When color changes, update end color and animate progress
|
||||||
|
endColor.value = color;
|
||||||
|
progress.value = 0; // Reset progress
|
||||||
|
progress.value = withTiming(1, { duration: 300 }); // Animate to 1 over 500ms
|
||||||
|
}, [color]);
|
||||||
|
|
||||||
|
// Animated style for primary color
|
||||||
|
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
progress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.average, endColor.value.average]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Animated style for text color
|
||||||
|
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||||
|
color: interpolateColor(
|
||||||
|
progress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.text, endColor.value.text]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update start color after animation completes
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
startColor.value = color;
|
||||||
|
}, 500); // Should match the duration in withTiming
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [color]);
|
||||||
|
|
||||||
const onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!url || !item) return;
|
if (!url || !item) return;
|
||||||
|
|
||||||
@@ -68,18 +123,56 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playbackPercent = useMemo(() => {
|
||||||
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
|
const userData = item.UserData;
|
||||||
|
if (!userData) return 0;
|
||||||
|
const PlaybackPositionTicks = userData.PlaybackPositionTicks;
|
||||||
|
if (!PlaybackPositionTicks) return 0;
|
||||||
|
return (PlaybackPositionTicks / item.RunTimeTicks) * 100;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<TouchableOpacity onPress={onPress} className="relative" {...props}>
|
||||||
onPress={onPress}
|
<Animated.View
|
||||||
iconRight={
|
style={[
|
||||||
|
animatedPrimaryStyle,
|
||||||
|
{
|
||||||
|
width:
|
||||||
|
playbackPercent === 0
|
||||||
|
? "100%"
|
||||||
|
: `${Math.max(playbackPercent, 15)}%`,
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="absolute w-full h-full top-0 left-0 rounded-xl z-10"
|
||||||
|
/>
|
||||||
|
<Animated.View
|
||||||
|
style={[animatedPrimaryStyle]}
|
||||||
|
className="absolute w-full h-full top-0 left-0 rounded-xl "
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: color.primary,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Ionicons name="play-circle" size={24} color="white" />
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
{client && <Feather name="cast" size={22} color="white" />}
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{client && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Feather name="cast" size={22} />
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
}
|
</View>
|
||||||
{...props}
|
</TouchableOpacity>
|
||||||
>
|
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ 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 from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -37,7 +41,10 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View
|
||||||
|
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{item.UserData?.Played ? (
|
{item.UserData?.Played ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
@@ -51,7 +58,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
<Ionicons name="checkmark-circle" size={30} color="white" />
|
<Ionicons name="checkmark-circle" size={24} color="white" />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
@@ -67,7 +74,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
|
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item?: BaseItemDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Ratings: React.FC<Props> = ({ item }) => {
|
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||||
|
if (!item) return null;
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-center mt-2 space-x-2">
|
<View className="flex flex-row items-center mt-2 space-x-2" {...props}>
|
||||||
{item.OfficialRating && (
|
{item.OfficialRating && (
|
||||||
<Badge text={item.OfficialRating} variant="gray" />
|
<Badge text={item.OfficialRating} variant="gray" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,23 +6,26 @@ 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 { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { ItemCardText } from "./ItemCardText";
|
import { ItemCardText } from "./ItemCardText";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
type SimilarItemsProps = {
|
interface SimilarItemsProps extends ViewProps {
|
||||||
itemId: string;
|
itemId?: string | null;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||||
|
itemId,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
|
const { data: similarItems, isLoading } = useQuery<BaseItemDto[]>({
|
||||||
queryKey: ["similarItems", itemId],
|
queryKey: ["similarItems", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id || !itemId) return [];
|
||||||
const response = await getLibraryApi(api).getSimilarItems({
|
const response = await getLibraryApi(api).getSimilarItems({
|
||||||
itemId,
|
itemId,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
@@ -41,8 +44,8 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props}>
|
||||||
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
|
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View className="my-12">
|
<View className="my-12">
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -53,7 +56,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
|||||||
{movies.map((item) => (
|
{movies.map((item) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
onPress={() => router.push(`/items/${item.Id}`)}
|
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-32"
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} />
|
<MoviePoster item={item} />
|
||||||
@@ -63,7 +66,9 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
{movies.length === 0 && <Text className="px-4">No similar items</Text>}
|
{movies.length === 0 && (
|
||||||
|
<Text className="px-4 text-neutral-500">No similar items</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,29 +2,29 @@ import { 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 { Text } from "./common/Text";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
source: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected: number;
|
selected: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||||
item,
|
source,
|
||||||
onChange,
|
onChange,
|
||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||||
item.MediaSources?.[0].MediaStreams?.filter(
|
[source]
|
||||||
(x) => x.Type === "Subtitle"
|
|
||||||
) ?? [],
|
|
||||||
[item]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
@@ -33,7 +33,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
|
const index = source.DefaultSubtitleStreamIndex;
|
||||||
if (index !== undefined && index !== null) {
|
if (index !== undefined && index !== null) {
|
||||||
onChange(index);
|
onChange(index);
|
||||||
} else {
|
} else {
|
||||||
@@ -44,20 +44,24 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between" {...props}>
|
<View
|
||||||
|
className="flex col shrink justify-start place-self-start items-start"
|
||||||
|
style={{
|
||||||
|
minWidth: 60,
|
||||||
|
maxWidth: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col mb-2">
|
<View className="flex flex-col " {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
|
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
|
||||||
<View className="flex flex-row">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<Text className=" ">
|
||||||
<Text className="">
|
{selectedSubtitleSteam
|
||||||
{selectedSubtitleSteam
|
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 13)
|
: "None"}
|
||||||
: "None"}
|
</Text>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -69,7 +73,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
|
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={"-1"}
|
key={"-1"}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
|||||||
59
components/common/HeaderBackButton.tsx
Normal file
59
components/common/HeaderBackButton.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView, BlurViewProps } from "expo-blur";
|
||||||
|
|
||||||
|
interface Props extends BlurViewProps {
|
||||||
|
background?: "blur" | "transparent";
|
||||||
|
touchableOpacityProps?: TouchableOpacityProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeaderBackButton: React.FC<Props> = ({
|
||||||
|
background = "transparent",
|
||||||
|
touchableOpacityProps,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
if (background === "transparent")
|
||||||
|
return (
|
||||||
|
<BlurView
|
||||||
|
{...props}
|
||||||
|
intensity={100}
|
||||||
|
className="overflow-hidden rounded-full p-2"
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
{...touchableOpacityProps}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
className="drop-shadow-2xl"
|
||||||
|
name="arrow-back"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</BlurView>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
className=" bg-neutral-800/80 rounded-full p-2"
|
||||||
|
{...touchableOpacityProps}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
className="drop-shadow-2xl"
|
||||||
|
name="arrow-back"
|
||||||
|
size={24}
|
||||||
|
color="#077DF2"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
import { FlashList, FlashListProps } from "@shopify/flash-list";
|
||||||
import React, { useEffect } from "react";
|
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||||
import { View, ViewStyle } from "react-native";
|
import { View, ViewStyle } from "react-native";
|
||||||
import Animated, {
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { Loader } from "../Loader";
|
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||||
|
|
||||||
|
export interface HorizontalScrollRef {
|
||||||
|
scrollToIndex: (index: number, viewOffset: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface HorizontalScrollProps<T>
|
interface HorizontalScrollProps<T>
|
||||||
extends PartialExcept<
|
extends PartialExcept<
|
||||||
Omit<FlashListProps<T>, "renderItem">,
|
Omit<FlashListProps<T>, "renderItem">,
|
||||||
@@ -23,61 +21,69 @@ interface HorizontalScrollProps<T>
|
|||||||
loadingContainerStyle?: ViewStyle;
|
loadingContainerStyle?: ViewStyle;
|
||||||
height?: number;
|
height?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
extraData?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HorizontalScroll<T>({
|
export const HorizontalScroll = forwardRef<
|
||||||
data = [],
|
HorizontalScrollRef,
|
||||||
renderItem,
|
HorizontalScrollProps<any>
|
||||||
containerStyle,
|
>(
|
||||||
contentContainerStyle,
|
<T,>(
|
||||||
loadingContainerStyle,
|
{
|
||||||
loading = false,
|
data = [],
|
||||||
height = 164,
|
renderItem,
|
||||||
...props
|
containerStyle,
|
||||||
}: HorizontalScrollProps<T>): React.ReactElement {
|
contentContainerStyle,
|
||||||
const animatedOpacity = useSharedValue(0);
|
loadingContainerStyle,
|
||||||
const animatedStyle1 = useAnimatedStyle(() => {
|
loading = false,
|
||||||
return {
|
height = 164,
|
||||||
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
|
extraData,
|
||||||
};
|
...props
|
||||||
});
|
}: HorizontalScrollProps<T>,
|
||||||
|
ref: React.ForwardedRef<HorizontalScrollRef>
|
||||||
|
) => {
|
||||||
|
const flashListRef = useRef<FlashList<T>>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useImperativeHandle(ref!, () => ({
|
||||||
if (data) {
|
scrollToIndex: (index: number, viewOffset: number) => {
|
||||||
animatedOpacity.value = 1;
|
flashListRef.current?.scrollToIndex({
|
||||||
}
|
index,
|
||||||
}, [data]);
|
animated: true,
|
||||||
|
viewPosition: 0,
|
||||||
|
viewOffset,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
if (data === undefined || data === null || loading) {
|
const renderFlashListItem = ({
|
||||||
return (
|
item,
|
||||||
<View
|
index,
|
||||||
style={[
|
}: {
|
||||||
{
|
item: T;
|
||||||
flex: 1,
|
index: number;
|
||||||
justifyContent: "center",
|
}) => (
|
||||||
alignItems: "center",
|
<View className="mr-2">
|
||||||
},
|
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||||
loadingContainerStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
|
if (!data || loading) {
|
||||||
<View className="mr-2">
|
return (
|
||||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
<View className="px-4 mb-2">
|
||||||
</View>
|
<View className="bg-neutral-950 h-24 w-full rounded-md mb-2"></View>
|
||||||
);
|
<View className="bg-neutral-950 h-10 w-full rounded-md mb-1"></View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[containerStyle, animatedStyle1]}>
|
<FlashList<T>
|
||||||
<FlashList
|
ref={flashListRef}
|
||||||
data={data}
|
data={data}
|
||||||
|
extraData={extraData}
|
||||||
renderItem={renderFlashListItem}
|
renderItem={renderFlashListItem}
|
||||||
horizontal
|
horizontal
|
||||||
estimatedItemSize={100}
|
estimatedItemSize={200}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
@@ -90,6 +96,6 @@ export function HorizontalScroll<T>({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
);
|
||||||
);
|
}
|
||||||
}
|
);
|
||||||
|
|||||||
97
components/common/ItemImage.tsx
Normal file
97
components/common/ItemImage.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image, ImageProps, ImageSource } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface Props extends ImageProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
variant?: "Backdrop" | "Primary" | "Thumb" | "Logo";
|
||||||
|
quality?: number;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemImage: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
variant,
|
||||||
|
quality = 90,
|
||||||
|
width = 1000,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const source = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
let tag: string | null | undefined;
|
||||||
|
let blurhash: string | null | undefined;
|
||||||
|
let src: ImageSource | null = null;
|
||||||
|
|
||||||
|
switch (variant) {
|
||||||
|
case "Backdrop":
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
tag = item.ParentBackdropImageTags?.[0];
|
||||||
|
if (!tag) break;
|
||||||
|
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||||
|
src = {
|
||||||
|
uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
|
||||||
|
blurhash,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
tag = item.BackdropImageTags?.[0];
|
||||||
|
if (!tag) break;
|
||||||
|
blurhash = item.ImageBlurHashes?.Backdrop?.[tag];
|
||||||
|
src = {
|
||||||
|
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`,
|
||||||
|
blurhash,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "Primary":
|
||||||
|
tag = item.ImageTags?.["Primary"];
|
||||||
|
if (!tag) break;
|
||||||
|
blurhash = item.ImageBlurHashes?.Primary?.[tag];
|
||||||
|
|
||||||
|
src = {
|
||||||
|
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
|
||||||
|
blurhash,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "Thumb":
|
||||||
|
tag = item.ImageTags?.["Thumb"];
|
||||||
|
if (!tag) break;
|
||||||
|
blurhash = item.ImageBlurHashes?.Thumb?.[tag];
|
||||||
|
|
||||||
|
src = {
|
||||||
|
uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`,
|
||||||
|
blurhash,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
tag = item.ImageTags?.["Primary"];
|
||||||
|
src = {
|
||||||
|
uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return src;
|
||||||
|
}, [item.ImageTags]);
|
||||||
|
|
||||||
|
useImageColors(source?.uri);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
transition={300}
|
||||||
|
placeholder={{
|
||||||
|
blurhash: source?.blurhash,
|
||||||
|
}}
|
||||||
|
source={{
|
||||||
|
uri: source?.uri,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
import {
|
|
||||||
TouchableOpacity,
|
|
||||||
TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { PropsWithChildren } from "react";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import { Alert, TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -20,46 +14,67 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
const segments = useSegments();
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
if (item.Type === "Series") {
|
const from = segments[2];
|
||||||
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 === "Person") {
|
|
||||||
router.push(`/actors/${item.Id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "BoxSet") {
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
router.push(`/collections/${item.Id}`);
|
return (
|
||||||
return;
|
<TouchableOpacity
|
||||||
}
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
router.push(`/items/${item.Id}`);
|
if (item.Type === "Series") {
|
||||||
}}
|
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
|
||||||
{...props}
|
return;
|
||||||
>
|
}
|
||||||
{children}
|
|
||||||
</TouchableOpacity>
|
if (item.Type === "MusicAlbum") {
|
||||||
);
|
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Audio") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "MusicArtist") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Person") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "BoxSet") {
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "UserView") {
|
||||||
|
Alert.alert("Not implemented");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "CollectionFolder") {
|
||||||
|
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same as default
|
||||||
|
// if (item.Type === "Episode") {
|
||||||
|
// router.push(`/items/${item.Id}`);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className="flex flex-row items-center justify-between">
|
||||||
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
|
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
<Text className="text-xs font-bold">{items.length}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -47,7 +47,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
return response.data.Items?.[0].Id || null;
|
return response.data.Items?.[0].Id || null;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPressPagination = (index: number) => {
|
const onPressPagination = (index: number) => {
|
||||||
@@ -75,7 +75,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!sf_carousel,
|
enabled: !!api && !!user?.Id && !!sf_carousel,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const width = Dimensions.get("screen").width;
|
const width = Dimensions.get("screen").width;
|
||||||
|
|||||||
@@ -6,50 +6,72 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
|||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import {
|
||||||
|
type QueryKey,
|
||||||
|
useQuery,
|
||||||
|
type QueryFunction,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
|
import { EpisodePoster } from "../posters/EpisodePoster";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title: string;
|
title?: string | null;
|
||||||
loading?: boolean;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
data?: BaseItemDto[] | null;
|
|
||||||
height?: "small" | "large";
|
height?: "small" | "large";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
queryKey: QueryKey;
|
||||||
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
title,
|
title,
|
||||||
data,
|
|
||||||
orientation = "vertical",
|
orientation = "vertical",
|
||||||
height = "small",
|
height = "small",
|
||||||
loading = false,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
queryFn,
|
||||||
|
queryKey,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (disabled) return null;
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
enabled: !disabled,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={data}
|
data={data}
|
||||||
height={orientation === "vertical" ? 247 : 164}
|
height={orientation === "vertical" ? 247 : 164}
|
||||||
loading={loading}
|
loading={isLoading}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
key={index}
|
key={index}
|
||||||
item={item}
|
item={item}
|
||||||
className={`flex flex-col
|
className={`flex flex-col
|
||||||
${orientation === "vertical" ? "w-28" : "w-44"}
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
{orientation === "vertical" ? (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<MoviePoster item={item} />
|
|
||||||
) : (
|
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
)}
|
)}
|
||||||
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
|
<SeriesPoster item={item} />
|
||||||
|
)}
|
||||||
|
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
)}
|
||||||
|
{item.Type === "Movie" && orientation === "vertical" && (
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
)}
|
||||||
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
|
|||||||
209
components/library/LibraryItemCard.tsx
Normal file
209
components/library/LibraryItemCard.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
CollectionType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { getColors, ImageColorsResult } from "react-native-image-colors";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { sortBy } from "lodash";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
library: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryColor = {
|
||||||
|
dominantColor: string;
|
||||||
|
averageColor: string;
|
||||||
|
secondary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
const icons: Record<CollectionType, IconName> = {
|
||||||
|
movies: "film",
|
||||||
|
tvshows: "tv",
|
||||||
|
music: "musical-notes",
|
||||||
|
books: "book",
|
||||||
|
homevideos: "videocam",
|
||||||
|
boxsets: "albums",
|
||||||
|
playlists: "list",
|
||||||
|
folders: "folder",
|
||||||
|
livetv: "tv",
|
||||||
|
musicvideos: "musical-notes",
|
||||||
|
photos: "images",
|
||||||
|
trailers: "videocam",
|
||||||
|
unknown: "help-circle",
|
||||||
|
} as const;
|
||||||
|
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
const [imageInfo, setImageInfo] = useState<LibraryColor>({
|
||||||
|
dominantColor: "#fff",
|
||||||
|
averageColor: "#fff",
|
||||||
|
secondary: "#fff",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() =>
|
||||||
|
getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item: library,
|
||||||
|
}),
|
||||||
|
[library]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: itemsCount } = useQuery({
|
||||||
|
queryKey: ["library-count", library.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: library.Id,
|
||||||
|
limit: 0,
|
||||||
|
});
|
||||||
|
return response.data.TotalRecordCount;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (url) {
|
||||||
|
getColors(url, {
|
||||||
|
fallback: "#fff",
|
||||||
|
cache: true,
|
||||||
|
key: url,
|
||||||
|
})
|
||||||
|
.then((colors) => {
|
||||||
|
let dominantColor: string = "#fff";
|
||||||
|
let averageColor: string = "#fff";
|
||||||
|
let secondary: string = "#fff";
|
||||||
|
|
||||||
|
if (colors.platform === "android") {
|
||||||
|
dominantColor = colors.dominant;
|
||||||
|
averageColor = colors.average;
|
||||||
|
secondary = colors.muted;
|
||||||
|
} else if (colors.platform === "ios") {
|
||||||
|
dominantColor = colors.primary;
|
||||||
|
averageColor = colors.background;
|
||||||
|
secondary = colors.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageInfo({
|
||||||
|
dominantColor,
|
||||||
|
averageColor,
|
||||||
|
secondary,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error getting colors", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
if (settings?.libraryOptions?.display === "row") {
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter item={library} className="w-full px-4">
|
||||||
|
<View className="flex flex-row items-center w-full relative ">
|
||||||
|
<Ionicons
|
||||||
|
name={icons[library.CollectionType!] || "folder"}
|
||||||
|
size={22}
|
||||||
|
color={"#e5e5e5"}
|
||||||
|
/>
|
||||||
|
<Text className="text-start px-4 text-neutral-200">
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
{settings?.libraryOptions?.showStats && (
|
||||||
|
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
|
||||||
|
{itemsCount} items
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.libraryOptions?.imageStyle === "cover") {
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter item={library} className="w-full">
|
||||||
|
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 8,
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.3)", // Adjust the alpha value (0.3) to control darkness
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{settings?.libraryOptions?.showTitles && (
|
||||||
|
<Text className="font-bold text-lg text-start px-4">
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{settings?.libraryOptions?.showStats && (
|
||||||
|
<Text className="font-bold text-xs text-start px-4">
|
||||||
|
{itemsCount} items
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter item={library} {...props}>
|
||||||
|
<View className="flex flex-row items-center justify-between rounded-xl w-full relative border bg-neutral-900 border-neutral-900 h-20">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-bold text-lg text-start px-4">
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
{settings?.libraryOptions?.showStats && (
|
||||||
|
<Text className="font-bold text-xs text-neutral-500 text-start px-4">
|
||||||
|
{itemsCount} items
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="p-2">
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
className="h-full aspect-[2/1] object-cover rounded-lg overflow-hidden"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,22 +12,38 @@ import { Text } from "../common/Text";
|
|||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import MoviePoster from "../posters/MoviePoster";
|
import MoviePoster from "../posters/MoviePoster";
|
||||||
|
import {
|
||||||
|
type QueryKey,
|
||||||
|
type QueryFunction,
|
||||||
|
useQuery,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
collection: BaseItemDto;
|
queryKey: QueryKey;
|
||||||
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
|
export const MediaListSection: React.FC<Props> = ({
|
||||||
|
queryFn,
|
||||||
|
queryKey,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { data: collection, isLoading } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
}: {
|
}: {
|
||||||
pageParam: number;
|
pageParam: number;
|
||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !user?.Id) return null;
|
if (!api || !user?.Id || !collection) return null;
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
@@ -38,7 +54,7 @@ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
[api, user?.Id, collection.Id]
|
[api, user?.Id, collection?.Id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
const router = useRouter();
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center self-center px-4" {...props}>
|
<View {...props}>
|
||||||
<Text className="text-center font-bold text-2xl mr-2">{item?.Name}</Text>
|
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
|
||||||
|
<Text className=" opacity-50">{item?.ProductionYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const play = async (type: "device" | "cast") => {
|
const play = async (type: "device" | "cast") => {
|
||||||
if (!user?.Id || !api || !item.Id) return;
|
if (!user?.Id || !api || !item.Id) {
|
||||||
|
console.warn("No user, api or item", user, api, item.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
itemId: item?.Id,
|
itemId: item?.Id,
|
||||||
@@ -87,9 +90,13 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||||
|
mediaSourceId: item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url || !item) return;
|
if (!url || !item) {
|
||||||
|
console.warn("No url or item", url, item.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
if (type === "cast" && client) {
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
await CastContext.getPlayServicesState().then((state) => {
|
||||||
@@ -111,6 +118,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
console.log("Playing on device", url, item.Id);
|
||||||
setCurrentlyPlayingState({
|
setCurrentlyPlayingState({
|
||||||
item,
|
item,
|
||||||
url,
|
url,
|
||||||
|
|||||||
64
components/posters/EpisodePoster.tsx
Normal file
64
components/posters/EpisodePoster.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const [progress, setProgress] = useState(
|
||||||
|
item.UserData?.PlayedPercentage || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.["Primary"] as string;
|
||||||
|
return item.ImageBlurHashes?.["Primary"]?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View className="h-1 bg-red-600 w-full"></View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -5,7 +6,6 @@ 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 "@/components/WatchedIndicator";
|
|
||||||
|
|
||||||
type MoviePosterProps = {
|
type MoviePosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -18,15 +18,13 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const url = useMemo(
|
const url = useMemo(() => {
|
||||||
() =>
|
return getPrimaryImageUrl({
|
||||||
getPrimaryImageUrl({
|
api,
|
||||||
api,
|
item,
|
||||||
item,
|
width: 300,
|
||||||
width: 300,
|
});
|
||||||
}),
|
}, [item]);
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [progress, setProgress] = useState(
|
const [progress, setProgress] = useState(
|
||||||
item.UserData?.PlayedPercentage || 0
|
item.UserData?.PlayedPercentage || 0
|
||||||
@@ -59,6 +57,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WatchedIndicator item={item} />
|
<WatchedIndicator item={item} />
|
||||||
{showProgress && progress > 0 && (
|
{showProgress && progress > 0 && (
|
||||||
<View className="h-1 bg-red-600 w-full"></View>
|
<View className="h-1 bg-red-600 w-full"></View>
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ type MoviePosterProps = {
|
|||||||
const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const url = useMemo(
|
const url = useMemo(() => {
|
||||||
() =>
|
if (item.Type === "Episode") {
|
||||||
getPrimaryImageUrl({
|
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=389&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||||
api,
|
}
|
||||||
item,
|
return getPrimaryImageUrl({
|
||||||
}),
|
api,
|
||||||
[item]
|
item,
|
||||||
);
|
width: 300,
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
const blurhash = useMemo(() => {
|
const blurhash = useMemo(() => {
|
||||||
const key = item.ImageTags?.["Primary"] as string;
|
const key = item.ImageTags?.["Primary"] as string;
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemPerson,
|
BaseItemPerson,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../posters/Poster";
|
import Poster from "../posters/Poster";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { router, usePathname } from "expo-router";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
interface Props extends ViewProps {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props} className="flex flex-col">
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
<HorizontalScroll<NonNullable<BaseItemPerson>>
|
<HorizontalScroll
|
||||||
data={item.People}
|
loading={loading}
|
||||||
|
data={item?.People || []}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -3,19 +3,23 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { router } from "expo-router";
|
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, ViewProps } from "react-native";
|
||||||
import Poster from "../posters/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";
|
||||||
|
|
||||||
export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
|
interface Props extends ViewProps {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View {...props}>
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={[item]}
|
data={[item]}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
34
components/series/EpisodeTitleHeader.tsx
Normal file
34
components/series/EpisodeTitleHeader.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="font-bold text-2xl">{item?.Name}</Text>
|
||||||
|
<View className="flex flex-row items-center mb-1">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="opacity-50">{item?.SeasonName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text className="opacity-50 mx-2">{"—"}</Text>
|
||||||
|
<Text className="opacity-50">{`Episode ${item.IndexNumber}`}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="opacity-50">{item?.ProductionYear}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,40 +23,6 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
// const { data: seasons } = useQuery({
|
|
||||||
// queryKey: ["seasons", item.SeriesId],
|
|
||||||
// queryFn: async () => {
|
|
||||||
// if (
|
|
||||||
// !api ||
|
|
||||||
// !user?.Id ||
|
|
||||||
// !item?.Id ||
|
|
||||||
// !item?.SeriesId ||
|
|
||||||
// !item?.IndexNumber
|
|
||||||
// )
|
|
||||||
// return [];
|
|
||||||
|
|
||||||
// const response = await getItemsApi(api).getItems({
|
|
||||||
// parentId: item?.SeriesId,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// console.log("seasons ~", type, response.data);
|
|
||||||
|
|
||||||
// return (response.data.Items as BaseItemDto[]) ?? [];
|
|
||||||
// },
|
|
||||||
// enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const nextSeason = useMemo(() => {
|
|
||||||
// if (!seasons) return null;
|
|
||||||
// const currentSeasonIndex = seasons.findIndex(
|
|
||||||
// (season) => season.Id === item.SeasonId,
|
|
||||||
// );
|
|
||||||
|
|
||||||
// if (currentSeasonIndex === seasons.length - 1) return null;
|
|
||||||
|
|
||||||
// return seasons[currentSeasonIndex + 1];
|
|
||||||
// }, [seasons]);
|
|
||||||
|
|
||||||
const { data: nextEpisode } = useQuery({
|
const { data: nextEpisode } = useQuery({
|
||||||
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -90,7 +56,7 @@ export const NextEpisodeButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onPress={() => router.replace(`/items/${nextEpisode?.Id}`)}
|
onPress={() => router.setParams({ id: nextEpisode?.Id })}
|
||||||
className={`h-12 aspect-square`}
|
className={`h-12 aspect-square`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
|
|
||||||
if (!items?.length)
|
if (!items?.length)
|
||||||
return (
|
return (
|
||||||
<View>
|
<View className="px-4">
|
||||||
<Text className="text-lg font-bold mb-2">Next up</Text>
|
<Text className="text-lg font-bold mb-2">Next up</Text>
|
||||||
<Text className="opacity-50">No items to display</Text>
|
<Text className="opacity-50">No items to display</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -43,17 +43,17 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/(auth)/items/${item.Id}`);
|
router.push(`/(auth)/items/page?id=${item.Id}`);
|
||||||
}}
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
className="flex flex-col w-44"
|
className="flex flex-col w-44"
|
||||||
>
|
>
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} useEpisodePoster />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|||||||
143
components/series/SeasonEpisodesCarousel.tsx
Normal file
143
components/series/SeasonEpisodesCarousel.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import {
|
||||||
|
HorizontalScroll,
|
||||||
|
HorizontalScrollRef,
|
||||||
|
} from "../common/HorrizontalScroll";
|
||||||
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
loading,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
|
|
||||||
|
const scrollToIndex = (index: number) => {
|
||||||
|
scrollRef.current?.scrollToIndex(index, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const seasonId = useMemo(() => {
|
||||||
|
return item?.SeasonId;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: episodes,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["episodes", seasonId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
const response = await api.axiosInstance.get(
|
||||||
|
`${api.basePath}/Shows/${item?.Id}/Episodes`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
userId: user?.Id,
|
||||||
|
seasonId,
|
||||||
|
Fields:
|
||||||
|
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.Items as BaseItemDto[];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!seasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch previous and next episode
|
||||||
|
*/
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousId = episodes?.find(
|
||||||
|
(ep) => ep.IndexNumber === item.IndexNumber! - 1
|
||||||
|
)?.Id;
|
||||||
|
if (previousId) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", previousId],
|
||||||
|
queryFn: async () =>
|
||||||
|
await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: previousId,
|
||||||
|
}),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextId = episodes?.find(
|
||||||
|
(ep) => ep.IndexNumber === item.IndexNumber! + 1
|
||||||
|
)?.Id;
|
||||||
|
if (nextId) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", nextId],
|
||||||
|
queryFn: async () =>
|
||||||
|
await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: nextId,
|
||||||
|
}),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [episodes, api, user?.Id, item]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item?.Type === "Episode" && item.Id) {
|
||||||
|
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||||
|
if (index !== undefined && index !== -1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToIndex(index);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [episodes, item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HorizontalScroll
|
||||||
|
ref={scrollRef}
|
||||||
|
data={episodes}
|
||||||
|
extraData={item}
|
||||||
|
loading={loading || isLoading || isFetching}
|
||||||
|
renderItem={(_item, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={_item.Id}
|
||||||
|
onPress={() => {
|
||||||
|
router.setParams({ id: _item.Id });
|
||||||
|
}}
|
||||||
|
className={`flex flex-col w-44
|
||||||
|
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<ContinueWatchingPoster item={_item} useEpisodePoster />
|
||||||
|
<ItemCardText item={_item} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -11,9 +11,14 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
|||||||
import { DownloadItem } from "../DownloadItem";
|
import { DownloadItem } from "../DownloadItem";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
initialSeasonIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SeasonIndexState = {
|
type SeasonIndexState = {
|
||||||
@@ -22,7 +27,7 @@ type SeasonIndexState = {
|
|||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
@@ -57,15 +62,35 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||||
const firstSeason = seasons[0];
|
let initialIndex: number | undefined;
|
||||||
if (firstSeason.IndexNumber !== undefined) {
|
|
||||||
|
if (initialSeasonIndex !== undefined) {
|
||||||
|
// Use the provided initialSeasonIndex if it exists in the seasons
|
||||||
|
const seasonExists = seasons.some(
|
||||||
|
(season: any) => season.IndexNumber === initialSeasonIndex
|
||||||
|
);
|
||||||
|
if (seasonExists) {
|
||||||
|
initialIndex = initialSeasonIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex === undefined) {
|
||||||
|
// Fall back to the previous logic if initialIndex is not set
|
||||||
|
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
|
||||||
|
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
|
||||||
|
const firstSeason = season1 || season0 || seasons[0];
|
||||||
|
initialIndex = firstSeason.IndexNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex !== undefined) {
|
||||||
setSeasonIndexState((prev) => ({
|
setSeasonIndexState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[item.Id ?? ""]: firstSeason.IndexNumber,
|
[item.Id ?? ""]: initialIndex,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id]);
|
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
|
||||||
|
|
||||||
const selectedSeasonId: string | null = useMemo(
|
const selectedSeasonId: string | null = useMemo(
|
||||||
() =>
|
() =>
|
||||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||||
@@ -75,27 +100,39 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
const { data: episodes, isFetching } = useQuery({
|
const { data: episodes, isFetching } = useQuery({
|
||||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !item.Id) return [];
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
const response = await api.axiosInstance.get(
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
`${api.basePath}/Shows/${item.Id}/Episodes`,
|
seriesId: item.Id,
|
||||||
{
|
userId: user.Id,
|
||||||
params: {
|
seasonId: selectedSeasonId,
|
||||||
userId: user?.Id,
|
enableUserData: true,
|
||||||
seasonId: selectedSeasonId,
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
Fields:
|
});
|
||||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data.Items as BaseItemDto[];
|
return res.data.Items;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
useEffect(() => {
|
||||||
|
for (let e of episodes || []) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", e.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!e.Id) return;
|
||||||
|
const res = await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: e.Id,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 5 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
// Used for height calculation
|
// Used for height calculation
|
||||||
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
|
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,26 +180,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
{/* Old View. Might have a setting later to manually select view. */}
|
|
||||||
{/* {episodes && (
|
|
||||||
<View className="mt-4">
|
|
||||||
<HorizontalScroll<BaseItemDto>
|
|
||||||
data={episodes}
|
|
||||||
renderItem={(item, index) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={item.Id}
|
|
||||||
onPress={() => {
|
|
||||||
router.push(`/(auth)/items/${item.Id}`);
|
|
||||||
}}
|
|
||||||
className="flex flex-col w-48"
|
|
||||||
>
|
|
||||||
<ContinueWatchingPoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)} */}
|
|
||||||
<View className="px-4 flex flex-col my-4">
|
<View className="px-4 flex flex-col my-4">
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<View
|
<View
|
||||||
@@ -178,13 +195,17 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={e.Id}
|
key={e.Id}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/(auth)/items/${e.Id}`);
|
router.push(`/(auth)/items/page?id=${e.Id}`);
|
||||||
}}
|
}}
|
||||||
className="flex flex-col mb-4"
|
className="flex flex-col mb-4"
|
||||||
>
|
>
|
||||||
<View className="flex flex-row items-center mb-2">
|
<View className="flex flex-row items-center mb-2">
|
||||||
<View className="w-32 aspect-video overflow-hidden mr-2">
|
<View className="w-32 aspect-video overflow-hidden mr-2">
|
||||||
<ContinueWatchingPoster item={e} width={128} />
|
<ContinueWatchingPoster
|
||||||
|
item={e}
|
||||||
|
width={128}
|
||||||
|
useEpisodePoster
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink">
|
<View className="shrink">
|
||||||
<Text numberOfLines={2} className="">
|
<Text numberOfLines={2} className="">
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SeriesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/series/${item.SeriesId}`)}
|
|
||||||
>
|
|
||||||
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View className="flex flex-row items-center self-center px-4">
|
|
||||||
<Text className="text-center font-bold text-2xl mr-2">
|
|
||||||
{item?.Name}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<View className="flex flex-row items-center self-center">
|
|
||||||
<TouchableOpacity onPress={() => {}}>
|
|
||||||
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
|
|
||||||
<Text className="text-center opacity-50">
|
|
||||||
{`Episode ${item.IndexNumber}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -58,7 +58,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View
|
{/* <View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
`}
|
`}
|
||||||
@@ -66,7 +66,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Download quality</Text>
|
<Text className="font-semibold">Download quality</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Choose the search engine you want to use.
|
Choose the download quality.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
@@ -97,7 +97,7 @@ export const SettingToggles: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View> */}
|
||||||
<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">
|
||||||
<View className="shrink">
|
<View className="shrink">
|
||||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||||
|
|||||||
24
components/stacks/NestedTabPageStack.tsx
Normal file
24
components/stacks/NestedTabPageStack.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { Chromecast } from "../Chromecast";
|
||||||
|
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||||
|
|
||||||
|
const commonScreenOptions = {
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerTransparent: true,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => <HeaderBackButton />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
"actors/[actorId]",
|
||||||
|
"albums/[albumId]",
|
||||||
|
"artists/index",
|
||||||
|
"artists/[artistId]",
|
||||||
|
"collections/[collectionId]",
|
||||||
|
"items/page",
|
||||||
|
"series/[id]",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const nestedTabPageScreenOptions: { [key: string]: any } =
|
||||||
|
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.8.2",
|
"channel": "0.10.2",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.8.2",
|
"channel": "0.10.2",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
42
hooks/useImageColors.ts
Normal file
42
hooks/useImageColors.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getColors } from "react-native-image-colors";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
export const useImageColors = (uri: string | undefined | null) => {
|
||||||
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (uri) {
|
||||||
|
getColors(uri, {
|
||||||
|
fallback: "#fff",
|
||||||
|
cache: true,
|
||||||
|
key: uri,
|
||||||
|
})
|
||||||
|
.then((colors) => {
|
||||||
|
let primary: string = "#fff";
|
||||||
|
let average: string = "#fff";
|
||||||
|
let secondary: string = "#fff";
|
||||||
|
|
||||||
|
if (colors.platform === "android") {
|
||||||
|
primary = colors.dominant;
|
||||||
|
average = colors.average;
|
||||||
|
secondary = colors.muted;
|
||||||
|
} else if (colors.platform === "ios") {
|
||||||
|
primary = colors.primary;
|
||||||
|
secondary = colors.detail;
|
||||||
|
average = colors.background;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrimaryColor({
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
average,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error getting colors", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [uri, setPrimaryColor]);
|
||||||
|
};
|
||||||
19
hooks/useInterval.ts
Normal file
19
hooks/useInterval.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useInterval(callback: () => void, delay: number | null) {
|
||||||
|
const savedCallback = useRef<() => void>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
savedCallback.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function tick() {
|
||||||
|
savedCallback.current?.();
|
||||||
|
}
|
||||||
|
if (delay !== null) {
|
||||||
|
const id = setInterval(tick, delay);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}
|
||||||
|
}, [delay]);
|
||||||
|
}
|
||||||
12
package.json
12
package.json
@@ -30,16 +30,17 @@
|
|||||||
"@types/lodash": "^4.17.7",
|
"@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.28",
|
"expo": "~51.0.31",
|
||||||
"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.23",
|
"expo-dev-client": "~4.0.25",
|
||||||
"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",
|
||||||
"expo-image": "~1.12.14",
|
"expo-image": "~1.12.15",
|
||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
|
"expo-linear-gradient": "~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.23",
|
"expo-router": "~3.5.23",
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
"expo-splash-screen": "~0.27.5",
|
"expo-splash-screen": "~0.27.5",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "~3.0.7",
|
"expo-system-ui": "~3.0.7",
|
||||||
"expo-updates": "~0.25.22",
|
"expo-updates": "~0.25.24",
|
||||||
"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",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.2",
|
"react-native-google-cast": "^4.8.2",
|
||||||
|
"react-native-image-colors": "^2.4.0",
|
||||||
"react-native-ios-context-menu": "^2.5.1",
|
"react-native-ios-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",
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
"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.5",
|
||||||
"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",
|
"use-debounce": "^10.0.3",
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import {
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
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 axios from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
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, {
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
@@ -34,6 +32,7 @@ interface JellyfinContextValue {
|
|||||||
removeServer: () => void;
|
removeServer: () => void;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
initiateQuickConnect: () => Promise<string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||||
@@ -56,7 +55,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
||||||
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
|
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
|
||||||
const [isConnected, setIsConnected] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -64,7 +62,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.8.2" },
|
clientInfo: { name: "Streamyfin", version: "0.10.2" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -74,6 +72,85 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const [api, setApi] = useAtom(apiAtom);
|
const [api, setApi] = useAtom(apiAtom);
|
||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||||
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const headers = useMemo(() => {
|
||||||
|
if (!deviceId) return {};
|
||||||
|
return {
|
||||||
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
|
}, DeviceId="${deviceId}", Version="0.10.2"`,
|
||||||
|
};
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
const initiateQuickConnect = useCallback(async () => {
|
||||||
|
if (!api || !deviceId) return;
|
||||||
|
try {
|
||||||
|
const response = await api.axiosInstance.post(
|
||||||
|
api.basePath + "/QuickConnect/Initiate",
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response?.status === 200) {
|
||||||
|
setSecret(response?.data?.Secret);
|
||||||
|
setIsPolling(true);
|
||||||
|
return response.data?.Code;
|
||||||
|
} else {
|
||||||
|
throw new Error("Failed to initiate quick connect");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [api, deviceId, headers]);
|
||||||
|
|
||||||
|
const pollQuickConnect = useCallback(async () => {
|
||||||
|
if (!api || !secret) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.axiosInstance.get(
|
||||||
|
`${api.basePath}/QuickConnect/Connect?Secret=${secret}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
if (response.data.Authenticated) {
|
||||||
|
setIsPolling(false);
|
||||||
|
|
||||||
|
const authResponse = await api.axiosInstance.post(
|
||||||
|
api.basePath + "/Users/AuthenticateWithQuickConnect",
|
||||||
|
{
|
||||||
|
secret,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { AccessToken, User } = authResponse.data;
|
||||||
|
api.accessToken = AccessToken;
|
||||||
|
setUser(User);
|
||||||
|
await AsyncStorage.setItem("token", AccessToken);
|
||||||
|
await AsyncStorage.setItem("user", JSON.stringify(User));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AxiosError && error.response?.status === 400) {
|
||||||
|
setIsPolling(false);
|
||||||
|
setSecret(null);
|
||||||
|
throw new Error("The code has expired. Please try again.");
|
||||||
|
} else {
|
||||||
|
console.error("Error polling Quick Connect:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [api, secret, headers]);
|
||||||
|
|
||||||
|
useInterval(pollQuickConnect, isPolling ? 1000 : null);
|
||||||
|
|
||||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||||
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||||
@@ -127,7 +204,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
console.log("Axios error", error.response?.status);
|
|
||||||
switch (error.response?.status) {
|
switch (error.response?.status) {
|
||||||
case 401:
|
case 401:
|
||||||
throw new Error("Invalid username or password");
|
throw new Error("Invalid username or password");
|
||||||
@@ -204,6 +280,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
login: (username, password) =>
|
login: (username, password) =>
|
||||||
loginMutation.mutateAsync({ username, password }),
|
loginMutation.mutateAsync({ username, password }),
|
||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
|
initiateQuickConnect,
|
||||||
};
|
};
|
||||||
|
|
||||||
useProtectedRoute(user, isLoading || isFetching);
|
useProtectedRoute(user, isLoading || isFetching);
|
||||||
@@ -233,7 +310,7 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
|
|||||||
if (!user?.Id && inAuthGroup) {
|
if (!user?.Id && inAuthGroup) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
} else if (user?.Id && !inAuthGroup) {
|
} else if (user?.Id && !inAuthGroup) {
|
||||||
router.replace("/home");
|
router.replace("/(auth)/(tabs)/(home)/");
|
||||||
}
|
}
|
||||||
}, [user, segments, loading]);
|
}, [user, segments, loading]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,21 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDeviceId } from "@/utils/device";
|
||||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||||
|
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
PlaybackInfoResponse,
|
PlaybackInfoResponse,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { Alert, Platform } from "react-native";
|
||||||
import { OnProgressData, type VideoRef } from "react-native-video";
|
import { OnProgressData, type VideoRef } from "react-native-video";
|
||||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||||
import { getDeviceId } from "@/utils/device";
|
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
type CurrentlyPlayingState = {
|
type CurrentlyPlayingState = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -36,14 +38,15 @@ interface PlaybackContextType {
|
|||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
progressTicks: number | null;
|
progressTicks: number | null;
|
||||||
playVideo: () => void;
|
playVideo: (triggerRef?: boolean) => void;
|
||||||
pauseVideo: () => void;
|
pauseVideo: (triggerRef?: boolean) => void;
|
||||||
stopPlayback: () => void;
|
stopPlayback: () => void;
|
||||||
presentFullscreenPlayer: () => void;
|
presentFullscreenPlayer: () => void;
|
||||||
dismissFullscreenPlayer: () => void;
|
dismissFullscreenPlayer: () => void;
|
||||||
setIsFullscreen: (isFullscreen: boolean) => void;
|
setIsFullscreen: (isFullscreen: boolean) => void;
|
||||||
setIsPlaying: (isPlaying: boolean) => void;
|
setIsPlaying: (isPlaying: boolean) => void;
|
||||||
onProgress: (data: OnProgressData) => void;
|
onProgress: (data: OnProgressData) => void;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
setCurrentlyPlayingState: (
|
setCurrentlyPlayingState: (
|
||||||
currentlyPlaying: CurrentlyPlayingState | null
|
currentlyPlaying: CurrentlyPlayingState | null
|
||||||
) => void;
|
) => void;
|
||||||
@@ -61,9 +64,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const previousVolume = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
const [progressTicks, setProgressTicks] = useState<number | null>(0);
|
||||||
|
const [volume, _setVolume] = useState<number | null>(null);
|
||||||
|
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
|
||||||
const [currentlyPlaying, setCurrentlyPlaying] =
|
const [currentlyPlaying, setCurrentlyPlaying] =
|
||||||
useState<CurrentlyPlayingState | null>(null);
|
useState<CurrentlyPlayingState | null>(null);
|
||||||
|
|
||||||
@@ -71,18 +78,14 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
const setVolume = useCallback(
|
||||||
queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
|
(newVolume: number) => {
|
||||||
queryFn: async () => {
|
previousVolume.current = volume;
|
||||||
if (!currentlyPlaying?.item.Id) return null;
|
_setVolume(newVolume);
|
||||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
videoRef.current?.setVolume(newVolume);
|
||||||
itemId: currentlyPlaying?.item.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
return playbackData.data;
|
|
||||||
},
|
},
|
||||||
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
|
[_setVolume]
|
||||||
});
|
);
|
||||||
|
|
||||||
const { data: deviceId } = useQuery({
|
const { data: deviceId } = useQuery({
|
||||||
queryKey: ["deviceId", api],
|
queryKey: ["deviceId", api],
|
||||||
@@ -90,82 +93,122 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const setCurrentlyPlayingState = useCallback(
|
const setCurrentlyPlayingState = useCallback(
|
||||||
(state: CurrentlyPlayingState | null) => {
|
async (state: CurrentlyPlayingState | null) => {
|
||||||
const vlcLink = "vlc://" + state?.url;
|
if (!api) return;
|
||||||
console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios");
|
|
||||||
if (vlcLink && settings?.openInVLC) {
|
|
||||||
Linking.openURL("vlc://" + state?.url || "");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state) {
|
if (state && state.item.Id && user?.Id) {
|
||||||
|
const vlcLink = "vlc://" + state?.url;
|
||||||
|
if (vlcLink && settings?.openInVLC) {
|
||||||
|
Linking.openURL("vlc://" + state?.url || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getMediaInfoApi(api).getPlaybackInfo({
|
||||||
|
itemId: state.item.Id,
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await postCapabilities({
|
||||||
|
api,
|
||||||
|
itemId: state.item.Id,
|
||||||
|
sessionId: res.data.PlaySessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSession(res.data);
|
||||||
setCurrentlyPlaying(state);
|
setCurrentlyPlaying(state);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault)
|
if (settings?.openFullScreenVideoPlayerByDefault) {
|
||||||
presentFullscreenPlayer();
|
setTimeout(() => {
|
||||||
|
presentFullscreenPlayer();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentlyPlaying(null);
|
setCurrentlyPlaying(null);
|
||||||
setIsFullscreen(false);
|
setIsFullscreen(false);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[settings]
|
[settings, user, api]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Define control methods
|
const playVideo = useCallback(
|
||||||
const playVideo = useCallback(() => {
|
(triggerRef: boolean = true) => {
|
||||||
videoRef.current?.resume();
|
if (triggerRef === true) {
|
||||||
setIsPlaying(true);
|
videoRef.current?.resume();
|
||||||
reportPlaybackProgress({
|
}
|
||||||
api,
|
_setIsPlaying(true);
|
||||||
itemId: currentlyPlaying?.item.Id,
|
reportPlaybackProgress({
|
||||||
positionTicks: progressTicks ? progressTicks : 0,
|
api,
|
||||||
sessionId: sessionData?.PlaySessionId,
|
itemId: currentlyPlaying?.item.Id,
|
||||||
IsPaused: true,
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
});
|
sessionId: session?.PlaySessionId,
|
||||||
}, [
|
IsPaused: false,
|
||||||
api,
|
});
|
||||||
currentlyPlaying?.item.Id,
|
},
|
||||||
sessionData?.PlaySessionId,
|
[api, currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks]
|
||||||
progressTicks,
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
const pauseVideo = useCallback(() => {
|
const pauseVideo = useCallback(
|
||||||
videoRef.current?.pause();
|
(triggerRef: boolean = true) => {
|
||||||
setIsPlaying(false);
|
if (triggerRef === true) {
|
||||||
reportPlaybackProgress({
|
videoRef.current?.pause();
|
||||||
api,
|
}
|
||||||
itemId: currentlyPlaying?.item.Id,
|
_setIsPlaying(false);
|
||||||
positionTicks: progressTicks ? progressTicks : 0,
|
reportPlaybackProgress({
|
||||||
sessionId: sessionData?.PlaySessionId,
|
api,
|
||||||
IsPaused: false,
|
itemId: currentlyPlaying?.item.Id,
|
||||||
});
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
}, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
|
sessionId: session?.PlaySessionId,
|
||||||
|
IsPaused: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[session?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]
|
||||||
|
);
|
||||||
|
|
||||||
const stopPlayback = useCallback(async () => {
|
const stopPlayback = useCallback(async () => {
|
||||||
await reportPlaybackStopped({
|
await reportPlaybackStopped({
|
||||||
api,
|
api,
|
||||||
itemId: currentlyPlaying?.item?.Id,
|
itemId: currentlyPlaying?.item?.Id,
|
||||||
sessionId: sessionData?.PlaySessionId,
|
sessionId: session?.PlaySessionId,
|
||||||
positionTicks: progressTicks ? progressTicks : 0,
|
positionTicks: progressTicks ? progressTicks : 0,
|
||||||
});
|
});
|
||||||
setCurrentlyPlayingState(null);
|
setCurrentlyPlayingState(null);
|
||||||
}, [currentlyPlaying, sessionData, progressTicks]);
|
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const setIsPlaying = useCallback(
|
||||||
|
debounce((value: boolean) => {
|
||||||
|
_setIsPlaying(value);
|
||||||
|
}, 500),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onProgress = useCallback(
|
||||||
({ currentTime }: OnProgressData) => {
|
({ currentTime }: OnProgressData) => {
|
||||||
|
if (
|
||||||
|
!session?.PlaySessionId ||
|
||||||
|
!currentlyPlaying?.item.Id ||
|
||||||
|
currentTime === 0
|
||||||
|
)
|
||||||
|
return;
|
||||||
const ticks = currentTime * 10000000;
|
const ticks = currentTime * 10000000;
|
||||||
setProgressTicks(ticks);
|
setProgressTicks(ticks);
|
||||||
reportPlaybackProgress({
|
reportPlaybackProgress({
|
||||||
api,
|
api,
|
||||||
itemId: currentlyPlaying?.item.Id,
|
itemId: currentlyPlaying?.item.Id,
|
||||||
positionTicks: ticks,
|
positionTicks: ticks,
|
||||||
sessionId: sessionData?.PlaySessionId,
|
sessionId: session?.PlaySessionId,
|
||||||
IsPaused: !isPlaying,
|
IsPaused: !isPlaying,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
|
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
debounce((e: OnProgressData) => {
|
||||||
|
_onProgress(e);
|
||||||
|
}, 1000),
|
||||||
|
[_onProgress]
|
||||||
);
|
);
|
||||||
|
|
||||||
const presentFullscreenPlayer = useCallback(() => {
|
const presentFullscreenPlayer = useCallback(() => {
|
||||||
@@ -187,8 +230,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
api?.accessToken
|
api?.accessToken
|
||||||
}&deviceId=${deviceId}`;
|
}&deviceId=${deviceId}`;
|
||||||
|
|
||||||
console.log("WS", url);
|
|
||||||
|
|
||||||
const newWebSocket = new WebSocket(url);
|
const newWebSocket = new WebSocket(url);
|
||||||
|
|
||||||
let keepAliveInterval: NodeJS.Timeout | null = null;
|
let keepAliveInterval: NodeJS.Timeout | null = null;
|
||||||
@@ -199,7 +240,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
keepAliveInterval = setInterval(() => {
|
keepAliveInterval = setInterval(() => {
|
||||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||||
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
|
||||||
console.log("KeepAlive message sent");
|
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
};
|
};
|
||||||
@@ -210,7 +250,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
newWebSocket.onclose = (e) => {
|
newWebSocket.onclose = (e) => {
|
||||||
console.log("WebSocket connection closed:", e.reason);
|
|
||||||
if (keepAliveInterval) {
|
if (keepAliveInterval) {
|
||||||
clearInterval(keepAliveInterval);
|
clearInterval(keepAliveInterval);
|
||||||
}
|
}
|
||||||
@@ -233,6 +272,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const json = JSON.parse(e.data);
|
const json = JSON.parse(e.data);
|
||||||
const command = json?.Data?.Command;
|
const command = json?.Data?.Command;
|
||||||
|
|
||||||
|
console.log("[WS] ~ ", json);
|
||||||
|
|
||||||
// On PlayPause
|
// On PlayPause
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
@@ -241,6 +282,19 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
} else if (command === "Stop") {
|
} else if (command === "Stop") {
|
||||||
console.log("Command ~ Stop");
|
console.log("Command ~ Stop");
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
|
} else if (command === "Mute") {
|
||||||
|
console.log("Command ~ Mute");
|
||||||
|
setVolume(0);
|
||||||
|
} else if (command === "Unmute") {
|
||||||
|
console.log("Command ~ Unmute");
|
||||||
|
setVolume(previousVolume.current || 20);
|
||||||
|
} else if (command === "SetVolume") {
|
||||||
|
console.log("Command ~ SetVolume");
|
||||||
|
} else if (json?.Data?.Name === "DisplayMessage") {
|
||||||
|
console.log("Command ~ DisplayMessage");
|
||||||
|
const title = json?.Data?.Arguments?.Header;
|
||||||
|
const body = json?.Data?.Arguments?.Text;
|
||||||
|
Alert.alert(title, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [ws, stopPlayback, playVideo, pauseVideo]);
|
}, [ws, stopPlayback, playVideo, pauseVideo]);
|
||||||
@@ -250,12 +304,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
value={{
|
value={{
|
||||||
onProgress,
|
onProgress,
|
||||||
progressTicks,
|
progressTicks,
|
||||||
|
setVolume,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
setIsFullscreen,
|
setIsFullscreen,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentlyPlaying,
|
currentlyPlaying,
|
||||||
sessionData,
|
sessionData: session,
|
||||||
videoRef,
|
videoRef,
|
||||||
playVideo,
|
playVideo,
|
||||||
setCurrentlyPlayingState,
|
setCurrentlyPlayingState,
|
||||||
|
|||||||
73
utils/atoms/primaryColor.ts
Normal file
73
utils/atoms/primaryColor.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
|
||||||
|
interface ThemeColors {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
average: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTextColor = (backgroundColor: string): string => {
|
||||||
|
// Convert hex to RGB
|
||||||
|
const r = parseInt(backgroundColor.slice(1, 3), 16);
|
||||||
|
const g = parseInt(backgroundColor.slice(3, 5), 16);
|
||||||
|
const b = parseInt(backgroundColor.slice(5, 7), 16);
|
||||||
|
|
||||||
|
// Calculate perceived brightness
|
||||||
|
// Using the formula: (R * 299 + G * 587 + B * 114) / 1000
|
||||||
|
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||||
|
|
||||||
|
// Calculate contrast ratio with white and black
|
||||||
|
const contrastWithWhite = calculateContrastRatio([255, 255, 255], [r, g, b]);
|
||||||
|
const contrastWithBlack = calculateContrastRatio([0, 0, 0], [r, g, b]);
|
||||||
|
|
||||||
|
// Use black text if the background is bright and has good contrast with black
|
||||||
|
if (brightness > 180 && contrastWithBlack >= 4.5) {
|
||||||
|
return "#000000";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use white text
|
||||||
|
return "#FFFFFF";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to calculate contrast ratio
|
||||||
|
const calculateContrastRatio = (rgb1: number[], rgb2: number[]): number => {
|
||||||
|
const l1 = calculateRelativeLuminance(rgb1);
|
||||||
|
const l2 = calculateRelativeLuminance(rgb2);
|
||||||
|
const lighter = Math.max(l1, l2);
|
||||||
|
const darker = Math.min(l1, l2);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to calculate relative luminance
|
||||||
|
const calculateRelativeLuminance = (rgb: number[]): number => {
|
||||||
|
const [r, g, b] = rgb.map((c) => {
|
||||||
|
c /= 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
});
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseThemeColorAtom = atom<ThemeColors>({
|
||||||
|
primary: "#FFFFFF",
|
||||||
|
secondary: "#000000",
|
||||||
|
average: "#888888",
|
||||||
|
text: "#000000",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemThemeColorAtom = atom(
|
||||||
|
(get) => get(baseThemeColorAtom),
|
||||||
|
(get, set, update: Partial<ThemeColors>) => {
|
||||||
|
const currentColors = get(baseThemeColorAtom);
|
||||||
|
const newColors = { ...currentColors, ...update };
|
||||||
|
|
||||||
|
// Recalculate text color if primary color changes
|
||||||
|
if (update.average) {
|
||||||
|
newColors.text = calculateTextColor(update.average);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(baseThemeColorAtom, newColors);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useItemThemeColor = () => useAtom(itemThemeColorAtom);
|
||||||
@@ -24,6 +24,14 @@ export const DownloadOptions: DownloadOption[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type LibraryOptions = {
|
||||||
|
display: "row" | "list";
|
||||||
|
cardStyle: "compact" | "detailed";
|
||||||
|
imageStyle: "poster" | "cover";
|
||||||
|
showTitles: boolean;
|
||||||
|
showStats: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type Settings = {
|
type Settings = {
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
forceLandscapeInVideoPlayer?: boolean;
|
forceLandscapeInVideoPlayer?: boolean;
|
||||||
@@ -36,6 +44,7 @@ type Settings = {
|
|||||||
marlinServerUrl?: string;
|
marlinServerUrl?: string;
|
||||||
openInVLC?: boolean;
|
openInVLC?: boolean;
|
||||||
downloadQuality?: DownloadOption;
|
downloadQuality?: DownloadOption;
|
||||||
|
libraryOptions: LibraryOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +68,13 @@ const loadSettings = async (): Promise<Settings> => {
|
|||||||
marlinServerUrl: "",
|
marlinServerUrl: "",
|
||||||
openInVLC: false,
|
openInVLC: false,
|
||||||
downloadQuality: DownloadOptions[0],
|
downloadQuality: DownloadOptions[0],
|
||||||
|
libraryOptions: {
|
||||||
|
display: "list",
|
||||||
|
cardStyle: "detailed",
|
||||||
|
imageStyle: "cover",
|
||||||
|
showTitles: true,
|
||||||
|
showStats: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export const getBackdropUrl = ({
|
|||||||
params.append("fillWidth", width.toString());
|
params.append("fillWidth", width.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return getPrimaryImageUrl({ api, item, quality, width });
|
||||||
|
}
|
||||||
|
|
||||||
if (backdropImageTags) {
|
if (backdropImageTags) {
|
||||||
params.append("tag", backdropImageTags);
|
params.append("tag", backdropImageTags);
|
||||||
return `${api.basePath}/Items/${
|
return `${api.basePath}/Items/${
|
||||||
|
|||||||
@@ -21,15 +21,29 @@ export const getLogoImageUrlById = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageTags = item.ImageTags?.["Logo"];
|
|
||||||
|
|
||||||
if (!imageTags) return null;
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
params.append("tag", imageTags);
|
|
||||||
params.append("quality", "90");
|
params.append("quality", "90");
|
||||||
params.append("fillHeight", height.toString());
|
params.append("fillHeight", height.toString());
|
||||||
|
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
const imageTag = item.ParentLogoImageTag;
|
||||||
|
const parentId = item.ParentLogoItemId;
|
||||||
|
|
||||||
|
if (!parentId || !imageTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.append("tag", imageTag);
|
||||||
|
|
||||||
|
return `${api.basePath}/Items/${parentId}/Images/Logo?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageTag = item.ImageTags?.["Logo"];
|
||||||
|
|
||||||
|
if (!imageTag) return null;
|
||||||
|
|
||||||
|
params.append("tag", imageTag);
|
||||||
|
|
||||||
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
|
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|||||||
42
utils/jellyfin/image/getParentBackdropImageUrl.ts
Normal file
42
utils/jellyfin/image/getParentBackdropImageUrl.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemPerson,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { isBaseItemDto } from "../jellyfin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the primary image URL for a given item.
|
||||||
|
*
|
||||||
|
* @param api - The Jellyfin API instance.
|
||||||
|
* @param item - The media item to retrieve the backdrop image URL for.
|
||||||
|
* @param quality - The desired image quality (default: 90).
|
||||||
|
*/
|
||||||
|
export const getParentBackdropImageUrl = ({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality = 80,
|
||||||
|
width = 400,
|
||||||
|
}: {
|
||||||
|
api?: Api | null;
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
quality?: number | null;
|
||||||
|
width?: number | null;
|
||||||
|
}) => {
|
||||||
|
if (!item || !api) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = item.ParentBackdropItemId;
|
||||||
|
const tag = item.ParentBackdropImageTags?.[0] || "";
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
fillWidth: width ? String(width) : "500",
|
||||||
|
quality: quality ? String(quality) : "80",
|
||||||
|
tag: tag,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${
|
||||||
|
api?.basePath
|
||||||
|
}/Items/${parentId}/Images/Backdrop/0?${params.toString()}`;
|
||||||
|
};
|
||||||
42
utils/jellyfin/image/getPrimaryParentImageUrl.ts
Normal file
42
utils/jellyfin/image/getPrimaryParentImageUrl.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemPerson,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { isBaseItemDto } from "../jellyfin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the primary image URL for a given item.
|
||||||
|
*
|
||||||
|
* @param api - The Jellyfin API instance.
|
||||||
|
* @param item - The media item to retrieve the backdrop image URL for.
|
||||||
|
* @param quality - The desired image quality (default: 90).
|
||||||
|
*/
|
||||||
|
export const getPrimaryParentImageUrl = ({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality = 80,
|
||||||
|
width = 400,
|
||||||
|
}: {
|
||||||
|
api?: Api | null;
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
quality?: number | null;
|
||||||
|
width?: number | null;
|
||||||
|
}) => {
|
||||||
|
if (!item || !api) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = item.ParentId;
|
||||||
|
const primaryTag = item.ParentPrimaryImageTag?.[0];
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
fillWidth: width ? String(width) : "500",
|
||||||
|
quality: quality ? String(quality) : "80",
|
||||||
|
tag: primaryTag || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${
|
||||||
|
api?.basePath
|
||||||
|
}/Items/${parentId}/Images/Primary?${params.toString()}`;
|
||||||
|
};
|
||||||
@@ -17,6 +17,8 @@ export const getStreamUrl = async ({
|
|||||||
audioStreamIndex = 0,
|
audioStreamIndex = 0,
|
||||||
subtitleStreamIndex = 0,
|
subtitleStreamIndex = 0,
|
||||||
forceDirectPlay = false,
|
forceDirectPlay = false,
|
||||||
|
height,
|
||||||
|
mediaSourceId,
|
||||||
}: {
|
}: {
|
||||||
api: Api | null | undefined;
|
api: Api | null | undefined;
|
||||||
item: BaseItemDto | null | undefined;
|
item: BaseItemDto | null | undefined;
|
||||||
@@ -28,8 +30,10 @@ export const getStreamUrl = async ({
|
|||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
subtitleStreamIndex?: number;
|
subtitleStreamIndex?: number;
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
|
height?: number;
|
||||||
|
mediaSourceId: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
if (!api || !userId || !item?.Id) {
|
if (!api || !userId || !item?.Id || !mediaSourceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,19 +48,25 @@ export const getStreamUrl = async ({
|
|||||||
StartTimeTicks: startTimeTicks,
|
StartTimeTicks: startTimeTicks,
|
||||||
EnableTranscoding: maxStreamingBitrate ? true : undefined,
|
EnableTranscoding: maxStreamingBitrate ? true : undefined,
|
||||||
AutoOpenLiveStream: true,
|
AutoOpenLiveStream: true,
|
||||||
MediaSourceId: itemId,
|
MediaSourceId: mediaSourceId,
|
||||||
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
|
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
|
||||||
AudioStreamIndex: audioStreamIndex,
|
AudioStreamIndex: audioStreamIndex,
|
||||||
SubtitleStreamIndex: subtitleStreamIndex,
|
SubtitleStreamIndex: subtitleStreamIndex,
|
||||||
|
DeInterlace: true,
|
||||||
|
BreakOnNonKeyFrames: false,
|
||||||
|
CopyTimestamps: false,
|
||||||
|
EnableMpegtsM2TsMode: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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: MediaSourceInfo = response.data.MediaSources.find(
|
||||||
|
(source: MediaSourceInfo) => source.Id === mediaSourceId
|
||||||
|
);
|
||||||
|
|
||||||
if (!mediaSource) {
|
if (!mediaSource) {
|
||||||
throw new Error("No media source");
|
throw new Error("No media source");
|
||||||
@@ -66,10 +76,12 @@ export const getStreamUrl = async ({
|
|||||||
throw new Error("no PlaySessionId");
|
throw new Error("no PlaySessionId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let url: string | null | undefined;
|
||||||
|
|
||||||
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`;
|
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
} 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,14 +99,16 @@ export const getStreamUrl = async ({
|
|||||||
EnableRedirection: "true",
|
EnableRedirection: "true",
|
||||||
EnableRemoteMedia: "false",
|
EnableRemoteMedia: "false",
|
||||||
});
|
});
|
||||||
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
url = `${
|
||||||
|
api.basePath
|
||||||
|
}/Audio/${itemId}/universal?${searchParams.toString()}`;
|
||||||
}
|
}
|
||||||
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
|
console.log("Using transcoded stream!");
|
||||||
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaSource.TranscodingUrl) {
|
if (!url) throw new Error("No url");
|
||||||
console.log("Using transcoded stream!");
|
|
||||||
return `${api.basePath}${mediaSource.TranscodingUrl}`;
|
return url;
|
||||||
} else {
|
|
||||||
throw new Error("No transcoding url");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ export const reportPlaybackProgress = async ({
|
|||||||
IsPaused = false,
|
IsPaused = false,
|
||||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||||
console.error(
|
|
||||||
"Missing required parameter",
|
|
||||||
sessionId,
|
|
||||||
itemId,
|
|
||||||
positionTicks
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ 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 = {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
SessionApiPostCapabilitiesRequest,
|
SessionApiPostCapabilitiesRequest,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
|
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError, AxiosResponse } from "axios";
|
||||||
import { getAuthHeaders } from "../jellyfin";
|
import { getAuthHeaders } from "../jellyfin";
|
||||||
|
|
||||||
interface PostCapabilitiesParams {
|
interface PostCapabilitiesParams {
|
||||||
@@ -23,17 +23,26 @@ export const postCapabilities = async ({
|
|||||||
api,
|
api,
|
||||||
itemId,
|
itemId,
|
||||||
sessionId,
|
sessionId,
|
||||||
}: PostCapabilitiesParams): Promise<void> => {
|
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||||
if (!api || !itemId || !sessionId) {
|
if (!api || !itemId || !sessionId) {
|
||||||
throw new Error("Missing required parameters");
|
throw new Error("Missing parameters for marking item as not played");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await api.axiosInstance.post(
|
const d = api.axiosInstance.post(
|
||||||
api.basePath + "/Sessions/Capabilities/Full",
|
api.basePath + "/Sessions/Capabilities/Full",
|
||||||
{
|
{
|
||||||
playableMediaTypes: ["Audio", "Video", "Audio"],
|
playableMediaTypes: ["Audio", "Video", "Audio"],
|
||||||
supportedCommands: ["PlayState", "Play"],
|
supportedCommands: [
|
||||||
|
"PlayState",
|
||||||
|
"Play",
|
||||||
|
"ToggleFullscreen",
|
||||||
|
"DisplayMessage",
|
||||||
|
"Mute",
|
||||||
|
"Unmute",
|
||||||
|
"SetVolume",
|
||||||
|
"ToggleMute",
|
||||||
|
],
|
||||||
supportsMediaControl: true,
|
supportsMediaControl: true,
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
},
|
},
|
||||||
@@ -41,8 +50,8 @@ export const postCapabilities = async ({
|
|||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
return d;
|
||||||
} catch (error: any | AxiosError) {
|
} catch (error: any | AxiosError) {
|
||||||
console.log("Failed to mark as not played", error);
|
|
||||||
throw new Error("Failed to mark as not played");
|
throw new Error("Failed to mark as not played");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user