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
4 changed files with 113 additions and 29 deletions

View File

@@ -1,43 +1,116 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import React from "react";
import { Platform, View } from "react-native"; 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 }) => { const isAggregateType = (item: BaseItemDto) =>
if (Platform.isTV) { item.Type === "Series" || item.Type === "BoxSet";
// TV: Show white checkmark when watched
if ( // TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
item.UserData?.Played && const tvBadgeBase: ViewStyle = {
(item.Type === "Movie" || item.Type === "Episode")
) {
return (
<View
style={{
position: "absolute", position: "absolute",
top: 8, top: scaleSize(8),
right: 8, right: scaleSize(8),
backgroundColor: "rgba(255,255,255,0.9)", height: scaleSize(28),
borderRadius: 14, borderRadius: scaleSize(14),
width: 28, backgroundColor: "rgba(255,255,255,0.92)",
height: 28,
alignItems: "center", alignItems: "center",
justifyContent: "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={[
tvBadgeBase,
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
]}
>
<Text
style={{
fontSize: scaleSize(15),
fontWeight: "700",
color: "black",
}} }}
> >
<Ionicons name='checkmark' size={18} color='black' /> {unplayed}
</Text>
</View> </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 ( return (
<> <>
{item.UserData?.Played === false && {isMovieOrEpisode && !isPlayed && (
(item.Type === "Movie" || item.Type === "Episode") && (
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' /> <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 { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useState } from "react"; import { useState } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { ItemImage } from "../common/ItemImage"; import { ItemImage } from "../common/ItemImage";
import { WatchedIndicator } from "../WatchedIndicator";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;

View File

@@ -3,6 +3,7 @@ import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -52,6 +53,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
width: "100%", width: "100%",
}} }}
/> />
<WatchedIndicator item={item} />
</View> </View>
); );
}; };

View File

@@ -12,7 +12,10 @@ import {
} from "react-native"; } from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar"; import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { WatchedIndicator } from "@/components/WatchedIndicator"; import {
UnplayedCountBadge,
WatchedIndicator,
} from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { import {
@@ -427,6 +430,12 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
/> />
{PlayButtonOverlay} {PlayButtonOverlay}
{NowPlayingBadge} {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>
); );
} }