diff --git a/app/(auth)/(tabs)/(favorites)/_layout.tsx b/app/(auth)/(tabs)/(favorites)/_layout.tsx new file mode 100644 index 00000000..f96cd516 --- /dev/null +++ b/app/(auth)/(tabs)/(favorites)/_layout.tsx @@ -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 ( + + + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( + + ))} + + ); +} diff --git a/app/(auth)/(tabs)/(favorites)/index.tsx b/app/(auth)/(tabs)/(favorites)/index.tsx new file mode 100644 index 00000000..e01f975e --- /dev/null +++ b/app/(auth)/(tabs)/(favorites)/index.tsx @@ -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 ( + + } + contentContainerStyle={{ + paddingLeft: insets.left, + paddingRight: insets.right, + paddingBottom: 16, + }} + > + + + ); +} diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index d3ef928a..a7cc3cb3 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,6 +1,6 @@ import { Chromecast } from "@/components/Chromecast"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; -import { Feather } from "@expo/vector-icons"; +import { Feather, Ionicons } from "@expo/vector-icons"; import { Stack, useRouter } from "expo-router"; import { Platform, TouchableOpacity, View } from "react-native"; diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 2a9cd1ab..0f777a45 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -107,9 +107,9 @@ export default function index() { setIsConnected(state.isConnected); }); - cleanCacheDirectory() - .then(r => console.log("Cache directory cleaned")) - .catch(e => console.error("Something went wrong cleaning cache directory")) + cleanCacheDirectory().catch((e) => + console.error("Something went wrong cleaning cache directory") + ); return () => { unsubscribe(); }; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/actors/[actorId].tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/albums/[albumId].tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/[artistId].tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/artists/index.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/artists/index.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/artists/index.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/collections/[collectionId].tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/_layout.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/channels.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/guide.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/programs.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/livetv/recordings.tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/livetv/recordings.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx similarity index 100% rename from app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx rename to app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index ac3d83fe..48507dbe 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -318,7 +318,7 @@ export default function search() { text="Library" textClass="p-1" className={ - searchType === "Library" ? "bg-neutral-600" : undefined + searchType === "Library" ? "bg-purple-600" : undefined } /> @@ -327,7 +327,7 @@ export default function search() { text="Discover" textClass="p-1" className={ - searchType === "Discover" ? "bg-neutral-600" : undefined + searchType === "Discover" ? "bg-purple-600" : undefined } /> diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index f256ab50..47e5bfaa 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -48,7 +48,10 @@ export default function TabLayout() { Platform.OS == "android" ? ({ color, focused, size }) => require("@/assets/icons/house.fill.png") - : () => ({ sfSymbol: "house" }), + : ({ focused }) => + focused + ? { sfSymbol: "house.fill" } + : { sfSymbol: "house" }, }} /> require("@/assets/icons/magnifyingglass.png") - : () => ({ sfSymbol: "magnifyingglass" }), + : ({ focused }) => + focused + ? { sfSymbol: "magnifyingglass" } + : { sfSymbol: "magnifyingglass" }, + }} + /> + + focused + ? require("@/assets/icons/heart.fill.png") + : require("@/assets/icons/heart.png") + : ({ focused }) => + focused + ? { sfSymbol: "heart.fill" } + : { sfSymbol: "heart" }, }} /> require("@/assets/icons/server.rack.png") - : () => ({ sfSymbol: "rectangle.stack" }), + : ({ focused }) => + focused + ? { sfSymbol: "rectangle.stack.fill" } + : { sfSymbol: "rectangle.stack" }, }} /> require("@/assets/icons/list.png") - : () => ({ sfSymbol: "list.dash" }), + ? ({ focused }) => require("@/assets/icons/list.png") + : ({ focused }) => + focused + ? { sfSymbol: "list.dash.fill" } + : { sfSymbol: "list.dash" }, }} /> diff --git a/app/_layout.tsx b/app/_layout.tsx index 8c9da2f7..bf779be5 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,4 +1,5 @@ import "@/augmentations"; +import { Text } from "@/components/common/Text"; import { DownloadProvider } from "@/providers/DownloadProvider"; import { getOrSetDeviceId, @@ -36,7 +37,7 @@ import * as SplashScreen from "expo-splash-screen"; import * as TaskManager from "expo-task-manager"; import { Provider as JotaiProvider, useAtom } from "jotai"; import { useEffect, useRef } from "react"; -import { Appearance, AppState } from "react-native"; +import { Appearance, AppState, TouchableOpacity } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; diff --git a/assets/icons/heart.fill.png b/assets/icons/heart.fill.png new file mode 100644 index 00000000..25bb2527 Binary files /dev/null and b/assets/icons/heart.fill.png differ diff --git a/assets/icons/heart.png b/assets/icons/heart.png new file mode 100644 index 00000000..96a448a7 Binary files /dev/null and b/assets/icons/heart.png differ diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index d50c88bf..b8af14b6 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -66,7 +66,12 @@ export const TouchableItemRouter: React.FC> = ({ const markAsPlayedStatus = useMarkAsPlayed(item); - if (from === "(home)" || from === "(search)" || from === "(libraries)") + if ( + from === "(home)" || + from === "(search)" || + from === "(libraries)" || + from === "(favorites)" + ) return ( diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx new file mode 100644 index 00000000..90e55b1a --- /dev/null +++ b/components/home/Favorites.tsx @@ -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 ( + + + + + + + + + + + ); +}; diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 04dd6004..17b4ec77 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -18,6 +18,7 @@ interface Props extends ViewProps { disabled?: boolean; queryKey: QueryKey; queryFn: QueryFunction; + hideIfEmpty?: boolean; } export const ScrollingCollectionList: React.FC = ({ @@ -26,10 +27,9 @@ export const ScrollingCollectionList: React.FC = ({ disabled = false, queryFn, queryKey, + hideIfEmpty = false, ...props }) => { - // console.log(queryKey); - const { data, isLoading } = useQuery({ queryKey: queryKey, queryFn, @@ -41,8 +41,10 @@ export const ScrollingCollectionList: React.FC = ({ if (disabled || !title) return null; + if (hideIfEmpty === true && data?.length === 0) return null; + return ( - + {title} @@ -82,15 +84,13 @@ export const ScrollingCollectionList: React.FC = ({ ) : ( - {data?.map((item, index) => ( + {data?.map((item) => ( {item.Type === "Episode" && orientation === "horizontal" && (