diff --git a/app/(auth)/(tabs)/(favorites)/index.tsx b/app/(auth)/(tabs)/(favorites)/index.tsx
index 10fffe9d0..fa684960f 100644
--- a/app/(auth)/(tabs)/(favorites)/index.tsx
+++ b/app/(auth)/(tabs)/(favorites)/index.tsx
@@ -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 ;
}
+ const isWatchlist = watchlistEnabled && viewType === "Watchlist";
+
return (
-
+ {watchlistEnabled && (
+
+
+
+ )}
+ {isWatchlist ? (
+
+ ) : (
+
+ )}
);
diff --git a/app/(auth)/(tabs)/(favorites)/see-all.tsx b/app/(auth)/(tabs)/(favorites)/see-all.tsx
index c885afdc2..d6cd2d55e 100644
--- a/app/(auth)/(tabs)/(favorites)/see-all.tsx
+++ b/app/(auth)/(tabs)/(favorites)/see-all.tsx
@@ -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";
@@ -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;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
index cc4b21b98..01de7b366 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/series/[id].tsx
@@ -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 ? (
+ {settings?.useKefinTweaks && }
{!Platform.isTV && (
{
) : 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;
diff --git a/components/AddToKefinWatchlist.tsx b/components/AddToKefinWatchlist.tsx
new file mode 100644
index 000000000..351f6fcd0
--- /dev/null
+++ b/components/AddToKefinWatchlist.tsx
@@ -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 = ({ item, ...props }) => {
+ const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
+
+ return (
+
+
+
+ );
+};
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index 4869a75e5..b92a90da0 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -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 = ({
+ {settings.useKefinTweaks && (
+
+ )}
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
@@ -160,6 +164,9 @@ const ItemContentMobile: React.FC = ({
+ {settings.useKefinTweaks && (
+
+ )}
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
@@ -178,6 +185,7 @@ const ItemContentMobile: React.FC = ({
settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl,
settings.hideWatchlistsTab,
+ settings.useKefinTweaks,
]);
useEffect(() => {
diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx
index 05d0d1ce2..7708612b6 100644
--- a/components/ItemContent.tv.tsx
+++ b/components/ItemContent.tv.tsx
@@ -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 = React.memo(
+ {settings.useKefinTweaks && }
diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx
index fed45dc99..561161159 100644
--- a/components/common/TouchableItemRouter.tsx
+++ b/components/common/TouchableItemRouter.tsx
@@ -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> = ({
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> = ({
)
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> = ({
isFavorite,
markAsPlayedStatus,
toggleFavorite,
+ isWatchlisted,
+ toggleWatchlist,
+ settings?.useKefinTweaks,
isOffline,
deleteFile,
item.Id,
diff --git a/components/favorites/FavoritesTabButtons.tsx b/components/favorites/FavoritesTabButtons.tsx
new file mode 100644
index 000000000..ad20b3596
--- /dev/null
+++ b/components/favorites/FavoritesTabButtons.tsx
@@ -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 = ({
+ viewType,
+ setViewType,
+ t,
+}) => {
+ if (Platform.OS === "ios" && !Platform.isTV) {
+ return (
+
+
+
+
+ );
+ }
+
+ // Android UI
+ return (
+
+ setViewType("Favorites")}>
+
+
+ setViewType("Watchlist")}>
+
+
+
+ );
+};
diff --git a/components/favorites/TVFavoritesTabBadges.tsx b/components/favorites/TVFavoritesTabBadges.tsx
new file mode 100644
index 000000000..185f7eb60
--- /dev/null
+++ b/components/favorites/TVFavoritesTabBadges.tsx
@@ -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 = ({
+ 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 (
+
+
+
+ {label}
+
+
+
+ );
+};
+
+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 = ({
+ viewType,
+ setViewType,
+ enabled,
+ hasTVPreferredFocus = false,
+}) => {
+ const { t } = useTranslation();
+
+ if (!enabled) {
+ return null;
+ }
+
+ return (
+
+ setViewType("Favorites")}
+ hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Favorites"}
+ />
+ setViewType("Watchlist")}
+ hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Watchlist"}
+ />
+
+ );
+};
diff --git a/components/home/Favorites.tsx b/components/home/Favorites.tsx
index 84fa36b99..3b364de4f 100644
--- a/components/home/Favorites.tsx
+++ b/components/home/Favorites.tsx
@@ -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;
-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 (
@@ -176,16 +193,16 @@ export const Favorites = () => {
source={heart}
/>
- {t("favorites.noDataTitle")}
+ {t(emptyTitleKey)}
- {t("favorites.noData")}
+ {t(emptyTextKey)}
)}
{
/>
{
/>
{
/>
{
/>
{
/>
{
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({
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 = (
+
+ );
+
if (areAllEmpty()) {
return (
-
-
- {t("favorites.noDataTitle")}
-
-
- {t("favorites.noData")}
-
+
+
+ {t(emptyTitleKey)}
+
+
+ {t(emptyTextKey)}
+
+
);
}
@@ -181,17 +229,22 @@ export const Favorites = () => {
}}
>
+ {watchlistEnabled && (
+
+ {tabBadges}
+
+ )}
{
/>
= ({
+ item,
+ disabled,
+}) => {
+ const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
+
+ return (
+
+
+
+ );
+};
diff --git a/components/tv/index.ts b/components/tv/index.ts
index a35104eb0..cb988695c 100644
--- a/components/tv/index.ts
+++ b/components/tv/index.ts
@@ -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";
diff --git a/hooks/useWatchlist.ts b/hooks/useWatchlist.ts
new file mode 100644
index 000000000..7dd90d5d2
--- /dev/null
+++ b/hooks/useWatchlist.ts
@@ -0,0 +1,144 @@
+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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+
+// Shared atom to store watchlist (Likes) status across all components
+// Maps itemId -> isWatchlisted
+const watchlistAtom = atom>({});
+
+/**
+ * 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 itemId = item.Id ?? "";
+
+ // Get current watchlist status from shared state, falling back to item data
+ const isWatchlisted = itemId
+ ? (watchlist[itemId] ?? item.UserData?.Likes)
+ : item.UserData?.Likes;
+
+ // Update shared state when item data changes
+ useEffect(() => {
+ if (itemId && item.UserData?.Likes !== undefined) {
+ setWatchlist((prev) => ({
+ ...prev,
+ [itemId]: item.UserData!.Likes!,
+ }));
+ }
+ }, [itemId, item.UserData?.Likes, setWatchlist]);
+
+ // Helper to update watchlist status in shared state
+ const setIsWatchlisted = useCallback(
+ (value: boolean | null | undefined) => {
+ if (itemId && typeof value === "boolean") {
+ setWatchlist((prev) => ({ ...prev, [itemId]: value }));
+ }
+ },
+ [itemId, 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) => {
+ queryClient.setQueriesData(
+ { 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) {
+ return;
+ }
+
+ // Watchlist == Jellyfin "Likes" rating:
+ // POST /Users/{userId}/Items/{itemId}/Rating?likes=true - add to watchlist
+ // DELETE /Users/{userId}/Items/{itemId}/Rating - remove from watchlist
+ const path = `/Users/${currentUser.Id}/Items/${currentItem.Id}/Rating`;
+
+ const response = nextIsWatchlisted
+ ? await currentApi.post(path, {}, { params: { likes: true } })
+ : await currentApi.delete(path, {});
+ return response.data;
+ },
+ onMutate: async (nextIsWatchlisted: boolean) => {
+ await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
+
+ const previousIsWatchlisted = isWatchlisted;
+ const previousQueries = queryClient.getQueriesData({
+ queryKey: itemQueryKeyPrefix,
+ });
+
+ setIsWatchlisted(nextIsWatchlisted);
+ updateItemInQueries({ UserData: { Likes: nextIsWatchlisted } });
+
+ return { previousIsWatchlisted, previousQueries };
+ },
+ onError: (_err, _nextIsWatchlisted, context) => {
+ if (context?.previousQueries) {
+ for (const [queryKey, data] of context.previousQueries) {
+ queryClient.setQueryData(queryKey, data);
+ }
+ }
+ setIsWatchlisted(context?.previousIsWatchlisted);
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
+ queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
+ },
+ });
+
+ const toggleWatchlist = useCallback(() => {
+ watchlistMutation.mutate(!isWatchlisted);
+ }, [watchlistMutation, isWatchlisted]);
+
+ return {
+ isWatchlisted,
+ toggleWatchlist,
+ watchlistMutation,
+ };
+};
diff --git a/translations/en.json b/translations/en.json
index 581e82c48..9dcf4c766 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -676,7 +676,10 @@
"boxsets": "Box sets",
"playlists": "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",
+ "noWatchlistTitle": "No watchlisted items yet",
+ "noWatchlistData": "Add items to your watchlist to see them appear here."
},
"custom_links": {
"no_links": "No links"
diff --git a/translations/sv.json b/translations/sv.json
index 37715e89b..516021a1f 100644
--- a/translations/sv.json
+++ b/translations/sv.json
@@ -676,7 +676,10 @@
"boxsets": "Box Set",
"playlists": "Spellistor",
"noDataTitle": "Inga favoriter än",
- "noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst."
+ "noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst.",
+ "watchlist": "Bevakningslista",
+ "noWatchlistTitle": "Inga bevakade objekt än",
+ "noWatchlistData": "Lägg till objekt i din bevakningslista för att se dem visas här."
},
"custom_links": {
"no_links": "Inga Länkar"
diff --git a/utils/atoms/filters.ts b/utils/atoms/filters.ts
index 2c04e917f..bda8a62cf 100644
--- a/utils/atoms/filters.ts
+++ b/utils/atoms/filters.ts
@@ -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;
};