mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-04 08:46:16 +00:00
580 lines
16 KiB
TypeScript
580 lines
16 KiB
TypeScript
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, useRef, useState } from "react";
|
|
import {
|
|
Animated,
|
|
Easing,
|
|
Pressable,
|
|
View,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
|
import { Text } from "@/components/common/Text";
|
|
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 { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
|
|
export interface TVPosterCardProps {
|
|
item: BaseItemDto;
|
|
/** Poster orientation: vertical = 10:15 (portrait), horizontal = 16:9 (landscape) */
|
|
orientation?: "vertical" | "horizontal";
|
|
/** Show text below the poster (title, subtitle) - default: true */
|
|
showText?: boolean;
|
|
/** Show progress bar - default: true for items with progress */
|
|
showProgress?: boolean;
|
|
/** Show watched indicator - default: true */
|
|
showWatchedIndicator?: boolean;
|
|
|
|
// Focus props
|
|
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;
|
|
/** Show a play button overlay */
|
|
showPlayButton?: boolean;
|
|
|
|
// Handlers
|
|
onPress: () => void;
|
|
onLongPress?: () => void;
|
|
onFocus?: () => void;
|
|
onBlur?: () => void;
|
|
|
|
/** Setter function for the ref (for focus guide destinations) */
|
|
refSetter?: (ref: View | null) => void;
|
|
|
|
/** Custom width - overrides default based on orientation */
|
|
width?: number;
|
|
|
|
/** Custom style for the outer container */
|
|
style?: ViewStyle;
|
|
|
|
/** Glow color for focus state */
|
|
glowColor?: "white" | "purple";
|
|
|
|
/** Scale amount for focus animation */
|
|
scaleAmount?: number;
|
|
|
|
/** Custom image URL getter - if not provided, uses smart URL logic */
|
|
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
|
}
|
|
|
|
/**
|
|
* TVPosterCard - Unified poster component for TV interface.
|
|
*
|
|
* Combines image rendering, focus handling, and text display into a single component.
|
|
* Supports both portrait (10:15) and landscape (16:9) orientations.
|
|
*
|
|
* Features:
|
|
* - Glass effect on tvOS 26+ with fallback
|
|
* - Focus handling with scale animation and glow
|
|
* - Progress bar and watched indicator
|
|
* - Smart subtitle text based on item type
|
|
* - "Now Playing" badge for current items
|
|
*/
|
|
export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|
item,
|
|
orientation = "vertical",
|
|
showText = true,
|
|
showProgress = true,
|
|
showWatchedIndicator = true,
|
|
hasTVPreferredFocus = false,
|
|
disabled = false,
|
|
focusableWhenDisabled = false,
|
|
isCurrent = false,
|
|
showPlayButton = false,
|
|
onPress,
|
|
onLongPress,
|
|
onFocus: onFocusProp,
|
|
onBlur: onBlurProp,
|
|
refSetter,
|
|
width: customWidth,
|
|
style,
|
|
glowColor = "white",
|
|
scaleAmount = 1.05,
|
|
imageUrlGetter,
|
|
}) => {
|
|
const api = useAtomValue(apiAtom);
|
|
const posterSizes = useScaledTVPosterSizes();
|
|
const typography = useScaledTVTypography();
|
|
|
|
const [focused, setFocused] = useState(false);
|
|
const scale = useRef(new Animated.Value(1)).current;
|
|
|
|
// Determine width based on orientation
|
|
const width = useMemo(() => {
|
|
if (customWidth) return customWidth;
|
|
return orientation === "horizontal"
|
|
? posterSizes.episode
|
|
: posterSizes.poster;
|
|
}, [customWidth, orientation, posterSizes]);
|
|
|
|
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
|
|
|
|
// Smart image URL selection
|
|
const imageUrl = useMemo(() => {
|
|
// Use custom getter if provided
|
|
if (imageUrlGetter) {
|
|
return imageUrlGetter(item) ?? null;
|
|
}
|
|
|
|
if (!api) return null;
|
|
|
|
// Horizontal orientation: prefer thumbs/backdrops for landscape images
|
|
if (orientation === "horizontal") {
|
|
// Episode: prefer series thumb image for consistent look (like hero section)
|
|
if (item.Type === "Episode") {
|
|
// First try parent/series thumb (horizontal series artwork)
|
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
|
}
|
|
// Fall back to episode's own primary image
|
|
if (item.ImageTags?.Primary) {
|
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`;
|
|
}
|
|
// Last resort: try primary without tag
|
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
|
}
|
|
|
|
// Movie/Series/Program: prefer thumb over primary
|
|
if (item.ImageTags?.Thumb) {
|
|
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags.Thumb}`;
|
|
}
|
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
|
}
|
|
|
|
// Vertical orientation: use primary image
|
|
// For episodes, get the series primary image
|
|
if (
|
|
item.Type === "Episode" &&
|
|
item.SeriesId &&
|
|
item.SeriesPrimaryImageTag
|
|
) {
|
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${width * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
|
}
|
|
|
|
return getPrimaryImageUrl({
|
|
api,
|
|
item,
|
|
width: width * 2, // 2x for quality on large screens
|
|
});
|
|
}, [api, item, orientation, width, imageUrlGetter]);
|
|
|
|
// Progress calculation
|
|
const progress = useMemo(() => {
|
|
if (!showProgress) return 0;
|
|
|
|
if (item.Type === "Program") {
|
|
if (!item.StartDate || !item.EndDate) return 0;
|
|
const startDate = new Date(item.StartDate);
|
|
const endDate = new Date(item.EndDate);
|
|
const now = new Date();
|
|
const total = endDate.getTime() - startDate.getTime();
|
|
if (total <= 0) return 0;
|
|
const elapsed = now.getTime() - startDate.getTime();
|
|
return (elapsed / total) * 100;
|
|
}
|
|
return item.UserData?.PlayedPercentage || 0;
|
|
}, [item, showProgress]);
|
|
|
|
const isWatched = showWatchedIndicator && item.UserData?.Played === true;
|
|
|
|
// Blurhash for placeholder
|
|
const blurhash = useMemo(() => {
|
|
const key = item.ImageTags?.Primary as string;
|
|
return item.ImageBlurHashes?.Primary?.[key];
|
|
}, [item]);
|
|
|
|
// Glass effect availability
|
|
const useGlass = isGlassEffectAvailable();
|
|
|
|
// Focus animation
|
|
const animateTo = (value: number) =>
|
|
Animated.timing(scale, {
|
|
toValue: value,
|
|
duration: 150,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7";
|
|
|
|
// Text rendering helpers
|
|
const renderSubtitle = () => {
|
|
if (!showText) return null;
|
|
|
|
// Episode: S#:E# • duration
|
|
if (item.Type === "Episode") {
|
|
const season = item.ParentIndexNumber;
|
|
const ep = item.IndexNumber;
|
|
const episodeLabel =
|
|
season !== undefined && ep !== undefined ? `S${season}:E${ep}` : null;
|
|
const duration = item.RunTimeTicks
|
|
? runtimeTicksToMinutes(item.RunTimeTicks)
|
|
: null;
|
|
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// Program: channel name
|
|
if (item.Type === "Program" && item.ChannelName) {
|
|
return (
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#9CA3AF",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{item.ChannelName}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
// MusicAlbum: artist
|
|
if (item.Type === "MusicAlbum") {
|
|
const artist = item.AlbumArtist || item.Artists?.join(", ");
|
|
if (artist) {
|
|
return (
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#9CA3AF",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{artist}
|
|
</Text>
|
|
);
|
|
}
|
|
}
|
|
|
|
// Audio: artist
|
|
if (item.Type === "Audio") {
|
|
const artist = item.Artists?.join(", ") || item.AlbumArtist;
|
|
if (artist) {
|
|
return (
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#9CA3AF",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{artist}
|
|
</Text>
|
|
);
|
|
}
|
|
}
|
|
|
|
// Playlist: track count
|
|
if (item.Type === "Playlist" && item.ChildCount) {
|
|
return (
|
|
<Text
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#9CA3AF",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{item.ChildCount} tracks
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
// Default: production year
|
|
if (item.ProductionYear) {
|
|
return (
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#9CA3AF",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{item.ProductionYear}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Now Playing badge component
|
|
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;
|
|
|
|
// Play button overlay component
|
|
const PlayButtonOverlay = showPlayButton ? (
|
|
<View
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Ionicons name='play-circle' size={56} color='white' />
|
|
</View>
|
|
) : null;
|
|
|
|
// Render poster image
|
|
const renderPosterImage = () => {
|
|
// Empty placeholder when no URL
|
|
if (!imageUrl) {
|
|
return (
|
|
<View
|
|
style={{
|
|
width,
|
|
aspectRatio,
|
|
borderRadius: 24,
|
|
backgroundColor: "#1a1a1a",
|
|
borderWidth: 2,
|
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Glass effect rendering (tvOS 26+)
|
|
if (useGlass) {
|
|
return (
|
|
<View style={{ position: "relative" }}>
|
|
<GlassPosterView
|
|
imageUrl={imageUrl}
|
|
aspectRatio={aspectRatio}
|
|
cornerRadius={24}
|
|
progress={progress}
|
|
showWatchedIndicator={isWatched}
|
|
isFocused={focused}
|
|
width={width}
|
|
style={{ width }}
|
|
/>
|
|
{PlayButtonOverlay}
|
|
{NowPlayingBadge}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Fallback rendering for older tvOS versions
|
|
return (
|
|
<View
|
|
style={{
|
|
position: "relative",
|
|
width,
|
|
aspectRatio,
|
|
borderRadius: 24,
|
|
overflow: "hidden",
|
|
backgroundColor: "#1a1a1a",
|
|
borderWidth: 2,
|
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
|
}}
|
|
>
|
|
<Image
|
|
placeholder={{ blurhash }}
|
|
key={item.Id}
|
|
id={item.Id}
|
|
source={{ uri: imageUrl }}
|
|
cachePolicy='memory-disk'
|
|
contentFit='cover'
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
/>
|
|
{PlayButtonOverlay}
|
|
{NowPlayingBadge}
|
|
<WatchedIndicator item={item} />
|
|
<ProgressBar item={item} />
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// Render title based on item type
|
|
const renderTitle = () => {
|
|
if (!showText) return null;
|
|
|
|
// Episode: show episode name as title
|
|
if (item.Type === "Episode") {
|
|
return (
|
|
<Text
|
|
numberOfLines={2}
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#FFFFFF",
|
|
marginTop: 4,
|
|
fontWeight: "500",
|
|
}}
|
|
>
|
|
{item.Name}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
// MusicArtist: centered text
|
|
if (item.Type === "MusicArtist") {
|
|
return (
|
|
<Text
|
|
numberOfLines={2}
|
|
style={{
|
|
fontSize: typography.callout,
|
|
color: "#FFFFFF",
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
{item.Name}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
// Default: show name
|
|
return (
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{ fontSize: typography.body, color: "#FFFFFF" }}
|
|
>
|
|
{item.Name}
|
|
</Text>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
{
|
|
width,
|
|
opacity: isCurrent
|
|
? 0.75
|
|
: disabled && !focusableWhenDisabled
|
|
? 0.5
|
|
: 1,
|
|
},
|
|
style,
|
|
]}
|
|
>
|
|
<Pressable
|
|
ref={refSetter}
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
onFocus={() => {
|
|
setFocused(true);
|
|
// Only animate scale when not using glass effect (glass handles its own focus visual)
|
|
if (!useGlass) {
|
|
animateTo(scaleAmount);
|
|
}
|
|
onFocusProp?.();
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
if (!useGlass) {
|
|
animateTo(1);
|
|
}
|
|
onBlurProp?.();
|
|
}}
|
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
|
disabled={disabled && !focusableWhenDisabled}
|
|
focusable={!disabled || focusableWhenDisabled}
|
|
>
|
|
<Animated.View
|
|
style={{
|
|
// Only apply scale transform when not using glass effect
|
|
transform: useGlass ? undefined : [{ scale }],
|
|
// Only apply shadow glow when not using glass (glass has its own glow)
|
|
shadowColor: useGlass ? undefined : shadowColor,
|
|
shadowOffset: useGlass ? undefined : { width: 0, height: 0 },
|
|
shadowOpacity: useGlass ? undefined : focused ? 0.3 : 0,
|
|
shadowRadius: useGlass ? undefined : focused ? 12 : 0,
|
|
}}
|
|
>
|
|
{renderPosterImage()}
|
|
</Animated.View>
|
|
</Pressable>
|
|
|
|
{/* Text below poster */}
|
|
{showText && (
|
|
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
|
|
{item.Type === "Episode" ? (
|
|
<>
|
|
{renderSubtitle()}
|
|
{renderTitle()}
|
|
</>
|
|
) : (
|
|
<>
|
|
{renderTitle()}
|
|
{renderSubtitle()}
|
|
</>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|