mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
feat: library page
This commit is contained in:
@@ -70,6 +70,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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
305
app/(auth)/(tabs)/library/collections/[collectionId].tsx
Normal file
305
app/(auth)/(tabs)/library/collections/[collectionId].tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import ArtistPoster from "@/components/ArtistPoster";
|
||||
import { ColumnItem } from "@/components/common/ColumnItem";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loading } from "@/components/Loading";
|
||||
import MoviePoster from "@/components/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
ItemSortBy,
|
||||
NameGuidPair,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getFilterApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import {
|
||||
QueryFilters,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
Stack,
|
||||
router,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
yearFilterAtom,
|
||||
sortByAtom,
|
||||
tagsFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
const navigation = useNavigation();
|
||||
const { collectionId } = searchParams as { collectionId: string };
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
|
||||
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 sortBy: ItemSortBy[] = [];
|
||||
const includeItemTypes: BaseItemKind[] = [];
|
||||
|
||||
switch (collection?.CollectionType) {
|
||||
case "movies":
|
||||
sortBy.push("SortName", "ProductionYear");
|
||||
break;
|
||||
case "boxsets":
|
||||
sortBy.push("IsFolder", "SortName");
|
||||
break;
|
||||
default:
|
||||
sortBy.push("SortName");
|
||||
break;
|
||||
}
|
||||
|
||||
switch (collection?.CollectionType) {
|
||||
case "movies":
|
||||
includeItemTypes.push("Movie");
|
||||
break;
|
||||
case "boxsets":
|
||||
includeItemTypes.push("BoxSet");
|
||||
break;
|
||||
case "tvshows":
|
||||
includeItemTypes.push("Series");
|
||||
break;
|
||||
case "music":
|
||||
includeItemTypes.push("MusicAlbum");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 50,
|
||||
startIndex: pageParam,
|
||||
sortBy,
|
||||
sortOrder: ["Ascending"],
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
status,
|
||||
data,
|
||||
error,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasPreviousPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"library-items",
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
const totalItems = lastPage?.TotalRecordCount || 0;
|
||||
if ((lastPage?.Items?.length || 0) < totalItems) {
|
||||
return lastPage?.Items?.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]);
|
||||
|
||||
if (!collection || !collection.CollectionType) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlashList
|
||||
refreshing={isFetching}
|
||||
data={data?.pages.flatMap((page) => page?.Items) || []}
|
||||
horizontal={false}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: 10,
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
onEndReached={fetchNextPage}
|
||||
onEndReachedThreshold={0.5}
|
||||
renderItem={({ item, index }) =>
|
||||
item ? (
|
||||
<ColumnItem index={index} numColumns={3} style={{}}>
|
||||
<TouchableItemRouter
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: 4,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
</ColumnItem>
|
||||
) : null
|
||||
}
|
||||
numColumns={3}
|
||||
estimatedItemSize={200}
|
||||
ListHeaderComponent={
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row space-x-1">
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</View>
|
||||
{!type && isFetching && (
|
||||
<ActivityIndicator
|
||||
style={{
|
||||
marginTop: 300,
|
||||
}}
|
||||
size={"small"}
|
||||
color={"white"}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
119
app/(auth)/(tabs)/library/index.tsx
Normal file
119
app/(auth)/(tabs)/library/index.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { Image } from "expo-image";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function index() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settings, _] = useSettings();
|
||||
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
queryKey: ["collections", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (
|
||||
await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
sortBy: ["SortName", "DateCreated"],
|
||||
})
|
||||
).data;
|
||||
|
||||
return data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<ActivityIndicator />
|
||||
</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 items-center justify-center rounded-xl w-full aspect-video relative border border-neutral-900">
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: 8,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
<Text className="font-bold text-2xl">{collection.Name}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user