Compare commits

..

3 Commits

Author SHA1 Message Date
Lance Chant
4db89ec916 fixed typo
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-04 13:10:51 +02:00
Lance Chant
0bb7aa68ae addressing PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-04 13:00:54 +02:00
Lance Chant
d1637a778e 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>
2026-07-04 12:33:59 +02:00
6 changed files with 116 additions and 32 deletions

View File

@@ -93,7 +93,7 @@
"react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3",
"sonner-native": "0.26.4",
"sonner-native": "0.21.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
"zod": "4.4.3",
@@ -1765,7 +1765,7 @@
"slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="],
"sonner-native": ["sonner-native@0.26.4", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.28.0", "react-native-reanimated": "^4.1.1", "react-native-safe-area-context": "^5.6.0", "react-native-screens": "^4.16.0", "react-native-svg": "^15.12.1", "react-native-worklets": ">=0.6.1" } }, "sha512-wBE/pctNfz+ieXjpYX7uu5hH38ws8xRmH0AFmEdEgkOS363k6N8QfFoyjGqDmdUDy15m4AAvOjd2Wz/jATukIA=="],
"sonner-native": ["sonner-native@0.21.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-LnGPmfgzrNIwcc+FvcLJqx8aH1dEHePRzvNR8aIR4kl9spySRkXK160GmQIazjfm6mSMlPqZwRa5eycvrzg/eQ=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],

View File

@@ -1,43 +1,116 @@
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;
if (Platform.isTV) {
return (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
style={[
tvBadgeBase,
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
]}
>
<Ionicons name='checkmark' size={18} color='black' />
<Text
style={{
fontSize: scaleSize(15),
fontWeight: "700",
color: "black",
}}
>
{unplayed}
</Text>
</View>
);
}
return null;
return (
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
{unplayed}
</Text>
</View>
);
},
);
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 (
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
</View>
);
}
// Series/BoxSet with remaining episodes → count badge
return <UnplayedCountBadge item={item} />;
}
// 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") && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
{isMovieOrEpisode && !isPlayed && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
)}
{/* Fully watched Series/BoxSet → small purple checkmark */}
{isAggregate && isPlayed && (
<View style={[mobileBadgeBase, { width: 20 }]}>
<Ionicons name='checkmark' size={13} color='white' />
</View>
)}
{/* Series/BoxSet with remaining episodes → count badge */}
<UnplayedCountBadge item={item} />
</>
);
};

View File

@@ -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;

View File

@@ -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<MoviePosterProps> = ({ item }) => {
width: "100%",
}}
/>
<WatchedIndicator item={item} />
</View>
);
};

View File

@@ -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<TVPosterCardProps> = ({
/>
{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).
*/}
<UnplayedCountBadge item={item} />
</View>
);
}

View File

@@ -116,7 +116,7 @@
"react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3",
"sonner-native": "0.26.4",
"sonner-native": "0.21.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
"zod": "4.4.3"