From f8d1fad6d5675995d48a50f1e7a6b979f53d528e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 18 Jan 2026 20:06:09 +0100 Subject: [PATCH] refactor --- components/ItemContent.tv.tsx | 398 +++++---------------------- components/tv/TVBackdrop.tsx | 56 ++++ components/tv/TVCastCrewText.tsx | 75 +++++ components/tv/TVCastSection.tsx | 78 ++++++ components/tv/TVMetadataBadges.tsx | 43 +++ components/tv/TVProgressBar.tsx | 49 ++++ components/tv/TVSeriesNavigation.tsx | 69 +++++ components/tv/TVTechnicalDetails.tsx | 77 ++++++ components/tv/index.ts | 14 + 9 files changed, 536 insertions(+), 323 deletions(-) create mode 100644 components/tv/TVBackdrop.tsx create mode 100644 components/tv/TVCastCrewText.tsx create mode 100644 components/tv/TVCastSection.tsx create mode 100644 components/tv/TVMetadataBadges.tsx create mode 100644 components/tv/TVProgressBar.tsx create mode 100644 components/tv/TVSeriesNavigation.tsx create mode 100644 components/tv/TVTechnicalDetails.tsx diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 5a8db4eb..9a99f001 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -7,23 +7,26 @@ import type { import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { LinearGradient } from "expo-linear-gradient"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Badge } from "@/components/Badge"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { GenreTags } from "@/components/GenreTags"; import { - TVActorCard, + TVBackdrop, TVButton, + TVCastCrewText, + TVCastSection, + TVMetadataBadges, TVOptionButton, + TVProgressBar, TVRefreshButton, - TVSeriesSeasonCard, + TVSeriesNavigation, + TVTechnicalDetails, } from "@/components/tv"; import useRouter from "@/hooks/useAppRouter"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; @@ -324,6 +327,13 @@ export const ItemContentTV: React.FC = React.memo( ); }, [item?.People]); + // Whether to show visual cast section + const showVisualCast = + (item?.Type === "Movie" || + item?.Type === "Series" || + item?.Type === "Episode") && + fullCast.length > 0; + // Series/Season image URLs for episodes const seriesImageUrl = useMemo(() => { if (item?.Type !== "Episode" || !item.SeriesId) return null; @@ -356,6 +366,28 @@ export const ItemContentTV: React.FC = React.memo( mediaSources.length, ]); + // Navigation handlers + const handleActorPress = useCallback( + (personId: string) => { + router.push(`/(auth)/persons/${personId}`); + }, + [router], + ); + + const handleSeriesPress = useCallback(() => { + if (item?.SeriesId) { + router.push(`/(auth)/series/${item.SeriesId}`); + } + }, [router, item?.SeriesId]); + + const handleSeasonPress = useCallback(() => { + if (item?.SeriesId && item?.ParentIndexNumber) { + router.push( + `/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`, + ); + } + }, [router, item?.SeriesId, item?.ParentIndexNumber]); + if (!item || !selectedOptions) return null; return ( @@ -366,48 +398,7 @@ export const ItemContentTV: React.FC = React.memo( }} > {/* Full-screen backdrop */} - - - {/* Gradient overlays for readability */} - - - + {/* Main content area */} = React.memo( minHeight: SCREEN_HEIGHT * 0.45, }} > - {/* Left side - Back button + Poster */} + {/* Left side - Poster */} - {/* Poster */} = React.memo( )} {/* Metadata badges row */} - - {year != null && ( - {year} - )} - {duration && ( - - {duration} - - )} - {item.OfficialRating && ( - - )} - {item.CommunityRating != null && ( - } - /> - )} - + {/* Genres */} {item.Genres && item.Genres.length > 0 && ( @@ -700,268 +668,52 @@ export const ItemContentTV: React.FC = React.memo( {/* Progress bar (if partially watched) */} {hasProgress && item.RunTimeTicks != null && ( - - - - - + )} {/* Additional info section */} - {/* Cast & Crew */} - {(director || (cast && cast.length > 0)) && ( - - - {t("item_card.cast_and_crew")} - - - {director && ( - - - {t("item_card.director")} - - - {director.Name} - - - )} - {/* Only show text cast if visual cast section won't be shown */} - {cast && - cast.length > 0 && - !( - (item.Type === "Movie" || - item.Type === "Series" || - item.Type === "Episode") && - fullCast.length > 0 - ) && ( - - - {t("item_card.cast")} - - - {cast.map((c) => c.Name).join(", ")} - - - )} - - - )} + {/* Cast & Crew (text version) */} + {/* Technical details */} {selectedOptions.mediaSource?.MediaStreams && selectedOptions.mediaSource.MediaStreams.length > 0 && ( - - - {t("item_card.technical_details")} - - - {/* Video info */} - {(() => { - const videoStream = - selectedOptions.mediaSource?.MediaStreams?.find( - (s) => s.Type === "Video", - ); - if (!videoStream) return null; - return ( - - - Video - - - {videoStream.DisplayTitle || - `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`} - - - ); - })()} - {/* Audio info */} - {(() => { - const audioStream = - selectedOptions.mediaSource?.MediaStreams?.find( - (s) => s.Type === "Audio", - ); - if (!audioStream) return null; - return ( - - - Audio - - - {audioStream.DisplayTitle || - `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`} - - - ); - })()} - - + )} {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */} - {(item.Type === "Movie" || - item.Type === "Series" || - item.Type === "Episode") && - fullCast.length > 0 && ( - - - {t("item_card.cast")} - - {/* Focus guide to direct upward navigation from cast back to options */} - {lastOptionButtonRef && ( - - )} - - {fullCast.map((person, index) => ( - { - if (person.Id) { - router.push(`/(auth)/persons/${person.Id}`); - } - }} - /> - ))} - - - )} + {showVisualCast && ( + + )} {/* From this Series - Episode only */} - {item.Type === "Episode" && item.SeriesId && ( - - - {t("item_card.from_this_series") || "From this Series"} - - - {/* Series card */} - { - router.push(`/(auth)/series/${item.SeriesId}`); - }} - hasTVPreferredFocus={false} - /> - - {/* Season card */} - {(item.SeasonId || item.ParentId) && ( - { - router.push( - `/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`, - ); - }} - /> - )} - - - )} + diff --git a/components/tv/TVBackdrop.tsx b/components/tv/TVBackdrop.tsx new file mode 100644 index 00000000..315afe41 --- /dev/null +++ b/components/tv/TVBackdrop.tsx @@ -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 = React.memo(({ item }) => { + return ( + + + {/* Gradient overlays for readability */} + + + + ); +}); diff --git a/components/tv/TVCastCrewText.tsx b/components/tv/TVCastCrewText.tsx new file mode 100644 index 00000000..2889717a --- /dev/null +++ b/components/tv/TVCastCrewText.tsx @@ -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 = React.memo( + ({ director, cast, hideCast = false }) => { + const { t } = useTranslation(); + + if (!director && (!cast || cast.length === 0)) { + return null; + } + + return ( + + + {t("item_card.cast_and_crew")} + + + {director && ( + + + {t("item_card.director")} + + + {director.Name} + + + )} + {!hideCast && cast && cast.length > 0 && ( + + + {t("item_card.cast")} + + + {cast.map((c) => c.Name).join(", ")} + + + )} + + + ); + }, +); diff --git a/components/tv/TVCastSection.tsx b/components/tv/TVCastSection.tsx new file mode 100644 index 00000000..9c58e273 --- /dev/null +++ b/components/tv/TVCastSection.tsx @@ -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 = React.memo( + ({ + cast, + apiBasePath, + onActorPress, + firstActorRefSetter, + upwardFocusDestination, + }) => { + const { t } = useTranslation(); + + if (cast.length === 0) { + return null; + } + + return ( + + + {t("item_card.cast")} + + {/* Focus guide to direct upward navigation from cast back to options */} + {upwardFocusDestination && ( + + )} + + {cast.map((person, index) => ( + { + if (person.Id) { + onActorPress(person.Id); + } + }} + /> + ))} + + + ); + }, +); diff --git a/components/tv/TVMetadataBadges.tsx b/components/tv/TVMetadataBadges.tsx new file mode 100644 index 00000000..feea2609 --- /dev/null +++ b/components/tv/TVMetadataBadges.tsx @@ -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 = React.memo( + ({ year, duration, officialRating, communityRating }) => { + return ( + + {year != null && ( + {year} + )} + {duration && ( + {duration} + )} + {officialRating && } + {communityRating != null && ( + } + /> + )} + + ); + }, +); diff --git a/components/tv/TVProgressBar.tsx b/components/tv/TVProgressBar.tsx new file mode 100644 index 00000000..54ac5df1 --- /dev/null +++ b/components/tv/TVProgressBar.tsx @@ -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 = 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 ( + + + + + + ); + }, +); diff --git a/components/tv/TVSeriesNavigation.tsx b/components/tv/TVSeriesNavigation.tsx new file mode 100644 index 00000000..088b9262 --- /dev/null +++ b/components/tv/TVSeriesNavigation.tsx @@ -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 = 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 ( + + + {t("item_card.from_this_series") || "From this Series"} + + + {/* Series card */} + + + {/* Season card */} + {(item.SeasonId || item.ParentId) && ( + + )} + + + ); + }, +); diff --git a/components/tv/TVTechnicalDetails.tsx b/components/tv/TVTechnicalDetails.tsx new file mode 100644 index 00000000..0bb264bd --- /dev/null +++ b/components/tv/TVTechnicalDetails.tsx @@ -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 = 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 ( + + + {t("item_card.technical_details")} + + + {videoStream && ( + + + Video + + + {videoStream.DisplayTitle || + `${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`} + + + )} + {audioStream && ( + + + Audio + + + {audioStream.DisplayTitle || + `${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`} + + + )} + + + ); + }, +); diff --git a/components/tv/index.ts b/components/tv/index.ts index 5c804cba..d05dc8df 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -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";