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" && (