mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-28 00:30:30 +01:00
fix(tv): poster design and other stuff
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user