From d1637a778e9851c20a0104650c98987d00c0be8d Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:33:59 +0200 Subject: [PATCH] feat: adding episode count indicator Added a series count indicator, and watched indicator for series Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- components/WatchedIndicator.tsx | 121 ++++++++++++++++++++++------ components/posters/SeriesPoster.tsx | 2 + components/tv/TVPosterCard.tsx | 11 ++- 3 files changed, 107 insertions(+), 27 deletions(-) diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx index 0d2999f5..04fad0ac 100644 --- a/components/WatchedIndicator.tsx +++ b/components/WatchedIndicator.tsx @@ -1,43 +1,112 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type React from "react"; -import { Platform, View } from "react-native"; +import { Platform, View, type ViewStyle } from "react-native"; +import { scaleSize } from "@/utils/scaleSize"; +import { Text } from "./common/Text"; + +const isAggregateType = (item: BaseItemDto) => + item.Type === "Series" || item.Type === "BoxSet"; + +// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize). +const tvBadgeBase: ViewStyle = { + position: "absolute", + top: scaleSize(8), + right: scaleSize(8), + height: scaleSize(28), + borderRadius: scaleSize(14), + backgroundColor: "rgba(255,255,255,0.92)", + alignItems: "center", + justifyContent: "center", +}; + +// Mobile uses raw dp — no scaling. +const mobileBadgeBase: ViewStyle = { + position: "absolute", + top: 4, + right: 4, + height: 20, + borderRadius: 10, + backgroundColor: "#9333ea", + alignItems: "center", + justifyContent: "center", +}; + +/** + * Renders the unplayed-episode count badge for Series/BoxSet items that still + * have episodes left to watch. Returns null for non-aggregate types, fully + * watched items, or items with no unplayed count, so it is safe to mount + * unconditionally as an overlay (e.g. on top of the tvOS glass poster, where + * the watched checkmark is drawn natively and only the count needs RN). + */ +export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = ({ + item, +}) => { + if (!isAggregateType(item)) return null; + if (item.UserData?.Played) return null; + const unplayed = item.UserData?.UnplayedItemCount ?? 0; + if (unplayed <= 0) return null; + + if (Platform.isTV) { + return ( + + + {unplayed} + + + ); + } + + return ( + + + {unplayed} + + + ); +}; export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { + const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode"; + const isAggregate = isAggregateType(item); + const isPlayed = item.UserData?.Played === true; + if (Platform.isTV) { - // TV: Show white checkmark when watched - if ( - item.UserData?.Played && - (item.Type === "Movie" || item.Type === "Episode") - ) { + // Fully watched → white checkmark badge (top-right) + if (isPlayed && (isMovieOrEpisode || isAggregate)) { return ( - - + + ); } - return null; + // Series/BoxSet with remaining episodes → count badge + return ; } - // Mobile: Show purple triangle for unwatched + // Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior) return ( <> - {item.UserData?.Played === false && - (item.Type === "Movie" || item.Type === "Episode") && ( - - )} + {isMovieOrEpisode && !isPlayed && ( + + )} + + {/* Fully watched Series/BoxSet → small purple checkmark */} + {isAggregate && isPlayed && ( + + + + )} + + {/* Series/BoxSet with remaining episodes → count badge */} + ); }; diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx index 07f212d7..5266ab3f 100644 --- a/components/posters/SeriesPoster.tsx +++ b/components/posters/SeriesPoster.tsx @@ -5,6 +5,7 @@ import { useMemo } from "react"; import { View } from "react-native"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { WatchedIndicator } from "../WatchedIndicator"; type MoviePosterProps = { item: BaseItemDto; @@ -52,6 +53,7 @@ const SeriesPoster: React.FC = ({ item }) => { width: "100%", }} /> + ); }; diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index 6c20075f..08306101 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -12,7 +12,10 @@ import { } from "react-native"; import { ProgressBar } from "@/components/common/ProgressBar"; import { Text } from "@/components/common/Text"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; +import { + UnplayedCountBadge, + WatchedIndicator, +} from "@/components/WatchedIndicator"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { @@ -427,6 +430,12 @@ export const TVPosterCard: React.FC = ({ /> {PlayButtonOverlay} {NowPlayingBadge} + {/* + The glass view draws the watched checkmark natively but cannot show + an unplayed-episode count, so render it as an RN overlay on top. + Returns null when not applicable (non-series / fully watched). + */} + ); }