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 && }
);