mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-11 08:20:25 +01:00
Compare commits
1 Commits
feat/kefin
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b6f456f2 |
2
.github/workflows/conflict.yml
vendored
2
.github/workflows/conflict.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: 🚩 Apply merge conflict label
|
- name: 🚩 Apply merge conflict label
|
||||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||||
with:
|
with:
|
||||||
dirtyLabel: '⚔️ merge-conflict'
|
dirtyLabel: '⚔️ merge-conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
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();
|
||||||
@@ -30,8 +20,6 @@ export default function FavoritesPage() {
|
|||||||
return <TVFavorites />;
|
return <TVFavorites />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -46,26 +34,7 @@ export default function FavoritesPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||||
{watchlistEnabled && (
|
<Favorites />
|
||||||
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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";
|
||||||
@@ -11,7 +10,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 { Platform, useWindowDimensions, View } from "react-native";
|
import { 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";
|
||||||
@@ -53,13 +52,9 @@ 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;
|
||||||
@@ -82,7 +77,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: [filter],
|
filters: ["IsFavorite"],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -95,12 +90,12 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
|
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
},
|
},
|
||||||
[api, itemType, user?.Id, filter],
|
[api, itemType, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: ["favorites", "see-all", itemType, filter],
|
queryKey: ["favorites", "see-all", itemType],
|
||||||
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;
|
||||||
@@ -160,7 +155,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
options={{
|
options={{
|
||||||
headerTitle: headerTitle,
|
headerTitle: headerTitle,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: true,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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";
|
||||||
@@ -19,7 +18,6 @@ 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,
|
||||||
@@ -32,7 +30,6 @@ 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,
|
||||||
@@ -140,7 +137,6 @@ 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'
|
||||||
@@ -161,7 +157,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
|
}, [allEpisodes, isLoading, item, isOffline]);
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -29,7 +29,6 @@ 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";
|
||||||
@@ -139,9 +138,6 @@ 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} />
|
||||||
@@ -164,9 +160,6 @@ 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} />
|
||||||
@@ -185,7 +178,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
settings.hideRemoteSessionButton,
|
settings.hideRemoteSessionButton,
|
||||||
settings.streamyStatsServerUrl,
|
settings.streamyStatsServerUrl,
|
||||||
settings.hideWatchlistsTab,
|
settings.hideWatchlistsTab,
|
||||||
settings.useKefinTweaks,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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";
|
||||||
@@ -753,7 +752,6 @@ 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>
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ 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;
|
||||||
@@ -157,8 +155,6 @@ 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();
|
||||||
@@ -187,66 +183,36 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Build options as { label, action } so dynamic entries (watchlist,
|
const options: string[] = [
|
||||||
// offline delete) don't break index-based handling.
|
t("common.mark_as_played"),
|
||||||
const actions: {
|
t("common.mark_as_not_played"),
|
||||||
label: string;
|
isFavorite
|
||||||
action: () => void;
|
? t("music.track_options.remove_from_favorites")
|
||||||
destructive?: boolean;
|
: t("music.track_options.add_to_favorites"),
|
||||||
}[] = [
|
...(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 = actions.findIndex((a) => a.destructive);
|
const destructiveButtonIndex = isOffline
|
||||||
|
? cancelButtonIndex - 1
|
||||||
|
: undefined;
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
options,
|
options,
|
||||||
cancelButtonIndex,
|
cancelButtonIndex,
|
||||||
destructiveButtonIndex:
|
destructiveButtonIndex,
|
||||||
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
|
|
||||||
},
|
},
|
||||||
(selectedIndex) => {
|
async (selectedIndex) => {
|
||||||
if (selectedIndex === undefined || selectedIndex >= actions.length)
|
if (selectedIndex === 0) {
|
||||||
return;
|
await markAsPlayedStatus(true);
|
||||||
actions[selectedIndex].action();
|
} else if (selectedIndex === 1) {
|
||||||
|
await markAsPlayedStatus(false);
|
||||||
|
} else if (selectedIndex === 2) {
|
||||||
|
toggleFavorite();
|
||||||
|
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||||
|
deleteFile(item.Id);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -254,9 +220,6 @@ 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,
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
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";
|
||||||
@@ -25,24 +22,7 @@ type FavoriteTypes =
|
|||||||
| "Playlist";
|
| "Playlist";
|
||||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||||
|
|
||||||
interface FavoritesProps {
|
export const Favorites = () => {
|
||||||
/** 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);
|
||||||
@@ -66,7 +46,7 @@ export const Favorites = ({
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: [filter],
|
filters: ["IsFavorite"],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -88,7 +68,7 @@ export const Favorites = ({
|
|||||||
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
[api, user, filter],
|
[api, user],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset empty state when component mounts or dependencies change
|
// Reset empty state when component mounts or dependencies change
|
||||||
@@ -146,68 +126,44 @@ 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: {
|
params: { type: "Series", title: t("favorites.series") },
|
||||||
type: "Series",
|
|
||||||
title: t(`${seeAllNamespace}.seeAllSeries`),
|
|
||||||
filter,
|
|
||||||
},
|
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, filter, seeAllNamespace]);
|
}, [router]);
|
||||||
|
|
||||||
const handleSeeAllMovies = useCallback(() => {
|
const handleSeeAllMovies = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: {
|
params: { type: "Movie", title: t("favorites.movies") },
|
||||||
type: "Movie",
|
|
||||||
title: t(`${seeAllNamespace}.seeAllMovies`),
|
|
||||||
filter,
|
|
||||||
},
|
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, filter, seeAllNamespace]);
|
}, [router]);
|
||||||
|
|
||||||
const handleSeeAllEpisodes = useCallback(() => {
|
const handleSeeAllEpisodes = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: {
|
params: { type: "Episode", title: t("favorites.episodes") },
|
||||||
type: "Episode",
|
|
||||||
title: t(`${seeAllNamespace}.seeAllEpisodes`),
|
|
||||||
filter,
|
|
||||||
},
|
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, filter, seeAllNamespace]);
|
}, [router]);
|
||||||
|
|
||||||
const handleSeeAllVideos = useCallback(() => {
|
const handleSeeAllVideos = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: {
|
params: { type: "Video", title: t("favorites.videos") },
|
||||||
type: "Video",
|
|
||||||
title: t(`${seeAllNamespace}.seeAllVideos`),
|
|
||||||
filter,
|
|
||||||
},
|
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, filter, seeAllNamespace]);
|
}, [router]);
|
||||||
|
|
||||||
const handleSeeAllBoxsets = useCallback(() => {
|
const handleSeeAllBoxsets = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: {
|
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||||
type: "BoxSet",
|
|
||||||
title: t(`${seeAllNamespace}.seeAllBoxsets`),
|
|
||||||
filter,
|
|
||||||
},
|
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, filter, seeAllNamespace]);
|
}, [router]);
|
||||||
|
|
||||||
const handleSeeAllPlaylists = useCallback(() => {
|
const handleSeeAllPlaylists = useCallback(() => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
params: {
|
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||||
type: "Playlist",
|
|
||||||
title: t(`${seeAllNamespace}.seeAllPlaylists`),
|
|
||||||
filter,
|
|
||||||
},
|
|
||||||
} as any);
|
} as any);
|
||||||
}, [router, filter, seeAllNamespace]);
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-co gap-y-4'>
|
<View className='flex flex-co gap-y-4'>
|
||||||
@@ -220,16 +176,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(emptyTitleKey)}
|
{t("favorites.noDataTitle")}
|
||||||
</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(emptyTextKey)}
|
{t("favorites.noData")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteSeries}
|
queryFn={fetchFavoriteSeries}
|
||||||
queryKey={["home", queryKeyBase, "series"]}
|
queryKey={["home", "favorites", "series"]}
|
||||||
title={t("favorites.series")}
|
title={t("favorites.series")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -237,7 +193,7 @@ export const Favorites = ({
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteMovies}
|
queryFn={fetchFavoriteMovies}
|
||||||
queryKey={["home", queryKeyBase, "movies"]}
|
queryKey={["home", "favorites", "movies"]}
|
||||||
title={t("favorites.movies")}
|
title={t("favorites.movies")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
orientation='vertical'
|
orientation='vertical'
|
||||||
@@ -246,7 +202,7 @@ export const Favorites = ({
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteEpisodes}
|
queryFn={fetchFavoriteEpisodes}
|
||||||
queryKey={["home", queryKeyBase, "episodes"]}
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
title={t("favorites.episodes")}
|
title={t("favorites.episodes")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -254,7 +210,7 @@ export const Favorites = ({
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteVideos}
|
queryFn={fetchFavoriteVideos}
|
||||||
queryKey={["home", queryKeyBase, "videos"]}
|
queryKey={["home", "favorites", "videos"]}
|
||||||
title={t("favorites.videos")}
|
title={t("favorites.videos")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -262,7 +218,7 @@ export const Favorites = ({
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteBoxsets}
|
queryFn={fetchFavoriteBoxsets}
|
||||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
title={t("favorites.boxsets")}
|
title={t("favorites.boxsets")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -270,7 +226,7 @@ export const Favorites = ({
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoritePlaylists}
|
queryFn={fetchFavoritePlaylists}
|
||||||
queryKey={["home", queryKeyBase, "playlists"]}
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
title={t("favorites.playlists")}
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
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";
|
||||||
@@ -12,12 +9,10 @@ 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;
|
||||||
@@ -38,27 +33,7 @@ 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,
|
||||||
@@ -78,7 +53,7 @@ export const Favorites = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: [filter],
|
filters: ["IsFavorite"],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -99,7 +74,7 @@ export const Favorites = () => {
|
|||||||
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
[api, user, filter],
|
[api, user],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,7 +86,7 @@ export const Favorites = () => {
|
|||||||
BoxSet: false,
|
BoxSet: false,
|
||||||
Playlist: false,
|
Playlist: false,
|
||||||
});
|
});
|
||||||
}, [api, user, viewType]);
|
}, [api, user]);
|
||||||
|
|
||||||
const areAllEmpty = () => {
|
const areAllEmpty = () => {
|
||||||
const loadedCategories = Object.values(emptyState);
|
const loadedCategories = Object.values(emptyState);
|
||||||
@@ -152,63 +127,46 @@ 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,
|
||||||
paddingTop: insets.top + TOP_PADDING,
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabBadges}
|
<Image
|
||||||
<View
|
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
width: 64,
|
||||||
alignItems: "center",
|
height: 64,
|
||||||
justifyContent: "center",
|
marginBottom: 16,
|
||||||
|
tintColor: Colors.primary,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
source={heart}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{t("favorites.noDataTitle")}
|
||||||
style={{
|
</Text>
|
||||||
width: 64,
|
<Text
|
||||||
height: 64,
|
style={{
|
||||||
marginBottom: 16,
|
textAlign: "center",
|
||||||
tintColor: Colors.primary,
|
opacity: 0.7,
|
||||||
}}
|
fontSize: typography.body,
|
||||||
contentFit='contain'
|
color: "#FFFFFF",
|
||||||
source={heart}
|
}}
|
||||||
/>
|
>
|
||||||
<Text
|
{t("favorites.noData")}
|
||||||
style={{
|
</Text>
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -223,22 +181,17 @@ 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", queryKeyBase, "series"]}
|
queryKey={["home", "favorites", "series"]}
|
||||||
title={t("favorites.series")}
|
title={t("favorites.series")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
isFirstSection={!watchlistEnabled}
|
isFirstSection
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteMovies}
|
queryFn={fetchFavoriteMovies}
|
||||||
queryKey={["home", queryKeyBase, "movies"]}
|
queryKey={["home", "favorites", "movies"]}
|
||||||
title={t("favorites.movies")}
|
title={t("favorites.movies")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
orientation='vertical'
|
orientation='vertical'
|
||||||
@@ -246,28 +199,28 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteEpisodes}
|
queryFn={fetchFavoriteEpisodes}
|
||||||
queryKey={["home", queryKeyBase, "episodes"]}
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
title={t("favorites.episodes")}
|
title={t("favorites.episodes")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteVideos}
|
queryFn={fetchFavoriteVideos}
|
||||||
queryKey={["home", queryKeyBase, "videos"]}
|
queryKey={["home", "favorites", "videos"]}
|
||||||
title={t("favorites.videos")}
|
title={t("favorites.videos")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteBoxsets}
|
queryFn={fetchFavoriteBoxsets}
|
||||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
title={t("favorites.boxsets")}
|
title={t("favorites.boxsets")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoritePlaylists}
|
queryFn={fetchFavoritePlaylists}
|
||||||
queryKey={["home", queryKeyBase, "playlists"]}
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
title={t("favorites.playlists")}
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -68,5 +68,3 @@ 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";
|
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -593,25 +593,8 @@
|
|||||||
"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"
|
||||||
|
|||||||
@@ -675,25 +675,8 @@
|
|||||||
"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"
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user