mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-05 03:52:51 +01:00
Compare commits
3 Commits
feat/count
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
888b735c42 | ||
|
|
a5daa5740e | ||
|
|
3950c01513 |
@@ -1,99 +0,0 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
streamType?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TrackSheet: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
streamType,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source, streamType],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
() => streams?.find((x) => x.Index === selected),
|
||||
[streams, selected],
|
||||
);
|
||||
|
||||
const noneOption = useMemo(
|
||||
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
// Creates a modified data array that includes a "None" option for subtitles
|
||||
// We might want to possibly do this for other places, like audio?
|
||||
const addNoneToSubtitles = useMemo(() => {
|
||||
if (streamType === "Subtitle") {
|
||||
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||
return result;
|
||||
}
|
||||
return streams;
|
||||
}, [streams, streamType, noneOption]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isTv || (streams && streams.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
? t("common.none")
|
||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={title}
|
||||
data={addNoneToSubtitles || []}
|
||||
values={
|
||||
selected === -1 && streamType === "Subtitle"
|
||||
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||
: selectedSteam
|
||||
? [selectedSteam]
|
||||
: []
|
||||
}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).DisplayTitle || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => (
|
||||
<Text>{(item as any).DisplayTitle || ""}</Text>
|
||||
)}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as any;
|
||||
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
|
||||
onChange(chosen.Index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,116 +1,43 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { Platform, View, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
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={[
|
||||
tvBadgeBase,
|
||||
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: scaleSize(15),
|
||||
fontWeight: "700",
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
{unplayed}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
||||
{unplayed}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
import type React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
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)) {
|
||||
// TV: Show white checkmark when watched
|
||||
if (
|
||||
item.UserData?.Played &&
|
||||
(item.Type === "Movie" || item.Type === "Episode")
|
||||
) {
|
||||
return (
|
||||
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Series/BoxSet with remaining episodes → count badge
|
||||
return <UnplayedCountBadge item={item} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||
// Mobile: Show purple triangle for unwatched
|
||||
return (
|
||||
<>
|
||||
{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} />
|
||||
{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' />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
|
||||
@@ -53,7 +52,6 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
} from "react-native";
|
||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
UnplayedCountBadge,
|
||||
WatchedIndicator,
|
||||
} from "@/components/WatchedIndicator";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
@@ -430,12 +427,6 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
|
||||
Reference in New Issue
Block a user