mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-28 11:26:29 +01:00
feat(tv): add glass poster module and refactor grid layouts
This commit is contained in:
@@ -5,6 +5,10 @@ import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProgressBar } from "./common/ProgressBar";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
@@ -60,6 +64,29 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}, [api, item, useEpisodePoster]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const isWatched = item.UserData?.Played === true;
|
||||
|
||||
// Use glass effect on tvOS 26+
|
||||
const useGlass = isGlassEffectAvailable();
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<View
|
||||
@@ -72,6 +99,39 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (useGlass) {
|
||||
return (
|
||||
<View style={{ position: "relative" }}>
|
||||
<GlassPosterView
|
||||
imageUrl={url}
|
||||
aspectRatio={16 / 9}
|
||||
cornerRadius={24}
|
||||
progress={progress}
|
||||
showWatchedIndicator={isWatched}
|
||||
isFocused={false}
|
||||
width={TV_LANDSCAPE_WIDTH}
|
||||
style={{ width: TV_LANDSCAPE_WIDTH }}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for older tvOS versions
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Dimensions,
|
||||
Easing,
|
||||
FlatList,
|
||||
Platform,
|
||||
Pressable,
|
||||
View,
|
||||
} from "react-native";
|
||||
@@ -24,6 +25,10 @@ import { Text } from "@/components/common/Text";
|
||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
@@ -53,6 +58,9 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Check if glass effect is available (tvOS 26+)
|
||||
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api) return null;
|
||||
// Try thumb first, then primary
|
||||
@@ -69,6 +77,8 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
return null;
|
||||
}, [api, item]);
|
||||
|
||||
const progress = item.UserData?.PlayedPercentage || 0;
|
||||
|
||||
const animateTo = useCallback(
|
||||
(value: number) =>
|
||||
Animated.timing(scale, {
|
||||
@@ -95,6 +105,31 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
onPress(item);
|
||||
}, [onPress, item]);
|
||||
|
||||
// Use glass poster for tvOS 26+
|
||||
if (useGlass) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirst}
|
||||
style={{ marginRight: CARD_GAP }}
|
||||
>
|
||||
<GlassPosterView
|
||||
imageUrl={posterUrl}
|
||||
aspectRatio={16 / 9}
|
||||
cornerRadius={16}
|
||||
progress={progress}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={focused}
|
||||
width={CARD_WIDTH}
|
||||
style={{ width: CARD_WIDTH }}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for non-tvOS or older tvOS
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -29,12 +33,32 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
}, [api, item]);
|
||||
|
||||
const progress = item.UserData?.PlayedPercentage || 0;
|
||||
const isWatched = item.UserData?.Played === true;
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.Primary as string;
|
||||
return item.ImageBlurHashes?.Primary?.[key];
|
||||
}, [item]);
|
||||
|
||||
// Use glass effect on tvOS 26+
|
||||
const useGlass = isGlassEffectAvailable();
|
||||
|
||||
if (useGlass) {
|
||||
return (
|
||||
<GlassPosterView
|
||||
imageUrl={url ?? null}
|
||||
aspectRatio={10 / 15}
|
||||
cornerRadius={24}
|
||||
progress={showProgress ? progress : 0}
|
||||
showWatchedIndicator={isWatched}
|
||||
isFocused={false}
|
||||
width={TV_POSTER_WIDTH}
|
||||
style={{ width: TV_POSTER_WIDTH }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for older tvOS versions
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -3,6 +3,10 @@ import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -32,6 +36,25 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
||||
return item.ImageBlurHashes?.Primary?.[key];
|
||||
}, [item]);
|
||||
|
||||
// Use glass effect on tvOS 26+
|
||||
const useGlass = isGlassEffectAvailable();
|
||||
|
||||
if (useGlass) {
|
||||
return (
|
||||
<GlassPosterView
|
||||
imageUrl={url ?? null}
|
||||
aspectRatio={10 / 15}
|
||||
cornerRadius={24}
|
||||
progress={0}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={false}
|
||||
width={TV_POSTER_WIDTH}
|
||||
style={{ width: TV_POSTER_WIDTH }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for older tvOS versions
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
@@ -71,7 +72,7 @@ const TVLoadingSkeleton: React.FC = () => {
|
||||
color: "#262626",
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 6,
|
||||
fontSize: 16,
|
||||
fontSize: TVTypography.callout,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -222,7 +223,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING }}>
|
||||
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING + 200 }}>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
value={search}
|
||||
@@ -307,7 +308,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
@@ -315,7 +316,12 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -11,6 +11,7 @@ import MoviePoster, {
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
|
||||
const ITEM_GAP = 16;
|
||||
const SCALE_PADDING = 20;
|
||||
@@ -21,12 +22,19 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
@@ -36,53 +44,92 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
) : item.Type === "MusicArtist" ? (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
) : item.Type === "MusicAlbum" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Audio" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Playlist" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.ChildCount} tracks
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Person" ? (
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</>
|
||||
@@ -311,11 +358,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
{/* Section Header */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: "600",
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginBottom: 20,
|
||||
marginLeft: SCALE_PADDING,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -70,8 +70,8 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
|
||||
transform: [{ scale }],
|
||||
shadowColor,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
shadowOpacity: focused ? 0.3 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Animated, Platform, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVSeriesSeasonCardProps {
|
||||
@@ -24,6 +28,59 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
// Check if glass effect is available (tvOS 26+)
|
||||
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||
|
||||
const renderPoster = () => {
|
||||
if (useGlass) {
|
||||
return (
|
||||
<GlassPosterView
|
||||
imageUrl={imageUrl}
|
||||
aspectRatio={10 / 15}
|
||||
cornerRadius={24}
|
||||
progress={0}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={focused}
|
||||
width={210}
|
||||
style={{ width: 210, marginBottom: 14 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 14,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film' size={56} color='rgba(255,255,255,0.4)' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -38,41 +95,12 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
width: 210,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
shadowOpacity: useGlass ? 0 : focused ? 0.5 : 0,
|
||||
shadowRadius: useGlass ? 0 : focused ? 20 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 14,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film' size={56} color='rgba(255,255,255,0.4)' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{renderPoster()}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user