feat: add kefintweaks watchlist integration properly

This commit is contained in:
Simon Eklundh
2026-06-08 19:11:11 +02:00
parent 1685571406
commit eba72e9d73
17 changed files with 657 additions and 96 deletions

View File

@@ -0,0 +1,28 @@
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 } = useWatchlist(item);
return (
<View {...props}>
<RoundButton
size='large'
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
color={isWatchlisted ? "purple" : "white"}
onPress={toggleWatchlist}
/>
</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

@@ -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,5 +1,8 @@
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";
@@ -22,7 +25,21 @@ type FavoriteTypes =
| "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>;
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;
}
export const Favorites = ({
filter = "IsFavorite",
queryKeyBase = "favorites",
emptyTitleKey = "favorites.noDataTitle",
emptyTextKey = "favorites.noData",
}: FavoritesProps = {}) => {
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -46,7 +63,7 @@ export const Favorites = () => {
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
filters: [filter],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -68,7 +85,7 @@ export const Favorites = () => {
return items;
},
[api, user],
[api, user, filter],
);
// Reset empty state when component mounts or dependencies change
@@ -126,44 +143,44 @@ export const Favorites = () => {
const handleSeeAllSeries = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Series", title: t("favorites.series") },
params: { type: "Series", title: t("favorites.series"), filter },
} as any);
}, [router]);
}, [router, filter]);
const handleSeeAllMovies = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Movie", title: t("favorites.movies") },
params: { type: "Movie", title: t("favorites.movies"), filter },
} as any);
}, [router]);
}, [router, filter]);
const handleSeeAllEpisodes = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Episode", title: t("favorites.episodes") },
params: { type: "Episode", title: t("favorites.episodes"), filter },
} as any);
}, [router]);
}, [router, filter]);
const handleSeeAllVideos = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Video", title: t("favorites.videos") },
params: { type: "Video", title: t("favorites.videos"), filter },
} as any);
}, [router]);
}, [router, filter]);
const handleSeeAllBoxsets = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "BoxSet", title: t("favorites.boxsets") },
params: { type: "BoxSet", title: t("favorites.boxsets"), filter },
} as any);
}, [router]);
}, [router, filter]);
const handleSeeAllPlaylists = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Playlist", title: t("favorites.playlists") },
params: { type: "Playlist", title: t("favorites.playlists"), filter },
} as any);
}, [router]);
}, [router, filter]);
return (
<View className='flex flex-co gap-y-4'>
@@ -176,16 +193,16 @@ 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}
@@ -193,7 +210,7 @@ export const Favorites = () => {
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
queryKey={["home", queryKeyBase, "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
@@ -202,7 +219,7 @@ export const Favorites = () => {
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
queryKey={["home", queryKeyBase, "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
@@ -210,7 +227,7 @@ export const Favorites = () => {
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
queryKey={["home", queryKeyBase, "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
@@ -218,7 +235,7 @@ export const Favorites = () => {
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
queryKey={["home", queryKeyBase, "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
@@ -226,7 +243,7 @@ export const Favorites = () => {
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
queryKey={["home", queryKeyBase, "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}

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, useEffect, useMemo, 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;
@@ -33,7 +38,33 @@ 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";
const emptyTitleKey = useMemo(
() =>
watchlistEnabled && viewType === "Watchlist"
? "favorites.noWatchlistTitle"
: "favorites.noDataTitle",
[watchlistEnabled, viewType],
);
const emptyTextKey = useMemo(
() =>
watchlistEnabled && viewType === "Watchlist"
? "favorites.noWatchlistData"
: "favorites.noData",
[watchlistEnabled, viewType],
);
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false,
Movie: false,
@@ -53,7 +84,7 @@ export const Favorites = () => {
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
filters: [filter],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -74,7 +105,7 @@ export const Favorites = () => {
return items;
},
[api, user],
[api, user, filter],
);
useEffect(() => {
@@ -86,7 +117,7 @@ export const Favorites = () => {
BoxSet: false,
Playlist: false,
});
}, [api, user]);
}, [api, user, viewType]);
const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState);
@@ -127,46 +158,63 @@ export const Favorites = () => {
[fetchFavoritesByType, pageSize],
);
const tabBadges = (
<TVFavoritesTabBadges
viewType={viewType}
setViewType={setViewType}
enabled={watchlistEnabled}
hasTVPreferredFocus={watchlistEnabled}
/>
);
if (areAllEmpty()) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingTop: insets.top + TOP_PADDING,
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
{tabBadges}
<View
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",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
{t("favorites.noDataTitle")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{t("favorites.noData")}
</Text>
<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>
</View>
);
}
@@ -181,17 +229,22 @@ export const Favorites = () => {
}}
>
<View style={{ gap: SECTION_GAP }}>
{watchlistEnabled && (
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
{tabBadges}
</View>
)}
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
queryKey={["home", queryKeyBase, "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection
isFirstSection={!watchlistEnabled}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
queryKey={["home", queryKeyBase, "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
@@ -199,28 +252,28 @@ export const Favorites = () => {
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
queryKey={["home", queryKeyBase, "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
queryKey={["home", queryKeyBase, "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
queryKey={["home", queryKeyBase, "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
queryKey={["home", queryKeyBase, "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}

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 } = useWatchlist(item);
return (
<TVButton
onPress={toggleWatchlist}
variant='glass'
square
disabled={disabled}
>
<Ionicons
name={isWatchlisted ? "bookmark" : "bookmark-outline"}
size={28}
color='#FFFFFF'
/>
</TVButton>
);
};

View File

@@ -68,3 +68,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";