Compare commits

...

4 Commits

Author SHA1 Message Date
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
17 changed files with 710 additions and 97 deletions

View File

@@ -1,14 +1,24 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, RefreshControl, ScrollView, View } from "react-native"; import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
import { Favorites } from "@/components/home/Favorites"; import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv"; import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useSettings } from "@/utils/atoms/settings";
export default function FavoritesPage() { export default function FavoritesPage() {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const { t } = useTranslation();
const { settings } = useSettings();
const [loading, setLoading] = useState(false); 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 () => { const refetch = useCallback(async () => {
setLoading(true); setLoading(true);
await invalidateCache(); await invalidateCache();
@@ -20,6 +30,8 @@ export default function FavoritesPage() {
return <TVFavorites />; return <TVFavorites />;
} }
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled
@@ -34,7 +46,26 @@ export default function FavoritesPage() {
}} }}
> >
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}> <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> </View>
</ScrollView> </ScrollView>
); );

View File

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

View File

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

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

View File

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

View File

@@ -11,8 +11,10 @@ import {
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useWatchlist } from "@/hooks/useWatchlist";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
@@ -155,6 +157,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
@@ -183,36 +187,66 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
) )
return; return;
const options: string[] = [ // Build options as { label, action } so dynamic entries (watchlist,
t("common.mark_as_played"), // offline delete) don't break index-based handling.
t("common.mark_as_not_played"), const actions: {
isFavorite label: string;
? t("music.track_options.remove_from_favorites") action: () => void;
: t("music.track_options.add_to_favorites"), destructive?: boolean;
...(isOffline ? [t("home.downloads.delete_download")] : []), }[] = [
t("common.cancel"), {
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 cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = isOffline const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
? cancelButtonIndex - 1
: undefined;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
options, options,
cancelButtonIndex, cancelButtonIndex,
destructiveButtonIndex, destructiveButtonIndex:
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
}, },
async (selectedIndex) => { (selectedIndex) => {
if (selectedIndex === 0) { if (selectedIndex === undefined || selectedIndex >= actions.length)
await markAsPlayedStatus(true); return;
} else if (selectedIndex === 1) { actions[selectedIndex].action();
await markAsPlayedStatus(false);
} else if (selectedIndex === 2) {
toggleFavorite();
} else if (isOffline && selectedIndex === 3 && item.Id) {
deleteFile(item.Id);
}
}, },
); );
}, [ }, [
@@ -220,6 +254,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isFavorite, isFavorite,
markAsPlayedStatus, markAsPlayedStatus,
toggleFavorite, toggleFavorite,
isWatchlisted,
toggleWatchlist,
settings?.useKefinTweaks,
isOffline, isOffline,
deleteFile, deleteFile,
item.Id, 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 { 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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { t } from "i18next"; import { t } from "i18next";
@@ -22,7 +25,24 @@ type FavoriteTypes =
| "Playlist"; | "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>; 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;
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
seeAllNamespace?: string;
}
export const Favorites = ({
filter = "IsFavorite",
queryKeyBase = "favorites",
emptyTitleKey = "favorites.noDataTitle",
emptyTextKey = "favorites.noData",
seeAllNamespace = "favorites",
}: FavoritesProps = {}) => {
const router = useRouter(); const router = useRouter();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -46,7 +66,7 @@ export const Favorites = () => {
userId: user?.Id, userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"], sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"], sortOrder: ["Ascending"],
filters: ["IsFavorite"], filters: [filter],
recursive: true, recursive: true,
fields: ["PrimaryImageAspectRatio"], fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false, collapseBoxSetItems: false,
@@ -68,7 +88,7 @@ export const Favorites = () => {
return items; return items;
}, },
[api, user], [api, user, filter],
); );
// Reset empty state when component mounts or dependencies change // Reset empty state when component mounts or dependencies change
@@ -126,44 +146,68 @@ export const Favorites = () => {
const handleSeeAllSeries = useCallback(() => { const handleSeeAllSeries = useCallback(() => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all", pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Series", title: t("favorites.series") }, params: {
type: "Series",
title: t(`${seeAllNamespace}.seeAllSeries`),
filter,
},
} as any); } as any);
}, [router]); }, [router, filter, seeAllNamespace]);
const handleSeeAllMovies = useCallback(() => { const handleSeeAllMovies = useCallback(() => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all", pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Movie", title: t("favorites.movies") }, params: {
type: "Movie",
title: t(`${seeAllNamespace}.seeAllMovies`),
filter,
},
} as any); } as any);
}, [router]); }, [router, filter, seeAllNamespace]);
const handleSeeAllEpisodes = useCallback(() => { const handleSeeAllEpisodes = useCallback(() => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all", pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Episode", title: t("favorites.episodes") }, params: {
type: "Episode",
title: t(`${seeAllNamespace}.seeAllEpisodes`),
filter,
},
} as any); } as any);
}, [router]); }, [router, filter, seeAllNamespace]);
const handleSeeAllVideos = useCallback(() => { const handleSeeAllVideos = useCallback(() => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all", pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Video", title: t("favorites.videos") }, params: {
type: "Video",
title: t(`${seeAllNamespace}.seeAllVideos`),
filter,
},
} as any); } as any);
}, [router]); }, [router, filter, seeAllNamespace]);
const handleSeeAllBoxsets = useCallback(() => { const handleSeeAllBoxsets = useCallback(() => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all", pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "BoxSet", title: t("favorites.boxsets") }, params: {
type: "BoxSet",
title: t(`${seeAllNamespace}.seeAllBoxsets`),
filter,
},
} as any); } as any);
}, [router]); }, [router, filter, seeAllNamespace]);
const handleSeeAllPlaylists = useCallback(() => { const handleSeeAllPlaylists = useCallback(() => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all", pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Playlist", title: t("favorites.playlists") }, params: {
type: "Playlist",
title: t(`${seeAllNamespace}.seeAllPlaylists`),
filter,
},
} as any); } as any);
}, [router]); }, [router, filter, seeAllNamespace]);
return ( return (
<View className='flex flex-co gap-y-4'> <View className='flex flex-co gap-y-4'>
@@ -176,16 +220,16 @@ export const Favorites = () => {
source={heart} source={heart}
/> />
<Text className='text-xl font-semibold text-white mb-2'> <Text className='text-xl font-semibold text-white mb-2'>
{t("favorites.noDataTitle")} {t(emptyTitleKey)}
</Text> </Text>
<Text className='text-base text-white/70 text-center max-w-xs px-4'> <Text className='text-base text-white/70 text-center max-w-xs px-4'>
{t("favorites.noData")} {t(emptyTextKey)}
</Text> </Text>
</View> </View>
)} )}
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries} queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]} queryKey={["home", queryKeyBase, "series"]}
title={t("favorites.series")} title={t("favorites.series")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
@@ -193,7 +237,7 @@ export const Favorites = () => {
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies} queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]} queryKey={["home", queryKeyBase, "movies"]}
title={t("favorites.movies")} title={t("favorites.movies")}
hideIfEmpty hideIfEmpty
orientation='vertical' orientation='vertical'
@@ -202,7 +246,7 @@ export const Favorites = () => {
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes} queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]} queryKey={["home", queryKeyBase, "episodes"]}
title={t("favorites.episodes")} title={t("favorites.episodes")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
@@ -210,7 +254,7 @@ export const Favorites = () => {
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos} queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]} queryKey={["home", queryKeyBase, "videos"]}
title={t("favorites.videos")} title={t("favorites.videos")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
@@ -218,7 +262,7 @@ export const Favorites = () => {
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets} queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]} queryKey={["home", queryKeyBase, "boxsets"]}
title={t("favorites.boxsets")} title={t("favorites.boxsets")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
@@ -226,7 +270,7 @@ export const Favorites = () => {
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists} queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]} queryKey={["home", queryKeyBase, "playlists"]}
title={t("favorites.playlists")} title={t("favorites.playlists")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}

View File

@@ -1,5 +1,8 @@
import type { Api } from "@jellyfin/sdk"; 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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -9,10 +12,12 @@ import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import heart from "@/assets/icons/heart.fill.png"; import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
const HORIZONTAL_PADDING = 60; const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100; const TOP_PADDING = 100;
@@ -33,7 +38,27 @@ export const Favorites = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { settings } = useSettings();
const pageSize = 20; 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>({ const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false, Series: false,
Movie: false, Movie: false,
@@ -53,7 +78,7 @@ export const Favorites = () => {
userId: user?.Id, userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"], sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"], sortOrder: ["Ascending"],
filters: ["IsFavorite"], filters: [filter],
recursive: true, recursive: true,
fields: ["PrimaryImageAspectRatio"], fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false, collapseBoxSetItems: false,
@@ -74,7 +99,7 @@ export const Favorites = () => {
return items; return items;
}, },
[api, user], [api, user, filter],
); );
useEffect(() => { useEffect(() => {
@@ -86,7 +111,7 @@ export const Favorites = () => {
BoxSet: false, BoxSet: false,
Playlist: false, Playlist: false,
}); });
}, [api, user]); }, [api, user, viewType]);
const areAllEmpty = () => { const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState); const loadedCategories = Object.values(emptyState);
@@ -127,46 +152,63 @@ export const Favorites = () => {
[fetchFavoritesByType, pageSize], [fetchFavoritesByType, pageSize],
); );
const tabBadges = (
<TVFavoritesTabBadges
viewType={viewType}
setViewType={setViewType}
enabled={watchlistEnabled}
hasTVPreferredFocus={watchlistEnabled}
/>
);
if (areAllEmpty()) { if (areAllEmpty()) {
return ( return (
<View <View
style={{ style={{
flex: 1, flex: 1,
alignItems: "center", paddingTop: insets.top + TOP_PADDING,
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING, paddingHorizontal: HORIZONTAL_PADDING,
}} }}
> >
<Image {tabBadges}
<View
style={{ style={{
width: 64, flex: 1,
height: 64, alignItems: "center",
marginBottom: 16, justifyContent: "center",
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}} }}
> >
{t("favorites.noDataTitle")} <Image
</Text> style={{
<Text width: 64,
style={{ height: 64,
textAlign: "center", marginBottom: 16,
opacity: 0.7, tintColor: Colors.primary,
fontSize: typography.body, }}
color: "#FFFFFF", contentFit='contain'
}} source={heart}
> />
{t("favorites.noData")} <Text
</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> </View>
); );
} }
@@ -181,17 +223,22 @@ export const Favorites = () => {
}} }}
> >
<View style={{ gap: SECTION_GAP }}> <View style={{ gap: SECTION_GAP }}>
{watchlistEnabled && (
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
{tabBadges}
</View>
)}
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries} queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]} queryKey={["home", queryKeyBase, "series"]}
title={t("favorites.series")} title={t("favorites.series")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
isFirstSection isFirstSection={!watchlistEnabled}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies} queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]} queryKey={["home", queryKeyBase, "movies"]}
title={t("favorites.movies")} title={t("favorites.movies")}
hideIfEmpty hideIfEmpty
orientation='vertical' orientation='vertical'
@@ -199,28 +246,28 @@ export const Favorites = () => {
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes} queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]} queryKey={["home", queryKeyBase, "episodes"]}
title={t("favorites.episodes")} title={t("favorites.episodes")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos} queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]} queryKey={["home", queryKeyBase, "videos"]}
title={t("favorites.videos")} title={t("favorites.videos")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets} queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]} queryKey={["home", queryKeyBase, "boxsets"]}
title={t("favorites.boxsets")} title={t("favorites.boxsets")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists} queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]} queryKey={["home", queryKeyBase, "playlists"]}
title={t("favorites.playlists")} title={t("favorites.playlists")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} 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 // User switching
export type { TVUserCardProps } from "./TVUserCard"; export type { TVUserCardProps } from "./TVUserCard";
export { TVUserCard } from "./TVUserCard"; export { TVUserCard } from "./TVUserCard";
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
export { TVWatchlistButton } from "./TVWatchlistButton";

146
hooks/useWatchlist.ts Normal file
View File

@@ -0,0 +1,146 @@
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<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 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<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) {
return;
}
// 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: (_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,
};
};

View File

@@ -593,8 +593,25 @@
"videos": "Videos", "videos": "Videos",
"boxsets": "Box sets", "boxsets": "Box sets",
"playlists": "Playlists", "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", "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": { "custom_links": {
"no_links": "No links" "no_links": "No links"

View File

@@ -675,8 +675,25 @@
"videos": "Videor", "videos": "Videor",
"boxsets": "Box Set", "boxsets": "Box Set",
"playlists": "Spellistor", "playlists": "Spellistor",
"seeAllSeries": "Favoritmarkerade serier",
"seeAllMovies": "Favoritmarkerade filmer",
"seeAllEpisodes": "Favoritmarkerade avsnitt",
"seeAllVideos": "Favoritmarkerade videor",
"seeAllBoxsets": "Favoritmarkerade box set",
"seeAllPlaylists": "Favoritmarkerade spellistor",
"noDataTitle": "Inga favoriter än", "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"
},
"kefintweaksWatchlist": {
"seeAllSeries": "Bevakade serier",
"seeAllMovies": "Bevakade filmer",
"seeAllEpisodes": "Bevakade avsnitt",
"seeAllVideos": "Bevakade videor",
"seeAllBoxsets": "Bevakade box set",
"seeAllPlaylists": "Bevakade spellistor",
"noDataTitle": "Inga bevakade objekt än",
"noData": "Lägg till objekt i din bevakningslista för att se dem visas här."
}, },
"custom_links": { "custom_links": {
"no_links": "Inga Länkar" "no_links": "Inga Länkar"

View File

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