fix(tv): poster design and other stuff

This commit is contained in:
Fredrik Burmester
2026-01-30 09:15:44 +01:00
parent 0cd74519d4
commit aed3a8f493
26 changed files with 758 additions and 1362 deletions

View File

@@ -1,222 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import React, { useMemo } from "react";
import { View } from "react-native";
import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time";
interface TVEpisodeCardProps {
episode: BaseItemDto;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
/** When true, the item remains focusable even when disabled (for navigation purposes) */
focusableWhenDisabled?: boolean;
/** Shows a "Now Playing" badge on the card */
isCurrent?: boolean;
onPress: () => void;
onLongPress?: () => void;
onFocus?: () => void;
onBlur?: () => void;
/** Setter function for the ref (for focus guide destinations) */
refSetter?: (ref: View | null) => void;
}
export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
episode,
hasTVPreferredFocus = false,
disabled = false,
focusableWhenDisabled = false,
isCurrent = false,
onPress,
onLongPress,
onFocus,
onBlur,
refSetter,
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const api = useAtomValue(apiAtom);
const thumbnailUrl = useMemo(() => {
if (!api) return null;
// Try to get episode primary image first
if (episode.ImageTags?.Primary) {
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`;
}
// Fall back to series thumb or backdrop
if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) {
return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`;
}
// Default episode image
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`;
}, [api, episode]);
const duration = useMemo(() => {
if (!episode.RunTimeTicks) return null;
return runtimeTicksToMinutes(episode.RunTimeTicks);
}, [episode.RunTimeTicks]);
const episodeLabel = useMemo(() => {
const season = episode.ParentIndexNumber;
const ep = episode.IndexNumber;
if (season !== undefined && ep !== undefined) {
return `S${season}:E${ep}`;
}
return null;
}, [episode.ParentIndexNumber, episode.IndexNumber]);
const progress = episode.UserData?.PlayedPercentage || 0;
const isWatched = episode.UserData?.Played === true;
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
// Now Playing badge component (shared between glass and fallback)
const NowPlayingBadge = isCurrent ? (
<View
style={{
position: "absolute",
top: 12,
left: 12,
backgroundColor: "#FFFFFF",
borderRadius: 8,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 8,
gap: 6,
zIndex: 10,
}}
>
<Ionicons name='play' size={16} color='#000000' />
<Text
style={{
color: "#000000",
fontSize: 14,
fontWeight: "700",
}}
>
Now Playing
</Text>
</View>
) : null;
return (
<View
style={{
width: posterSizes.episode,
opacity: isCurrent ? 0.75 : disabled ? 0.5 : 1,
}}
>
<TVFocusablePoster
onPress={onPress}
onLongPress={onLongPress}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusableWhenDisabled={focusableWhenDisabled}
onFocus={onFocus}
onBlur={onBlur}
refSetter={refSetter}
>
{useGlass ? (
<View style={{ position: "relative" }}>
<GlassPosterView
imageUrl={thumbnailUrl}
aspectRatio={16 / 9}
cornerRadius={24}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={false}
width={posterSizes.episode}
style={{ width: posterSizes.episode }}
/>
{NowPlayingBadge}
</View>
) : (
<View
style={{
width: posterSizes.episode,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{thumbnailUrl ? (
<Image
source={{ uri: thumbnailUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "#262626",
}}
/>
)}
<WatchedIndicator item={episode} />
<ProgressBar item={episode} />
{NowPlayingBadge}
</View>
)}
</TVFocusablePoster>
{/* Episode info below thumbnail */}
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
{episodeLabel && (
<Text
style={{
fontSize: typography.callout,
color: "#FFFFFF",
fontWeight: "500",
}}
>
{episodeLabel}
</Text>
)}
{duration && (
<>
<Text style={{ color: "#FFFFFF", fontSize: typography.callout }}>
</Text>
<Text style={{ fontSize: typography.callout, color: "#FFFFFF" }}>
{duration}
</Text>
</>
)}
</View>
<Text
numberOfLines={2}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: 4,
fontWeight: "500",
}}
>
{episode.Name}
</Text>
</View>
</View>
);
};

View File

@@ -1,12 +1,8 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import React, { useCallback } from "react";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import { useScaledTVTypography } from "@/constants/TVTypography";
const LIST_GAP = 24;
const VERTICAL_PADDING = 12;
import { TVHorizontalList } from "@/components/tv/TVHorizontalList";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
interface TVEpisodeListProps {
episodes: BaseItemDto[];
@@ -28,7 +24,7 @@ interface TVEpisodeListProps {
firstEpisodeRefSetter?: (ref: View | null) => void;
/** Text to show when episodes array is empty */
emptyText?: string;
/** Horizontal padding for the list content (default: 80) */
/** Horizontal padding for the list content */
horizontalPadding?: number;
}
@@ -43,57 +39,51 @@ export const TVEpisodeList: React.FC<TVEpisodeListProps> = ({
scrollViewRef,
firstEpisodeRefSetter,
emptyText,
horizontalPadding = 80,
horizontalPadding,
}) => {
const typography = useScaledTVTypography();
const renderItem = useCallback(
({ item: episode, index }: { item: BaseItemDto; index: number }) => {
const isCurrent = currentEpisodeId
? episode.Id === currentEpisodeId
: false;
return (
<TVPosterCard
item={episode}
orientation='horizontal'
onPress={() => onEpisodePress(episode)}
onLongPress={
onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined
}
onFocus={onFocus}
onBlur={onBlur}
disabled={isCurrent || disabled}
focusableWhenDisabled={isCurrent}
isCurrent={isCurrent}
refSetter={index === 0 ? firstEpisodeRefSetter : undefined}
/>
);
},
[
currentEpisodeId,
disabled,
firstEpisodeRefSetter,
onBlur,
onEpisodeLongPress,
onEpisodePress,
onFocus,
],
);
if (episodes.length === 0 && emptyText) {
return (
<Text
style={{
color: "#737373",
fontSize: typography.callout,
marginLeft: 20,
}}
>
{emptyText}
</Text>
);
}
const keyExtractor = useCallback((episode: BaseItemDto) => episode.Id!, []);
return (
<ScrollView
ref={scrollViewRef as React.RefObject<ScrollView>}
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -horizontalPadding, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: horizontalPadding,
paddingVertical: VERTICAL_PADDING,
gap: LIST_GAP,
}}
>
{episodes.map((episode, index) => {
const isCurrent = currentEpisodeId
? episode.Id === currentEpisodeId
: false;
return (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => onEpisodePress(episode)}
onLongPress={
onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined
}
onFocus={onFocus}
onBlur={onBlur}
disabled={isCurrent || disabled}
focusableWhenDisabled={isCurrent}
isCurrent={isCurrent}
refSetter={index === 0 ? firstEpisodeRefSetter : undefined}
/>
);
})}
</ScrollView>
<TVHorizontalList
data={episodes}
keyExtractor={keyExtractor}
renderItem={renderItem}
emptyText={emptyText}
scrollViewRef={scrollViewRef}
horizontalPadding={horizontalPadding}
/>
);
};