From 8d54d65b63ec98bff4aed7217a9d1117e7e97101 Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:04:15 +0200 Subject: [PATCH] feat: adding episode count indicator (#1787) Co-authored-by: Gauvain Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- components/WatchedIndicator.tsx | 130 ++++++++++++++++++++++------ components/posters/ItemPoster.tsx | 2 +- components/posters/SeriesPoster.tsx | 2 + components/tv/TVPosterCard.tsx | 13 ++- 4 files changed, 117 insertions(+), 30 deletions(-) diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx index 0d2999f53..145ee7e0e 100644 --- a/components/WatchedIndicator.tsx +++ b/components/WatchedIndicator.tsx @@ -1,43 +1,119 @@ 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 React from "react"; +import { Platform, View, type ViewStyle } from "react-native"; +import { Text } from "@/components/common/Text"; +import { scaleSize } from "@/utils/scaleSize"; -export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { - if (Platform.isTV) { - // TV: Show white checkmark when watched - if ( - item.UserData?.Played && - (item.Type === "Movie" || item.Type === "Episode") - ) { +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 }> = React.memo( + ({ item }) => { + if (!isAggregateType(item)) return null; + if (item.UserData?.Played) return null; + const unplayed = item.UserData?.UnplayedItemCount ?? 0; + if (unplayed <= 0) return null; + // Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+). + const label = unplayed >= 1000 ? "1k+" : String(unplayed); + + if (Platform.isTV) { return ( - + + {label} + ); } - return null; + + return ( + + + {label} + + + ); + }, +); + +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) { + // Fully watched → white checkmark badge (top-right) + if (isPlayed && (isMovieOrEpisode || isAggregate)) { + return ( + + + + ); + } + // 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") && ( - - )} + {/* Strict === false: items without UserData (unknown state) get no ribbon */} + {isMovieOrEpisode && item.UserData?.Played === false && ( + + )} + + {/* Fully watched Series/BoxSet → small purple checkmark */} + {isAggregate && isPlayed && ( + + + + )} + + {/* Series/BoxSet with remaining episodes → count badge */} + ); }; diff --git a/components/posters/ItemPoster.tsx b/components/posters/ItemPoster.tsx index bcd857e79..9990727be 100644 --- a/components/posters/ItemPoster.tsx +++ b/components/posters/ItemPoster.tsx @@ -1,8 +1,8 @@ import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useState } from "react"; import { View, type ViewProps } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { ItemImage } from "../common/ItemImage"; -import { WatchedIndicator } from "../WatchedIndicator"; interface Props extends ViewProps { item: BaseItemDto; diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx index 07f212d7f..82cf146c7 100644 --- a/components/posters/SeriesPoster.tsx +++ b/components/posters/SeriesPoster.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import { useAtom } from "jotai"; import { useMemo } from "react"; import { View } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -52,6 +53,7 @@ const SeriesPoster: React.FC = ({ item }) => { width: "100%", }} /> + ); }; diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index 6c20075fd..9a24a5034 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). + */} + {showWatchedIndicator && } ); } @@ -459,7 +468,7 @@ export const TVPosterCard: React.FC = ({ /> {PlayButtonOverlay} {NowPlayingBadge} - + {showWatchedIndicator && } );