mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-05 03:52:51 +01:00
Compare commits
3 Commits
renovate/s
...
feat/count
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4db89ec916 | ||
|
|
0bb7aa68ae | ||
|
|
d1637a778e |
@@ -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")
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={[
|
||||||
position: "absolute",
|
tvBadgeBase,
|
||||||
top: 8,
|
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||||
right: 8,
|
]}
|
||||||
backgroundColor: "rgba(255,255,255,0.9)",
|
|
||||||
borderRadius: 14,
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Ionicons name='checkmark' size={18} color='black' />
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: scaleSize(15),
|
||||||
|
fontWeight: "700",
|
||||||
|
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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user