From eba72e9d7319c51d7a48c5f4f9493eaf111da150 Mon Sep 17 00:00:00 2001 From: Simon Eklundh Date: Mon, 8 Jun 2026 19:11:11 +0200 Subject: [PATCH] feat: add kefintweaks watchlist integration properly --- app/(auth)/(tabs)/(favorites)/index.tsx | 32 +++- app/(auth)/(tabs)/(favorites)/see-all.tsx | 11 +- .../series/[id].tsx | 6 +- components/AddToKefinWatchlist.tsx | 28 ++++ components/ItemContent.tsx | 8 + components/ItemContent.tv.tsx | 2 + components/common/TouchableItemRouter.tsx | 81 +++++++--- components/favorites/FavoritesTabButtons.tsx | 74 +++++++++ components/favorites/TVFavoritesTabBadges.tsx | 117 ++++++++++++++ components/home/Favorites.tsx | 65 +++++--- components/home/Favorites.tv.tsx | 135 +++++++++++----- components/tv/TVWatchlistButton.tsx | 36 +++++ components/tv/index.ts | 2 + hooks/useWatchlist.ts | 144 ++++++++++++++++++ translations/en.json | 5 +- translations/sv.json | 5 +- utils/atoms/filters.ts | 2 - 17 files changed, 657 insertions(+), 96 deletions(-) create mode 100644 components/AddToKefinWatchlist.tsx create mode 100644 components/favorites/FavoritesTabButtons.tsx create mode 100644 components/favorites/TVFavoritesTabBadges.tsx create mode 100644 components/tv/TVWatchlistButton.tsx create mode 100644 hooks/useWatchlist.ts 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 ( + + +