Compare commits

..

22 Commits

Author SHA1 Message Date
Gauvain
21635d6eab feat(tv): add KefinTweaks watchlist toggle to TV settings
The useKefinTweaks setting is stored per-device and only had a toggle in
the mobile settings, so the watchlist feature could never be enabled from
a TV-only install. Add a Plugins section to the TV settings with the same
toggle, disabled when the server plugin locks the setting.
2026-07-05 02:34:52 +02:00
Gauvain
f101deb8fa chore(i18n): drop manual sv.json entries, Crowdin syncs locales 2026-07-04 18:24:01 +02:00
Gauvain
28749fe042 fix(tv): keep favorites lists mounted when all sections are empty
The all-empty screen early-returned before mounting any collection list,
so after a favorites/watchlist switch the stale all-empty state kept the
lists unmounted and onEmptyStateChange could never fire again, leaving
the screen stuck on the no-data view. Render the empty view inline with
the lists instead, matching the mobile Favorites structure.
2026-07-04 18:23:07 +02:00
Gauvain
28bf70c0ac fix(favorites): report unknown empty state on query errors
A first fetch that fails with no cached data settles with isLoading false
and zero items, which reported the section as empty and let the screen
show the no-data view on transport/auth failures. Report null (unknown)
instead so the aggregate empty state stays hidden.
2026-07-04 18:22:16 +02:00
Gauvain
75f30842f5 fix(watchlist): remove stray unary plus in setIsWatchlisted 2026-07-04 18:21:30 +02:00
Gauvain
03b2aec931 fix(tv): type lastSegment as string for typed-routes index check 2026-07-04 18:21:10 +02:00
Simon Eklundh
a39637f187 blocks repeated watchlist actions so one happens at a time, and gates watchlist behind user id + item 2026-07-04 12:12:32 +02:00
Gauvain
0cac1f8779 fix(favorites): sync empty-state on cache hits to avoid blank view
Switching Favorites<->Watchlist (props swap in place, no remount) left the
page blank with only the tab buttons: the empty-state was written as a side
effect inside the queryFn, which React Query skips on cache hits, and a reset
effect then cleared it — so on a cache-served switch nothing repopulated it,
every list was hidden by hideIfEmpty, and the empty message never rendered.

Each list now reports emptiness once its query settles (incl. cache hits) via
a new optional onEmptyStateChange callback on InfiniteScrollingCollectionList
(mobile + TV), reporting null while loading so a switch never flashes a stale
state. The parent derives the aggregate empty-state from that (null = not
settled yet, so the message stays hidden during a switch).

The reset effect is removed entirely, not kept: React runs child effects
before parent effects within a commit, so a parent reset setEmptyState(false)
would clobber the children's reported values on the same render. Dropping it
and using the per-list callbacks (tri-state null/true/false) is what makes the
switch — including a cache-served one — resolve to the correct state. Applies
to both mobile and TV Favorites.
2026-07-01 23:13:21 +02:00
Gauvain
17a591cd3c fix(watchlist): refresh see-all grid after toggling watchlist
The see-all screen keeps its own infinite query keyed
["favorites", "see-all", type, filter]. The watchlist mutation only
invalidated ["item", id] and ["home", "watchlist"], so removing an item
from within the see-all grid left it visible until a manual refresh.
Invalidate the ["favorites", "see-all"] prefix in onSettled as well.
2026-07-01 22:11:53 +02:00
lance chant
8f1b8fea31 Merge branch 'develop' into feat/kefintweaks-watchlist 2026-07-01 09:16:32 +02:00
Simon Eklundh
41c2631b35 Merge branch 'develop' into feat/kefintweaks-watchlist 2026-06-26 09:22:09 +02:00
Simon Eklundh
a8698a5c11 Merge branch 'develop' into feat/kefintweaks-watchlist 2026-06-18 18:05:54 +02:00
Gauvain
f2e54cd230 Merge branch 'develop' into feat/kefintweaks-watchlist 2026-06-15 20:33:14 +02:00
Simon Eklundh
14c84f5ec2 merge develop and add filter to fetchFavoritesByType callback 2026-06-15 16:41:57 +02:00
Simon Eklundh
803ee368ad Merge branch 'develop' into feat/kefintweaks-watchlist 2026-06-15 16:33:02 +02:00
Simon Eklundh
000e873922 rewrite seeAll logic to fix the i18n check error 2026-06-14 14:38:49 +02:00
Simon Eklundh
bc13317f00 some cleanups 2026-06-14 13:48:57 +02:00
Simon Eklundh
c024d1ed05 Merge branch 'develop' into feat/kefintweaks-watchlist 2026-06-11 13:05:29 +02:00
Simon Eklundh
c648134954 add header to 'see all' pages and change headers 2026-06-10 22:31:53 +02:00
Simon Eklundh
97eec2438b Merge branch 'develop' into feat/kefintweaks-watchlist 2026-06-10 20:47:41 +02:00
Simon Eklundh
1d0c2f0a31 fixes the api call so it actually updates remotely 2026-06-10 20:31:59 +02:00
Simon Eklundh
eba72e9d73 feat: add kefintweaks watchlist integration properly 2026-06-08 19:11:11 +02:00
25 changed files with 885 additions and 350 deletions

View File

@@ -1,14 +1,24 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useSettings } from "@/utils/atoms/settings";
export default function FavoritesPage() {
const invalidateCache = useInvalidatePlaybackProgressCache();
const { t } = useTranslation();
const { settings } = useSettings();
const [loading, setLoading] = useState(false);
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
const watchlistEnabled = settings?.useKefinTweaks ?? false;
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
"Favorites",
);
const refetch = useCallback(async () => {
setLoading(true);
await invalidateCache();
@@ -20,6 +30,8 @@ export default function FavoritesPage() {
return <TVFavorites />;
}
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
return (
<ScrollView
nestedScrollEnabled
@@ -34,7 +46,26 @@ export default function FavoritesPage() {
}}
>
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
<Favorites />
{watchlistEnabled && (
<View className='pl-4 pr-4 flex flex-row mb-2'>
<FavoritesTabButtons
viewType={viewType}
setViewType={setViewType}
t={t}
/>
</View>
)}
{isWatchlist ? (
<Favorites
filter='Likes'
queryKeyBase='watchlist'
seeAllNamespace='kefintweaksWatchlist'
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
emptyTextKey='kefintweaksWatchlist.noData'
/>
) : (
<Favorites />
)}
</View>
</ScrollView>
);

View File

@@ -2,6 +2,7 @@ import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
@@ -10,7 +11,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useWindowDimensions, View } from "react-native";
import { Platform, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -52,9 +53,13 @@ export default function FavoritesSeeAllScreen() {
const searchParams = useLocalSearchParams<{
type?: string;
title?: string;
filter?: string;
}>();
const typeParam = searchParams.type;
const titleParam = searchParams.title;
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
const filter: ItemFilter =
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
const itemType = useMemo(() => {
if (!isFavoriteType(typeParam)) return null;
@@ -77,7 +82,7 @@ export default function FavoritesSeeAllScreen() {
userId: user.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
filters: [filter],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -90,12 +95,12 @@ export default function FavoritesSeeAllScreen() {
return response.data.Items || [];
},
[api, itemType, user?.Id],
[api, itemType, user?.Id, filter],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["favorites", "see-all", itemType],
queryKey: ["favorites", "see-all", itemType, filter],
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
getNextPageParam: (lastPage, pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined;
@@ -155,7 +160,7 @@ export default function FavoritesSeeAllScreen() {
options={{
headerTitle: headerTitle,
headerBlurEffect: "none",
headerTransparent: true,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>

View File

@@ -50,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { settings, updateSettings, pluginSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
@@ -877,6 +877,15 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* Plugins Section */}
<TVSectionHeader title={t("home.settings.plugins.plugins_title")} />
<TVSettingsToggle
label={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
value={settings.useKefinTweaks}
onToggle={(value) => updateSettings({ useKefinTweaks: value })}
disabled={pluginSettings?.useKefinTweaks?.locked === true}
/>
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton

View File

@@ -9,6 +9,7 @@ import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { AddToFavorites } from "@/components/AddToFavorites";
import { AddToKefinWatchlist } from "@/components/AddToKefinWatchlist";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
@@ -18,6 +19,7 @@ import { TVSeriesPage } from "@/components/series/TVSeriesPage";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
buildOfflineSeriesFromEpisodes,
getDownloadedEpisodesForSeries,
@@ -30,6 +32,7 @@ import { storage } from "@/utils/mmkv";
const page: React.FC = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const { settings } = useSettings();
const params = useLocalSearchParams();
const {
id: seriesId,
@@ -137,6 +140,7 @@ const page: React.FC = () => {
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{settings?.useKefinTweaks && <AddToKefinWatchlist item={item} />}
{!Platform.isTV && (
<DownloadItems
size='large'
@@ -157,7 +161,7 @@ const page: React.FC = () => {
</View>
) : null,
});
}, [allEpisodes, isLoading, item, isOffline]);
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
// For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null;

View File

@@ -50,7 +50,9 @@ function TVTabLayout() {
const router = useRouter();
const currentTab = segments.find(isTabRoute);
const lastSegment = segments[segments.length - 1] ?? "";
// Widened to string: typed routes omit "index" segments, but they can still
// appear at runtime, so keep the guard without tripping TS2367.
const lastSegment: string = segments[segments.length - 1] ?? "";
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
const tabs: TVNavBarTab[] = useMemo(

View File

@@ -0,0 +1,29 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useWatchlist } from "@/hooks/useWatchlist";
interface Props extends ViewProps {
item: BaseItemDto;
}
/**
* KefinTweaks watchlist toggle, backed by Jellyfin's "Likes" rating.
* Render only when settings.useKefinTweaks is enabled.
*/
export const AddToKefinWatchlist: FC<Props> = ({ item, ...props }) => {
const { isWatchlisted, toggleWatchlist, isPending } = useWatchlist(item);
return (
<View {...props}>
<RoundButton
size='large'
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
color={isWatchlisted ? "purple" : "white"}
onPress={toggleWatchlist}
disabled={isPending}
/>
</View>
);
};

View File

@@ -29,6 +29,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites";
import { AddToKefinWatchlist } from "./AddToKefinWatchlist";
import { AddToWatchlist } from "./AddToWatchlist";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
@@ -138,6 +139,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.useKefinTweaks && (
<AddToKefinWatchlist item={item} />
)}
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
@@ -160,6 +164,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.useKefinTweaks && (
<AddToKefinWatchlist item={item} />
)}
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
@@ -178,6 +185,7 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl,
settings.hideWatchlistsTab,
settings.useKefinTweaks,
]);
useEffect(() => {

View File

@@ -39,6 +39,7 @@ import {
TVRefreshButton,
TVSeriesNavigation,
TVTechnicalDetails,
TVWatchlistButton,
} from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types";
import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -752,6 +753,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text>
</TVButton>
<TVFavoriteButton item={item} />
{settings.useKefinTweaks && <TVWatchlistButton item={item} />}
<TVPlayedButton item={item} />
<TVRefreshButton itemId={item.Id} />
</View>

View File

@@ -13,6 +13,7 @@ interface Props extends ViewProps {
fillColor?: "primary";
color?: "white" | "purple";
hapticFeedback?: boolean;
disabled?: boolean;
}
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
@@ -24,6 +25,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
fillColor,
color = "white",
hapticFeedback = true,
disabled = false,
...viewProps
}) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
@@ -31,6 +33,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
const lightHapticFeedback = useHaptic("light");
const handlePress = () => {
if (disabled) return;
if (hapticFeedback) {
lightHapticFeedback();
}
@@ -41,7 +44,8 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return (
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
{...(viewProps as any)}
>
{icon ? (
@@ -60,7 +64,8 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return (
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
{...(viewProps as any)}
>
{icon ? (
@@ -78,7 +83,8 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return (
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
{...(viewProps as any)}
>
{icon ? (
@@ -96,9 +102,10 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return (
<Pressable
onPress={handlePress}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-transparent"
}`}
} ${disabled ? "opacity-50" : ""}`}
{...(viewProps as any)}
>
{icon ? (
@@ -113,10 +120,14 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
);
return (
<Pressable onPress={handlePress} {...(viewProps as any)}>
<Pressable
onPress={handlePress}
disabled={disabled}
{...(viewProps as any)}
>
<BlurView
intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
{...(viewProps as any)}
>
{icon ? (

View File

@@ -1,116 +1,43 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { Platform, View, type ViewStyle } from "react-native";
import { Text } from "@/components/common/Text";
import { scaleSize } from "@/utils/scaleSize";
const isAggregateType = (item: BaseItemDto) =>
item.Type === "Series" || item.Type === "BoxSet";
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
const tvBadgeBase: ViewStyle = {
position: "absolute",
top: scaleSize(8),
right: scaleSize(8),
height: scaleSize(28),
borderRadius: scaleSize(14),
backgroundColor: "rgba(255,255,255,0.92)",
alignItems: "center",
justifyContent: "center",
};
// Mobile uses raw dp — no scaling.
const mobileBadgeBase: ViewStyle = {
position: "absolute",
top: 4,
right: 4,
height: 20,
borderRadius: 10,
backgroundColor: "#9333ea",
alignItems: "center",
justifyContent: "center",
};
/**
* Renders the unplayed-episode count badge for Series/BoxSet items that still
* have episodes left to watch. Returns null for non-aggregate types, fully
* watched items, or items with no unplayed count, so it is safe to mount
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
* the watched checkmark is drawn natively and only the count needs RN).
*/
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
if (!isAggregateType(item)) return null;
if (item.UserData?.Played) return null;
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
if (unplayed <= 0) return null;
if (Platform.isTV) {
return (
<View
style={[
tvBadgeBase,
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
]}
>
<Text
style={{
fontSize: scaleSize(15),
fontWeight: "700",
color: "black",
}}
>
{unplayed}
</Text>
</View>
);
}
return (
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
{unplayed}
</Text>
</View>
);
},
);
import type React from "react";
import { Platform, View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
const isAggregate = isAggregateType(item);
const isPlayed = item.UserData?.Played === true;
if (Platform.isTV) {
// Fully watched → white checkmark badge (top-right)
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
// TV: Show white checkmark when watched
if (
item.UserData?.Played &&
(item.Type === "Movie" || item.Type === "Episode")
) {
return (
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
);
}
// Series/BoxSet with remaining episodes → count badge
return <UnplayedCountBadge item={item} />;
return null;
}
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
// Mobile: Show purple triangle for unwatched
return (
<>
{isMovieOrEpisode && !isPlayed && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
{/* Fully watched Series/BoxSet → small purple checkmark */}
{isAggregate && isPlayed && (
<View style={[mobileBadgeBase, { width: 20 }]}>
<Ionicons name='checkmark' size={13} color='white' />
</View>
)}
{/* Series/BoxSet with remaining episodes → count badge */}
<UnplayedCountBadge item={item} />
{item.UserData?.Played === false &&
(item.Type === "Movie" || item.Type === "Episode") && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
</>
);
};

View File

@@ -11,8 +11,10 @@ import {
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useWatchlist } from "@/hooks/useWatchlist";
import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
@@ -155,6 +157,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
const { settings } = useSettings();
const router = useRouter();
const isOffline = useOfflineMode();
const { deleteFile } = useDownload();
@@ -183,36 +187,66 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
)
return;
const options: string[] = [
t("common.mark_as_played"),
t("common.mark_as_not_played"),
isFavorite
? t("music.track_options.remove_from_favorites")
: t("music.track_options.add_to_favorites"),
...(isOffline ? [t("home.downloads.delete_download")] : []),
t("common.cancel"),
// Build options as { label, action } so dynamic entries (watchlist,
// offline delete) don't break index-based handling.
const actions: {
label: string;
action: () => void;
destructive?: boolean;
}[] = [
{
label: t("common.mark_as_played"),
action: () => {
markAsPlayedStatus(true);
},
},
{
label: t("common.mark_as_not_played"),
action: () => {
markAsPlayedStatus(false);
},
},
{
label: isFavorite
? t("music.track_options.remove_from_favorites")
: t("music.track_options.add_to_favorites"),
action: toggleFavorite,
},
];
if (settings?.useKefinTweaks) {
actions.push({
label: isWatchlisted
? t("watchlists.remove_from_watchlist")
: t("watchlists.add_to_watchlist"),
action: toggleWatchlist,
});
}
if (isOffline && item.Id) {
const id = item.Id;
actions.push({
label: t("home.downloads.delete_download"),
action: () => deleteFile(id),
destructive: true,
});
}
const options = [...actions.map((a) => a.label), t("common.cancel")];
const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline
? cancelButtonIndex - 1
: undefined;
const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
destructiveButtonIndex,
destructiveButtonIndex:
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
},
async (selectedIndex) => {
if (selectedIndex === 0) {
await markAsPlayedStatus(true);
} else if (selectedIndex === 1) {
await markAsPlayedStatus(false);
} else if (selectedIndex === 2) {
toggleFavorite();
} else if (isOffline && selectedIndex === 3 && item.Id) {
deleteFile(item.Id);
}
(selectedIndex) => {
if (selectedIndex === undefined || selectedIndex >= actions.length)
return;
actions[selectedIndex].action();
},
);
}, [
@@ -220,6 +254,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isFavorite,
markAsPlayedStatus,
toggleFavorite,
isWatchlisted,
toggleWatchlist,
settings?.useKefinTweaks,
isOffline,
deleteFile,
item.Id,

View File

@@ -0,0 +1,74 @@
import { Platform, TouchableOpacity, View } from "react-native";
import { Tag } from "@/components/GenreTags";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import crashes the route tree on tvOS at module load.
// Load it lazily and only off-TV; TV never renders this component.
const { Button, Host, HStack, Spacer } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { buttonStyle } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
type ViewType = "Favorites" | "Watchlist";
interface FavoritesTabButtonsProps {
viewType: ViewType;
setViewType: (type: ViewType) => void;
t: (key: string) => string;
}
export const FavoritesTabButtons: React.FC<FavoritesTabButtonsProps> = ({
viewType,
setViewType,
t,
}) => {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<Host style={{ height: 40, flex: 1 }}>
<HStack spacing={8}>
<Button
modifiers={[
buttonStyle(
viewType === "Favorites" ? "glassProminent" : "glass",
),
]}
onPress={() => setViewType("Favorites")}
label={t("tabs.favorites")}
/>
<Button
modifiers={[
buttonStyle(
viewType === "Watchlist" ? "glassProminent" : "glass",
),
]}
onPress={() => setViewType("Watchlist")}
label={t("favorites.watchlist")}
/>
<Spacer />
</HStack>
</Host>
);
}
// Android UI
return (
<View className='flex flex-row gap-1 mr-1'>
<TouchableOpacity onPress={() => setViewType("Favorites")}>
<Tag
text={t("tabs.favorites")}
textClass='p-1'
className={viewType === "Favorites" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setViewType("Watchlist")}>
<Tag
text={t("favorites.watchlist")}
textClass='p-1'
className={viewType === "Watchlist" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -0,0 +1,117 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
type ViewType = "Favorites" | "Watchlist";
interface TVFavoritesTabBadgeProps {
label: string;
isSelected: boolean;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}
const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
label,
isSelected,
onPress,
hasTVPreferredFocus = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ duration: 150 });
// Design language: white for focused/selected, transparent white for unfocused
const getBackgroundColor = () => {
if (focused) return "#fff";
if (isSelected) return "rgba(255,255,255,0.25)";
return "rgba(255,255,255,0.1)";
};
const getTextColor = () => {
if (focused) return "#000";
return "#fff";
};
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 24,
backgroundColor: getBackgroundColor(),
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<Text
style={{
fontSize: typography.callout,
color: getTextColor(),
fontWeight: isSelected || focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export interface TVFavoritesTabBadgesProps {
viewType: ViewType;
setViewType: (type: ViewType) => void;
/** Only render the toggle when the KefinTweaks watchlist is enabled. */
enabled: boolean;
hasTVPreferredFocus?: boolean;
}
export const TVFavoritesTabBadges: React.FC<TVFavoritesTabBadgesProps> = ({
viewType,
setViewType,
enabled,
hasTVPreferredFocus = false,
}) => {
const { t } = useTranslation();
if (!enabled) {
return null;
}
return (
<View
style={{
flexDirection: "row",
gap: 16,
marginBottom: 24,
}}
>
<TVFavoritesTabBadge
label={t("tabs.favorites")}
isSelected={viewType === "Favorites"}
onPress={() => setViewType("Favorites")}
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Favorites"}
/>
<TVFavoritesTabBadge
label={t("favorites.watchlist")}
isSelected={viewType === "Watchlist"}
onPress={() => setViewType("Watchlist")}
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Watchlist"}
/>
</View>
);
};

View File

@@ -1,10 +1,13 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import type {
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
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";
@@ -20,20 +23,39 @@ type FavoriteTypes =
| "Video"
| "BoxSet"
| "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>;
// `null` = not settled yet (loading/unknown); avoids flashing the empty
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
export const Favorites = () => {
interface FavoritesProps {
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
filter?: ItemFilter;
/** Query key segment used to keep favorites/watchlist caches separate. */
queryKeyBase?: string;
emptyTitleKey?: string;
emptyTextKey?: string;
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
seeAllNamespace?: "kefintweaksWatchlist" | "favorites";
}
export const Favorites = ({
filter = "IsFavorite",
queryKeyBase = "favorites",
emptyTitleKey = "favorites.noDataTitle",
emptyTextKey = "favorites.noData",
seeAllNamespace = "favorites",
}: FavoritesProps = {}) => {
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({
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(
@@ -46,7 +68,7 @@ export const Favorites = () => {
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
filters: [filter],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -56,39 +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],
[api, user, filter],
);
// Reset empty state when component mounts or dependencies change
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
// 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)
);
};
@@ -123,47 +134,26 @@ export const Favorites = () => {
[fetchFavoritesByType, pageSize],
);
const handleSeeAllSeries = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Series", title: t("favorites.series") },
} as any);
}, [router]);
const handleSeeAllMovies = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Movie", title: t("favorites.movies") },
} as any);
}, [router]);
const handleSeeAllEpisodes = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Episode", title: t("favorites.episodes") },
} as any);
}, [router]);
const handleSeeAllVideos = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Video", title: t("favorites.videos") },
} as any);
}, [router]);
const handleSeeAllBoxsets = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "BoxSet", title: t("favorites.boxsets") },
} as any);
}, [router]);
const handleSeeAllPlaylists = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Playlist", title: t("favorites.playlists") },
} as any);
}, [router]);
// Navigate to the shared see-all screen. `name` is the capitalized type
// suffix of the see-all header key (e.g. "Series" -> "seeAllSeries").
// The namespace is branched explicitly so each t() call has a static prefix
// (favorites.seeAll* / kefintweaksWatchlist.seeAll*) that the i18n usage
// checker can detect — see scripts/check-i18n-keys.mjs. The `as any` is
// needed because the route's custom params aren't part of expo-router's
// typed Href.
const seeAll = useCallback(
(type: FavoriteTypes, name: string) => {
const title =
seeAllNamespace === "kefintweaksWatchlist"
? t(`kefintweaksWatchlist.seeAll${name}`)
: t(`favorites.seeAll${name}`);
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type, title, filter },
} as any);
},
[router, filter, seeAllNamespace],
);
return (
<View className='flex flex-co gap-y-4'>
@@ -176,61 +166,67 @@ export const Favorites = () => {
source={heart}
/>
<Text className='text-xl font-semibold text-white mb-2'>
{t("favorites.noDataTitle")}
{t(emptyTitleKey)}
</Text>
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
{t("favorites.noData")}
{t(emptyTextKey)}
</Text>
</View>
)}
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
queryKey={["home", queryKeyBase, "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
onPressSeeAll={handleSeeAllSeries}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
onPressSeeAll={() => seeAll("Series", "Series")}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
queryKey={["home", queryKeyBase, "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
onPressSeeAll={handleSeeAllMovies}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
onPressSeeAll={() => seeAll("Movie", "Movies")}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
queryKey={["home", queryKeyBase, "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
onPressSeeAll={handleSeeAllEpisodes}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
onPressSeeAll={() => seeAll("Episode", "Episodes")}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
queryKey={["home", queryKeyBase, "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
onPressSeeAll={handleSeeAllVideos}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
onPressSeeAll={() => seeAll("Video", "Videos")}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
queryKey={["home", queryKeyBase, "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
onPressSeeAll={handleSeeAllBoxsets}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
onPressSeeAll={() => seeAll("BoxSet", "Boxsets")}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
queryKey={["home", queryKeyBase, "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
onPressSeeAll={handleSeeAllPlaylists}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
onPressSeeAll={() => seeAll("Playlist", "Playlists")}
/>
</View>
);

View File

@@ -1,18 +1,23 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import type {
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
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";
import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text";
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
@@ -25,7 +30,9 @@ type FavoriteTypes =
| "Video"
| "BoxSet"
| "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>;
// `null` = not settled yet (loading/unknown); avoids flashing the empty
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
export const Favorites = () => {
const typography = useScaledTVTypography();
@@ -33,14 +40,34 @@ export const Favorites = () => {
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { settings } = useSettings();
const pageSize = 20;
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
const watchlistEnabled = settings?.useKefinTweaks ?? false;
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
"Favorites",
);
const filter: ItemFilter =
watchlistEnabled && viewType === "Watchlist" ? "Likes" : "IsFavorite";
const queryKeyBase =
watchlistEnabled && viewType === "Watchlist" ? "watchlist" : "favorites";
// Translation namespace for the empty state, swapped for the KefinTweaks
// watchlist (Likes-backed) view. Section titles stay generic ("Series").
const emptyNamespace =
watchlistEnabled && viewType === "Watchlist"
? "kefintweaksWatchlist"
: "favorites";
const emptyTitleKey = `${emptyNamespace}.noDataTitle`;
const emptyTextKey = `${emptyNamespace}.noData`;
const [emptyState, setEmptyState] = useState<EmptyState>({
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(
@@ -53,7 +80,7 @@ export const Favorites = () => {
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
filters: [filter],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -63,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],
[api, user, filter],
);
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
// 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)
);
};
@@ -127,49 +146,14 @@ export const Favorites = () => {
[fetchFavoritesByType, pageSize],
);
if (areAllEmpty()) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
style={{
width: 64,
height: 64,
marginBottom: 16,
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("favorites.noDataTitle")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{t("favorites.noData")}
</Text>
</View>
);
}
const tabBadges = (
<TVFavoritesTabBadges
viewType={viewType}
setViewType={setViewType}
enabled={watchlistEnabled}
hasTVPreferredFocus={watchlistEnabled}
/>
);
return (
<ScrollView
@@ -178,52 +162,108 @@ export const Favorites = () => {
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
flexGrow: 1,
}}
>
<View style={{ gap: SECTION_GAP }}>
<View style={{ gap: SECTION_GAP, flex: 1 }}>
{watchlistEnabled && (
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
{tabBadges}
</View>
)}
{/* Rendered alongside the lists (never instead of them) so they stay
mounted and re-report emptiness on a favorites/watchlist switch;
an early return here would freeze the all-empty state. */}
{areAllEmpty() && (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
style={{
width: 64,
height: 64,
marginBottom: 16,
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t(emptyTitleKey)}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{t(emptyTextKey)}
</Text>
</View>
)}
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
queryKey={["home", queryKeyBase, "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection
isFirstSection={!watchlistEnabled}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
queryKey={["home", queryKeyBase, "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
queryKey={["home", queryKeyBase, "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
queryKey={["home", queryKeyBase, "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
queryKey={["home", queryKeyBase, "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
queryKey={["home", queryKeyBase, "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
/>
</View>
</ScrollView>

View File

@@ -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<Props> = ({
@@ -45,6 +52,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPressSeeAll,
enabled = true,
onLoaded,
onEmptyStateChange,
...props
}) => {
const effectivePageSize = Math.max(1, pageSize);
@@ -52,6 +60,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const {
data,
isLoading,
isError,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
@@ -103,6 +112,17 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped;
}, [data]);
// Report emptiness on every settle (incl. cache hits). Errors report null
// (unknown) so a failed fetch never reads as "no content". 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 || isError ? null : allItems.length === 0,
);
}, [isLoading, isError, 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);

View File

@@ -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<typeof useScaledTVTypography>;
@@ -123,6 +130,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection = false,
onItemFocus,
parentId,
onEmptyStateChange,
...props
}) => {
const typography = useScaledTVTypography();
@@ -145,24 +153,30 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
[onItemFocus],
);
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
const {
data,
isLoading,
isError,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
const { t } = useTranslation();
@@ -182,6 +196,17 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped;
}, [data]);
// Report emptiness on every settle (incl. cache hits). Errors report null
// (unknown) so a failed fetch never reads as "no content". 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 || isError ? null : allItems.length === 0,
);
}, [isLoading, isError, allItems.length]);
const itemWidth =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;

View File

@@ -1,8 +1,8 @@
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useState } from "react";
import { View, type ViewProps } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { ItemImage } from "../common/ItemImage";
import { WatchedIndicator } from "../WatchedIndicator";
interface Props extends ViewProps {
item: BaseItemDto;

View File

@@ -3,7 +3,6 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -53,7 +52,6 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
width: "100%",
}}
/>
<WatchedIndicator item={item} />
</View>
);
};

View File

@@ -12,10 +12,7 @@ import {
} from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import {
UnplayedCountBadge,
WatchedIndicator,
} from "@/components/WatchedIndicator";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
@@ -430,12 +427,6 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/>
{PlayButtonOverlay}
{NowPlayingBadge}
{/*
The glass view draws the watched checkmark natively but cannot show
an unplayed-episode count, so render it as an RN overlay on top.
Returns null when not applicable (non-series / fully watched).
*/}
<UnplayedCountBadge item={item} />
</View>
);
}

View File

@@ -0,0 +1,36 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { useWatchlist } from "@/hooks/useWatchlist";
import { TVButton } from "./TVButton";
export interface TVWatchlistButtonProps {
item: BaseItemDto;
disabled?: boolean;
}
/**
* KefinTweaks watchlist toggle (Likes-backed) for TV detail pages.
* Render only when settings.useKefinTweaks is enabled.
*/
export const TVWatchlistButton: React.FC<TVWatchlistButtonProps> = ({
item,
disabled,
}) => {
const { isWatchlisted, toggleWatchlist, isPending } = useWatchlist(item);
return (
<TVButton
onPress={toggleWatchlist}
variant='glass'
square
disabled={disabled || isPending}
>
<Ionicons
name={isWatchlisted ? "bookmark" : "bookmark-outline"}
size={28}
color='#FFFFFF'
/>
</TVButton>
);
};

View File

@@ -70,3 +70,5 @@ export { TVTrackCard } from "./TVTrackCard";
// User switching
export type { TVUserCardProps } from "./TVUserCard";
export { TVUserCard } from "./TVUserCard";
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
export { TVWatchlistButton } from "./TVWatchlistButton";

156
hooks/useWatchlist.ts Normal file
View File

@@ -0,0 +1,156 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { toast } from "sonner-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Shared atom to store watchlist (Likes) status across all components
// Maps itemId -> isWatchlisted
const watchlistAtom = atom<Record<string, boolean>>({});
/**
* KefinTweaks watchlist is backed by Jellyfin's native "Likes" rating.
* Toggling watchlist membership toggles UserData.Likes on the item.
*/
export const useWatchlist = (item: BaseItemDto) => {
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [watchlist, setWatchlist] = useAtom(watchlistAtom);
const watchlistKey = user?.Id && item.Id ? `${user.Id}:${item.Id}` : "";
// Get current watchlist status from shared state, falling back to item data
const isWatchlisted = watchlistKey
? (watchlist[watchlistKey] ?? item.UserData?.Likes)
: item.UserData?.Likes;
// Update shared state when item data changes
useEffect(() => {
if (watchlistKey && item.UserData?.Likes !== undefined) {
setWatchlist((prev) => ({
...prev,
[watchlistKey]: item.UserData!.Likes!,
}));
}
}, [watchlistKey, item.UserData?.Likes, setWatchlist]);
// Helper to update watchlist status in shared state
const setIsWatchlisted = useCallback(
(value: boolean | null | undefined) => {
if (watchlistKey && typeof value === "boolean") {
setWatchlist((prev) => ({ ...prev, [watchlistKey]: value }));
}
},
[watchlistKey, setWatchlist],
);
// Use refs to avoid stale closure issues in mutationFn
const itemRef = useRef(item);
const apiRef = useRef(api);
const userRef = useRef(user);
// Keep refs updated
useEffect(() => {
itemRef.current = item;
}, [item]);
useEffect(() => {
apiRef.current = api;
}, [api]);
useEffect(() => {
userRef.current = user;
}, [user]);
const itemQueryKeyPrefix = useMemo(
() => ["item", item.Id] as const,
[item.Id],
);
const updateItemInQueries = useCallback(
(newData: Partial<BaseItemDto>) => {
queryClient.setQueriesData<BaseItemDto | null | undefined>(
{ queryKey: itemQueryKeyPrefix },
(old) => {
if (!old) return old;
return {
...old,
...newData,
UserData: { ...old.UserData, ...newData.UserData },
};
},
);
},
[itemQueryKeyPrefix, queryClient],
);
const watchlistMutation = useMutation({
mutationFn: async (nextIsWatchlisted: boolean) => {
const currentApi = apiRef.current;
const currentUser = userRef.current;
const currentItem = itemRef.current;
if (!currentApi || !currentUser?.Id || !currentItem?.Id) {
throw new Error("Cannot update watchlist: not signed in");
}
// Watchlist == Jellyfin "Likes" rating:
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=true - add to watchlist
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=false - remove from watchlist
const path = `/UserItems/${currentItem.Id}/Rating`;
const response = await currentApi.post(
path,
{},
{ params: { userId: currentUser.Id, likes: nextIsWatchlisted } },
);
return response.data;
},
onMutate: async (nextIsWatchlisted: boolean) => {
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
const previousIsWatchlisted = isWatchlisted;
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
queryKey: itemQueryKeyPrefix,
});
setIsWatchlisted(nextIsWatchlisted);
updateItemInQueries({ UserData: { Likes: nextIsWatchlisted } });
return { previousIsWatchlisted, previousQueries };
},
onError: (error: Error, _nextIsWatchlisted, context) => {
// Roll back the optimistic Likes flip applied in onMutate.
if (context?.previousQueries) {
for (const [queryKey, data] of context.previousQueries) {
queryClient.setQueryData(queryKey, data);
}
}
setIsWatchlisted(context?.previousIsWatchlisted);
toast.error(error.message || "Failed to update watchlist");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
// The favorites/watchlist "see all" grid keeps its own infinite query
// (["favorites", "see-all", ...]); invalidate it so removing an item
// from within the see-all screen updates the list in place.
queryClient.invalidateQueries({ queryKey: ["favorites", "see-all"] });
},
});
const toggleWatchlist = useCallback(() => {
// Ignore taps while a flip is in flight so overlapping requests can't
// race and leave Jellyfin's Likes value out of sync with the UI.
if (watchlistMutation.isPending) return;
watchlistMutation.mutate(!isWatchlisted);
}, [watchlistMutation, isWatchlisted]);
return {
isWatchlisted,
toggleWatchlist,
isPending: watchlistMutation.isPending,
watchlistMutation,
};
};

View File

@@ -588,8 +588,25 @@
"videos": "Videos",
"boxsets": "Box sets",
"playlists": "Playlists",
"seeAllSeries": "Favorited Series",
"seeAllMovies": "Favorited Movies",
"seeAllEpisodes": "Favorited Episodes",
"seeAllVideos": "Favorited Videos",
"seeAllBoxsets": "Favorited Box sets",
"seeAllPlaylists": "Favorited Playlists",
"noDataTitle": "No favorites yet",
"noData": "Mark items as favorites to see them appear here for quick access."
"noData": "Mark items as favorites to see them appear here for quick access.",
"watchlist": "Watchlist"
},
"kefintweaksWatchlist": {
"seeAllSeries": "Watchlisted Series",
"seeAllMovies": "Watchlisted Movies",
"seeAllEpisodes": "Watchlisted Episodes",
"seeAllVideos": "Watchlisted Videos",
"seeAllBoxsets": "Watchlisted Box sets",
"seeAllPlaylists": "Watchlisted Playlists",
"noDataTitle": "No watchlisted items yet",
"noData": "Add items to your watchlist to see them appear here."
},
"custom_links": {
"no_links": "No links"

View File

@@ -82,8 +82,6 @@ export const useFilterOptions = () => {
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
];
console.log("filterOptions");
console.log(filterOptions);
return filterOptions;
};