This commit is contained in:
Fredrik Burmester
2026-01-18 20:06:09 +01:00
parent 81af2afef8
commit f8d1fad6d5
9 changed files with 536 additions and 323 deletions

View File

@@ -0,0 +1,56 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { LinearGradient } from "expo-linear-gradient";
import React from "react";
import { View } from "react-native";
import { ItemImage } from "@/components/common/ItemImage";
export interface TVBackdropProps {
item: BaseItemDto;
}
export const TVBackdrop: React.FC<TVBackdropProps> = React.memo(({ item }) => {
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
<ItemImage
variant='Backdrop'
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
<LinearGradient
colors={["rgba(0,0,0,0.8)", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 0.6, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "60%",
}}
/>
</View>
);
});

View File

@@ -0,0 +1,75 @@
import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
export interface TVCastCrewTextProps {
director?: BaseItemPerson | null;
cast?: BaseItemPerson[];
/** Hide the cast section (e.g., when visual cast section is shown) */
hideCast?: boolean;
}
export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
({ director, cast, hideCast = false }) => {
const { t } = useTranslation();
if (!director && (!cast || cast.length === 0)) {
return null;
}
return (
<View style={{ marginBottom: 32 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("item_card.cast_and_crew")}
</Text>
<View style={{ flexDirection: "row", gap: 40 }}>
{director && (
<View>
<Text
style={{
fontSize: 14,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
}}
>
{t("item_card.director")}
</Text>
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
{director.Name}
</Text>
</View>
)}
{!hideCast && cast && cast.length > 0 && (
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 14,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
}}
>
{t("item_card.cast")}
</Text>
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
{cast.map((c) => c.Name).join(", ")}
</Text>
</View>
)}
</View>
</View>
);
},
);

View File

@@ -0,0 +1,78 @@
import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, TVFocusGuideView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVActorCard } from "./TVActorCard";
export interface TVCastSectionProps {
cast: BaseItemPerson[];
apiBasePath?: string;
onActorPress: (personId: string) => void;
/** Setter function for the first actor card ref (for focus guide) */
firstActorRefSetter?: (ref: View | null) => void;
/** Ref to focus guide destination for upward navigation */
upwardFocusDestination?: View | null;
}
export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
({
cast,
apiBasePath,
onActorPress,
firstActorRefSetter,
upwardFocusDestination,
}) => {
const { t } = useTranslation();
if (cast.length === 0) {
return null;
}
return (
<View style={{ marginBottom: 32 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 20,
}}
>
{t("item_card.cast")}
</Text>
{/* Focus guide to direct upward navigation from cast back to options */}
{upwardFocusDestination && (
<TVFocusGuideView
destinations={[upwardFocusDestination]}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -80, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 12,
gap: 20,
}}
>
{cast.map((person, index) => (
<TVActorCard
key={person.Id || index}
ref={index === 0 ? firstActorRefSetter : undefined}
person={person}
apiBasePath={apiBasePath}
onPress={() => {
if (person.Id) {
onActorPress(person.Id);
}
}}
/>
))}
</ScrollView>
</View>
);
},
);

View File

@@ -0,0 +1,43 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
export interface TVMetadataBadgesProps {
year?: number | null;
duration?: string | null;
officialRating?: string | null;
communityRating?: number | null;
}
export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
({ year, duration, officialRating, communityRating }) => {
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: 12,
marginBottom: 20,
}}
>
{year != null && (
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{year}</Text>
)}
{duration && (
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{duration}</Text>
)}
{officialRating && <Badge text={officialRating} variant='gray' />}
{communityRating != null && (
<Badge
text={communityRating.toFixed(1)}
variant='gray'
iconLeft={<Ionicons name='star' size={16} color='gold' />}
/>
)}
</View>
);
},
);

View File

@@ -0,0 +1,49 @@
import React from "react";
import { View } from "react-native";
export interface TVProgressBarProps {
/** Progress value between 0 and 1 */
progress: number;
/** Background color of the track */
trackColor?: string;
/** Color of the progress fill */
fillColor?: string;
/** Maximum width of the progress bar */
maxWidth?: number;
/** Height of the progress bar */
height?: number;
}
export const TVProgressBar: React.FC<TVProgressBarProps> = React.memo(
({
progress,
trackColor = "rgba(255,255,255,0.2)",
fillColor = "#a855f7",
maxWidth = 400,
height = 4,
}) => {
const clampedProgress = Math.max(0, Math.min(1, progress));
return (
<View style={{ maxWidth, marginBottom: 24 }}>
<View
style={{
height,
backgroundColor: trackColor,
borderRadius: height / 2,
overflow: "hidden",
}}
>
<View
style={{
width: `${clampedProgress * 100}%`,
height: "100%",
backgroundColor: fillColor,
borderRadius: height / 2,
}}
/>
</View>
</View>
);
},
);

View File

@@ -0,0 +1,69 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
export interface TVSeriesNavigationProps {
item: BaseItemDto;
seriesImageUrl?: string | null;
seasonImageUrl?: string | null;
onSeriesPress: () => void;
onSeasonPress: () => void;
}
export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
const { t } = useTranslation();
// Only show for episodes with a series
if (item.Type !== "Episode" || !item.SeriesId) {
return null;
}
return (
<View style={{ marginBottom: 32 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 20,
}}
>
{t("item_card.from_this_series") || "From this Series"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -80, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 12,
gap: 24,
}}
>
{/* Series card */}
<TVSeriesSeasonCard
title={item.SeriesName || "Series"}
subtitle={t("item_card.view_series") || "View Series"}
imageUrl={seriesImageUrl ?? null}
onPress={onSeriesPress}
hasTVPreferredFocus={false}
/>
{/* Season card */}
{(item.SeasonId || item.ParentId) && (
<TVSeriesSeasonCard
title={item.SeasonName || `Season ${item.ParentIndexNumber}`}
subtitle={t("item_card.view_season") || "View Season"}
imageUrl={seasonImageUrl ?? null}
onPress={onSeasonPress}
/>
)}
</ScrollView>
</View>
);
},
);

View File

@@ -0,0 +1,77 @@
import type { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
export interface TVTechnicalDetailsProps {
mediaStreams: MediaStream[];
}
export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
({ mediaStreams }) => {
const { t } = useTranslation();
const videoStream = mediaStreams.find((s) => s.Type === "Video");
const audioStream = mediaStreams.find((s) => s.Type === "Audio");
if (!videoStream && !audioStream) {
return null;
}
return (
<View style={{ marginBottom: 32 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("item_card.technical_details")}
</Text>
<View style={{ flexDirection: "row", gap: 40 }}>
{videoStream && (
<View>
<Text
style={{
fontSize: 14,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
}}
>
Video
</Text>
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
{videoStream.DisplayTitle ||
`${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
</Text>
</View>
)}
{audioStream && (
<View>
<Text
style={{
fontSize: 14,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
}}
>
Audio
</Text>
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
{audioStream.DisplayTitle ||
`${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
</Text>
</View>
)}
</View>
</View>
);
},
);

View File

@@ -9,11 +9,17 @@ export * from "./settings";
// Item content components
export type { TVActorCardProps } from "./TVActorCard";
export { TVActorCard } from "./TVActorCard";
export type { TVBackdropProps } from "./TVBackdrop";
export { TVBackdrop } from "./TVBackdrop";
// Core components
export type { TVButtonProps } from "./TVButton";
export { TVButton } from "./TVButton";
export type { TVCancelButtonProps } from "./TVCancelButton";
export { TVCancelButton } from "./TVCancelButton";
export type { TVCastCrewTextProps } from "./TVCastCrewText";
export { TVCastCrewText } from "./TVCastCrewText";
export type { TVCastSectionProps } from "./TVCastSection";
export { TVCastSection } from "./TVCastSection";
// Player control components
export type { TVControlButtonProps } from "./TVControlButton";
export { TVControlButton } from "./TVControlButton";
@@ -21,6 +27,8 @@ export type { TVFocusablePosterProps } from "./TVFocusablePoster";
export { TVFocusablePoster } from "./TVFocusablePoster";
export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
export { TVMetadataBadges } from "./TVMetadataBadges";
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
export type { TVOptionButtonProps } from "./TVOptionButton";
@@ -29,14 +37,20 @@ export type { TVOptionCardProps } from "./TVOptionCard";
export { TVOptionCard } from "./TVOptionCard";
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
export { TVOptionSelector } from "./TVOptionSelector";
export type { TVProgressBarProps } from "./TVProgressBar";
export { TVProgressBar } from "./TVProgressBar";
export type { TVRefreshButtonProps } from "./TVRefreshButton";
export { TVRefreshButton } from "./TVRefreshButton";
export type { TVSeriesNavigationProps } from "./TVSeriesNavigation";
export { TVSeriesNavigation } from "./TVSeriesNavigation";
export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard";
export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard";
export { TVSubtitleResultCard } from "./TVSubtitleResultCard";
export type { TVTabButtonProps } from "./TVTabButton";
export { TVTabButton } from "./TVTabButton";
export type { TVTechnicalDetailsProps } from "./TVTechnicalDetails";
export { TVTechnicalDetails } from "./TVTechnicalDetails";
// Subtitle sheet components
export type { TVTrackCardProps } from "./TVTrackCard";
export { TVTrackCard } from "./TVTrackCard";