mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-20 11:04:19 +01:00
rewrite where watchlists ends up and cleanup
This commit is contained in:
@@ -1,37 +1,25 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||
"Favorites",
|
||||
);
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [invalidateCache]);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (Platform.isTV) {
|
||||
return <TVFavorites />;
|
||||
}
|
||||
|
||||
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
@@ -46,26 +34,7 @@ export default function FavoritesPage() {
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
{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 />
|
||||
)}
|
||||
<Favorites />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -1,215 +1 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type FavoriteTypes =
|
||||
| "Series"
|
||||
| "Movie"
|
||||
| "Episode"
|
||||
| "Video"
|
||||
| "BoxSet"
|
||||
| "Playlist";
|
||||
|
||||
const favoriteTypes: readonly FavoriteTypes[] = [
|
||||
"Series",
|
||||
"Movie",
|
||||
"Episode",
|
||||
"Video",
|
||||
"BoxSet",
|
||||
"Playlist",
|
||||
] as const;
|
||||
|
||||
function isFavoriteType(value: unknown): value is FavoriteTypes {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(favoriteTypes as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export default function FavoritesSeeAllScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const searchParams = useLocalSearchParams<{
|
||||
type?: string;
|
||||
title?: string;
|
||||
filter?: string;
|
||||
}>();
|
||||
const typeParam = searchParams.type;
|
||||
const titleParam = searchParams.title;
|
||||
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
|
||||
const filter: ItemFilter =
|
||||
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
if (!isFavoriteType(typeParam)) return null;
|
||||
return typeParam as BaseItemKind;
|
||||
}, [typeParam]);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (typeof titleParam === "string" && titleParam.trim().length > 0)
|
||||
return titleParam;
|
||||
return "";
|
||||
}, [titleParam]);
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
|
||||
if (!api || !user?.Id || !itemType) return [];
|
||||
|
||||
const response = await getItemsApi(api as Api).getItems({
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
excludeLocationTypes: ["Virtual"],
|
||||
enableTotalRecordCount: true,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
includeItemTypes: [itemType],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id, filter],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType, filter],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
return pages.reduce((acc, page) => acc + page.length, 0);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!itemType,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (screenWidth < 350) return 2;
|
||||
if (screenWidth < 600) return 3;
|
||||
if (screenWidth < 900) return 5;
|
||||
return 6;
|
||||
}, [screenWidth]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[nrOfCols],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{!itemType ? (
|
||||
<View className='flex-1 items-center justify-center px-6'>
|
||||
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.8}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("home.no_items")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
isFetching ? (
|
||||
<View style={{ paddingVertical: 16 }}>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export { default } from "@/components/favorites/FavoritesSeeAll";
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||
|
||||
export default function WatchlistsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* The create ("+") button is set from the index screen based on the
|
||||
active source (Streamystats vs KefinTweaks); see index.tsx. */}
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
@@ -22,18 +18,6 @@ export default function WatchlistsLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: streamystatsEnabled
|
||||
? () => (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(watchlists)/create")
|
||||
}
|
||||
className='p-1.5'
|
||||
>
|
||||
<Ionicons name='add' size={24} color='white' />
|
||||
</Pressable>
|
||||
)
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
|
||||
@@ -1,239 +1,239 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Stack } from "expo-router";
|
||||
import { useHeaderHeight } from "expo-router/react-navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { SegmentedToggle } from "@/components/common/SegmentedToggle";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { TVSegmentedControl } from "@/components/tv/TVSegmentedControl";
|
||||
import { StreamystatsWatchlists } from "@/components/watchlists/StreamystatsWatchlists";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
useStreamystatsEnabled,
|
||||
useWatchlistsQuery,
|
||||
} from "@/hooks/useWatchlists";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface WatchlistCardProps {
|
||||
watchlist: StreamystatsWatchlist;
|
||||
isOwner: boolean;
|
||||
onPress: () => void;
|
||||
const TV_TOP_PADDING = 100;
|
||||
const TV_HORIZONTAL_PADDING = 60;
|
||||
|
||||
type WatchlistSource = "streamystats" | "kefin";
|
||||
|
||||
interface WatchlistsViewProps {
|
||||
streamystatsShown: boolean;
|
||||
kefinShown: boolean;
|
||||
}
|
||||
|
||||
const WatchlistCard: React.FC<WatchlistCardProps> = ({
|
||||
watchlist,
|
||||
isOwner,
|
||||
onPress,
|
||||
}) => {
|
||||
function useToggleOptions() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className='flex-row items-center justify-between mb-2'>
|
||||
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
|
||||
{watchlist.name}
|
||||
</Text>
|
||||
<View className='flex-row items-center gap-2'>
|
||||
{isOwner && (
|
||||
<View className='bg-purple-600/20 px-2 py-1 rounded'>
|
||||
<Text className='text-purple-400 text-xs'>
|
||||
{t("watchlists.you")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Ionicons
|
||||
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
|
||||
size={16}
|
||||
color='#9ca3af'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{watchlist.description && (
|
||||
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
|
||||
{watchlist.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className='flex-row items-center gap-4'>
|
||||
<View className='flex-row items-center gap-1'>
|
||||
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{watchlist.itemCount ?? 0}{" "}
|
||||
{(watchlist.itemCount ?? 0) === 1
|
||||
? t("watchlists.item")
|
||||
: t("watchlists.items")}
|
||||
</Text>
|
||||
</View>
|
||||
{watchlist.allowedItemType && (
|
||||
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
|
||||
<Text className='text-neutral-400 text-xs'>
|
||||
{watchlist.allowedItemType}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "streamystats" as const,
|
||||
label: t("watchlists.source_streamystats"),
|
||||
},
|
||||
{ value: "kefin" as const, label: t("watchlists.source_kefintweaks") },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
|
||||
onCreatePress: _onCreatePress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='list-outline' size={64} color='#4b5563' />
|
||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||
{t("watchlists.empty_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||
{t("watchlists.empty_description")}
|
||||
</Text>
|
||||
</View>
|
||||
/**
|
||||
* Resolves which watchlist source is active given what's enabled. When both
|
||||
* are shown the user toggles between them (defaulting to Streamystats);
|
||||
* otherwise the single enabled source wins.
|
||||
*/
|
||||
function useWatchlistSource(streamystatsShown: boolean, kefinShown: boolean) {
|
||||
const showToggle = streamystatsShown && kefinShown;
|
||||
const [source, setSource] = useState<WatchlistSource>(
|
||||
streamystatsShown ? "streamystats" : "kefin",
|
||||
);
|
||||
};
|
||||
|
||||
const NotConfiguredState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const activeSource: WatchlistSource = showToggle
|
||||
? source
|
||||
: streamystatsShown
|
||||
? "streamystats"
|
||||
: "kefin";
|
||||
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='settings-outline' size={64} color='#4b5563' />
|
||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||
{t("watchlists.not_configured_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||
{t("watchlists.not_configured_description")}
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() =>
|
||||
router.push(
|
||||
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
|
||||
)
|
||||
}
|
||||
className='px-6'
|
||||
>
|
||||
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return { source, setSource, activeSource, showToggle };
|
||||
}
|
||||
|
||||
export default function WatchlistsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
/** Shared KefinTweaks (Likes-backed) view — the favorites grid with a Likes filter. */
|
||||
function KefinWatchlistView() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const user = useAtomValue(userAtom);
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
}, [refetch]);
|
||||
|
||||
const handleCreatePress = useCallback(() => {
|
||||
router.push("/(auth)/(tabs)/(watchlists)/create");
|
||||
}, [router]);
|
||||
|
||||
const handleWatchlistPress = useCallback(
|
||||
(watchlistId: number) => {
|
||||
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
// Separate watchlists into "mine" and "public"
|
||||
const { myWatchlists, publicWatchlists } = useMemo(() => {
|
||||
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
|
||||
|
||||
const mine: StreamystatsWatchlist[] = [];
|
||||
const pub: StreamystatsWatchlist[] = [];
|
||||
|
||||
for (const w of watchlists) {
|
||||
if (w.userId === user?.Id) {
|
||||
mine.push(w);
|
||||
} else {
|
||||
pub.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
return { myWatchlists: mine, publicWatchlists: pub };
|
||||
}, [watchlists, user?.Id]);
|
||||
|
||||
// Combine into sections for FlashList
|
||||
const sections = useMemo(() => {
|
||||
const result: Array<
|
||||
| { type: "header"; title: string }
|
||||
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
|
||||
> = [];
|
||||
|
||||
if (myWatchlists.length > 0) {
|
||||
result.push({ type: "header", title: t("watchlists.my_watchlists") });
|
||||
for (const w of myWatchlists) {
|
||||
result.push({ type: "watchlist", data: w, isOwner: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (publicWatchlists.length > 0) {
|
||||
result.push({ type: "header", title: t("watchlists.public_watchlists") });
|
||||
for (const w of publicWatchlists) {
|
||||
result.push({ type: "watchlist", data: w, isOwner: false });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [myWatchlists, publicWatchlists, t]);
|
||||
|
||||
if (!streamystatsEnabled) {
|
||||
return <NotConfiguredState />;
|
||||
}
|
||||
|
||||
if (!isLoading && (!watchlists || watchlists.length === 0)) {
|
||||
return <EmptyState onCreatePress={handleCreatePress} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={sections}
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: Platform.OS === "android" ? 10 : 0,
|
||||
paddingBottom: 100,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
if (item.type === "header") {
|
||||
return (
|
||||
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
|
||||
{item.title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
<Favorites
|
||||
filter='Likes'
|
||||
queryKeyBase='watchlist'
|
||||
seeAllNamespace='kefintweaksWatchlist'
|
||||
seeAllPathname='/(auth)/(tabs)/(watchlists)/see-all'
|
||||
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
|
||||
emptyTextKey='kefintweaksWatchlist.noData'
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WatchlistCard
|
||||
watchlist={item.data}
|
||||
isOwner={item.isOwner}
|
||||
onPress={() => handleWatchlistPress(item.data.id)}
|
||||
function MobileWatchlists({
|
||||
streamystatsShown,
|
||||
kefinShown,
|
||||
}: WatchlistsViewProps) {
|
||||
const headerHeight = useHeaderHeight();
|
||||
const options = useToggleOptions();
|
||||
const router = useRouter();
|
||||
const { source, setSource, activeSource, showToggle } = useWatchlistSource(
|
||||
streamystatsShown,
|
||||
kefinShown,
|
||||
);
|
||||
|
||||
if (!streamystatsShown && !kefinShown) return null;
|
||||
|
||||
const activeView =
|
||||
activeSource === "streamystats" ? (
|
||||
<StreamystatsWatchlists />
|
||||
) : (
|
||||
<KefinWatchlistView />
|
||||
);
|
||||
|
||||
// The "+" only creates Streamystats watchlists, so hide it whenever the
|
||||
// active view is KefinTweaks (Likes-backed, nothing to create).
|
||||
const headerRight =
|
||||
activeSource === "streamystats"
|
||||
? () => (
|
||||
<Pressable
|
||||
onPress={() => router.push("/(auth)/(tabs)/(watchlists)/create")}
|
||||
className='p-1.5'
|
||||
>
|
||||
<Ionicons name='add' size={24} color='white' />
|
||||
</Pressable>
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerRight }} />
|
||||
{showToggle ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Clear the transparent iOS header; the Android header is opaque so
|
||||
content already starts below it. */}
|
||||
<View
|
||||
style={{
|
||||
paddingTop: Platform.OS === "ios" ? headerHeight : 10,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
>
|
||||
<SegmentedToggle
|
||||
options={options}
|
||||
value={source}
|
||||
onChange={setSource}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>{activeView}</View>
|
||||
</View>
|
||||
) : (
|
||||
activeView
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TVWatchlists({ streamystatsShown, kefinShown }: WatchlistsViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const options = useToggleOptions();
|
||||
const { source, setSource, activeSource, showToggle } = useWatchlistSource(
|
||||
streamystatsShown,
|
||||
kefinShown,
|
||||
);
|
||||
|
||||
if (!streamystatsShown && !kefinShown) return null;
|
||||
|
||||
if (!showToggle) {
|
||||
return activeSource === "streamystats" ? (
|
||||
<StreamystatsWatchlists />
|
||||
) : (
|
||||
<TVFavorites
|
||||
filter='Likes'
|
||||
queryKeyBase='watchlist'
|
||||
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
|
||||
emptyTextKey='kefintweaksWatchlist.noData'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
paddingTop: insets.top + TV_TOP_PADDING,
|
||||
paddingHorizontal: TV_HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<TVSegmentedControl
|
||||
options={options}
|
||||
value={source}
|
||||
onChange={setSource}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
{activeSource === "streamystats" ? (
|
||||
<StreamystatsWatchlists />
|
||||
) : (
|
||||
<TVFavorites
|
||||
filter='Likes'
|
||||
queryKeyBase='watchlist'
|
||||
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
|
||||
emptyTextKey='kefintweaksWatchlist.noData'
|
||||
isFirstSection={false}
|
||||
contentTopPadding={0}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
getItemType={(item) => item.type}
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watchlists tab. Hosts the Streamystats (plugin) watchlists and/or the
|
||||
* KefinTweaks (Likes-backed) watchlist depending on which is enabled:
|
||||
* - streamystats only -> Streamystats list
|
||||
* - kefintweaks only -> KefinTweaks grid
|
||||
* - both -> Streamystats by default, with a source toggle
|
||||
* - neither -> nothing (the tab is hidden upstream)
|
||||
*
|
||||
* `hideWatchlistsTab` suppresses only the Streamystats side (see `streamystatsShown`).
|
||||
*/
|
||||
export default function WatchlistsScreen() {
|
||||
const { settings } = useSettings();
|
||||
const streamystatsShown =
|
||||
useStreamystatsEnabled() && !settings?.hideWatchlistsTab;
|
||||
const kefinShown = settings?.useKefinTweaks ?? false;
|
||||
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<TVWatchlists
|
||||
streamystatsShown={streamystatsShown}
|
||||
kefinShown={kefinShown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileWatchlists
|
||||
streamystatsShown={streamystatsShown}
|
||||
kefinShown={kefinShown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
1
app/(auth)/(tabs)/(watchlists)/see-all.tsx
Normal file
1
app/(auth)/(tabs)/(watchlists)/see-all.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/components/favorites/FavoritesSeeAll";
|
||||
@@ -61,9 +61,12 @@ function TVTabLayout() {
|
||||
{ key: "(home)", label: t("tabs.home") },
|
||||
{ key: "(search)", label: t("tabs.search") },
|
||||
{ key: "(favorites)", label: t("tabs.favorites") },
|
||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
||||
? null
|
||||
: { key: "(watchlists)", label: t("watchlists.title") },
|
||||
// `hideWatchlistsTab` only suppresses the Streamystats side; KefinTweaks
|
||||
// keeps the tab visible on its own.
|
||||
(settings?.streamyStatsServerUrl && !settings?.hideWatchlistsTab) ||
|
||||
settings?.useKefinTweaks
|
||||
? { key: "(watchlists)", label: t("watchlists.title") }
|
||||
: null,
|
||||
{ key: "(libraries)", label: t("tabs.library") },
|
||||
!settings?.showCustomMenuLinks
|
||||
? null
|
||||
@@ -73,6 +76,7 @@ function TVTabLayout() {
|
||||
[
|
||||
settings?.streamyStatsServerUrl,
|
||||
settings?.hideWatchlistsTab,
|
||||
settings?.useKefinTweaks,
|
||||
settings?.showCustomMenuLinks,
|
||||
t,
|
||||
],
|
||||
@@ -203,8 +207,13 @@ export default function TabLayout() {
|
||||
name='(watchlists)'
|
||||
options={{
|
||||
title: t("watchlists.title"),
|
||||
tabBarItemHidden:
|
||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||
// Shown when Streamystats (URL set & not hidden) OR KefinTweaks is
|
||||
// enabled. `hideWatchlistsTab` only suppresses the Streamystats side.
|
||||
tabBarItemHidden: !(
|
||||
(settings?.streamyStatsServerUrl &&
|
||||
!settings?.hideWatchlistsTab) ||
|
||||
settings?.useKefinTweaks
|
||||
),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.star.png")
|
||||
|
||||
50
components/common/SegmentedToggle.tsx
Normal file
50
components/common/SegmentedToggle.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
export interface SegmentedToggleOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SegmentedToggleProps<T extends string> {
|
||||
options: SegmentedToggleOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-width segmented view switcher: the options split the available width
|
||||
* evenly as chunky segments inside a rounded track. Used to swap between two
|
||||
* (or more) views, e.g. Streamystats vs KefinTweaks watchlists.
|
||||
*/
|
||||
export function SegmentedToggle<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: SegmentedToggleProps<T>) {
|
||||
return (
|
||||
<View className='flex-row w-full bg-neutral-800 rounded-xl p-1'>
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => onChange(option.value)}
|
||||
activeOpacity={0.8}
|
||||
className={`flex-1 items-center justify-center rounded-lg py-4 ${
|
||||
selected ? "bg-purple-600" : ""
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-base font-semibold ${
|
||||
selected ? "text-white" : "text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
220
components/favorites/FavoritesSeeAll.tsx
Normal file
220
components/favorites/FavoritesSeeAll.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type FavoriteTypes =
|
||||
| "Series"
|
||||
| "Movie"
|
||||
| "Episode"
|
||||
| "Video"
|
||||
| "BoxSet"
|
||||
| "Playlist";
|
||||
|
||||
const favoriteTypes: readonly FavoriteTypes[] = [
|
||||
"Series",
|
||||
"Movie",
|
||||
"Episode",
|
||||
"Video",
|
||||
"BoxSet",
|
||||
"Playlist",
|
||||
] as const;
|
||||
|
||||
function isFavoriteType(value: unknown): value is FavoriteTypes {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(favoriteTypes as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "See all" grid for the favorites / KefinTweaks-watchlist rows. Reads
|
||||
* `type` / `title` / `filter` route params, so it renders identically whether
|
||||
* mounted under the (favorites) or (watchlists) tab.
|
||||
*/
|
||||
export default function FavoritesSeeAll() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const searchParams = useLocalSearchParams<{
|
||||
type?: string;
|
||||
title?: string;
|
||||
filter?: string;
|
||||
}>();
|
||||
const typeParam = searchParams.type;
|
||||
const titleParam = searchParams.title;
|
||||
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
|
||||
const filter: ItemFilter =
|
||||
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
if (!isFavoriteType(typeParam)) return null;
|
||||
return typeParam as BaseItemKind;
|
||||
}, [typeParam]);
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (typeof titleParam === "string" && titleParam.trim().length > 0)
|
||||
return titleParam;
|
||||
return "";
|
||||
}, [titleParam]);
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
|
||||
if (!api || !user?.Id || !itemType) return [];
|
||||
|
||||
const response = await getItemsApi(api as Api).getItems({
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
excludeLocationTypes: ["Virtual"],
|
||||
enableTotalRecordCount: true,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
includeItemTypes: [itemType],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id, filter],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType, filter],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
return pages.reduce((acc, page) => acc + page.length, 0);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!itemType,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (screenWidth < 350) return 2;
|
||||
if (screenWidth < 600) return 3;
|
||||
if (screenWidth < 900) return 5;
|
||||
return 6;
|
||||
}, [screenWidth]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf:
|
||||
index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[nrOfCols],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{!itemType ? (
|
||||
<View className='flex-1 items-center justify-center px-6'>
|
||||
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.8}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 24,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("home.no_items")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
isFetching ? (
|
||||
<View style={{ paddingVertical: 16 }}>
|
||||
<Loader />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -36,6 +36,8 @@ interface FavoritesProps {
|
||||
emptyTextKey?: string;
|
||||
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
|
||||
seeAllNamespace?: "kefintweaksWatchlist" | "favorites";
|
||||
/** Route the "See all" screen lives at; defaults to the favorites tab. */
|
||||
seeAllPathname?: string;
|
||||
}
|
||||
|
||||
export const Favorites = ({
|
||||
@@ -44,6 +46,7 @@ export const Favorites = ({
|
||||
emptyTitleKey = "favorites.noDataTitle",
|
||||
emptyTextKey = "favorites.noData",
|
||||
seeAllNamespace = "favorites",
|
||||
seeAllPathname = "/(auth)/(tabs)/(favorites)/see-all",
|
||||
}: FavoritesProps = {}) => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -148,11 +151,11 @@ export const Favorites = ({
|
||||
? t(`kefintweaksWatchlist.seeAll${name}`)
|
||||
: t(`favorites.seeAll${name}`);
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
pathname: seeAllPathname,
|
||||
params: { type, title, filter },
|
||||
} as any);
|
||||
},
|
||||
[router, filter, seeAllNamespace],
|
||||
[router, filter, seeAllNamespace, seeAllPathname],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,12 +12,10 @@ import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
@@ -34,32 +32,35 @@ type FavoriteTypes =
|
||||
// message during a favorites/watchlist switch before the new queries resolve.
|
||||
type EmptyState = Record<FavoriteTypes, boolean | null>;
|
||||
|
||||
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;
|
||||
/** false when a toggle sits above these lists (so the toggle takes first focus). */
|
||||
isFirstSection?: boolean;
|
||||
/** Overrides the default nav-bar clearance; used when a toggle already clears it. */
|
||||
contentTopPadding?: number;
|
||||
}
|
||||
|
||||
export const Favorites = ({
|
||||
filter = "IsFavorite",
|
||||
queryKeyBase = "favorites",
|
||||
emptyTitleKey = "favorites.noDataTitle",
|
||||
emptyTextKey = "favorites.noData",
|
||||
isFirstSection = true,
|
||||
contentTopPadding,
|
||||
}: FavoritesProps = {}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const pageSize = 20;
|
||||
|
||||
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||
"Favorites",
|
||||
);
|
||||
const filter: ItemFilter =
|
||||
watchlistEnabled && viewType === "Watchlist" ? "Likes" : "IsFavorite";
|
||||
const queryKeyBase =
|
||||
watchlistEnabled && viewType === "Watchlist" ? "watchlist" : "favorites";
|
||||
// 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 topPadding = contentTopPadding ?? insets.top + TOP_PADDING;
|
||||
|
||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: null,
|
||||
@@ -146,31 +147,17 @@ export const Favorites = () => {
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const tabBadges = (
|
||||
<TVFavoritesTabBadges
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
enabled={watchlistEnabled}
|
||||
hasTVPreferredFocus={watchlistEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingTop: topPadding,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: SECTION_GAP, flex: 1 }}>
|
||||
{watchlistEnabled && (
|
||||
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
|
||||
{tabBadges}
|
||||
</View>
|
||||
)}
|
||||
{/* Rendered alongside the lists (never instead of them) so they stay
|
||||
mounted and re-report emptiness on a favorites/watchlist switch;
|
||||
an early return here would freeze the all-empty state. */}
|
||||
@@ -221,7 +208,7 @@ export const Favorites = () => {
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
isFirstSection={!watchlistEnabled}
|
||||
isFirstSection={isFirstSection}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type React from "react";
|
||||
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 {
|
||||
interface TVSegmentBadgeProps {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
|
||||
const TVSegmentBadge: React.FC<TVSegmentBadgeProps> = ({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
@@ -72,46 +69,41 @@ const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export interface TVFavoritesTabBadgesProps {
|
||||
viewType: ViewType;
|
||||
setViewType: (type: ViewType) => void;
|
||||
/** Only render the toggle when the KefinTweaks watchlist is enabled. */
|
||||
enabled: boolean;
|
||||
export interface TVSegmentedControlOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TVSegmentedControlProps<T extends string> {
|
||||
options: TVSegmentedControlOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
/** When true, the currently-selected badge receives initial TV focus. */
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
export const TVFavoritesTabBadges: React.FC<TVFavoritesTabBadgesProps> = ({
|
||||
viewType,
|
||||
setViewType,
|
||||
enabled,
|
||||
/**
|
||||
* Focusable TV segmented control (design: white focus, blurred pill unfocused).
|
||||
* Generalized from the former TVFavoritesTabBadges so it can drive any
|
||||
* segmented swap on TV. Only the selected badge takes preferred focus.
|
||||
*/
|
||||
export function TVSegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
}: TVSegmentedControlProps<T>) {
|
||||
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 style={{ flexDirection: "row", gap: 16, marginBottom: 24 }}>
|
||||
{options.map((option) => (
|
||||
<TVSegmentBadge
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
isSelected={value === option.value}
|
||||
onPress={() => onChange(option.value)}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && value === option.value}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
}
|
||||
238
components/watchlists/StreamystatsWatchlists.tsx
Normal file
238
components/watchlists/StreamystatsWatchlists.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
useStreamystatsEnabled,
|
||||
useWatchlistsQuery,
|
||||
} from "@/hooks/useWatchlists";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||
|
||||
interface WatchlistCardProps {
|
||||
watchlist: StreamystatsWatchlist;
|
||||
isOwner: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const WatchlistCard: React.FC<WatchlistCardProps> = ({
|
||||
watchlist,
|
||||
isOwner,
|
||||
onPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className='flex-row items-center justify-between mb-2'>
|
||||
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
|
||||
{watchlist.name}
|
||||
</Text>
|
||||
<View className='flex-row items-center gap-2'>
|
||||
{isOwner && (
|
||||
<View className='bg-purple-600/20 px-2 py-1 rounded'>
|
||||
<Text className='text-purple-400 text-xs'>
|
||||
{t("watchlists.you")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Ionicons
|
||||
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
|
||||
size={16}
|
||||
color='#9ca3af'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{watchlist.description && (
|
||||
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
|
||||
{watchlist.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className='flex-row items-center gap-4'>
|
||||
<View className='flex-row items-center gap-1'>
|
||||
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{watchlist.itemCount ?? 0}{" "}
|
||||
{(watchlist.itemCount ?? 0) === 1
|
||||
? t("watchlists.item")
|
||||
: t("watchlists.items")}
|
||||
</Text>
|
||||
</View>
|
||||
{watchlist.allowedItemType && (
|
||||
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
|
||||
<Text className='text-neutral-400 text-xs'>
|
||||
{watchlist.allowedItemType}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='list-outline' size={64} color='#4b5563' />
|
||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||
{t("watchlists.empty_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||
{t("watchlists.empty_description")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const NotConfiguredState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View className='flex-1 items-center justify-center px-8'>
|
||||
<Ionicons name='settings-outline' size={64} color='#4b5563' />
|
||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||
{t("watchlists.not_configured_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||
{t("watchlists.not_configured_description")}
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() =>
|
||||
router.push(
|
||||
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
|
||||
)
|
||||
}
|
||||
className='px-6'
|
||||
>
|
||||
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Streamystats (plugin-backed) custom/shared watchlists list. Extracted from
|
||||
* the watchlists tab index so the tab can switch between this and the
|
||||
* KefinTweaks (Likes) view.
|
||||
*/
|
||||
export const StreamystatsWatchlists: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const user = useAtomValue(userAtom);
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
}, [refetch]);
|
||||
|
||||
const handleWatchlistPress = useCallback(
|
||||
(watchlistId: number) => {
|
||||
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
// Separate watchlists into "mine" and "public"
|
||||
const { myWatchlists, publicWatchlists } = useMemo(() => {
|
||||
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
|
||||
|
||||
const mine: StreamystatsWatchlist[] = [];
|
||||
const pub: StreamystatsWatchlist[] = [];
|
||||
|
||||
for (const w of watchlists) {
|
||||
if (w.userId === user?.Id) {
|
||||
mine.push(w);
|
||||
} else {
|
||||
pub.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
return { myWatchlists: mine, publicWatchlists: pub };
|
||||
}, [watchlists, user?.Id]);
|
||||
|
||||
// Combine into sections for FlashList
|
||||
const sections = useMemo(() => {
|
||||
const result: Array<
|
||||
| { type: "header"; title: string }
|
||||
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
|
||||
> = [];
|
||||
|
||||
if (myWatchlists.length > 0) {
|
||||
result.push({ type: "header", title: t("watchlists.my_watchlists") });
|
||||
for (const w of myWatchlists) {
|
||||
result.push({ type: "watchlist", data: w, isOwner: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (publicWatchlists.length > 0) {
|
||||
result.push({ type: "header", title: t("watchlists.public_watchlists") });
|
||||
for (const w of publicWatchlists) {
|
||||
result.push({ type: "watchlist", data: w, isOwner: false });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [myWatchlists, publicWatchlists, t]);
|
||||
|
||||
if (!streamystatsEnabled) {
|
||||
return <NotConfiguredState />;
|
||||
}
|
||||
|
||||
if (!isLoading && (!watchlists || watchlists.length === 0)) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={sections}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: Platform.OS === "android" ? 10 : 0,
|
||||
paddingBottom: 100,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
if (item.type === "header") {
|
||||
return (
|
||||
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
|
||||
{item.title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WatchlistCard
|
||||
watchlist={item.data}
|
||||
isOwner={item.isOwner}
|
||||
onPress={() => handleWatchlistPress(item.data.id)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
getItemType={(item) => item.type}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -85,7 +85,7 @@ const optIdx = lines.findIndex(
|
||||
if (optIdx === -1)
|
||||
throw new Error(`options: not found after id: ${DROPDOWN_ID}`);
|
||||
|
||||
const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper
|
||||
const itemIndent = `${lines[optIdx].match(/^\s*/)[0]} `; // options items are nested one level deeper
|
||||
let end = optIdx + 1;
|
||||
const sentinels = [];
|
||||
while (end < lines.length && /^\s*-\s+/.test(lines[end])) {
|
||||
|
||||
@@ -601,8 +601,7 @@
|
||||
"seeAllBoxsets": "Favorited Box sets",
|
||||
"seeAllPlaylists": "Favorited Playlists",
|
||||
"noDataTitle": "No favorites yet",
|
||||
"noData": "Mark items as favorites to see them appear here for quick access.",
|
||||
"watchlist": "Watchlist"
|
||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
||||
},
|
||||
"kefintweaksWatchlist": {
|
||||
"seeAllSeries": "Watchlisted Series",
|
||||
@@ -919,7 +918,9 @@
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
"create_one_first": "Create a watchlist that accepts this content type",
|
||||
"source_streamystats": "Streamystats",
|
||||
"source_kefintweaks": "Kefintweaks"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback speed",
|
||||
|
||||
@@ -854,6 +854,16 @@
|
||||
"date_created": "Datum skapad"
|
||||
}
|
||||
},
|
||||
"kefintweaksWatchlist": {
|
||||
"seeAllSeries": "Bevakade serier",
|
||||
"seeAllMovies": "Bevakade filmer",
|
||||
"seeAllEpisodes": "Bevakade avsnitt",
|
||||
"seeAllVideos": "Bevakade videor",
|
||||
"seeAllBoxsets": "Bevakade boxset",
|
||||
"seeAllPlaylists": "Bevakade spellistor",
|
||||
"noDataTitle": "Inga bevakade objekt än",
|
||||
"noData": "Lägg till objekt i din bevakningslista för att se dem här."
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Bevakningslistor",
|
||||
"my_watchlists": "Mina bevakningslistor",
|
||||
@@ -897,7 +907,9 @@
|
||||
"remove_item_message": "Ta bort \"{{name}}\" från denna bevakningslista?",
|
||||
"loading": "Laddar bevakningslistor...",
|
||||
"no_compatible_watchlists": "Inga kompatibla bevakningslistor",
|
||||
"create_one_first": "Skapa en bevakningslista som accepterar denna innehållstyp"
|
||||
"create_one_first": "Skapa en bevakningslista som accepterar denna innehållstyp",
|
||||
"source_streamystats": "Streamystats",
|
||||
"source_kefintweaks": "Kefintweaks"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Uppspelningshastighet",
|
||||
|
||||
Reference in New Issue
Block a user