mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 11:08:26 +01:00
feat: favorites tab
This commit is contained in:
24
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
24
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function SearchLayout() {
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: "Favorites",
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
34
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
export default function favorites() {
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Favorites />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -318,7 +318,7 @@ export default function search() {
|
|||||||
text="Library"
|
text="Library"
|
||||||
textClass="p-1"
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Library" ? "bg-neutral-600" : undefined
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -327,7 +327,7 @@ export default function search() {
|
|||||||
text="Discover"
|
text="Discover"
|
||||||
textClass="p-1"
|
textClass="p-1"
|
||||||
className={
|
className={
|
||||||
searchType === "Discover" ? "bg-neutral-600" : undefined
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ export default function TabLayout() {
|
|||||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
: () => ({ sfSymbol: "magnifyingglass" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name="(favorites)"
|
||||||
|
options={{
|
||||||
|
title: "Favorites",
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS == "android"
|
||||||
|
? ({ color, focused, size }) =>
|
||||||
|
require("@/assets/icons/heart.png")
|
||||||
|
: () => ({ sfSymbol: "heart" }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(libraries)"
|
name="(libraries)"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
BIN
assets/icons/heart.png
Normal file
BIN
assets/icons/heart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -66,7 +66,12 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (
|
||||||
|
from === "(home)" ||
|
||||||
|
from === "(search)" ||
|
||||||
|
from === "(libraries)" ||
|
||||||
|
from === "(favorites)"
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Trigger>
|
<ContextMenu.Trigger>
|
||||||
|
|||||||
119
components/home/Favorites.tsx
Normal file
119
components/home/Favorites.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ScrollingCollectionList } from "./ScrollingCollectionList";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
export const Favorites = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const fetchFavoritesByType = useCallback(
|
||||||
|
async (itemType: BaseItemKind) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id!,
|
||||||
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
filters: ["IsFavorite"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeLocationTypes: ["Virtual"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
limit: 20,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
[api, user]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchFavoriteSeries = useCallback(
|
||||||
|
() => fetchFavoritesByType("Series"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteMovies = useCallback(
|
||||||
|
() => fetchFavoritesByType("Movie"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteEpisodes = useCallback(
|
||||||
|
() => fetchFavoritesByType("Episode"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteVideos = useCallback(
|
||||||
|
() => fetchFavoritesByType("Video"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteBoxsets = useCallback(
|
||||||
|
() => fetchFavoritesByType("BoxSet"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoritePlaylists = useCallback(
|
||||||
|
() => fetchFavoritesByType("Playlist"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteMusicAlbum = useCallback(
|
||||||
|
() => fetchFavoritesByType("MusicAlbum"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
const fetchFavoriteAudio = useCallback(
|
||||||
|
() => fetchFavoritesByType("Audio"),
|
||||||
|
[fetchFavoritesByType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col space-y-4">
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteSeries}
|
||||||
|
queryKey={["home", "favorites", "series"]}
|
||||||
|
title="Series"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteMovies}
|
||||||
|
queryKey={["home", "favorites", "movies"]}
|
||||||
|
title="Movies"
|
||||||
|
hideIfEmpty
|
||||||
|
orientation="vertical"
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteEpisodes}
|
||||||
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
|
title="Episodes"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteVideos}
|
||||||
|
queryKey={["home", "favorites", "videos"]}
|
||||||
|
title="Videos"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteBoxsets}
|
||||||
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
|
title="Boxsets"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoritePlaylists}
|
||||||
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
|
title="Playlists"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteMusicAlbum}
|
||||||
|
queryKey={["home", "favorites", "musicAlbums"]}
|
||||||
|
title="Music Albums"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
<ScrollingCollectionList
|
||||||
|
queryFn={fetchFavoriteAudio}
|
||||||
|
queryKey={["home", "favorites", "audio"]}
|
||||||
|
title="Audio"
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
|||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import SeriesPoster from "../posters/SeriesPoster";
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -18,6 +19,7 @@ interface Props extends ViewProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
hideIfEmpty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -26,10 +28,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// console.log(queryKey);
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
@@ -41,6 +42,8 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
|
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="">
|
<View {...props} className="">
|
||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
||||||
@@ -86,11 +89,9 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={index}
|
key={index}
|
||||||
className={`
|
className={`mr-2
|
||||||
mr-2
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
|
`}
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
<ContinueWatchingPoster item={item} />
|
<ContinueWatchingPoster item={item} />
|
||||||
|
|||||||
Reference in New Issue
Block a user