forked from Ninjalama/streamyfin_mirror
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c1221138 | ||
|
|
6a8a155547 | ||
|
|
dbb7c6c9a5 | ||
|
|
30280e8b3a | ||
|
|
5281cba284 | ||
|
|
da666d3991 | ||
|
|
817a758b8a | ||
|
|
f04a29b757 | ||
|
|
550fc39faa | ||
|
|
d56bb79ac2 | ||
|
|
30781a6dfe | ||
|
|
ba6c2d5409 | ||
|
|
73b266adb4 | ||
|
|
e0ca83ae1f | ||
|
|
4a17a00f81 | ||
|
|
6bfc0c72d1 | ||
|
|
26050f7179 |
@@ -26,6 +26,10 @@ Streamyfin includes some exciting experimental features like media downloading a
|
||||
|
||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
## Roadmap for V1
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||
|
||||
## Get it now
|
||||
|
||||
<div style="display:flex;">
|
||||
@@ -108,7 +112,6 @@ If you have questions or need support, feel free to reach out:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
-
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
|
||||
@@ -29,6 +29,8 @@ export default function TabLayout() {
|
||||
borderTopRightRadius: 0,
|
||||
borderTopWidth: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
||||
height: Platform.OS === "android" ? 58 : 74,
|
||||
},
|
||||
tabBarBackground: () =>
|
||||
Platform.OS === "ios" ? (
|
||||
@@ -70,6 +72,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
BaseItemDto,
|
||||
ItemFields,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getChannelsApi,
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
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";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
|
||||
export default function index() {
|
||||
const router = useRouter();
|
||||
@@ -44,6 +32,24 @@ export default function index() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settings, _] = useSettings();
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
if (state.isConnected == false || state.isInternetReachable === false)
|
||||
setIsConnected(false);
|
||||
else setIsConnected(true);
|
||||
});
|
||||
|
||||
NetInfo.fetch().then((state) => {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["resumeItems", user?.Id],
|
||||
queryFn: async () =>
|
||||
@@ -77,35 +83,21 @@ export default function index() {
|
||||
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
|
||||
}, [_nextUpData]);
|
||||
|
||||
const { data: collections, isLoading: isLoadingCollections } = useQuery({
|
||||
queryKey: ["collections", user?.Id],
|
||||
const { data: collections } = useQuery({
|
||||
queryKey: ["collectinos", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (
|
||||
await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
})
|
||||
).data;
|
||||
|
||||
const order = ["boxsets", "tvshows", "movies"];
|
||||
|
||||
const cs = data.Items?.sort((a, b) => {
|
||||
if (
|
||||
order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!)
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return data.Items || [];
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const movieCollectionId = useMemo(() => {
|
||||
@@ -178,10 +170,14 @@ export default function index() {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: mediaListCollection } = useQuery<string | null>({
|
||||
queryKey: ["mediaListCollection", user?.Id],
|
||||
const { data: mediaListCollections } = useQuery({
|
||||
queryKey: [
|
||||
"mediaListCollections-home",
|
||||
user?.Id,
|
||||
settings?.mediaListCollectionIds,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
@@ -191,58 +187,32 @@ export default function index() {
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
return response.data.Items?.[0].Id || null;
|
||||
const ids =
|
||||
response.data.Items?.filter(
|
||||
(c) =>
|
||||
c.Name !== "cf_carousel" &&
|
||||
settings?.mediaListCollectionIds?.includes(c.Id!)
|
||||
) ?? [];
|
||||
|
||||
return ids;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
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,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
|
||||
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
|
||||
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
|
||||
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["mediaListCollections-home"],
|
||||
});
|
||||
setLoading(false);
|
||||
}, [queryClient, user?.Id]);
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
if (state.isConnected == false || state.isInternetReachable === false)
|
||||
setIsConnected(false);
|
||||
else setIsConnected(true);
|
||||
});
|
||||
|
||||
NetInfo.fetch().then((state) => {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
@@ -279,7 +249,7 @@ export default function index() {
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<ActivityIndicator />
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -292,6 +262,8 @@ export default function index() {
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
||||
<LargeMovieCarousel />
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Continue Watching"
|
||||
data={data}
|
||||
@@ -299,13 +271,6 @@ export default function index() {
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Popular"
|
||||
data={popularItems}
|
||||
loading={isLoadingPopular}
|
||||
disabled={!mediaListCollection}
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Next Up"
|
||||
data={nextUpData}
|
||||
@@ -313,6 +278,10 @@ export default function index() {
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
{mediaListCollections?.map((ml) => (
|
||||
<MediaListSection key={ml.Id} collection={ml} />
|
||||
))}
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Recently Added in Movies"
|
||||
data={recentlyAddedInMovies}
|
||||
@@ -325,13 +294,6 @@ export default function index() {
|
||||
loading={isLoadingRecentlyAddedTVShows}
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="Collections"
|
||||
data={collections}
|
||||
loading={isLoadingCollections}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title="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 { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
import Poster from "@/components/Poster";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, Stack, useNavigation } from "expo-router";
|
||||
import { router, useNavigation } from "expo-router";
|
||||
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 { useDebounce } from "use-debounce";
|
||||
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
"Avengers",
|
||||
"Game of Thrones",
|
||||
"Breaking Bad",
|
||||
"Stranger Things",
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
export default function search() {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
@@ -36,13 +49,13 @@ export default function search() {
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const { data: movies } = useQuery({
|
||||
queryKey: ["search-movies", search],
|
||||
const { data: movies, isLoading: l1 } = useQuery({
|
||||
queryKey: ["search-movies", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || search.length === 0) return [];
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: search,
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Movie"],
|
||||
});
|
||||
@@ -51,13 +64,13 @@ export default function search() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: series } = useQuery({
|
||||
queryKey: ["search-series", search],
|
||||
const { data: series, isLoading: l2 } = useQuery({
|
||||
queryKey: ["search-series", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || search.length === 0) return [];
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: search,
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
includeItemTypes: ["Series"],
|
||||
});
|
||||
@@ -65,13 +78,14 @@ export default function search() {
|
||||
return searchApi.data.SearchHints;
|
||||
},
|
||||
});
|
||||
const { data: episodes } = useQuery({
|
||||
queryKey: ["search-episodes", search],
|
||||
|
||||
const { data: episodes, isLoading: l3 } = useQuery({
|
||||
queryKey: ["search-episodes", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || search.length === 0) return [];
|
||||
if (!api || !user || debouncedSearch.length === 0) return [];
|
||||
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: search,
|
||||
searchTerm: debouncedSearch,
|
||||
limit: 10,
|
||||
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 (
|
||||
<>
|
||||
<ScrollView
|
||||
keyboardDismissMode="on-drag"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
<View className="flex flex-col pt-2 pb-20">
|
||||
<View className="flex flex-col pt-4 pb-32">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
@@ -99,8 +173,8 @@ export default function search() {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
@@ -112,7 +186,9 @@ export default function search() {
|
||||
onPress={() => router.push(`/items/${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">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
@@ -121,9 +197,9 @@ export default function search() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={data}
|
||||
@@ -133,12 +209,10 @@ export default function search() {
|
||||
onPress={() => router.push(`/series/${item.Id}`)}
|
||||
className="flex flex-col w-32"
|
||||
>
|
||||
<Poster
|
||||
item={item}
|
||||
key={item.Id}
|
||||
url={getPrimaryImageUrl({ api, item })}
|
||||
/>
|
||||
<Text className="mt-2">{item.Name}</Text>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
@@ -147,9 +221,9 @@ export default function search() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(data) => (
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
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>
|
||||
</ScrollView>
|
||||
</>
|
||||
@@ -175,9 +332,10 @@ export default function search() {
|
||||
type Props = {
|
||||
ids?: string[] | null;
|
||||
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 [user] = useAtom(userAtom);
|
||||
|
||||
@@ -193,21 +351,26 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: id,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(itemPromises);
|
||||
|
||||
// Filter out null items
|
||||
return results.filter(
|
||||
(item) => item !== null,
|
||||
(item) => item !== null
|
||||
) as unknown as BaseItemDto[];
|
||||
},
|
||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (!data) return <Text className="opacity-50 text-xs px-4">No results</Text>;
|
||||
if (!data) return null;
|
||||
|
||||
return renderItem(data);
|
||||
return (
|
||||
<>
|
||||
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
|
||||
{renderItem(data)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,33 +1,15 @@
|
||||
import ArtistPoster from "@/components/ArtistPoster";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loading } from "@/components/Loading";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
import { SongsList } from "@/components/music/SongsList";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
ItemSortBy,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getArtistsApi,
|
||||
getItemsApi,
|
||||
getUserApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -40,8 +22,6 @@ export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [startIndex, setStartIndex] = useState<number>(0);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,6 +99,21 @@ export default function page() {
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-bold text-3xl">{album?.Name}</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>
|
||||
<SongsList
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ArtistPoster from "@/components/ArtistPoster";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import ArtistPoster from "@/components/ArtistPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loading } from "@/components/Loading";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
ItemSortBy,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useLocalSearchParams();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ArtistPoster from "@/components/ArtistPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loading } from "@/components/Loading";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
@@ -13,13 +13,8 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -155,7 +150,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<View className="my-12">
|
||||
<ActivityIndicator color={"white"} />
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex flex-row flex-wrap">
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const [process, setProcess] = useAtom(runningProcesses);
|
||||
@@ -27,14 +22,14 @@ const downloads: React.FC = () => {
|
||||
queryKey: ["downloaded_files", process?.item.Id],
|
||||
queryFn: async () =>
|
||||
JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
) as BaseItemDto[],
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||
[downloadedFiles],
|
||||
[downloadedFiles]
|
||||
);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
@@ -61,7 +56,7 @@ const downloads: React.FC = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
@@ -19,29 +20,28 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios12 from "@/utils/profiles/ios12";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
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 local = useLocalSearchParams();
|
||||
@@ -173,7 +173,7 @@ const page: React.FC = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[playbackUrl, item, settings],
|
||||
[playbackUrl, item, settings]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
@@ -184,18 +184,18 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<ActivityIndicator />
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -244,7 +244,7 @@ const page: React.FC = () => {
|
||||
<Ratings item={item} />
|
||||
</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} />
|
||||
) : (
|
||||
@@ -283,29 +283,6 @@ const page: React.FC = () => {
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal className="flex px-4 mb-4">
|
||||
<View className="flex flex-row space-x-2 ">
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-sm opacity-70">Video</Text>
|
||||
<Text className="text-sm opacity-70">Audio</Text>
|
||||
<Text className="text-sm opacity-70">Subtitles</Text>
|
||||
</View>
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-sm opacity-70">
|
||||
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
|
||||
</Text>
|
||||
<Text className="text-sm opacity-70">
|
||||
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
|
||||
</Text>
|
||||
<Text className="text-sm opacity-70">
|
||||
{
|
||||
item.MediaStreams?.find((i) => i.Type === "Subtitle")
|
||||
?.DisplayTitle
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<CastAndCrew item={item} />
|
||||
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { 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 { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
@@ -84,12 +85,12 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
@@ -173,13 +174,13 @@ const page: React.FC = () => {
|
||||
setPlaying(true);
|
||||
}
|
||||
},
|
||||
[playbackUrl, item],
|
||||
[playbackUrl, item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<ActivityIndicator />
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -8,10 +8,6 @@ import { useEffect } from "react";
|
||||
export default function NotFoundScreen() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`Navigated to ${pathname}`);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
|
||||
212
app/_layout.tsx
212
app/_layout.tsx
@@ -15,6 +15,8 @@ import { useJobProcessor } from "@/utils/atoms/queue";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -61,7 +63,7 @@ function Layout() {
|
||||
retryOnMount: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,111 +71,115 @@ function Layout() {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
else
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JobQueueProvider>
|
||||
<ActionSheetProvider>
|
||||
<JellyfinProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/downloads"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/[artistId]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/albums/[albumId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/songs/[songId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</JellyfinProvider>
|
||||
</ActionSheetProvider>
|
||||
</JobQueueProvider>
|
||||
</QueryClientProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JobQueueProvider>
|
||||
<ActionSheetProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<JellyfinProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/downloads"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/[artistId]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/albums/[albumId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/songs/[songId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</JellyfinProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ActionSheetProvider>
|
||||
</JobQueueProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
204
app/login.tsx
204
app/login.tsx
@@ -6,7 +6,13 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { AxiosError } from "axios";
|
||||
import { useAtom } from "jotai";
|
||||
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";
|
||||
|
||||
@@ -46,102 +52,134 @@ const Login: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleConnect = (url: string) => {
|
||||
if (!url.startsWith("http")) {
|
||||
Alert.alert("Error", "URL needs to start with http or https.");
|
||||
return;
|
||||
}
|
||||
setServer({ address: url.trim() });
|
||||
};
|
||||
|
||||
if (api?.basePath) {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
|
||||
<View>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
|
||||
<View></View>
|
||||
<View>
|
||||
<View className="mb-4">
|
||||
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
|
||||
<Text className="text-neutral-500 mb-2">
|
||||
Server: {api.basePath}
|
||||
</Text>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
setServerURL("");
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="arrow-back-outline"
|
||||
size={18}
|
||||
color={"white"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Change server
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Log in to any user account
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="text-red-600 mb-2">{error}</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
setServerURL("");
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
|
||||
}
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
className="mt-auto mb-2"
|
||||
>
|
||||
Change server
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="text-red-600 mb-2">{error}</Text>
|
||||
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-center h-full">
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="opacity-50">Enter a server adress</Text>
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="http(s)://..."
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-between h-full">
|
||||
<View></View>
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Connect to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="opacity-30">
|
||||
Server URL requires http or https
|
||||
</Text>
|
||||
</View>
|
||||
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ["nativewind/babel"],
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPress?: () => void;
|
||||
@@ -57,7 +58,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={"white"} size={24} />
|
||||
<Loader />
|
||||
) : (
|
||||
<View
|
||||
className={`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||
@@ -14,13 +13,7 @@ import { BlurView } from "expo-blur";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -28,6 +21,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
export const currentlyPlayingItemAtom = atom<{
|
||||
item: BaseItemDto;
|
||||
@@ -45,7 +39,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [playing, setPlaying] = useAtom(playingAtom);
|
||||
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
|
||||
currentlyPlayingItemAtom,
|
||||
currentlyPlayingItemAtom
|
||||
);
|
||||
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
|
||||
|
||||
@@ -143,7 +137,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
sessionId: sessionData.PlaySessionId,
|
||||
});
|
||||
},
|
||||
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id],
|
||||
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,7 +167,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
}, [playing, progress, item, sessionData]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Full screen changed", fullScreen);
|
||||
if (fullScreen === true) {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
} else {
|
||||
@@ -186,7 +179,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0,
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
@@ -197,7 +190,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
quality: 70,
|
||||
width: 200,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
if (!currentlyPlaying || !api) return null;
|
||||
@@ -284,7 +277,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
console.log(e);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Video playback error: " + JSON.stringify(e),
|
||||
"Video playback error: " + JSON.stringify(e)
|
||||
);
|
||||
Alert.alert("Error", "Cannot play this video file.");
|
||||
setPlaying(false);
|
||||
@@ -293,7 +286,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
renderLoader={
|
||||
item?.Type !== "Audio" && (
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -303,7 +296,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
<View className="shrink text-xs">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
console.log(JSON.stringify(item));
|
||||
if (item?.Type === "Audio")
|
||||
router.push(`/albums/${item?.AlbumId}`);
|
||||
else router.push(`/items/${item?.Id}`);
|
||||
@@ -331,7 +323,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
{item?.Type === "Audio" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
console.log(JSON.stringify(item));
|
||||
router.push(`/albums/${item?.AlbumId}`);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,8 @@ 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 { TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
|
||||
type DownloadProps = {
|
||||
@@ -39,7 +40,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
if (!item.Id) return false;
|
||||
|
||||
const data: BaseItemDto[] = JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
);
|
||||
|
||||
return data.some((d) => d.Id === item.Id);
|
||||
@@ -50,7 +51,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
if (isLoading || isLoadingDownloaded) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +73,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
{process.progress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback } from "react";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
@@ -15,15 +15,15 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = useCallback(() => {
|
||||
const invalidateQueries = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
queryKey: ["item"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems", user?.Id],
|
||||
queryKey: ["resumeItems"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp", item.SeriesId],
|
||||
queryKey: ["nextUp"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
@@ -31,7 +31,10 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["seasons"],
|
||||
});
|
||||
}, [api, item.Id, queryClient, user?.Id]);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import ContinueWatchingPoster from "./ContinueWatchingPoster";
|
||||
import { ItemCardText } from "./ItemCardText";
|
||||
import { Text } from "./common/Text";
|
||||
import MoviePoster from "./MoviePoster";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { ItemCardText } from "./ItemCardText";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
type SimilarItemsProps = {
|
||||
itemId: string;
|
||||
@@ -42,7 +37,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
|
||||
|
||||
const movies = useMemo(
|
||||
() => similarItems?.filter((i) => i.Type === "Movie") || [],
|
||||
[similarItems],
|
||||
[similarItems]
|
||||
);
|
||||
|
||||
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>
|
||||
{isLoading ? (
|
||||
<View className="my-12">
|
||||
<ActivityIndicator />
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView horizontal>
|
||||
|
||||
@@ -37,11 +37,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
if (index !== undefined && index !== null) {
|
||||
onChange(index);
|
||||
} else {
|
||||
// Get first subtitle stream
|
||||
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
|
||||
if (firstSubtitle?.Index !== undefined) {
|
||||
onChange(firstSubtitle.Index);
|
||||
}
|
||||
onChange(-1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -56,7 +52,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
<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)}
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 13)
|
||||
: "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -72,6 +70,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"-1"}
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
|
||||
42
components/common/ColumnItem.tsx
Normal file
42
components/common/ColumnItem.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from "react";
|
||||
import { StyleSheet, View, ViewProps } from "react-native";
|
||||
|
||||
const getItemStyle = (index: number, numColumns: number) => {
|
||||
const alignItems = (() => {
|
||||
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
|
||||
if ((index + 1) % numColumns === 0) return "flex-end";
|
||||
|
||||
return "center";
|
||||
})();
|
||||
|
||||
return {
|
||||
padding: 20,
|
||||
alignItems,
|
||||
width: "100%",
|
||||
} as const;
|
||||
};
|
||||
|
||||
type ColumnItemProps = ViewProps & {
|
||||
children: React.ReactNode;
|
||||
index: number;
|
||||
numColumns: number;
|
||||
};
|
||||
|
||||
export const ColumnItem = ({
|
||||
children,
|
||||
index,
|
||||
numColumns,
|
||||
...rest
|
||||
}: ColumnItemProps) => {
|
||||
return (
|
||||
<View className="flex flex-col mb-2 p-4" style={{ width: "33.3%" }}>
|
||||
<View
|
||||
className={`
|
||||
`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,11 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
View,
|
||||
ViewStyle,
|
||||
ActivityIndicator,
|
||||
ScrollViewProps,
|
||||
} from "react-native";
|
||||
import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "./Text";
|
||||
|
||||
interface HorizontalScrollProps<T> extends ScrollViewProps {
|
||||
@@ -58,7 +53,7 @@ export function HorizontalScroll<T>({
|
||||
loadingContainerStyle,
|
||||
]}
|
||||
>
|
||||
<ActivityIndicator size="small" />
|
||||
<Loader />
|
||||
</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, { useEffect } from "react";
|
||||
import { TextInputProps, TextProps } from "react-native";
|
||||
import { TextInput } from "react-native";
|
||||
import React from "react";
|
||||
import { TextInput, TextInputProps } from "react-native";
|
||||
export function Input(props: TextInputProps) {
|
||||
const { style, ...otherProps } = props;
|
||||
const inputRef = React.useRef<TextInput>(null);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []),
|
||||
);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
|
||||
62
components/common/TouchableItemRouter.tsx
Normal file
62
components/common/TouchableItemRouter.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
if (item.Type === "Series") {
|
||||
router.push(`/series/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
router.push(`/items/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
if (item.Type === "MusicAlbum") {
|
||||
router.push(`/albums/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
if (item.Type === "Audio") {
|
||||
router.push(`/albums/${item.AlbumId}`);
|
||||
return;
|
||||
}
|
||||
if (item.Type === "MusicArtist") {
|
||||
router.push(`/artists/${item.Id}/page`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Movies and all other cases
|
||||
if (item.Type === "BoxSet") {
|
||||
router.push(`/collections/${item.Id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/items/${item.Id}`);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
93
components/filters/_SortButton.tsx
Normal file
93
components/filters/_SortButton.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import {
|
||||
sortByAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
} from "@/utils/atoms/filters";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const SortButton: React.FC<Props> = ({ title, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity>
|
||||
<View
|
||||
className={`
|
||||
px-3 py-2 rounded-full flex flex-row items-center space-x-2 bg-neutral-900
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<Text>Sort by</Text>
|
||||
<Ionicons
|
||||
name="filter"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
{sortOptions?.map((g) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={sortBy.key === g.key ? "on" : "off"}
|
||||
onValueChange={(next, previous) => {
|
||||
if (next === "on") {
|
||||
setSortBy(g);
|
||||
} else {
|
||||
setSortBy(sortOptions[0]);
|
||||
}
|
||||
}}
|
||||
key={g.key}
|
||||
textValue={g.value}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Group>
|
||||
{sortOrderOptions.map((g) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
value={sortOrder.key === g.key ? "on" : "off"}
|
||||
onValueChange={(next, previous) => {
|
||||
if (next === "on") {
|
||||
setSortOrder(g);
|
||||
} else {
|
||||
setSortOrder(sortOrderOptions[0]);
|
||||
}
|
||||
}}
|
||||
key={g.key}
|
||||
textValue={g.value}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
};
|
||||
176
components/home/LargeMovieCarousel.tsx
Normal file
176
components/home/LargeMovieCarousel.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo } from "react";
|
||||
import { Dimensions, View, ViewProps } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Carousel, {
|
||||
ICarouselInstance,
|
||||
Pagination,
|
||||
} from "react-native-reanimated-carousel";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const ref = React.useRef<ICarouselInstance>(null);
|
||||
const progress = useSharedValue<number>(0);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const onPressPagination = (index: number) => {
|
||||
ref.current?.scrollTo({
|
||||
/**
|
||||
* Calculate the difference between the current index and the target index
|
||||
* to ensure that the carousel scrolls to the nearest index
|
||||
*/
|
||||
count: index - progress.value,
|
||||
animated: true,
|
||||
});
|
||||
};
|
||||
|
||||
const { data: mediaListCollection, isLoading: l1 } = useQuery<string | null>({
|
||||
queryKey: ["mediaListCollection", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
tags: ["medialist", "promoted"],
|
||||
recursive: true,
|
||||
fields: ["Tags"],
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
|
||||
return id || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: popularItems, isLoading: l2 } = useQuery<BaseItemDto[]>({
|
||||
queryKey: ["popular", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !mediaListCollection) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
parentId: mediaListCollection,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!mediaListCollection,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const width = Dimensions.get("screen").width;
|
||||
|
||||
if (l1 || l2)
|
||||
return (
|
||||
<View className="h-[242px] flex items-center justify-center">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!popularItems) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col items-center" {...props}>
|
||||
<Carousel
|
||||
autoPlay={true}
|
||||
autoPlayInterval={2000}
|
||||
loop={true}
|
||||
ref={ref}
|
||||
width={width}
|
||||
height={204}
|
||||
data={popularItems}
|
||||
onProgressChange={progress}
|
||||
renderItem={({ item, index }) => <RenderItem item={item} />}
|
||||
/>
|
||||
<Pagination.Basic
|
||||
progress={progress}
|
||||
data={popularItems}
|
||||
dotStyle={{
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 50,
|
||||
}}
|
||||
activeDotStyle={{
|
||||
backgroundColor: "rgba(255,255,255,0.8)",
|
||||
borderRadius: 50,
|
||||
}}
|
||||
containerStyle={{ gap: 5, marginTop: 12 }}
|
||||
onPress={onPressPagination}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const uri = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
const logoUri = useMemo(() => {
|
||||
if (!api) return null;
|
||||
return getLogoImageUrlById({ api, item, height: 100 });
|
||||
}, [item]);
|
||||
|
||||
if (!uri || !logoUri) return null;
|
||||
|
||||
return (
|
||||
<TouchableItemRouter item={item}>
|
||||
<View className="px-4">
|
||||
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
||||
<Image
|
||||
source={{
|
||||
uri,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
<View className="absolute bottom-0 left-0 w-full h-24 p-4 flex items-center">
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUri,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
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 { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import MoviePoster from "../MoviePoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title: string;
|
||||
@@ -29,22 +29,17 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<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>
|
||||
data={data}
|
||||
height={orientation === "vertical" ? 247 : 164}
|
||||
loading={loading}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
<TouchableItemRouter
|
||||
key={index}
|
||||
onPress={() => {
|
||||
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}`);
|
||||
}}
|
||||
item={item}
|
||||
className={`flex flex-col
|
||||
${orientation === "vertical" ? "w-32" : "w-48"}
|
||||
`}
|
||||
@@ -57,7 +52,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
|
||||
import { type ComponentProps } from 'react';
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { type IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||
import { type ComponentProps } from "react";
|
||||
|
||||
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
|
||||
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
export function TabBarIcon({
|
||||
style,
|
||||
...rest
|
||||
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
}
|
||||
|
||||
82
components/posters/AlbumCover.tsx
Normal file
82
components/posters/AlbumCover.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
type ArtistPosterProps = {
|
||||
item?: BaseItemDto | null;
|
||||
id?: string | null;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(() => {
|
||||
const u = getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
});
|
||||
return u;
|
||||
}, [item]);
|
||||
|
||||
const url2 = useMemo(() => {
|
||||
const u = getPrimaryImageUrlById({
|
||||
api,
|
||||
id,
|
||||
quality: 85,
|
||||
width: 300,
|
||||
});
|
||||
return u;
|
||||
}, [item]);
|
||||
|
||||
if (!item && id)
|
||||
return (
|
||||
<View className="relative rounded-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 { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
type ArtistPosterProps = {
|
||||
item: BaseItemDto;
|
||||
@@ -24,7 +23,7 @@ const ArtistPoster: React.FC<ArtistPosterProps> = ({
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
if (!url)
|
||||
@@ -1,11 +1,11 @@
|
||||
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 "./WatchedIndicator";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
@@ -24,35 +24,38 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const [progress, setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0,
|
||||
item.UserData?.PlayedPercentage || 0
|
||||
);
|
||||
|
||||
if (!url)
|
||||
return (
|
||||
<View
|
||||
className="rounded-md overflow-hidden border border-neutral-900"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
></View>
|
||||
);
|
||||
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={{
|
||||
uri: url,
|
||||
}}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
@@ -9,10 +9,11 @@ type PosterProps = {
|
||||
item?: BaseItemDto | BaseItemPerson | null;
|
||||
url?: string | null;
|
||||
showProgress?: boolean;
|
||||
blurhash?: string | null;
|
||||
};
|
||||
|
||||
const Poster: React.FC<PosterProps> = ({ item, url }) => {
|
||||
if (!url || !item)
|
||||
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
|
||||
if (!item)
|
||||
return (
|
||||
<View
|
||||
className="border border-neutral-900"
|
||||
@@ -25,11 +26,22 @@ const Poster: React.FC<PosterProps> = ({ item, url }) => {
|
||||
return (
|
||||
<View className="rounded-md overflow-hidden border border-neutral-900">
|
||||
<Image
|
||||
placeholder={
|
||||
blurhash
|
||||
? {
|
||||
blurhash,
|
||||
}
|
||||
: null
|
||||
}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
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 { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import Poster from "../Poster";
|
||||
import Poster from "../posters/Poster";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Poster from "../Poster";
|
||||
import Poster from "../posters/Poster";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import Poster from "../Poster";
|
||||
import Poster from "../posters/Poster";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { router } from "expo-router";
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
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 * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
export const SettingToggles: React.FC = () => {
|
||||
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 (
|
||||
<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">
|
||||
@@ -36,25 +69,76 @@ export const SettingToggles: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL(
|
||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className="text-xs text-purple-600">More info</Text>
|
||||
</TouchableOpacity>
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">Use popular lists plugin</Text>
|
||||
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL(
|
||||
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className="text-xs text-purple-600">More info</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.usePopularPlugin}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ usePopularPlugin: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.usePopularPlugin}
|
||||
onValueChange={(value) => updateSettings({ usePopularPlugin: value })}
|
||||
/>
|
||||
{settings?.usePopularPlugin && (
|
||||
<View className="flex flex-col py-2 bg-neutral-900">
|
||||
{mediaListCollections?.map((mlc) => (
|
||||
<View
|
||||
key={mlc.Id}
|
||||
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">{mlc.Name}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.mediaListCollectionIds?.includes(mlc.Id!)}
|
||||
onValueChange={(value) => {
|
||||
if (!settings.mediaListCollectionIds) {
|
||||
updateSettings({
|
||||
mediaListCollectionIds: [mlc.Id!],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateSettings({
|
||||
mediaListCollectionIds:
|
||||
settings?.mediaListCollectionIds.includes(mlc.Id!)
|
||||
? settings?.mediaListCollectionIds.filter(
|
||||
(id) => id !== mlc.Id
|
||||
)
|
||||
: [...settings?.mediaListCollectionIds, mlc.Id!],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
{isLoadingMediaListCollections && (
|
||||
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
|
||||
<Loader />
|
||||
</View>
|
||||
)}
|
||||
{mediaListCollections?.length === 0 && (
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<Text className="text-xs opacity-50">
|
||||
No collections found. Add some in Jellyfin.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Force direct play</Text>
|
||||
|
||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.6.0",
|
||||
"channel": "0.6.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.6.0",
|
||||
"channel": "0.6.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
12
package.json
12
package.json
@@ -18,20 +18,23 @@
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@gorhom/bottom-sheet": "^4",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/netinfo": "11.3.1",
|
||||
"@react-native-menu/menu": "^1.1.2",
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/react-query": "^5.51.16",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.7.3",
|
||||
"expo": "~51.0.27",
|
||||
"expo": "~51.0.28",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.22",
|
||||
"expo-dev-client": "~4.0.23",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "~12.0.9",
|
||||
"expo-haptics": "~13.0.1",
|
||||
@@ -39,7 +42,7 @@
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-router": "~3.5.21",
|
||||
"expo-router": "~3.5.23",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
@@ -49,6 +52,7 @@
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"jotai": "^2.9.1",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -61,6 +65,7 @@
|
||||
"react-native-ios-context-menu": "^2.5.1",
|
||||
"react-native-ios-utilities": "^4.4.5",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
@@ -69,6 +74,7 @@
|
||||
"react-native-video": "^6.4.3",
|
||||
"react-native-web": "~0.19.10",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.3",
|
||||
"uuid": "^10.0.0",
|
||||
"zeego": "^1.10.0",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -56,7 +56,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.6.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.6.1" },
|
||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
console.info("Queue changed", queue, isProcessing);
|
||||
if (queue.length > 0 && !isProcessing) {
|
||||
console.info("Processing queue", queue);
|
||||
queueActions.processJob(queue, setQueue, setProcessing);
|
||||
|
||||
@@ -9,6 +9,7 @@ type Settings = {
|
||||
usePopularPlugin?: boolean;
|
||||
deviceProfile?: "Expo" | "Native" | "Old";
|
||||
forceDirectPlay?: boolean;
|
||||
mediaListCollectionIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,7 @@ const loadSettings = async (): Promise<Settings> => {
|
||||
usePopularPlugin: false,
|
||||
deviceProfile: "Expo",
|
||||
forceDirectPlay: false,
|
||||
mediaListCollectionIds: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
export const getLogoImageUrlById = ({
|
||||
api,
|
||||
item,
|
||||
height = 130,
|
||||
}: {
|
||||
api?: Api | null;
|
||||
item?: BaseItemDto | null;
|
||||
height?: number;
|
||||
}) => {
|
||||
if (!api || !item) {
|
||||
return null;
|
||||
@@ -27,7 +29,7 @@ export const getLogoImageUrlById = ({
|
||||
|
||||
params.append("tag", imageTags);
|
||||
params.append("quality", "90");
|
||||
params.append("fillHeight", "130");
|
||||
params.append("fillHeight", height.toString());
|
||||
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user