diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx index 9c876c05..5beea9a0 100644 --- a/components/home/Favorites.tsx +++ b/components/home/Favorites.tsx @@ -7,7 +7,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { Image } from "expo-image"; import { t } from "i18next"; import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Text, View } from "react-native"; // PNG ASSET import heart from "@/assets/icons/heart.fill.png"; @@ -23,7 +23,9 @@ type FavoriteTypes = | "Video" | "BoxSet" | "Playlist"; -type EmptyState = Record; +// `null` = not settled yet (loading/unknown); avoids flashing the empty +// message during a favorites/watchlist switch before the new queries resolve. +type EmptyState = Record; interface FavoritesProps { /** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */ @@ -48,12 +50,12 @@ export const Favorites = ({ const [user] = useAtom(userAtom); const pageSize = 20; const [emptyState, setEmptyState] = useState({ - Series: false, - Movie: false, - Episode: false, - Video: false, - BoxSet: false, - Playlist: false, + Series: null, + Movie: null, + Episode: null, + Video: null, + BoxSet: null, + Playlist: null, }); const fetchFavoritesByType = useCallback( @@ -76,42 +78,28 @@ export const Favorites = ({ limit: limit, includeItemTypes: [itemType], }); - const items = response.data.Items || []; - - // Update empty state for this specific type only for the first page - if (startIndex === 0) { - setEmptyState((prev) => ({ - ...prev, - [itemType as FavoriteTypes]: items.length === 0, - })); - } - - return items; + return response.data.Items || []; }, [api, user, filter], ); - // Reset empty state when the account or active view changes. `filter` - // matters because switching the favorites/watchlist toggle swaps this - // component's props in place (no remount), so stale per-type emptiness - // from the previous view must be cleared until the new queries resolve. - useEffect(() => { - setEmptyState({ - Series: false, - Movie: false, - Episode: false, - Video: false, - BoxSet: false, - Playlist: false, - }); - }, [api, user, filter]); + // Emptiness is reported by each list once its query settles (incl. cache + // hits), so it stays correct where a queryFn side effect would go stale. + const setTypeEmpty = useCallback( + (type: FavoriteTypes, isEmpty: boolean | null) => + setEmptyState((prev) => + prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty }, + ), + [], + ); - // Check if all categories that have been loaded are empty + // Show the empty message only once every category has settled AND is empty. + // A `null` (still loading) keeps it hidden, so switching favorites/watchlist + // (props swap in place, no remount) never flashes a stale empty state. const areAllEmpty = () => { - const loadedCategories = Object.values(emptyState); + const categories = Object.values(emptyState); return ( - loadedCategories.length > 0 && - loadedCategories.every((isEmpty) => isEmpty) + categories.length > 0 && categories.every((isEmpty) => isEmpty === true) ); }; @@ -191,6 +179,7 @@ export const Favorites = ({ title={t("favorites.series")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)} onPressSeeAll={() => seeAll("Series", "Series")} /> setTypeEmpty("Movie", isEmpty)} onPressSeeAll={() => seeAll("Movie", "Movies")} /> setTypeEmpty("Episode", isEmpty)} onPressSeeAll={() => seeAll("Episode", "Episodes")} /> setTypeEmpty("Video", isEmpty)} onPressSeeAll={() => seeAll("Video", "Videos")} /> setTypeEmpty("BoxSet", isEmpty)} onPressSeeAll={() => seeAll("BoxSet", "Boxsets")} /> setTypeEmpty("Playlist", isEmpty)} onPressSeeAll={() => seeAll("Playlist", "Playlists")} /> diff --git a/components/home/Favorites.tv.tsx b/components/home/Favorites.tv.tsx index 6e25e7fa..89356058 100644 --- a/components/home/Favorites.tv.tsx +++ b/components/home/Favorites.tv.tsx @@ -6,7 +6,7 @@ import type { import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { Image } from "expo-image"; import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -30,7 +30,9 @@ type FavoriteTypes = | "Video" | "BoxSet" | "Playlist"; -type EmptyState = Record; +// `null` = not settled yet (loading/unknown); avoids flashing the empty +// message during a favorites/watchlist switch before the new queries resolve. +type EmptyState = Record; export const Favorites = () => { const typography = useScaledTVTypography(); @@ -60,12 +62,12 @@ export const Favorites = () => { const emptyTextKey = `${emptyNamespace}.noData`; const [emptyState, setEmptyState] = useState({ - Series: false, - Movie: false, - Episode: false, - Video: false, - BoxSet: false, - Playlist: false, + Series: null, + Movie: null, + Episode: null, + Video: null, + BoxSet: null, + Playlist: null, }); const fetchFavoritesByType = useCallback( @@ -88,36 +90,28 @@ export const Favorites = () => { limit: limit, includeItemTypes: [itemType], }); - const items = response.data.Items || []; - - if (startIndex === 0) { - setEmptyState((prev) => ({ - ...prev, - [itemType as FavoriteTypes]: items.length === 0, - })); - } - - return items; + return response.data.Items || []; }, [api, user, filter], ); - useEffect(() => { - setEmptyState({ - Series: false, - Movie: false, - Episode: false, - Video: false, - BoxSet: false, - Playlist: false, - }); - }, [api, user, viewType]); + // Emptiness is reported by each list once its query settles (incl. cache + // hits), so it stays correct where a queryFn side effect would go stale. + const setTypeEmpty = useCallback( + (type: FavoriteTypes, isEmpty: boolean | null) => + setEmptyState((prev) => + prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty }, + ), + [], + ); + // Show the empty message only once every category has settled AND is empty. + // A `null` (still loading) keeps it hidden, so switching favorites/watchlist + // never flashes a stale empty state. const areAllEmpty = () => { - const loadedCategories = Object.values(emptyState); + const categories = Object.values(emptyState); return ( - loadedCategories.length > 0 && - loadedCategories.every((isEmpty) => isEmpty) + categories.length > 0 && categories.every((isEmpty) => isEmpty === true) ); }; @@ -235,6 +229,7 @@ export const Favorites = () => { hideIfEmpty pageSize={pageSize} isFirstSection={!watchlistEnabled} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)} /> { hideIfEmpty orientation='vertical' pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)} /> { title={t("favorites.episodes")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)} /> { title={t("favorites.videos")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)} /> { title={t("favorites.boxsets")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)} /> { title={t("favorites.playlists")} hideIfEmpty pageSize={pageSize} + onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)} /> diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx index de4c6462..e31b69cc 100644 --- a/components/home/InfiniteScrollingCollectionList.tsx +++ b/components/home/InfiniteScrollingCollectionList.tsx @@ -32,6 +32,13 @@ interface Props extends ViewProps { onPressSeeAll?: () => void; enabled?: boolean; onLoaded?: () => void; + /** + * Reports emptiness whenever the query settles (incl. cache hits): + * `null` while loading (unknown), otherwise whether the list is empty. + * Lets a parent derive an aggregate empty-state reactively instead of via a + * queryFn side effect, which React Query skips when it serves cache. + */ + onEmptyStateChange?: (isEmpty: boolean | null) => void; } export const InfiniteScrollingCollectionList: React.FC = ({ @@ -45,6 +52,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ onPressSeeAll, enabled = true, onLoaded, + onEmptyStateChange, ...props }) => { const effectivePageSize = Math.max(1, pageSize); @@ -103,6 +111,14 @@ export const InfiniteScrollingCollectionList: React.FC = ({ return deduped; }, [data]); + // Report emptiness on every settle (incl. cache hits). Callback held in a ref + // so an inline parent callback doesn't retrigger the effect each render. + const onEmptyStateChangeRef = useRef(onEmptyStateChange); + onEmptyStateChangeRef.current = onEmptyStateChange; + useEffect(() => { + onEmptyStateChangeRef.current?.(isLoading ? null : allItems.length === 0); + }, [isLoading, allItems.length]); + const snapOffsets = useMemo(() => { const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px) return allItems.map((_, index) => index * itemWidth); diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 1db314f2..1e27fae8 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -6,7 +6,7 @@ import { useInfiniteQuery, } from "@tanstack/react-query"; import { useSegments } from "expo-router"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -42,6 +42,13 @@ interface Props extends ViewProps { isFirstSection?: boolean; onItemFocus?: (item: BaseItemDto) => void; parentId?: string; + /** + * Reports emptiness whenever the query settles (incl. cache hits): + * `null` while loading (unknown), otherwise whether the list is empty. + * Lets a parent derive an aggregate empty-state reactively instead of via a + * queryFn side effect, which React Query skips when it serves cache. + */ + onEmptyStateChange?: (isEmpty: boolean | null) => void; } type Typography = ReturnType; @@ -123,6 +130,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ isFirstSection = false, onItemFocus, parentId, + onEmptyStateChange, ...props }) => { const typography = useScaledTVTypography(); @@ -182,6 +190,14 @@ export const InfiniteScrollingCollectionList: React.FC = ({ return deduped; }, [data]); + // Report emptiness on every settle (incl. cache hits). Callback held in a ref + // so an inline parent callback doesn't retrigger the effect each render. + const onEmptyStateChangeRef = useRef(onEmptyStateChange); + onEmptyStateChangeRef.current = onEmptyStateChange; + useEffect(() => { + onEmptyStateChangeRef.current?.(isLoading ? null : allItems.length === 0); + }, [isLoading, allItems.length]); + const itemWidth = orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;