mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-21 16:24:41 +01:00
Compare commits
25 Commits
v0.6.0
...
feat/tv-os
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7fd382f2 | ||
|
|
d8201aa1fc | ||
|
|
dec45056f3 | ||
|
|
1d41b7080f | ||
|
|
83a09ad74a | ||
|
|
65ac147441 | ||
|
|
6a070cfbe0 | ||
|
|
9d1a03a5f2 | ||
|
|
08b28f7599 | ||
|
|
6a8a155547 | ||
|
|
dbb7c6c9a5 | ||
|
|
30280e8b3a | ||
|
|
5281cba284 | ||
|
|
da666d3991 | ||
|
|
817a758b8a | ||
|
|
f04a29b757 | ||
|
|
550fc39faa | ||
|
|
d56bb79ac2 | ||
|
|
30781a6dfe | ||
|
|
ba6c2d5409 | ||
|
|
73b266adb4 | ||
|
|
e0ca83ae1f | ||
|
|
4a17a00f81 | ||
|
|
6bfc0c72d1 | ||
|
|
26050f7179 |
@@ -108,7 +108,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
|
## Support
|
||||||
|
|
||||||
|
|||||||
39
app.json
39
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -46,15 +46,9 @@
|
|||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
|
||||||
[
|
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
@@ -67,35 +61,6 @@
|
|||||||
"useExoplayerDash": false
|
"useExoplayerDash": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-build-properties",
|
|
||||||
{
|
|
||||||
"ios": {
|
|
||||||
"deploymentTarget": "14.0"
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"minSdkVersion": 24,
|
|
||||||
"usesCleartextTraffic": true,
|
|
||||||
"packagingOptions": {
|
|
||||||
"jniLibs": {
|
|
||||||
"useLegacyPackaging": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-screen-orientation",
|
|
||||||
{
|
|
||||||
"initialOrientation": "DEFAULT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-sensors",
|
|
||||||
{
|
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -29,6 +26,8 @@ export default function TabLayout() {
|
|||||||
borderTopRightRadius: 0,
|
borderTopRightRadius: 0,
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
|
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
||||||
|
height: Platform.OS === "android" ? 58 : 74,
|
||||||
},
|
},
|
||||||
tabBarBackground: () =>
|
tabBarBackground: () =>
|
||||||
Platform.OS === "ios" ? (
|
Platform.OS === "ios" ? (
|
||||||
@@ -70,6 +69,19 @@ export default function TabLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="library"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "Library",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<TabBarIcon
|
||||||
|
name={focused ? "apps" : "apps-outline"}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
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, View } from "react-native";
|
||||||
@@ -17,21 +16,8 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
marginRight: Platform.OS === "android" ? 17 : 0,
|
|
||||||
}}
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Feather name="download" color={"white"} size={22} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast />
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
|
|||||||
@@ -1,38 +1,25 @@
|
|||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
|
||||||
ItemFields,
|
|
||||||
ItemFilter,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getChannelsApi,
|
|
||||||
getItemsApi,
|
getItemsApi,
|
||||||
getSuggestionsApi,
|
getSuggestionsApi,
|
||||||
getTvShowsApi,
|
getTvShowsApi,
|
||||||
getUserApi,
|
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -42,7 +29,24 @@ export default function index() {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
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[]>({
|
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
|
||||||
queryKey: ["resumeItems", user?.Id],
|
queryKey: ["resumeItems", user?.Id],
|
||||||
@@ -77,35 +81,21 @@ export default function index() {
|
|||||||
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
|
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
|
||||||
}, [_nextUpData]);
|
}, [_nextUpData]);
|
||||||
|
|
||||||
const { data: collections, isLoading: isLoadingCollections } = useQuery({
|
const { data: collections } = useQuery({
|
||||||
queryKey: ["collections", user?.Id],
|
queryKey: ["collectinos", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) {
|
if (!api || !user?.Id) {
|
||||||
return [];
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
await getItemsApi(api).getItems({
|
userId: user.Id,
|
||||||
userId: user.Id,
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
|
|
||||||
const order = ["boxsets", "tvshows", "movies"];
|
|
||||||
|
|
||||||
const cs = data.Items?.sort((a, b) => {
|
|
||||||
if (
|
|
||||||
order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!)
|
|
||||||
) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return data.Items || [];
|
return response.data.Items || null;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const movieCollectionId = useMemo(() => {
|
const movieCollectionId = useMemo(() => {
|
||||||
@@ -178,10 +168,10 @@ export default function index() {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: mediaListCollection } = useQuery<string | null>({
|
const { data: mediaListCollections } = useQuery({
|
||||||
queryKey: ["mediaListCollection", user?.Id],
|
queryKey: ["mediaListCollections-home", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) return null;
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
@@ -191,77 +181,29 @@ export default function index() {
|
|||||||
includeItemTypes: ["BoxSet"],
|
includeItemTypes: ["BoxSet"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data.Items?.[0].Id || null;
|
return [];
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
enabled: !!api && !!user?.Id && false,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 0,
|
||||||
});
|
|
||||||
|
|
||||||
const { data: popularItems, isLoading: isLoadingPopular } = useQuery<
|
|
||||||
BaseItemDto[]
|
|
||||||
>({
|
|
||||||
queryKey: ["popular", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id || !mediaListCollection) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
parentId: mediaListCollection,
|
|
||||||
limit: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!mediaListCollection,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
|
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
||||||
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
|
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
||||||
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: ["mediaListCollections-home"],
|
||||||
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [queryClient, user?.Id]);
|
}, [queryClient, user?.Id]);
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
|
||||||
if (state.isConnected == false || state.isInternetReachable === false)
|
|
||||||
setIsConnected(false);
|
|
||||||
else setIsConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
NetInfo.fetch().then((state) => {
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isConnected === false) {
|
if (isConnected === false) {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -279,7 +221,7 @@ export default function index() {
|
|||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
<ActivityIndicator />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -292,6 +234,8 @@ export default function index() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
||||||
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
title="Continue Watching"
|
title="Continue Watching"
|
||||||
data={data}
|
data={data}
|
||||||
@@ -299,13 +243,6 @@ export default function index() {
|
|||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Popular"
|
|
||||||
data={popularItems}
|
|
||||||
loading={isLoadingPopular}
|
|
||||||
disabled={!mediaListCollection}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
title="Next Up"
|
title="Next Up"
|
||||||
data={nextUpData}
|
data={nextUpData}
|
||||||
@@ -325,13 +262,6 @@ export default function index() {
|
|||||||
loading={isLoadingRecentlyAddedTVShows}
|
loading={isLoadingRecentlyAddedTVShows}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollingCollectionList
|
|
||||||
title="Collections"
|
|
||||||
data={collections}
|
|
||||||
loading={isLoadingCollections}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
title="Suggestions"
|
title="Suggestions"
|
||||||
data={suggestions}
|
data={suggestions}
|
||||||
|
|||||||
30
app/(auth)/(tabs)/library/_layout.tsx
Normal file
30
app/(auth)/(tabs)/library/_layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function IndexLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: "Library",
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="collections/[collectionId]"
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
app/(auth)/(tabs)/library/collections/[collectionId].tsx
Normal file
335
app/(auth)/(tabs)/library/collections/[collectionId].tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
import {
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getFilterApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
||||||
|
|
||||||
|
const isCloseToBottom = ({
|
||||||
|
layoutMeasurement,
|
||||||
|
contentOffset,
|
||||||
|
contentSize,
|
||||||
|
}: NativeScrollEvent) => {
|
||||||
|
const paddingToBottom = 200;
|
||||||
|
return (
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - paddingToBottom
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const page: React.FC = () => {
|
||||||
|
const searchParams = useLocalSearchParams();
|
||||||
|
const { collectionId } = searchParams as { collectionId: string };
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
|
const { data: collection } = useQuery({
|
||||||
|
queryKey: ["collection", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
ids: [collectionId],
|
||||||
|
});
|
||||||
|
const data = response.data.Items?.[0];
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!collectionId,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async ({
|
||||||
|
pageParam,
|
||||||
|
}: {
|
||||||
|
pageParam: number;
|
||||||
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
|
if (!api || !collection) return null;
|
||||||
|
|
||||||
|
const includeItemTypes: BaseItemKind[] = [];
|
||||||
|
|
||||||
|
switch (collection?.CollectionType) {
|
||||||
|
case "movies":
|
||||||
|
includeItemTypes.push("Movie");
|
||||||
|
break;
|
||||||
|
case "boxsets":
|
||||||
|
includeItemTypes.push("BoxSet");
|
||||||
|
break;
|
||||||
|
case "tvshows":
|
||||||
|
includeItemTypes.push("Series");
|
||||||
|
break;
|
||||||
|
case "music":
|
||||||
|
includeItemTypes.push("MusicAlbum");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
limit: 66,
|
||||||
|
startIndex: pageParam,
|
||||||
|
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
|
||||||
|
sortOrder: [sortOrder[0].key],
|
||||||
|
includeItemTypes,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
recursive: true,
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
genres: selectedGenres,
|
||||||
|
tags: selectedTags,
|
||||||
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data || null;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
collectionId,
|
||||||
|
collection?.CollectionType,
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: [
|
||||||
|
"library-items",
|
||||||
|
collection,
|
||||||
|
selectedGenres,
|
||||||
|
selectedYears,
|
||||||
|
selectedTags,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
],
|
||||||
|
queryFn: fetchItems,
|
||||||
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
if (
|
||||||
|
!lastPage?.Items ||
|
||||||
|
!lastPage?.TotalRecordCount ||
|
||||||
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const type = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const flatData = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((p) => p?.Items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!collection || !collection.CollectionType) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
onScroll={({ nativeEvent }) => {
|
||||||
|
if (isCloseToBottom(nativeEvent)) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={400}
|
||||||
|
>
|
||||||
|
<View className="mt-4 mb-24">
|
||||||
|
<View className="mb-4">
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className="flex flex-row space-x-1 px-3">
|
||||||
|
<ResetFiltersButton />
|
||||||
|
<FilterButton
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="genreFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
}}
|
||||||
|
set={setSelectedGenres}
|
||||||
|
values={selectedGenres}
|
||||||
|
title="Genres"
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="tagsFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
}}
|
||||||
|
set={setSelectedTags}
|
||||||
|
values={selectedTags}
|
||||||
|
title="Tags"
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="yearFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getFilterApi(
|
||||||
|
api
|
||||||
|
).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: type ? [type] : [],
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
response.data.Years?.sort((a, b) => b - a).map((y) =>
|
||||||
|
y.toString()
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
set={setSelectedYears}
|
||||||
|
values={selectedYears}
|
||||||
|
title="Years"
|
||||||
|
renderItemLabel={(item) => item.toString()}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
icon="sort"
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="sortByFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
return sortOptions;
|
||||||
|
}}
|
||||||
|
set={setSortBy}
|
||||||
|
values={sortBy}
|
||||||
|
title="Sort by"
|
||||||
|
renderItemLabel={(item) => item.value}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
icon="sort"
|
||||||
|
showSearch={false}
|
||||||
|
collectionId={collectionId}
|
||||||
|
queryKey="orderByFilter"
|
||||||
|
queryFn={async () => {
|
||||||
|
return sortOrderOptions;
|
||||||
|
}}
|
||||||
|
set={setSortOrder}
|
||||||
|
values={sortOrder}
|
||||||
|
title="Order by"
|
||||||
|
renderItemLabel={(item) => item.value}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
item.value.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
{!type && isFetching && (
|
||||||
|
<Loader
|
||||||
|
style={{
|
||||||
|
marginTop: 300,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
|
||||||
|
{flatData.map(
|
||||||
|
(item, index) =>
|
||||||
|
item && (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={`${item.Id}`}
|
||||||
|
style={{
|
||||||
|
width: "32%",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
className={`
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{flatData.length % 3 !== 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "33%",
|
||||||
|
}}
|
||||||
|
></View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
104
app/(auth)/(tabs)/library/index.tsx
Normal file
104
app/(auth)/(tabs)/library/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export default function index() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { data, isLoading: isLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 17,
|
||||||
|
paddingHorizontal: 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
}}
|
||||||
|
data={data}
|
||||||
|
renderItem={({ item }) => <CollectionCard collection={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-4" />}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collection: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionCard: React.FC<Props> = ({ collection }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() =>
|
||||||
|
getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item: collection,
|
||||||
|
}),
|
||||||
|
[collection]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/library/collections/${collection.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">
|
||||||
|
{collection.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,25 +1,38 @@
|
|||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
import { Loader } from "@/components/Loader";
|
||||||
import Poster from "@/components/Poster";
|
import AlbumCover from "@/components/posters/AlbumCover";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router, Stack, useNavigation } from "expo-router";
|
import { router, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useLayoutEffect, useState } from "react";
|
import React, { useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
const exampleSearches = [
|
||||||
|
"Lord of the rings",
|
||||||
|
"Avengers",
|
||||||
|
"Game of Thrones",
|
||||||
|
"Breaking Bad",
|
||||||
|
"Stranger Things",
|
||||||
|
"The Mandalorian",
|
||||||
|
];
|
||||||
|
|
||||||
export default function search() {
|
export default function search() {
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -36,13 +49,13 @@ export default function search() {
|
|||||||
});
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
const { data: movies } = useQuery({
|
const { data: movies, isLoading: l1 } = useQuery({
|
||||||
queryKey: ["search-movies", search],
|
queryKey: ["search-movies", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user || search.length === 0) return [];
|
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||||
|
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
searchTerm: search,
|
searchTerm: debouncedSearch,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
includeItemTypes: ["Movie"],
|
includeItemTypes: ["Movie"],
|
||||||
});
|
});
|
||||||
@@ -51,13 +64,13 @@ export default function search() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: series } = useQuery({
|
const { data: series, isLoading: l2 } = useQuery({
|
||||||
queryKey: ["search-series", search],
|
queryKey: ["search-series", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user || search.length === 0) return [];
|
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||||
|
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
searchTerm: search,
|
searchTerm: debouncedSearch,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
includeItemTypes: ["Series"],
|
includeItemTypes: ["Series"],
|
||||||
});
|
});
|
||||||
@@ -65,13 +78,14 @@ export default function search() {
|
|||||||
return searchApi.data.SearchHints;
|
return searchApi.data.SearchHints;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { data: episodes } = useQuery({
|
|
||||||
queryKey: ["search-episodes", search],
|
const { data: episodes, isLoading: l3 } = useQuery({
|
||||||
|
queryKey: ["search-episodes", debouncedSearch],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user || search.length === 0) return [];
|
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||||
|
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
searchTerm: search,
|
searchTerm: debouncedSearch,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
includeItemTypes: ["Episode"],
|
includeItemTypes: ["Episode"],
|
||||||
});
|
});
|
||||||
@@ -80,13 +94,73 @@ export default function search() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: artists, isLoading: l4 } = useQuery({
|
||||||
|
queryKey: ["search-artists", debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||||
|
|
||||||
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
|
searchTerm: debouncedSearch,
|
||||||
|
limit: 10,
|
||||||
|
includeItemTypes: ["MusicArtist"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return searchApi.data.SearchHints;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: albums, isLoading: l5 } = useQuery({
|
||||||
|
queryKey: ["search-albums", debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||||
|
|
||||||
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
|
searchTerm: debouncedSearch,
|
||||||
|
limit: 10,
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return searchApi.data.SearchHints;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: songs, isLoading: l6 } = useQuery({
|
||||||
|
queryKey: ["search-songs", debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||||
|
|
||||||
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
|
searchTerm: debouncedSearch,
|
||||||
|
limit: 10,
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return searchApi.data.SearchHints;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const noResults = useMemo(() => {
|
||||||
|
return !(
|
||||||
|
artists?.length ||
|
||||||
|
albums?.length ||
|
||||||
|
songs?.length ||
|
||||||
|
movies?.length ||
|
||||||
|
episodes?.length ||
|
||||||
|
series?.length
|
||||||
|
);
|
||||||
|
}, [artists, episodes, albums, songs, movies, series]);
|
||||||
|
|
||||||
|
const loading = useMemo(() => {
|
||||||
|
return l1 || l2 || l3 || l4 || l5 || l6;
|
||||||
|
}, [l1, l2, l3, l4, l5, l6]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2 pb-20">
|
<View className="flex flex-col pt-4 pb-32">
|
||||||
{Platform.OS === "android" && (
|
{Platform.OS === "android" && (
|
||||||
<View className="mb-4 px-4">
|
<View className="mb-4 px-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -99,8 +173,8 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
|
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
|
header="Movies"
|
||||||
ids={movies?.map((m) => m.Id!)}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
@@ -112,7 +186,9 @@ export default function search() {
|
|||||||
onPress={() => router.push(`/items/${item.Id}`)}
|
onPress={() => router.push(`/items/${item.Id}`)}
|
||||||
>
|
>
|
||||||
<MoviePoster item={item} key={item.Id} />
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text className="mt-2">{item.Name}</Text>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
<Text className="opacity-50 text-xs">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -121,9 +197,9 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
|
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={series?.map((m) => m.Id!)}
|
ids={series?.map((m) => m.Id!)}
|
||||||
|
header="Series"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
data={data}
|
data={data}
|
||||||
@@ -133,12 +209,10 @@ export default function search() {
|
|||||||
onPress={() => router.push(`/series/${item.Id}`)}
|
onPress={() => router.push(`/series/${item.Id}`)}
|
||||||
className="flex flex-col w-32"
|
className="flex flex-col w-32"
|
||||||
>
|
>
|
||||||
<Poster
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
item={item}
|
<Text numberOfLines={2} className="mt-2">
|
||||||
key={item.Id}
|
{item.Name}
|
||||||
url={getPrimaryImageUrl({ api, item })}
|
</Text>
|
||||||
/>
|
|
||||||
<Text className="mt-2">{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">
|
<Text className="opacity-50 text-xs">
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -147,9 +221,9 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
|
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
|
header="Episodes"
|
||||||
renderItem={(data) => (
|
renderItem={(data) => (
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
data={data}
|
data={data}
|
||||||
@@ -166,6 +240,89 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
ids={artists?.map((m) => m.Id!)}
|
||||||
|
header="Artists"
|
||||||
|
renderItem={(data) => (
|
||||||
|
<HorizontalScroll<BaseItemDto>
|
||||||
|
data={data}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className="flex flex-col w-32"
|
||||||
|
>
|
||||||
|
<AlbumCover id={item.Id} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
ids={albums?.map((m) => m.Id!)}
|
||||||
|
header="Albums"
|
||||||
|
renderItem={(data) => (
|
||||||
|
<HorizontalScroll<BaseItemDto>
|
||||||
|
data={data}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className="flex flex-col w-32"
|
||||||
|
>
|
||||||
|
<AlbumCover id={item.Id} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
ids={songs?.map((m) => m.Id!)}
|
||||||
|
header="Songs"
|
||||||
|
renderItem={(data) => (
|
||||||
|
<HorizontalScroll<BaseItemDto>
|
||||||
|
data={data}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className="flex flex-col w-32"
|
||||||
|
>
|
||||||
|
<AlbumCover id={item.AlbumId} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<View className="mt-4 flex justify-center items-center">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : noResults && debouncedSearch.length > 0 ? (
|
||||||
|
<View>
|
||||||
|
<Text className="text-center text-lg font-bold mt-4">
|
||||||
|
No results found for
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-purple-600 text-center">
|
||||||
|
"{debouncedSearch}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : debouncedSearch.length === 0 ? (
|
||||||
|
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||||
|
{exampleSearches.map((e) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setSearch(e)}
|
||||||
|
key={e}
|
||||||
|
className="mb-2"
|
||||||
|
>
|
||||||
|
<Text className="text-purple-600">{e}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
@@ -175,9 +332,10 @@ export default function search() {
|
|||||||
type Props = {
|
type Props = {
|
||||||
ids?: string[] | null;
|
ids?: string[] | null;
|
||||||
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
||||||
|
header?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
|
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -193,21 +351,26 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
|
|||||||
api,
|
api,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
itemId: id,
|
itemId: id,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = await Promise.all(itemPromises);
|
const results = await Promise.all(itemPromises);
|
||||||
|
|
||||||
// Filter out null items
|
// Filter out null items
|
||||||
return results.filter(
|
return results.filter(
|
||||||
(item) => item !== null,
|
(item) => item !== null
|
||||||
) as unknown as BaseItemDto[];
|
) as unknown as BaseItemDto[];
|
||||||
},
|
},
|
||||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) return <Text className="opacity-50 text-xs px-4">No results</Text>;
|
if (!data) return null;
|
||||||
|
|
||||||
return renderItem(data);
|
return (
|
||||||
|
<>
|
||||||
|
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
|
||||||
|
{renderItem(data)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,14 @@
|
|||||||
import ArtistPoster from "@/components/ArtistPoster";
|
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loading } from "@/components/Loading";
|
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
|
||||||
import { SongsList } from "@/components/music/SongsList";
|
import { SongsList } from "@/components/music/SongsList";
|
||||||
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
ItemSortBy,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getArtistsApi,
|
|
||||||
getItemsApi,
|
|
||||||
getUserApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -40,20 +21,8 @@ export default function page() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: album } = useQuery({
|
const { data: album } = useQuery({
|
||||||
queryKey: ["album", albumId, artistId],
|
queryKey: ["album", albumId, artistId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -119,6 +88,21 @@ export default function page() {
|
|||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-bold text-3xl">{album?.Name}</Text>
|
<Text className="font-bold text-3xl">{album?.Name}</Text>
|
||||||
<Text className="">{album?.ProductionYear}</Text>
|
<Text className="">{album?.ProductionYear}</Text>
|
||||||
|
|
||||||
|
<View className="flex flex-row space-x-2 mt-1">
|
||||||
|
{album.AlbumArtists?.map((a) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={a.Id}
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/artists/${a.Id}/page`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="font-bold text-purple-600">
|
||||||
|
{album?.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<SongsList
|
<SongsList
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ArtistPoster from "@/components/ArtistPoster";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import ArtistPoster from "@/components/ArtistPoster";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loading } from "@/components/Loading";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
ItemSortBy,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
FlatList,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import ArtistPoster from "@/components/ArtistPoster";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loading } from "@/components/Loading";
|
import { Loader } from "@/components/Loader";
|
||||||
import MoviePoster from "@/components/MoviePoster";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
@@ -13,13 +13,8 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -155,7 +150,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View className="my-12">
|
<View className="my-12">
|
||||||
<ActivityIndicator color={"white"} />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex flex-row flex-wrap">
|
<View className="flex flex-row flex-wrap">
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
|
||||||
const [process, setProcess] = useAtom(runningProcesses);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
|
|
||||||
const { data: downloadedFiles, isLoading } = useQuery({
|
|
||||||
queryKey: ["downloaded_files", process?.item.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
|
||||||
) as BaseItemDto[],
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const movies = useMemo(
|
|
||||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
|
||||||
[downloadedFiles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
|
||||||
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
|
||||||
const series: { [key: string]: BaseItemDto[] } = {};
|
|
||||||
episodes?.forEach((e) => {
|
|
||||||
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
|
||||||
series[e.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const eta = useMemo(() => {
|
|
||||||
const length = process?.item?.RunTimeTicks || 0;
|
|
||||||
|
|
||||||
if (!process?.speed || !process?.progress) return "";
|
|
||||||
|
|
||||||
const timeLeft =
|
|
||||||
(length - length * (process.progress / 100)) / process.speed;
|
|
||||||
|
|
||||||
return formatNumber(timeLeft / 10000);
|
|
||||||
}, [process]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
|
||||||
<ActivityIndicator size="small" color="white" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView>
|
|
||||||
<View className="px-4 py-4">
|
|
||||||
<View className="mb-4 flex flex-col space-y-4">
|
|
||||||
<View>
|
|
||||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
{queue.map((q) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/items/${q.item.Id}`)}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setQueue((prev) => prev.filter((i) => i.id !== q.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className="opacity-50">No items in queue</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
|
||||||
{process?.item ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{process.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{process.item.Type}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
|
||||||
<Text className="text-xs">
|
|
||||||
{process.progress.toFixed(0)}%
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs">
|
|
||||||
{process.speed?.toFixed(2)}x
|
|
||||||
</Text>
|
|
||||||
<View>
|
|
||||||
<Text className="text-xs">ETA {eta}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
setProcess(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
absolute bottom-0 left-0 h-1 bg-purple-600
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
width: process.progress
|
|
||||||
? `${Math.max(5, process.progress)}%`
|
|
||||||
: "5%",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<Text className="opacity-50">No active downloads</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2">
|
|
||||||
<Text className="text-2xl font-bold">Movies</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{movies?.map((item: BaseItemDto) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
|
||||||
<MovieCard item={item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
|
||||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default downloads;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
|
|
||||||
* @param {number} num - The number to format
|
|
||||||
*
|
|
||||||
* @returns {string} - The formatted string
|
|
||||||
*/
|
|
||||||
const formatNumber = (num: number) => {
|
|
||||||
const minutes = Math.floor(num / 60000);
|
|
||||||
const seconds = ((num % 60000) / 1000).toFixed(0);
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
fullScreenAtom,
|
fullScreenAtom,
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
} from "@/components/CurrentlyPlayingBar";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
import { Loader } from "@/components/Loader";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
@@ -19,29 +19,22 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
|
|||||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import ios from "@/utils/profiles/ios";
|
||||||
import ios12 from "@/utils/profiles/ios12";
|
import native from "@/utils/profiles/native";
|
||||||
|
import old from "@/utils/profiles/old";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import old from "@/utils/profiles/old";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -52,14 +45,10 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
|
|
||||||
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
const [, setPlaying] = useAtom(playingAtom);
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||||
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
@@ -100,7 +89,6 @@ const page: React.FC = () => {
|
|||||||
"playbackUrl",
|
"playbackUrl",
|
||||||
item?.Id,
|
item?.Id,
|
||||||
maxBitrate,
|
maxBitrate,
|
||||||
castDevice,
|
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
settings,
|
settings,
|
||||||
@@ -110,9 +98,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
let deviceProfile: any = ios;
|
let deviceProfile: any = ios;
|
||||||
|
|
||||||
if (castDevice?.deviceId) {
|
if (settings?.deviceProfile === "Native") {
|
||||||
deviceProfile = chromecastProfile;
|
|
||||||
} else if (settings?.deviceProfile === "Native") {
|
|
||||||
deviceProfile = native;
|
deviceProfile = native;
|
||||||
} else if (settings?.deviceProfile === "Old") {
|
} else if (settings?.deviceProfile === "Old") {
|
||||||
deviceProfile = old;
|
deviceProfile = old;
|
||||||
@@ -143,37 +129,16 @@ const page: React.FC = () => {
|
|||||||
async (type: "device" | "cast" = "device") => {
|
async (type: "device" | "cast" = "device") => {
|
||||||
if (!playbackUrl || !item) return;
|
if (!playbackUrl || !item) return;
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
setCurrentlyPlying({
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
item,
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
playbackUrl,
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
});
|
||||||
else {
|
setPlaying(true);
|
||||||
client.loadMedia({
|
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||||
mediaInfo: {
|
setFullscreen(true);
|
||||||
contentUrl: playbackUrl,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCurrentlyPlying({
|
|
||||||
item,
|
|
||||||
playbackUrl,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
|
||||||
setFullscreen(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackUrl, item, settings],
|
[playbackUrl, item, settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
@@ -184,18 +149,18 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
<ActivityIndicator />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,12 +209,7 @@ const page: React.FC = () => {
|
|||||||
<Ratings item={item} />
|
<Ratings item={item} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
<View className="flex flex-row justify-between items-center mb-2">
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -276,36 +236,13 @@ const page: React.FC = () => {
|
|||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||||
<PlayButton
|
<PlayButton
|
||||||
item={item}
|
item={item}
|
||||||
chromecastReady={chromecastReady}
|
chromecastReady={false}
|
||||||
onPress={onPressPlay}
|
onPress={onPressPlay}
|
||||||
className="grow"
|
className="grow"
|
||||||
/>
|
/>
|
||||||
<NextEpisodeButton item={item} className="ml-2" />
|
<NextEpisodeButton item={item} className="ml-2" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal className="flex px-4 mb-4">
|
|
||||||
<View className="flex flex-row space-x-2 ">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-sm opacity-70">Video</Text>
|
|
||||||
<Text className="text-sm opacity-70">Audio</Text>
|
|
||||||
<Text className="text-sm opacity-70">Subtitles</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-sm opacity-70">
|
|
||||||
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-sm opacity-70">
|
|
||||||
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-sm opacity-70">
|
|
||||||
{
|
|
||||||
item.MediaStreams?.find((i) => i.Type === "Subtitle")
|
|
||||||
?.DisplayTitle
|
|
||||||
}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<CastAndCrew item={item} />
|
<CastAndCrew item={item} />
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,9 @@ import { clearLogs, readFromLog } from "@/utils/log";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
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 { useFiles } from "@/hooks/useFiles";
|
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useFiles();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -33,30 +29,14 @@ export default function settings() {
|
|||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SettingToggles />
|
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Button color="black" onPress={logout}>
|
<Button color="black" onPress={logout}>
|
||||||
Log out
|
Log out
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await clearLogs();
|
await clearLogs();
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete all logs
|
Delete all logs
|
||||||
|
|||||||
@@ -1,37 +1,30 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { Text } from "@/components/common/Text";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import {
|
import {
|
||||||
currentlyPlayingItemAtom,
|
currentlyPlayingItemAtom,
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
} from "@/components/CurrentlyPlayingBar";
|
||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { Loader } from "@/components/Loader";
|
||||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
|
||||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
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 { 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 ios from "@/utils/profiles/ios";
|
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, useMemo, useState } from "react";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -42,20 +35,8 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
const [, setPlaying] = useAtom(playingAtom);
|
||||||
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
@@ -84,12 +65,12 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: sessionData } = useQuery({
|
const { data: sessionData } = useQuery({
|
||||||
@@ -112,7 +93,6 @@ const page: React.FC = () => {
|
|||||||
"playbackUrl",
|
"playbackUrl",
|
||||||
item?.Id,
|
item?.Id,
|
||||||
maxBitrate,
|
maxBitrate,
|
||||||
castDevice,
|
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
],
|
],
|
||||||
@@ -126,7 +106,7 @@ const page: React.FC = () => {
|
|||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
deviceProfile: ios,
|
||||||
audioStreamIndex: selectedAudioStream,
|
audioStreamIndex: selectedAudioStream,
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
subtitleStreamIndex: selectedSubtitleStream,
|
||||||
});
|
});
|
||||||
@@ -140,46 +120,24 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
|
|
||||||
const onPressPlay = useCallback(
|
const onPressPlay = useCallback(
|
||||||
async (type: "device" | "cast" = "device") => {
|
async (type: "device" | "cast" = "device") => {
|
||||||
if (!playbackUrl || !item) return;
|
if (!playbackUrl || !item) return;
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
setCp({
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
item,
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
playbackUrl,
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
});
|
||||||
else {
|
setPlaying(true);
|
||||||
client.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: playbackUrl,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCp({
|
|
||||||
item,
|
|
||||||
playbackUrl,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[playbackUrl, item],
|
[playbackUrl, item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className="justify-center items-center h-full">
|
||||||
<ActivityIndicator />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -220,14 +178,6 @@ const page: React.FC = () => {
|
|||||||
<MoviesTitleHeader item={item} />
|
<MoviesTitleHeader item={item} />
|
||||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col p-4 w-full">
|
<View className="flex flex-col p-4 w-full">
|
||||||
<View className="flex flex-row items-center space-x-2 w-full">
|
<View className="flex flex-row items-center space-x-2 w-full">
|
||||||
@@ -250,7 +200,7 @@ const page: React.FC = () => {
|
|||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||||
<PlayButton
|
<PlayButton
|
||||||
item={item}
|
item={item}
|
||||||
chromecastReady={chromecastReady}
|
chromecastReady={false}
|
||||||
onPress={onPressPlay}
|
onPress={onPressPlay}
|
||||||
className="grow"
|
className="grow"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import { useEffect } from "react";
|
|||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(`Navigated to ${pathname}`);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
|
|||||||
223
app/_layout.tsx
223
app/_layout.tsx
@@ -1,20 +1,19 @@
|
|||||||
|
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useFonts } from "expo-font";
|
import { useFonts } from "expo-font";
|
||||||
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import "react-native-reanimated";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { useEffect, useRef } from "react";
|
||||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import "react-native-reanimated";
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
@@ -46,8 +45,6 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
|
|
||||||
const queryClientRef = useRef<QueryClient>(
|
const queryClientRef = useRef<QueryClient>(
|
||||||
@@ -61,119 +58,103 @@ function Layout() {
|
|||||||
retryOnMount: true,
|
retryOnMount: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.autoRotate === true)
|
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
|
||||||
else
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
);
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<JobQueueProvider>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<JellyfinProvider>
|
<BottomSheetModalProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<JellyfinProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<Stack initialRouteName="/home">
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack.Screen
|
<Stack initialRouteName="/home">
|
||||||
name="(auth)/(tabs)"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/(tabs)"
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/settings"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/settings"
|
||||||
headerShown: true,
|
options={{
|
||||||
title: "Settings",
|
headerShown: true,
|
||||||
headerStyle: { backgroundColor: "black" },
|
title: "Settings",
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/downloads"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/items/[id]"
|
||||||
headerShown: true,
|
options={{
|
||||||
title: "Downloads",
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: false,
|
||||||
headerShadowVisible: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/collections/[collectionId]"
|
||||||
name="(auth)/items/[id]"
|
options={{
|
||||||
options={{
|
title: "",
|
||||||
title: "",
|
headerShown: true,
|
||||||
headerShown: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/collections/[collectionId]"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/artists/page"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: true,
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: true,
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/artists/page"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/artists/[artistId]/page"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: true,
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: true,
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/artists/[artistId]/page"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/albums/[albumId]"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: true,
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: true,
|
||||||
headerShadowVisible: false,
|
headerStyle: { backgroundColor: "black" },
|
||||||
}}
|
headerShadowVisible: false,
|
||||||
/>
|
}}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/albums/[albumId]"
|
<Stack.Screen
|
||||||
options={{
|
name="(auth)/songs/[songId]"
|
||||||
title: "",
|
options={{
|
||||||
headerShown: true,
|
title: "",
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShown: false,
|
||||||
headerShadowVisible: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/series/[id]"
|
||||||
name="(auth)/songs/[songId]"
|
options={{
|
||||||
options={{
|
title: "",
|
||||||
title: "",
|
headerShown: false,
|
||||||
headerShown: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="login"
|
||||||
name="(auth)/series/[id]"
|
options={{ headerShown: false, title: "Login" }}
|
||||||
options={{
|
/>
|
||||||
title: "",
|
<Stack.Screen name="+not-found" />
|
||||||
headerShown: false,
|
</Stack>
|
||||||
}}
|
<CurrentlyPlayingBar />
|
||||||
/>
|
</ThemeProvider>
|
||||||
<Stack.Screen
|
</JellyfinProvider>
|
||||||
name="login"
|
</BottomSheetModalProvider>
|
||||||
options={{ headerShown: false, title: "Login" }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="+not-found" />
|
|
||||||
</Stack>
|
|
||||||
<CurrentlyPlayingBar />
|
|
||||||
</ThemeProvider>
|
|
||||||
</JellyfinProvider>
|
|
||||||
</ActionSheetProvider>
|
</ActionSheetProvider>
|
||||||
</JobQueueProvider>
|
</QueryClientProvider>
|
||||||
</QueryClientProvider>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
204
app/login.tsx
204
app/login.tsx
@@ -6,7 +6,13 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { KeyboardAvoidingView, Platform, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
SafeAreaView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -46,102 +52,134 @@ const Login: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConnect = (url: string) => {
|
const handleConnect = (url: string) => {
|
||||||
|
if (!url.startsWith("http")) {
|
||||||
|
Alert.alert("Error", "URL needs to start with http or https.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setServer({ address: url.trim() });
|
setServer({ address: url.trim() });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (api?.basePath) {
|
if (api?.basePath) {
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
>
|
style={{ flex: 1, height: "100%" }}
|
||||||
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
|
>
|
||||||
<View>
|
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<View></View>
|
||||||
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
|
<View>
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
|
||||||
|
<Text className="text-neutral-500 mb-2">
|
||||||
|
Server: {api.basePath}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="black"
|
||||||
|
onPress={() => {
|
||||||
|
removeServer();
|
||||||
|
setServerURL("");
|
||||||
|
}}
|
||||||
|
justify="between"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name="arrow-back-outline"
|
||||||
|
size={18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Change server
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
<Text className="text-2xl font-bold">Log in</Text>
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
Log in to any user account
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="Username"
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials({ ...credentials, username: text })
|
||||||
|
}
|
||||||
|
value={credentials.username}
|
||||||
|
autoFocus
|
||||||
|
secureTextEntry={false}
|
||||||
|
keyboardType="default"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="username"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="mb-2"
|
||||||
|
placeholder="Password"
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials({ ...credentials, password: text })
|
||||||
|
}
|
||||||
|
value={credentials.password}
|
||||||
|
secureTextEntry
|
||||||
|
keyboardType="default"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="password"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-red-600 mb-2">{error}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="black"
|
onPress={handleLogin}
|
||||||
onPress={() => {
|
loading={loading}
|
||||||
removeServer();
|
className="mt-auto mb-2"
|
||||||
setServerURL("");
|
|
||||||
}}
|
|
||||||
justify="between"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Change server
|
Log in
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col space-y-2">
|
</KeyboardAvoidingView>
|
||||||
<Text className="text-2xl font-bold">Log in</Text>
|
</SafeAreaView>
|
||||||
<Input
|
|
||||||
placeholder="Username"
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials({ ...credentials, username: text })
|
|
||||||
}
|
|
||||||
value={credentials.username}
|
|
||||||
autoFocus
|
|
||||||
secureTextEntry={false}
|
|
||||||
keyboardType="default"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="username"
|
|
||||||
clearButtonMode="while-editing"
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
className="mb-2"
|
|
||||||
placeholder="Password"
|
|
||||||
onChangeText={(text) =>
|
|
||||||
setCredentials({ ...credentials, password: text })
|
|
||||||
}
|
|
||||||
value={credentials.password}
|
|
||||||
secureTextEntry
|
|
||||||
keyboardType="default"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="password"
|
|
||||||
clearButtonMode="while-editing"
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className="text-red-600 mb-2">{error}</Text>
|
|
||||||
|
|
||||||
<Button onPress={handleLogin} loading={loading}>
|
|
||||||
Log in
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
<KeyboardAvoidingView
|
||||||
style={{ flex: 1 }}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
>
|
style={{ flex: 1 }}
|
||||||
<View className="flex flex-col px-4 justify-center h-full">
|
>
|
||||||
<View className="flex flex-col gap-y-2">
|
<View className="flex flex-col px-4 justify-between h-full">
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<View></View>
|
||||||
<Text className="opacity-50">Enter a server adress</Text>
|
<View className="flex flex-col gap-y-2">
|
||||||
<Input
|
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||||
className="mb-2"
|
<Text className="text-neutral-500">
|
||||||
placeholder="http(s)://..."
|
Connect to your Jellyfin server
|
||||||
onChangeText={setServerURL}
|
</Text>
|
||||||
value={serverURL}
|
<Input
|
||||||
keyboardType="url"
|
placeholder="Server URL"
|
||||||
returnKeyType="done"
|
onChangeText={setServerURL}
|
||||||
autoCapitalize="none"
|
value={serverURL}
|
||||||
textContentType="URL"
|
keyboardType="url"
|
||||||
maxLength={500}
|
returnKeyType="done"
|
||||||
/>
|
autoCapitalize="none"
|
||||||
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
|
textContentType="URL"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<Text className="opacity-30">
|
||||||
|
Server URL requires http or https
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</KeyboardAvoidingView>
|
||||||
</KeyboardAvoidingView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = function (api) {
|
module.exports = function (api) {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ['babel-preset-expo'],
|
presets: ["babel-preset-expo"],
|
||||||
plugins: ["nativewind/babel"],
|
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
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 } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -22,12 +21,12 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedAudioSteam = useMemo(
|
const selectedAudioSteam = useMemo(
|
||||||
() => audioStreams?.find((x) => x.Index === selected),
|
() => audioStreams?.find((x) => x.Index === selected),
|
||||||
[audioStreams, selected],
|
[audioStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,45 +35,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between" {...props}>
|
<View
|
||||||
<DropdownMenu.Root>
|
className="flex flex-row items-center justify-between"
|
||||||
<DropdownMenu.Trigger>
|
{...props}
|
||||||
<View className="flex flex-col mb-2">
|
></View>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
|
||||||
<View className="flex flex-row">
|
|
||||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text className="">
|
|
||||||
{tc(selectedAudioSteam?.DisplayTitle, 13)}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
|
||||||
{audioStreams?.map((audio, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (audio.Index !== null && audio.Index !== undefined)
|
|
||||||
onChange(audio.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{audio.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
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";
|
||||||
|
|
||||||
@@ -46,42 +45,9 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between" {...props}>
|
<View
|
||||||
<DropdownMenu.Root>
|
className="flex flex-row items-center justify-between"
|
||||||
<DropdownMenu.Trigger>
|
{...props}
|
||||||
<View className="flex flex-col mb-2">
|
></View>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
|
||||||
<View className="flex flex-row">
|
|
||||||
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>
|
|
||||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
|
||||||
{BITRATES?.map((b, index: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={index.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(b);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -50,14 +50,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!loading && !disabled && onPress) {
|
if (!loading && !disabled && onPress) {
|
||||||
onPress();
|
onPress();
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color={"white"} size={24} />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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 { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||||
@@ -14,13 +13,7 @@ import { BlurView } from "expo-blur";
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
ActivityIndicator,
|
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -28,6 +21,7 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export const currentlyPlayingItemAtom = atom<{
|
export const currentlyPlayingItemAtom = atom<{
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -45,7 +39,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [playing, setPlaying] = useAtom(playingAtom);
|
const [playing, setPlaying] = useAtom(playingAtom);
|
||||||
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
|
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
|
||||||
currentlyPlayingItemAtom,
|
currentlyPlayingItemAtom
|
||||||
);
|
);
|
||||||
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
|
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
|
||||||
|
|
||||||
@@ -143,7 +137,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
sessionId: sessionData.PlaySessionId,
|
sessionId: sessionData.PlaySessionId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id],
|
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,7 +167,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
}, [playing, progress, item, sessionData]);
|
}, [playing, progress, item, sessionData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("Full screen changed", fullScreen);
|
|
||||||
if (fullScreen === true) {
|
if (fullScreen === true) {
|
||||||
videoRef.current?.presentFullscreenPlayer();
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
} else {
|
} else {
|
||||||
@@ -186,7 +179,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
item?.UserData?.PlaybackPositionTicks
|
item?.UserData?.PlaybackPositionTicks
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||||
: 0,
|
: 0,
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
@@ -197,7 +190,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
quality: 70,
|
quality: 70,
|
||||||
width: 200,
|
width: 200,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentlyPlaying || !api) return null;
|
if (!currentlyPlaying || !api) return null;
|
||||||
@@ -284,7 +277,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
console.log(e);
|
console.log(e);
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"ERROR",
|
"ERROR",
|
||||||
"Video playback error: " + JSON.stringify(e),
|
"Video playback error: " + JSON.stringify(e)
|
||||||
);
|
);
|
||||||
Alert.alert("Error", "Cannot play this video file.");
|
Alert.alert("Error", "Cannot play this video file.");
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
@@ -293,7 +286,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
renderLoader={
|
renderLoader={
|
||||||
item?.Type !== "Audio" && (
|
item?.Type !== "Audio" && (
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -303,7 +296,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
<View className="shrink text-xs">
|
<View className="shrink text-xs">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
console.log(JSON.stringify(item));
|
|
||||||
if (item?.Type === "Audio")
|
if (item?.Type === "Audio")
|
||||||
router.push(`/albums/${item?.AlbumId}`);
|
router.push(`/albums/${item?.AlbumId}`);
|
||||||
else router.push(`/items/${item?.Id}`);
|
else router.push(`/items/${item?.Id}`);
|
||||||
@@ -331,7 +323,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
{item?.Type === "Audio" && (
|
{item?.Type === "Audio" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
console.log(JSON.stringify(item));
|
|
||||||
router.push(`/albums/${item?.AlbumId}`);
|
router.push(`/albums/${item?.AlbumId}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
||||||
import ProgressCircle from "./ProgressCircle";
|
|
||||||
|
|
||||||
type DownloadProps = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
playbackUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
|
||||||
item,
|
|
||||||
playbackUrl,
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [process] = useAtom(runningProcesses);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
|
|
||||||
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
|
||||||
|
|
||||||
const { data: playbackInfo, isLoading } = useQuery({
|
|
||||||
queryKey: ["playbackInfo", item.Id],
|
|
||||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
|
|
||||||
queryKey: ["downloaded", item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!item.Id) return false;
|
|
||||||
|
|
||||||
const data: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
|
||||||
);
|
|
||||||
|
|
||||||
return data.some((d) => d.Id === item.Id);
|
|
||||||
},
|
|
||||||
enabled: !!item.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading || isLoadingDownloaded) {
|
|
||||||
return (
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
|
||||||
return (
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process && process?.item.Id === item.Id) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
<View className="-rotate-45">
|
|
||||||
<ProgressCircle
|
|
||||||
size={24}
|
|
||||||
fill={process.progress}
|
|
||||||
width={4}
|
|
||||||
tintColor="#9334E9"
|
|
||||||
backgroundColor="#bdc3c7"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queue.some((i) => i.id === item.Id)) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
|
||||||
<Ionicons name="hourglass" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloaded) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
queueActions.enqueue(queue, setQueue, {
|
|
||||||
id: item.Id!,
|
|
||||||
execute: async () => {
|
|
||||||
await startRemuxing();
|
|
||||||
},
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -36,7 +36,7 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text>{item.Name}</Text>
|
<Text numberOfLines={2}>{item.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
18
components/Loader.tsx
Normal file
18
components/Loader.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
ActivityIndicatorProps,
|
||||||
|
Platform,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ActivityIndicatorProps {}
|
||||||
|
|
||||||
|
export const Loader: React.FC<Props> = ({ ...props }) => {
|
||||||
|
return (
|
||||||
|
<ActivityIndicator
|
||||||
|
size={"small"}
|
||||||
|
color={Platform.OS === "ios" ? "white" : "#9333ea"}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { ActivityIndicator, View } from "react-native";
|
|
||||||
|
|
||||||
export const Loading: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<ActivityIndicator />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -9,7 +9,6 @@ import Animated, {
|
|||||||
useScrollViewOffset,
|
useScrollViewOffset,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 400;
|
const HEADER_HEIGHT = 400;
|
||||||
|
|
||||||
@@ -33,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
translateY: interpolate(
|
translateY: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scale: interpolate(
|
scale: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||||
[2, 1, 1],
|
[2, 1, 1]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -73,15 +72,6 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</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 className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
|
||||||
{logo}
|
{logo}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback } from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
@@ -15,15 +14,15 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = useCallback(() => {
|
const invalidateQueries = () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["item", item.Id],
|
queryKey: ["item"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["resumeItems", user?.Id],
|
queryKey: ["resumeItems"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["nextUp", item.SeriesId],
|
queryKey: ["nextUp"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["episodes"],
|
queryKey: ["episodes"],
|
||||||
@@ -31,14 +30,16 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["seasons"],
|
queryKey: ["seasons"],
|
||||||
});
|
});
|
||||||
}, [api, item.Id, queryClient, user?.Id]);
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["nextUp-all"],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{item.UserData?.Played ? (
|
{item.UserData?.Played ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
await markAsNotPlayed({
|
await markAsNotPlayed({
|
||||||
api: api,
|
api: api,
|
||||||
itemId: item?.Id,
|
itemId: item?.Id,
|
||||||
@@ -54,7 +55,6 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
await markAsPlayed({
|
await markAsPlayed({
|
||||||
api: api,
|
api: api,
|
||||||
item: item,
|
item: item,
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import ContinueWatchingPoster from "./ContinueWatchingPoster";
|
|
||||||
import { ItemCardText } from "./ItemCardText";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import MoviePoster from "./MoviePoster";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { ItemCardText } from "./ItemCardText";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
type SimilarItemsProps = {
|
type SimilarItemsProps = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -42,7 +37,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
|||||||
|
|
||||||
const movies = useMemo(
|
const movies = useMemo(
|
||||||
() => similarItems?.filter((i) => i.Type === "Movie") || [],
|
() => similarItems?.filter((i) => i.Type === "Movie") || [],
|
||||||
[similarItems],
|
[similarItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +45,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
|||||||
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
|
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View className="my-12">
|
<View className="my-12">
|
||||||
<ActivityIndicator />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView horizontal>
|
<ScrollView horizontal>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
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 } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -22,14 +21,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.[0].MediaStreams?.filter(
|
item.MediaSources?.[0].MediaStreams?.filter(
|
||||||
(x) => x.Type === "Subtitle",
|
(x) => x.Type === "Subtitle"
|
||||||
) ?? [],
|
) ?? [],
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
() => subtitleStreams.find((x) => x.Index === selected),
|
() => subtitleStreams.find((x) => x.Index === selected),
|
||||||
[subtitleStreams, selected],
|
[subtitleStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,56 +36,16 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
if (index !== undefined && index !== null) {
|
if (index !== undefined && index !== null) {
|
||||||
onChange(index);
|
onChange(index);
|
||||||
} else {
|
} else {
|
||||||
// Get first subtitle stream
|
onChange(-1);
|
||||||
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
|
|
||||||
if (firstSubtitle?.Index !== undefined) {
|
|
||||||
onChange(firstSubtitle.Index);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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
|
||||||
<DropdownMenu.Root>
|
className="flex flex-row items-center justify-between"
|
||||||
<DropdownMenu.Trigger>
|
{...props}
|
||||||
<View className="flex flex-col mb-2">
|
></View>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
|
|
||||||
<View className="flex flex-row">
|
|
||||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text className="">
|
|
||||||
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
|
|
||||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
|
||||||
onChange(subtitle.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{subtitle.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
42
components/common/ColumnItem.tsx
Normal file
42
components/common/ColumnItem.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { StyleSheet, View, ViewProps } from "react-native";
|
||||||
|
|
||||||
|
const getItemStyle = (index: number, numColumns: number) => {
|
||||||
|
const alignItems = (() => {
|
||||||
|
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
|
||||||
|
if ((index + 1) % numColumns === 0) return "flex-end";
|
||||||
|
|
||||||
|
return "center";
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
padding: 20,
|
||||||
|
alignItems,
|
||||||
|
width: "100%",
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColumnItemProps = ViewProps & {
|
||||||
|
children: React.ReactNode;
|
||||||
|
index: number;
|
||||||
|
numColumns: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ColumnItem = ({
|
||||||
|
children,
|
||||||
|
index,
|
||||||
|
numColumns,
|
||||||
|
...rest
|
||||||
|
}: ColumnItemProps) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col mb-2 p-4" style={{ width: "33.3%" }}>
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import {
|
import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native";
|
||||||
ScrollView,
|
|
||||||
View,
|
|
||||||
ViewStyle,
|
|
||||||
ActivityIndicator,
|
|
||||||
ScrollViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
interface HorizontalScrollProps<T> extends ScrollViewProps {
|
interface HorizontalScrollProps<T> extends ScrollViewProps {
|
||||||
@@ -58,7 +53,7 @@ export function HorizontalScroll<T>({
|
|||||||
loadingContainerStyle,
|
loadingContainerStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<ActivityIndicator size="small" />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
164
components/common/InfiniteHorrizontalScroll.tsx
Normal file
164
components/common/InfiniteHorrizontalScroll.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
NativeScrollEvent,
|
||||||
|
ScrollView,
|
||||||
|
ScrollViewProps,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
import { Text } from "./Text";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
interface HorizontalScrollProps extends ScrollViewProps {
|
||||||
|
queryFn: ({
|
||||||
|
pageParam,
|
||||||
|
}: {
|
||||||
|
pageParam: number;
|
||||||
|
}) => Promise<BaseItemDtoQueryResult | null>;
|
||||||
|
queryKey: string[];
|
||||||
|
initialData?: BaseItemDto[];
|
||||||
|
renderItem: (item: BaseItemDto, index: number) => React.ReactNode;
|
||||||
|
containerStyle?: ViewStyle;
|
||||||
|
contentContainerStyle?: ViewStyle;
|
||||||
|
loadingContainerStyle?: ViewStyle;
|
||||||
|
height?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCloseToBottom = ({
|
||||||
|
layoutMeasurement,
|
||||||
|
contentOffset,
|
||||||
|
contentSize,
|
||||||
|
}: NativeScrollEvent) => {
|
||||||
|
const paddingToBottom = 50;
|
||||||
|
return (
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - paddingToBottom
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InfiniteHorizontalScroll({
|
||||||
|
queryFn,
|
||||||
|
queryKey,
|
||||||
|
initialData = [],
|
||||||
|
renderItem,
|
||||||
|
containerStyle,
|
||||||
|
contentContainerStyle,
|
||||||
|
loadingContainerStyle,
|
||||||
|
loading = false,
|
||||||
|
height = 164,
|
||||||
|
...props
|
||||||
|
}: HorizontalScrollProps): React.ReactElement {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const animatedOpacity = useSharedValue(0);
|
||||||
|
const animatedStyle1 = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn,
|
||||||
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
if (
|
||||||
|
!lastPage?.Items ||
|
||||||
|
!lastPage?.TotalRecordCount ||
|
||||||
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
animatedOpacity.value = 1;
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (data === undefined || data === null || loading) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
loadingContainerStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
onScroll={({ nativeEvent }) => {
|
||||||
|
if (isCloseToBottom(nativeEvent)) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={400}
|
||||||
|
style={containerStyle}
|
||||||
|
contentContainerStyle={contentContainerStyle}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
className={`
|
||||||
|
flex flex-row px-4
|
||||||
|
`}
|
||||||
|
style={[animatedStyle1]}
|
||||||
|
>
|
||||||
|
{data?.pages
|
||||||
|
.flatMap((page) => page?.Items)
|
||||||
|
.map(
|
||||||
|
(item, index) =>
|
||||||
|
item && (
|
||||||
|
<View className="mr-2" key={index}>
|
||||||
|
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{data?.pages.flatMap((page) => page?.Items).length === 0 && (
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<Text className="text-center text-gray-500">No data available</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
import { useFocusEffect } from "expo-router";
|
import React from "react";
|
||||||
import React, { useEffect } from "react";
|
import { TextInput, TextInputProps } from "react-native";
|
||||||
import { TextInputProps, TextProps } from "react-native";
|
|
||||||
import { TextInput } from "react-native";
|
|
||||||
export function Input(props: TextInputProps) {
|
export function Input(props: TextInputProps) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
const inputRef = React.useRef<TextInput>(null);
|
const inputRef = React.useRef<TextInput>(null);
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
59
components/common/TouchableItemRouter.tsx
Normal file
59
components/common/TouchableItemRouter.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
item,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (item.Type === "Series") {
|
||||||
|
router.push(`/series/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
router.push(`/items/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "MusicAlbum") {
|
||||||
|
router.push(`/albums/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "Audio") {
|
||||||
|
router.push(`/albums/${item.AlbumId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "MusicArtist") {
|
||||||
|
router.push(`/artists/${item.Id}/page`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movies and all other cases
|
||||||
|
if (item.Type === "BoxSet") {
|
||||||
|
router.push(`/collections/${item.Id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/items/${item.Id}`);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { TouchableOpacity } from "react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "../CurrentlyPlayingBar";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EpisodeCard component displays an episode with context menu options.
|
|
||||||
* @param {EpisodeCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
|
||||||
*/
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useFiles();
|
|
||||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles opening the file for playback.
|
|
||||||
*/
|
|
||||||
const handleOpenFile = useCallback(async () => {
|
|
||||||
setCurrentlyPlaying({
|
|
||||||
item,
|
|
||||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true)
|
|
||||||
setFullscreen(true);
|
|
||||||
}, [item, setCurrentlyPlaying, settings]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const contextMenuOptions = [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
onSelect: handleDeleteFile,
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu.Root>
|
|
||||||
<ContextMenu.Trigger>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenFile}
|
|
||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
|
||||||
>
|
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions
|
|
||||||
collisionPadding={10}
|
|
||||||
loop={false}
|
|
||||||
>
|
|
||||||
{contextMenuOptions.map((option) => (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key={option.label}
|
|
||||||
onSelect={option.onSelect}
|
|
||||||
destructive={option.destructive}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
|
||||||
{option.label}
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
))}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "../CurrentlyPlayingBar";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
interface MovieCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MovieCard component displays a movie with context menu options.
|
|
||||||
* @param {MovieCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
|
||||||
*/
|
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useFiles();
|
|
||||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles opening the file for playback.
|
|
||||||
*/
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
console.log("Open movie file", item.Name);
|
|
||||||
setCurrentlyPlaying({
|
|
||||||
item,
|
|
||||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
|
||||||
setFullscreen(true);
|
|
||||||
}
|
|
||||||
}, [item, setCurrentlyPlaying, setPlaying, settings]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const contextMenuOptions = [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
onSelect: handleDeleteFile,
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu.Root>
|
|
||||||
<ContextMenu.Trigger>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenFile}
|
|
||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
|
||||||
>
|
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content>
|
|
||||||
{contextMenuOptions.map((option) => (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key={option.label}
|
|
||||||
onSelect={option.onSelect}
|
|
||||||
destructive={option.destructive}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
|
||||||
{option.label}
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
))}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { EpisodeCard } from "./EpisodeCard";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { SeasonPicker } from "../series/SeasonPicker";
|
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|
||||||
const groupBySeason = useMemo(() => {
|
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (!seasons[item.SeasonName!]) {
|
|
||||||
seasons[item.SeasonName!] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons[item.SeasonName!].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(seasons).sort(
|
|
||||||
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
|
||||||
);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View className="flex flex-row items-center justify-between">
|
|
||||||
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
|
|
||||||
<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>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className="opacity-50 mb-2">TV-Series</Text>
|
|
||||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
|
||||||
<View key={seasonIndex}>
|
|
||||||
<Text className="mb-2 font-semibold">
|
|
||||||
{seasonItems[0].SeasonName}
|
|
||||||
</Text>
|
|
||||||
{seasonItems.map((item, index) => (
|
|
||||||
<View className="mb-2" key={index}>
|
|
||||||
<EpisodeCard item={item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
97
components/filters/FilterButton.tsx
Normal file
97
components/filters/FilterButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { FilterSheet } from "./FilterSheet";
|
||||||
|
|
||||||
|
interface FilterButtonProps<T> extends ViewProps {
|
||||||
|
collectionId: string;
|
||||||
|
showSearch?: boolean;
|
||||||
|
queryKey: string;
|
||||||
|
values: T[];
|
||||||
|
title: string;
|
||||||
|
set: (value: T[]) => void;
|
||||||
|
queryFn: (params: any) => Promise<any>;
|
||||||
|
searchFilter: (item: T, query: string) => boolean;
|
||||||
|
renderItemLabel: (item: T) => React.ReactNode;
|
||||||
|
icon?: "filter" | "sort";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilterButton = <T,>({
|
||||||
|
collectionId,
|
||||||
|
queryFn,
|
||||||
|
queryKey,
|
||||||
|
set,
|
||||||
|
values,
|
||||||
|
title,
|
||||||
|
renderItemLabel,
|
||||||
|
searchFilter,
|
||||||
|
showSearch = true,
|
||||||
|
icon = "filter",
|
||||||
|
...props
|
||||||
|
}: FilterButtonProps<T>) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: filters } = useQuery<T[]>({
|
||||||
|
queryKey: [queryKey, collectionId],
|
||||||
|
queryFn,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters?.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity onPress={() => setOpen(true)}>
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||||
|
${
|
||||||
|
values.length > 0
|
||||||
|
? "bg-purple-600 border border-purple-700"
|
||||||
|
: "bg-neutral-900 border border-neutral-900"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
|
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
|
||||||
|
text-xs font-semibold`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{icon === "filter" ? (
|
||||||
|
<Ionicons
|
||||||
|
name="filter"
|
||||||
|
size={14}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesome
|
||||||
|
name="sort"
|
||||||
|
size={14}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<FilterSheet<T>
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
data={filters}
|
||||||
|
values={values}
|
||||||
|
set={set}
|
||||||
|
renderItemLabel={renderItemLabel}
|
||||||
|
searchFilter={searchFilter}
|
||||||
|
showSearch={showSearch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
190
components/filters/FilterSheet.tsx
Normal file
190
components/filters/FilterSheet.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetFlatList,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
|
||||||
|
interface Props<T> extends ViewProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
data?: T[] | null;
|
||||||
|
values: T[];
|
||||||
|
set: (value: T[]) => void;
|
||||||
|
title: string;
|
||||||
|
searchFilter: (item: T, query: string) => boolean;
|
||||||
|
renderItemLabel: (item: T) => React.ReactNode;
|
||||||
|
showSearch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT = 100;
|
||||||
|
|
||||||
|
export const FilterSheet = <T,>({
|
||||||
|
values,
|
||||||
|
data: _data,
|
||||||
|
open,
|
||||||
|
set,
|
||||||
|
setOpen,
|
||||||
|
title,
|
||||||
|
searchFilter,
|
||||||
|
renderItemLabel,
|
||||||
|
showSearch = true,
|
||||||
|
...props
|
||||||
|
}: Props<T>) => {
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const snapPoints = useMemo(() => ["80%"], []);
|
||||||
|
|
||||||
|
const [data, setData] = useState<T[]>([]);
|
||||||
|
const [offset, setOffset] = useState<number>(0);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!search) return _data;
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < (_data?.length || 0); i++) {
|
||||||
|
if (_data && searchFilter(_data[i], search)) {
|
||||||
|
results.push(_data[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results.slice(0, 100);
|
||||||
|
}, [search, _data, searchFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_data || _data.length === 0) return;
|
||||||
|
const tmp = new Set(data);
|
||||||
|
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
||||||
|
tmp.add(_data[i]);
|
||||||
|
}
|
||||||
|
setData(Array.from(tmp));
|
||||||
|
}, [offset, _data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) bottomSheetModalRef.current?.present();
|
||||||
|
else bottomSheetModalRef.current?.dismiss();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderData = useMemo(() => {
|
||||||
|
if (search.length > 0 && showSearch) return filteredData;
|
||||||
|
return data;
|
||||||
|
}, [search, filteredData, data]);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
style={{}}
|
||||||
|
>
|
||||||
|
<BottomSheetScrollView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="px-4 mt-2 mb-8">
|
||||||
|
<Text className="font-bold text-2xl">{title}</Text>
|
||||||
|
<Text className="mb-2 text-neutral-500">{_data?.length} items</Text>
|
||||||
|
{showSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
className="my-2"
|
||||||
|
value={search}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setSearch(text);
|
||||||
|
}}
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
className="mb-4 flex flex-col rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{renderData?.map((item, index) => (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
set(
|
||||||
|
values.includes(item)
|
||||||
|
? values.filter((i) => i !== item)
|
||||||
|
: [item]
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, 250);
|
||||||
|
}}
|
||||||
|
key={index}
|
||||||
|
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
<Text>{renderItemLabel(item)}</Text>
|
||||||
|
{values.includes(item) ? (
|
||||||
|
<Ionicons name="radio-button-on" size={24} color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="radio-button-off" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className="h-1 divide-neutral-700 "
|
||||||
|
></View>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{data.length < (_data?.length || 0) && (
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setOffset(offset + 100);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
components/filters/ResetFiltersButton.tsx
Normal file
38
components/filters/ResetFiltersButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {}
|
||||||
|
|
||||||
|
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedGenres.length === 0 &&
|
||||||
|
selectedTags.length === 0 &&
|
||||||
|
selectedYears.length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
}}
|
||||||
|
className="bg-purple-600 rounded-full w-8 h-8 flex items-center justify-center"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
components/home/LargeMovieCarousel.tsx
Normal file
176
components/home/LargeMovieCarousel.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Dimensions, View, ViewProps } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Carousel, {
|
||||||
|
ICarouselInstance,
|
||||||
|
Pagination,
|
||||||
|
} from "react-native-reanimated-carousel";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
const ref = React.useRef<ICarouselInstance>(null);
|
||||||
|
const progress = useSharedValue<number>(0);
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const onPressPagination = (index: number) => {
|
||||||
|
ref.current?.scrollTo({
|
||||||
|
/**
|
||||||
|
* Calculate the difference between the current index and the target index
|
||||||
|
* to ensure that the carousel scrolls to the nearest index
|
||||||
|
*/
|
||||||
|
count: index - progress.value,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: mediaListCollection, isLoading: l1 } = useQuery<string | null>({
|
||||||
|
queryKey: ["mediaListCollection", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return null;
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
tags: ["medialist", "promoted"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["Tags"],
|
||||||
|
includeItemTypes: ["BoxSet"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
|
||||||
|
return id || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: popularItems, isLoading: l2 } = useQuery<BaseItemDto[]>({
|
||||||
|
queryKey: ["popular", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !mediaListCollection) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
parentId: mediaListCollection,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!mediaListCollection,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = Dimensions.get("screen").width;
|
||||||
|
|
||||||
|
if (l1 || l2)
|
||||||
|
return (
|
||||||
|
<View className="h-[242px] flex items-center justify-center">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!popularItems) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col items-center" {...props}>
|
||||||
|
<Carousel
|
||||||
|
autoPlay={true}
|
||||||
|
autoPlayInterval={2000}
|
||||||
|
loop={true}
|
||||||
|
ref={ref}
|
||||||
|
width={width}
|
||||||
|
height={204}
|
||||||
|
data={popularItems}
|
||||||
|
onProgressChange={progress}
|
||||||
|
renderItem={({ item, index }) => <RenderItem item={item} />}
|
||||||
|
/>
|
||||||
|
<Pagination.Basic
|
||||||
|
progress={progress}
|
||||||
|
data={popularItems}
|
||||||
|
dotStyle={{
|
||||||
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
|
borderRadius: 50,
|
||||||
|
}}
|
||||||
|
activeDotStyle={{
|
||||||
|
backgroundColor: "rgba(255,255,255,0.8)",
|
||||||
|
borderRadius: 50,
|
||||||
|
}}
|
||||||
|
containerStyle={{ gap: 5, marginTop: 12 }}
|
||||||
|
onPress={onPressPagination}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const uri = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 1000,
|
||||||
|
});
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const logoUri = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
return getLogoImageUrlById({ api, item, height: 100 });
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
if (!uri || !logoUri) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableItemRouter item={item}>
|
||||||
|
<View className="px-4">
|
||||||
|
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 200,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className="absolute bottom-0 left-0 w-full h-24 p-4 flex items-center">
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: logoUri,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router } from "expo-router";
|
import { View, ViewProps } from "react-native";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import MoviePoster from "../MoviePoster";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -29,22 +29,17 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-2xl font-bold mb-2">{title}</Text>
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
data={data}
|
data={data}
|
||||||
height={orientation === "vertical" ? 247 : 164}
|
height={orientation === "vertical" ? 247 : 164}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
renderItem={(item, index) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableItemRouter
|
||||||
key={index}
|
key={index}
|
||||||
onPress={() => {
|
item={item}
|
||||||
if (item.Type === "Series") router.push(`/series/${item.Id}`);
|
|
||||||
else if (item.CollectionType === "music")
|
|
||||||
router.push(`/artists/page?collectionId=${item.Id}`);
|
|
||||||
else if (item.Type === "CollectionFolder")
|
|
||||||
router.push(`/collections/${item.Id}`);
|
|
||||||
else router.push(`/items/${item.Id}`);
|
|
||||||
}}
|
|
||||||
className={`flex flex-col
|
className={`flex flex-col
|
||||||
${orientation === "vertical" ? "w-32" : "w-48"}
|
${orientation === "vertical" ? "w-32" : "w-48"}
|
||||||
`}
|
`}
|
||||||
@@ -57,7 +52,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
72
components/medialists/MediaListSection.tsx
Normal file
72
components/medialists/MediaListSection.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { ScrollingCollectionList } from "../home/ScrollingCollectionList";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import MoviePoster from "../posters/MoviePoster";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
collection: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async ({
|
||||||
|
pageParam,
|
||||||
|
}: {
|
||||||
|
pageParam: number;
|
||||||
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
|
if (!api || !user?.Id) return null;
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
parentId: collection.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
[api, user?.Id, collection.Id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!collection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
|
{collection.Name}
|
||||||
|
</Text>
|
||||||
|
<InfiniteHorizontalScroll
|
||||||
|
height={247}
|
||||||
|
renderItem={(item, index) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={index}
|
||||||
|
item={item}
|
||||||
|
className={`flex flex-col
|
||||||
|
${"w-32"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
queryFn={fetchItems}
|
||||||
|
queryKey={["media-list", collection.Id!]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,13 +12,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
|
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import ios from "@/utils/profiles/ios";
|
import ios from "@/utils/profiles/ios";
|
||||||
@@ -41,40 +35,14 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
const [, setPlaying] = useAtom(playingAtom);
|
||||||
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const openSelect = () => {
|
const openSelect = () => {
|
||||||
if (!castDevice?.deviceId) {
|
play("device");
|
||||||
play("device");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
|
||||||
const cancelButtonIndex = 2;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex: number | undefined) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case 0:
|
|
||||||
play("cast");
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
play("device");
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = async (type: "device" | "cast") => {
|
const play = async (type: "device" | "cast") => {
|
||||||
@@ -93,37 +61,16 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
item,
|
item,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
deviceProfile: ios,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url || !item) return;
|
if (!url || !item) return;
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
setCp({
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
item,
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
playbackUrl: url,
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
});
|
||||||
else {
|
setPlaying(true);
|
||||||
client.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: url,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCp({
|
|
||||||
item,
|
|
||||||
playbackUrl: url,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||||
|
|
||||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
|
import { type IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||||
import { type ComponentProps } from 'react';
|
import { type ComponentProps } from "react";
|
||||||
|
|
||||||
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
|
export function TabBarIcon({
|
||||||
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
style,
|
||||||
|
...rest
|
||||||
|
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||||
|
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||||
}
|
}
|
||||||
|
|||||||
82
components/posters/AlbumCover.tsx
Normal file
82
components/posters/AlbumCover.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
type ArtistPosterProps = {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
id?: string | null;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
const u = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
return u;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const url2 = useMemo(() => {
|
||||||
|
const u = getPrimaryImageUrlById({
|
||||||
|
api,
|
||||||
|
id,
|
||||||
|
quality: 85,
|
||||||
|
width: 300,
|
||||||
|
});
|
||||||
|
return u;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
if (!item && id)
|
||||||
|
return (
|
||||||
|
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||||
|
<Image
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
source={
|
||||||
|
url2
|
||||||
|
? {
|
||||||
|
uri: url2,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "1/1",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item)
|
||||||
|
return (
|
||||||
|
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||||
|
<Image
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "1/1",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlbumCover;
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
type ArtistPosterProps = {
|
type ArtistPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -24,7 +23,7 @@ const ArtistPoster: React.FC<ArtistPosterProps> = ({
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
type MoviePosterProps = {
|
type MoviePosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -24,35 +24,38 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [progress, setProgress] = useState(
|
const [progress, setProgress] = useState(
|
||||||
item.UserData?.PlayedPercentage || 0,
|
item.UserData?.PlayedPercentage || 0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!url)
|
const blurhash = useMemo(() => {
|
||||||
return (
|
const key = item.ImageTags?.["Primary"] as string;
|
||||||
<View
|
return item.ImageBlurHashes?.["Primary"]?.[key];
|
||||||
className="rounded-md overflow-hidden border border-neutral-900"
|
}, [item]);
|
||||||
style={{
|
|
||||||
aspectRatio: "10/15",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
id={item.Id}
|
||||||
source={{
|
source={
|
||||||
uri: url,
|
url
|
||||||
}}
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "10/15",
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<WatchedIndicator item={item} />
|
<WatchedIndicator item={item} />
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -9,10 +9,11 @@ type PosterProps = {
|
|||||||
item?: BaseItemDto | BaseItemPerson | null;
|
item?: BaseItemDto | BaseItemPerson | null;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
showProgress?: boolean;
|
showProgress?: boolean;
|
||||||
|
blurhash?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Poster: React.FC<PosterProps> = ({ item, url }) => {
|
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
||||||
if (!url || !item)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="border border-neutral-900"
|
className="border border-neutral-900"
|
||||||
@@ -25,11 +26,22 @@ const Poster: React.FC<PosterProps> = ({ item, url }) => {
|
|||||||
return (
|
return (
|
||||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
<View className="rounded-md overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
|
placeholder={
|
||||||
|
blurhash
|
||||||
|
? {
|
||||||
|
blurhash,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
id={item.Id}
|
id={item.Id}
|
||||||
source={{
|
source={
|
||||||
uri: url,
|
url
|
||||||
}}
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
cachePolicy={"memory-disk"}
|
cachePolicy={"memory-disk"}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
style={{
|
style={{
|
||||||
58
components/posters/SeriesPoster.tsx
Normal file
58
components/posters/SeriesPoster.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() =>
|
||||||
|
getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
}),
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.["Primary"] as string;
|
||||||
|
return item.ImageBlurHashes?.["Primary"]?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SeriesPoster;
|
||||||
@@ -6,7 +6,7 @@ import React from "react";
|
|||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../Poster";
|
import Poster from "../posters/Poster";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { router } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Poster from "../Poster";
|
import Poster from "../posters/Poster";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../Poster";
|
import Poster from "../posters/Poster";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useRouter } from "expo-router";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
@@ -40,7 +39,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data.Items;
|
return response.data.Items;
|
||||||
@@ -51,7 +50,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
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,
|
||||||
[seasons, seasonIndex],
|
[seasons, seasonIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: episodes } = useQuery({
|
const { data: episodes } = useQuery({
|
||||||
@@ -70,7 +69,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data.Items as BaseItemDto[];
|
return response.data.Items as BaseItemDto[];
|
||||||
@@ -80,36 +79,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mb-2">
|
<View className="mb-2">
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<View className="flex flex-row px-4">
|
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>Season {seasonIndex}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
|
||||||
{seasons?.map((season: any) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={season.Name}
|
|
||||||
onSelect={() => {
|
|
||||||
setSeasonIndex(season.IndexNumber);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
{episodes && (
|
{episodes && (
|
||||||
<View className="mt-4">
|
<View className="mt-4">
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
|
|||||||
@@ -1,11 +1,43 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { Loader } from "../Loader";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
|
|
||||||
export const SettingToggles: React.FC = () => {
|
export const SettingToggles: React.FC = () => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mediaListCollections,
|
||||||
|
isLoading: isLoadingMediaListCollections,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["mediaListCollections", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
tags: ["medialist", "promoted"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["Tags"],
|
||||||
|
includeItemTypes: ["BoxSet"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids =
|
||||||
|
response.data.Items?.filter((c) => c.Name !== "sf_carousel") ?? [];
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
@@ -36,25 +68,76 @@ export const SettingToggles: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-col">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<Text className="font-semibold">Use popular lists plugin</Text>
|
<View className="flex flex-col">
|
||||||
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||||
<TouchableOpacity
|
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||||
onPress={() => {
|
<TouchableOpacity
|
||||||
Linking.openURL(
|
onPress={() => {
|
||||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists",
|
Linking.openURL(
|
||||||
);
|
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
||||||
}}
|
);
|
||||||
>
|
}}
|
||||||
<Text className="text-xs text-purple-600">More info</Text>
|
>
|
||||||
</TouchableOpacity>
|
<Text className="text-xs text-purple-600">More info</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings?.usePopularPlugin}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ usePopularPlugin: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
{settings?.usePopularPlugin && (
|
||||||
value={settings?.usePopularPlugin}
|
<View className="flex flex-col py-2 bg-neutral-900">
|
||||||
onValueChange={(value) => updateSettings({ usePopularPlugin: value })}
|
{mediaListCollections?.map((mlc) => (
|
||||||
/>
|
<View
|
||||||
|
key={mlc.Id}
|
||||||
|
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
|
||||||
|
>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">{mlc.Name}</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings?.mediaListCollectionIds?.includes(mlc.Id!)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!settings.mediaListCollectionIds) {
|
||||||
|
updateSettings({
|
||||||
|
mediaListCollectionIds: [mlc.Id!],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings({
|
||||||
|
mediaListCollectionIds:
|
||||||
|
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
||||||
|
? settings?.mediaListCollectionIds.filter(
|
||||||
|
(id) => id !== mlc.Id
|
||||||
|
)
|
||||||
|
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{isLoadingMediaListCollections && (
|
||||||
|
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{mediaListCollections?.length === 0 && (
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
No collections found. Add some in Jellyfin.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Force direct play</Text>
|
<Text className="font-semibold">Force direct play</Text>
|
||||||
@@ -81,48 +164,6 @@ export const SettingToggles: React.FC = () => {
|
|||||||
supports.
|
supports.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>{settings?.deviceProfile}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Expo" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Native" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="3"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Old" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.6.0",
|
"channel": "0.6.1",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.6.0",
|
"channel": "0.6.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for downloading media using the Jellyfin API.
|
|
||||||
*
|
|
||||||
* @param api - The Jellyfin API instance
|
|
||||||
* @param userId - The user ID
|
|
||||||
* @returns An object with download-related functions and state
|
|
||||||
*/
|
|
||||||
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [_, setProgress] = useAtom(runningProcesses);
|
|
||||||
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadMedia = useCallback(
|
|
||||||
async (item: BaseItemDto | null): Promise<boolean> => {
|
|
||||||
if (!item?.Id || !api || !userId) {
|
|
||||||
setError("Invalid item or API");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDownloading(true);
|
|
||||||
setError(null);
|
|
||||||
setProgress({ item, progress: 0 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filename = item.Id;
|
|
||||||
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
|
||||||
const url = `${api.basePath}/Items/${item.Id}/File`;
|
|
||||||
|
|
||||||
downloadResumableRef.current = FileSystem.createDownloadResumable(
|
|
||||||
url,
|
|
||||||
fileUri,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
(downloadProgress) => {
|
|
||||||
const currentProgress =
|
|
||||||
downloadProgress.totalBytesWritten /
|
|
||||||
downloadProgress.totalBytesExpectedToWrite;
|
|
||||||
setProgress({ item, progress: currentProgress * 100 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await downloadResumableRef.current.downloadAsync();
|
|
||||||
|
|
||||||
if (!res?.uri) {
|
|
||||||
throw new Error("Download failed: No URI returned");
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateDownloadedFiles(item);
|
|
||||||
|
|
||||||
setIsDownloading(false);
|
|
||||||
setProgress(null);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error downloading media:", error);
|
|
||||||
setError("Failed to download media");
|
|
||||||
setIsDownloading(false);
|
|
||||||
setProgress(null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api, userId, setProgress],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelDownload = useCallback(async (): Promise<void> => {
|
|
||||||
if (!downloadResumableRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadResumableRef.current.pauseAsync();
|
|
||||||
setIsDownloading(false);
|
|
||||||
setError("Download cancelled");
|
|
||||||
setProgress(null);
|
|
||||||
downloadResumableRef.current = null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error cancelling download:", error);
|
|
||||||
setError("Failed to cancel download");
|
|
||||||
}
|
|
||||||
}, [setProgress]);
|
|
||||||
|
|
||||||
return { downloadMedia, isDownloading, error, cancelDownload };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the list of downloaded files in AsyncStorage.
|
|
||||||
*
|
|
||||||
* @param item - The item to add to the downloaded files list
|
|
||||||
*/
|
|
||||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) ?? "[]",
|
|
||||||
);
|
|
||||||
const updatedFiles = [
|
|
||||||
...currentFiles.filter((file) => file.Id !== item.Id),
|
|
||||||
item,
|
|
||||||
];
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating downloaded files:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for managing downloaded files.
|
|
||||||
* @returns An object with functions to delete individual files and all files.
|
|
||||||
*/
|
|
||||||
export const useFiles = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes all downloaded files and clears the download record.
|
|
||||||
*/
|
|
||||||
const deleteAllFiles = async (): Promise<void> => {
|
|
||||||
const directoryUri = FileSystem.documentDirectory;
|
|
||||||
if (!directoryUri) {
|
|
||||||
console.error("Document directory is undefined");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileNames = await FileSystem.readDirectoryAsync(directoryUri);
|
|
||||||
await Promise.all(
|
|
||||||
fileNames.map((item) =>
|
|
||||||
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
|
|
||||||
idempotent: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await AsyncStorage.removeItem("downloaded_files");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete all files:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a specific file and updates the download record.
|
|
||||||
* @param id - The ID of the file to delete.
|
|
||||||
*/
|
|
||||||
const deleteFile = async (id: string): Promise<void> => {
|
|
||||||
if (!id) {
|
|
||||||
console.error("Invalid file ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await FileSystem.deleteAsync(
|
|
||||||
`${FileSystem.documentDirectory}/${id}.mp4`,
|
|
||||||
{ idempotent: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentFiles = await getDownloadedFiles();
|
|
||||||
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
|
|
||||||
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to delete file with ID ${id}:`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { deleteFile, deleteAllFiles };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the list of downloaded files from AsyncStorage.
|
|
||||||
* @returns An array of BaseItemDto objects representing downloaded files.
|
|
||||||
*/
|
|
||||||
async function getDownloadedFiles(): Promise<BaseItemDto[]> {
|
|
||||||
try {
|
|
||||||
const filesJson = await AsyncStorage.getItem("downloaded_files");
|
|
||||||
return filesJson ? JSON.parse(filesJson) : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve downloaded files:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
|
||||||
*
|
|
||||||
* @param url - The URL of the HLS stream
|
|
||||||
* @param item - The BaseItemDto object representing the media item
|
|
||||||
* @returns An object with remuxing-related functions
|
|
||||||
*/
|
|
||||||
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
|
||||||
const [_, setProgress] = useAtom(runningProcesses);
|
|
||||||
|
|
||||||
if (!item.Id || !item.Name) {
|
|
||||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
|
||||||
throw new Error("Item must have an Id and Name");
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
|
||||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
|
||||||
|
|
||||||
const startRemuxing = useCallback(async () => {
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
|
||||||
|
|
||||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
|
||||||
const videoLength =
|
|
||||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
|
||||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
|
||||||
const totalFrames = videoLength * fps;
|
|
||||||
const processedFrames = statistics.getVideoFrameNumber();
|
|
||||||
const speed = statistics.getSpeed();
|
|
||||||
|
|
||||||
const percentage =
|
|
||||||
totalFrames > 0
|
|
||||||
? Math.floor((processedFrames / totalFrames) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
setProgress((prev) =>
|
|
||||||
prev?.item.Id === item.Id!
|
|
||||||
? { ...prev, progress: percentage, speed }
|
|
||||||
: prev,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
FFmpegKit.executeAsync(command, async (session) => {
|
|
||||||
try {
|
|
||||||
const returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
|
||||||
await updateDownloadedFiles(item);
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
} else if (returnCode.isValueError()) {
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
|
||||||
} else if (returnCode.isValueCancel()) {
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(null);
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to remux:", error);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
setProgress(null);
|
|
||||||
throw error; // Re-throw the error to propagate it to the caller
|
|
||||||
}
|
|
||||||
}, [output, item, command, setProgress]);
|
|
||||||
|
|
||||||
const cancelRemuxing = useCallback(() => {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
setProgress(null);
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
}, [item.Name, setProgress]);
|
|
||||||
|
|
||||||
return { startRemuxing, cancelRemuxing };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the list of downloaded files in AsyncStorage.
|
|
||||||
*
|
|
||||||
* @param item - The item to add to the downloaded files list
|
|
||||||
*/
|
|
||||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
|
||||||
);
|
|
||||||
const updatedFiles = [
|
|
||||||
...currentFiles.filter((i) => i.Id !== item.Id),
|
|
||||||
item,
|
|
||||||
];
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating downloaded files:", error);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`Failed to update downloaded files for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
package.json
33
package.json
@@ -15,52 +15,50 @@
|
|||||||
"preset": "jest-expo"
|
"preset": "jest-expo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
|
"@gorhom/bottom-sheet": "^4",
|
||||||
"@jellyfin/sdk": "^0.10.0",
|
"@jellyfin/sdk": "^0.10.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.2",
|
"@react-native-menu/menu": "^1.1.2",
|
||||||
|
"@react-native-tvos/config-tv": "^0.0.10",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
|
"@shopify/flash-list": "1.6.4",
|
||||||
"@tanstack/react-query": "^5.51.16",
|
"@tanstack/react-query": "^5.51.16",
|
||||||
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.7.3",
|
"axios": "^1.7.3",
|
||||||
"expo": "~51.0.27",
|
"expo": "~51.0.28",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-dev-client": "~4.0.22",
|
"expo-dev-client": "~4.0.23",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "~12.0.9",
|
"expo-font": "~12.0.9",
|
||||||
"expo-haptics": "~13.0.1",
|
|
||||||
"expo-image": "~1.12.13",
|
"expo-image": "~1.12.13",
|
||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
"expo-navigation-bar": "~3.0.7",
|
"expo-navigation-bar": "~3.0.7",
|
||||||
"expo-router": "~3.5.21",
|
"expo-router": "~3.5.23",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
|
||||||
"expo-sensors": "~13.0.9",
|
|
||||||
"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.22",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
|
||||||
"jotai": "^2.9.1",
|
"jotai": "^2.9.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "npm:react-native-tvos@latest",
|
||||||
"react-native-circular-progress": "^1.4.0",
|
"react-native-circular-progress": "^1.4.0",
|
||||||
"react-native-compressor": "^1.8.25",
|
"react-native-compressor": "^1.8.25",
|
||||||
"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-ios-utilities": "^4.5.0",
|
||||||
"react-native-ios-context-menu": "^2.5.1",
|
|
||||||
"react-native-ios-utilities": "^4.4.5",
|
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.10.1",
|
||||||
|
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "4.10.5",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.2.0",
|
||||||
@@ -69,8 +67,8 @@
|
|||||||
"react-native-video": "^6.4.3",
|
"react-native-video": "^6.4.3",
|
||||||
"react-native-web": "~0.19.10",
|
"react-native-web": "~0.19.10",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
|
"use-debounce": "^10.0.3",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zeego": "^1.10.0",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -83,5 +81,12 @@
|
|||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3"
|
||||||
},
|
},
|
||||||
|
"expo": {
|
||||||
|
"install": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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 { useMutation } from "@tanstack/react-query";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { isLoaded } from "expo-font";
|
|
||||||
import { router, useSegments } from "expo-router";
|
import { router, useSegments } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -31,15 +29,14 @@ interface JellyfinContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||||
undefined,
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOrSetDeviceId = async () => {
|
const getOrSetDeviceId = async () => {
|
||||||
let deviceId = await AsyncStorage.getItem("deviceId");
|
let deviceId = null;
|
||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
deviceId = uuid.v4() as string;
|
deviceId = uuid.v4() as string;
|
||||||
await AsyncStorage.setItem("deviceId", deviceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return deviceId;
|
return deviceId;
|
||||||
@@ -56,9 +53,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.6.0" },
|
clientInfo: { name: "Streamyfin", version: "0.6.1" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -67,8 +64,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
|
||||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||||
const servers =
|
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||||
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
url
|
||||||
|
);
|
||||||
return servers?.map((server) => ({ address: server.address })) || [];
|
return servers?.map((server) => ({ address: server.address })) || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,7 +77,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
||||||
|
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
await AsyncStorage.setItem("serverUrl", server.address);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Failed to set server:", error);
|
console.error("Failed to set server:", error);
|
||||||
@@ -88,7 +85,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const removeServerMutation = useMutation({
|
const removeServerMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await AsyncStorage.removeItem("serverUrl");
|
|
||||||
setApi(null);
|
setApi(null);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -110,9 +106,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
if (auth.data.AccessToken && auth.data.User) {
|
if (auth.data.AccessToken && auth.data.User) {
|
||||||
setUser(auth.data.User);
|
setUser(auth.data.User);
|
||||||
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
|
|
||||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||||
await AsyncStorage.setItem("token", auth.data?.AccessToken);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Invalid username or password");
|
throw new Error("Invalid username or password");
|
||||||
}
|
}
|
||||||
@@ -124,7 +118,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await AsyncStorage.removeItem("token");
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -132,36 +125,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isLoading, isFetching } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"initializeJellyfin",
|
|
||||||
user?.Id,
|
|
||||||
api?.basePath,
|
|
||||||
jellyfin?.clientInfo,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const token = await AsyncStorage.getItem("token");
|
|
||||||
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
|
||||||
const user = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("user")) as string,
|
|
||||||
) as UserDto;
|
|
||||||
|
|
||||||
if (serverUrl && token && user.Id && jellyfin) {
|
|
||||||
const apiInstance = jellyfin.createApi(serverUrl, token);
|
|
||||||
setApi(apiInstance);
|
|
||||||
setUser(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
enabled: !user?.Id || !api || !jellyfin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const contextValue: JellyfinContextValue = {
|
const contextValue: JellyfinContextValue = {
|
||||||
discoverServers,
|
discoverServers,
|
||||||
setServer: (server) => setServerMutation.mutateAsync(server),
|
setServer: (server) => setServerMutation.mutateAsync(server),
|
||||||
@@ -171,7 +134,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
};
|
};
|
||||||
|
|
||||||
useProtectedRoute(user, isLoading || isFetching);
|
useProtectedRoute(user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JellyfinContext.Provider value={contextValue}>
|
<JellyfinContext.Provider value={contextValue}>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import React, { createContext } from "react";
|
|
||||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
|
||||||
|
|
||||||
const JobQueueContext = createContext(null);
|
|
||||||
|
|
||||||
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
useJobProcessor();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
47
utils/atoms/filters.ts
Normal file
47
utils/atoms/filters.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
ItemFilter,
|
||||||
|
ItemSortBy,
|
||||||
|
NameGuidPair,
|
||||||
|
SortOrder,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
|
||||||
|
export const sortOptions: {
|
||||||
|
key: ItemSortBy;
|
||||||
|
value: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "SortName", value: "Name" },
|
||||||
|
{ key: "CommunityRating", value: "Community Rating" },
|
||||||
|
{ key: "CriticRating", value: "Critics Rating" },
|
||||||
|
{ key: "DateLastContentAdded", value: "Content Added" },
|
||||||
|
{ key: "DatePlayed", value: "Date Played" },
|
||||||
|
{ key: "PlayCount", value: "Play Count" },
|
||||||
|
{ key: "ProductionYear", value: "Production Year" },
|
||||||
|
{ key: "Runtime", value: "Runtime" },
|
||||||
|
{ key: "OfficialRating", value: "Official Rating" },
|
||||||
|
{ key: "PremiereDate", value: "Premiere Date" },
|
||||||
|
{ key: "StartDate", value: "Start Date" },
|
||||||
|
{ key: "IsUnplayed", value: "Is Unplayed" },
|
||||||
|
{ key: "IsPlayed", value: "Is Played" },
|
||||||
|
{ key: "VideoBitRate", value: "Video Bit Rate" },
|
||||||
|
{ key: "AirTime", value: "Air Time" },
|
||||||
|
{ key: "Studio", value: "Studio" },
|
||||||
|
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
|
||||||
|
{ key: "Random", value: "Random" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const sortOrderOptions: {
|
||||||
|
key: SortOrder;
|
||||||
|
value: string;
|
||||||
|
}[] = [
|
||||||
|
{ key: "Ascending", value: "Ascending" },
|
||||||
|
{ key: "Descending", value: "Descending" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const genreFilterAtom = atom<string[]>([]);
|
||||||
|
export const tagsFilterAtom = atom<string[]>([]);
|
||||||
|
export const yearFilterAtom = atom<string[]>([]);
|
||||||
|
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
|
||||||
|
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
|
||||||
|
sortOrderOptions[0],
|
||||||
|
]);
|
||||||
@@ -46,7 +46,6 @@ export const useJobProcessor = () => {
|
|||||||
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
|
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.info("Queue changed", queue, isProcessing);
|
|
||||||
if (queue.length > 0 && !isProcessing) {
|
if (queue.length > 0 && !isProcessing) {
|
||||||
console.info("Processing queue", queue);
|
console.info("Processing queue", queue);
|
||||||
queueActions.processJob(queue, setQueue, setProcessing);
|
queueActions.processJob(queue, setQueue, setProcessing);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
type Settings = {
|
type Settings = {
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
@@ -9,56 +7,30 @@ type Settings = {
|
|||||||
usePopularPlugin?: boolean;
|
usePopularPlugin?: boolean;
|
||||||
deviceProfile?: "Expo" | "Native" | "Old";
|
deviceProfile?: "Expo" | "Native" | "Old";
|
||||||
forceDirectPlay?: boolean;
|
forceDirectPlay?: boolean;
|
||||||
|
mediaListCollectionIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Default settings
|
||||||
*
|
const defaultSettings: Settings = {
|
||||||
* The settings atom is a Jotai atom that stores the user's settings.
|
autoRotate: true,
|
||||||
* It is initialized with a default value of null, which indicates that the settings have not been loaded yet.
|
forceLandscapeInVideoPlayer: false,
|
||||||
* The settings are loaded from AsyncStorage when the atom is read for the first time.
|
openFullScreenVideoPlayerByDefault: true,
|
||||||
*
|
usePopularPlugin: false,
|
||||||
*/
|
deviceProfile: "Expo",
|
||||||
|
forceDirectPlay: false,
|
||||||
// Utility function to load settings from AsyncStorage
|
mediaListCollectionIds: [],
|
||||||
const loadSettings = async (): Promise<Settings> => {
|
|
||||||
const jsonValue = await AsyncStorage.getItem("settings");
|
|
||||||
return jsonValue != null
|
|
||||||
? JSON.parse(jsonValue)
|
|
||||||
: {
|
|
||||||
autoRotate: true,
|
|
||||||
forceLandscapeInVideoPlayer: false,
|
|
||||||
openFullScreenVideoPlayerByDefault: false,
|
|
||||||
usePopularPlugin: false,
|
|
||||||
deviceProfile: "Expo",
|
|
||||||
forceDirectPlay: false,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utility function to save settings to AsyncStorage
|
// Create an atom to store the settings in memory, initialized with default settings
|
||||||
const saveSettings = async (settings: Settings) => {
|
const settingsAtom = atom<Settings>(defaultSettings);
|
||||||
const jsonValue = JSON.stringify(settings);
|
|
||||||
await AsyncStorage.setItem("settings", jsonValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create an atom to store the settings in memory
|
// A hook to manage settings, providing a way to update them
|
||||||
const settingsAtom = atom<Settings | null>(null);
|
|
||||||
|
|
||||||
// A hook to manage settings, loading them on initial mount and providing a way to update them
|
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
const [settings, setSettings] = useAtom(settingsAtom);
|
const [settings, setSettings] = useAtom(settingsAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
const updateSettings = (update: Partial<Settings>) => {
|
||||||
if (settings === null) {
|
const newSettings = { ...settings, ...update };
|
||||||
loadSettings().then(setSettings);
|
setSettings(newSettings);
|
||||||
}
|
|
||||||
}, [settings, setSettings]);
|
|
||||||
|
|
||||||
const updateSettings = async (update: Partial<Settings>) => {
|
|
||||||
if (settings) {
|
|
||||||
const newSettings = { ...settings, ...update };
|
|
||||||
setSettings(newSettings);
|
|
||||||
await saveSettings(newSettings);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return [settings, updateSettings] as const;
|
return [settings, updateSettings] as const;
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
export const getLogoImageUrlById = ({
|
export const getLogoImageUrlById = ({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
|
height = 130,
|
||||||
}: {
|
}: {
|
||||||
api?: Api | null;
|
api?: Api | null;
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
|
height?: number;
|
||||||
}) => {
|
}) => {
|
||||||
if (!api || !item) {
|
if (!api || !item) {
|
||||||
return null;
|
return null;
|
||||||
@@ -27,7 +29,7 @@ export const getLogoImageUrlById = ({
|
|||||||
|
|
||||||
params.append("tag", imageTags);
|
params.append("tag", imageTags);
|
||||||
params.append("quality", "90");
|
params.append("quality", "90");
|
||||||
params.append("fillHeight", "130");
|
params.append("fillHeight", height.toString());
|
||||||
|
|
||||||
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
|
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|||||||
18
utils/log.ts
18
utils/log.ts
@@ -1,4 +1,4 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import { atom } from "jotai";
|
||||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||||
|
|
||||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||||
@@ -10,8 +10,7 @@ interface LogEntry {
|
|||||||
data?: any;
|
data?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asyncStorage = createJSONStorage(() => AsyncStorage);
|
const logsAtom = atom([]);
|
||||||
const logsAtom = atomWithStorage("logs", [], asyncStorage);
|
|
||||||
|
|
||||||
export const writeToLog = async (
|
export const writeToLog = async (
|
||||||
level: LogLevel,
|
level: LogLevel,
|
||||||
@@ -25,23 +24,16 @@ export const writeToLog = async (
|
|||||||
data: data,
|
data: data,
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentLogs = await AsyncStorage.getItem("logs");
|
const logs: LogEntry[] = [];
|
||||||
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
|
|
||||||
logs.push(newEntry);
|
logs.push(newEntry);
|
||||||
|
|
||||||
const maxLogs = 100;
|
const maxLogs = 100;
|
||||||
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
|
|
||||||
|
|
||||||
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const readFromLog = async (): Promise<LogEntry[]> => {
|
export const readFromLog = async (): Promise<LogEntry[]> => {
|
||||||
const logs = await AsyncStorage.getItem("logs");
|
return [];
|
||||||
return logs ? JSON.parse(logs) : [];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearLogs = async () => {
|
export const clearLogs = async () => {};
|
||||||
await AsyncStorage.removeItem("logs");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default logsAtom;
|
export default logsAtom;
|
||||||
|
|||||||
Reference in New Issue
Block a user