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

@@ -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<ItemContentTVProps> = 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<ItemContentTVProps> = 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<ItemContentTVProps> = React.memo(
}}
>
{/* Full-screen backdrop */}
<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>
<TVBackdrop item={item} />
{/* Main content area */}
<ScrollView
@@ -426,14 +417,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Back button + Poster */}
{/* Left side - Poster */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
{/* Poster */}
<View
style={{
aspectRatio: 2 / 3,
@@ -509,34 +499,12 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)}
{/* Metadata badges row */}
<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>
)}
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant='gray' />
)}
{item.CommunityRating != null && (
<Badge
text={item.CommunityRating.toFixed(1)}
variant='gray'
iconLeft={<Ionicons name='star' size={16} color='gold' />}
/>
)}
</View>
<TVMetadataBadges
year={year}
duration={duration}
officialRating={item.OfficialRating}
communityRating={item.CommunityRating}
/>
{/* Genres */}
{item.Genres && item.Genres.length > 0 && (
@@ -700,268 +668,52 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
{/* Progress bar (if partially watched) */}
{hasProgress && item.RunTimeTicks != null && (
<View style={{ maxWidth: 400, marginBottom: 24 }}>
<View
style={{
height: 4,
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 2,
overflow: "hidden",
}}
>
<View
style={{
width: `${((item.UserData?.PlaybackPositionTicks || 0) / item.RunTimeTicks) * 100}%`,
height: "100%",
backgroundColor: "#a855f7",
borderRadius: 2,
}}
/>
</View>
</View>
<TVProgressBar
progress={
(item.UserData?.PlaybackPositionTicks || 0) /
item.RunTimeTicks
}
/>
)}
</View>
</View>
{/* Additional info section */}
<View style={{ marginTop: 40 }}>
{/* Cast & Crew */}
{(director || (cast && cast.length > 0)) && (
<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>
)}
{/* 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
) && (
<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>
)}
{/* Cast & Crew (text version) */}
<TVCastCrewText
director={director}
cast={cast}
hideCast={showVisualCast}
/>
{/* Technical details */}
{selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<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 }}>
{/* Video info */}
{(() => {
const videoStream =
selectedOptions.mediaSource?.MediaStreams?.find(
(s) => s.Type === "Video",
);
if (!videoStream) return null;
return (
<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>
);
})()}
{/* Audio info */}
{(() => {
const audioStream =
selectedOptions.mediaSource?.MediaStreams?.find(
(s) => s.Type === "Audio",
);
if (!audioStream) return null;
return (
<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>
<TVTechnicalDetails
mediaStreams={selectedOptions.mediaSource.MediaStreams}
/>
)}
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
{(item.Type === "Movie" ||
item.Type === "Series" ||
item.Type === "Episode") &&
fullCast.length > 0 && (
<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 */}
{lastOptionButtonRef && (
<TVFocusGuideView
destinations={[lastOptionButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -80, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 12,
gap: 20,
}}
>
{fullCast.map((person, index) => (
<TVActorCard
key={person.Id || index}
ref={index === 0 ? setFirstActorCardRef : undefined}
person={person}
apiBasePath={api?.basePath}
onPress={() => {
if (person.Id) {
router.push(`/(auth)/persons/${person.Id}`);
}
}}
/>
))}
</ScrollView>
</View>
)}
{showVisualCast && (
<TVCastSection
cast={fullCast}
apiBasePath={api?.basePath}
onActorPress={handleActorPress}
firstActorRefSetter={setFirstActorCardRef}
upwardFocusDestination={lastOptionButtonRef}
/>
)}
{/* From this Series - Episode only */}
{item.Type === "Episode" && item.SeriesId && (
<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}
onPress={() => {
router.push(`/(auth)/series/${item.SeriesId}`);
}}
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}
onPress={() => {
router.push(
`/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`,
);
}}
/>
)}
</ScrollView>
</View>
)}
<TVSeriesNavigation
item={item}
seriesImageUrl={seriesImageUrl}
seasonImageUrl={seasonImageUrl}
onSeriesPress={handleSeriesPress}
onSeasonPress={handleSeasonPress}
/>
</View>
</ScrollView>
</View>

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";