feat(tv): add glass poster module and refactor grid layouts

This commit is contained in:
Fredrik Burmester
2026-01-25 17:02:10 +01:00
parent 2c6938c739
commit c2d61654b0
21 changed files with 980 additions and 130 deletions

View File

@@ -15,7 +15,13 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
import {
FlatList,
Platform,
ScrollView,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
@@ -64,8 +70,9 @@ import {
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const _TV_SCALE_PADDING = 20;
const Page = () => {
const searchParams = useLocalSearchParams() as {
@@ -223,12 +230,8 @@ const Page = () => {
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
// TV uses flexWrap, so nrOfCols is just for mobile
return 1;
}
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
@@ -394,7 +397,7 @@ const Page = () => {
);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
(item: BaseItemDto) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
@@ -402,9 +405,8 @@ const Page = () => {
return (
<View
key={item.Id}
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
@@ -843,15 +845,32 @@ const Page = () => {
// TV return with filter bar
return (
<View style={{ flex: 1 }}>
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
onScroll={({ nativeEvent }) => {
// Load more when near bottom
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isNearBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - 500;
if (isNearBottom && hasNextPage && !isFetching) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
justifyContent: "center",
paddingBottom: 24,
gap: 12,
}}
>
@@ -918,45 +937,40 @@ const Page = () => {
/>
</View>
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
removeClippedSubviews={false}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</View>
{/* Grid with flexWrap */}
{flatData.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Text style={{ fontSize: 20, color: "#737373" }}>
{t("library.no_results")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{flatData.map((item) => renderTVItem(item))}
</View>
)}
{/* Loading indicator */}
{isFetching && (
<View style={{ paddingVertical: 20 }}>
<Loader />
</View>
)}
</ScrollView>
);
};

View File

@@ -10,6 +10,7 @@ import {
Alert,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
useWindowDimensions,
View,
@@ -28,6 +29,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";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import {
@@ -41,15 +43,24 @@ import {
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<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 - 2,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
@@ -70,14 +81,8 @@ export default function WatchlistDetailScreen() {
: undefined;
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
// TV uses flexWrap, so nrOfCols is just for mobile
if (Platform.isTV) return 1;
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
@@ -185,7 +190,7 @@ export default function WatchlistDetailScreen() {
);
const renderTVItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
(item: BaseItemDto, index: number) => {
const handlePress = () => {
const navigation = getItemNavigation(item, "(watchlists)");
router.push(navigation as any);
@@ -193,9 +198,8 @@ export default function WatchlistDetailScreen() {
return (
<View
key={item.Id}
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
@@ -328,6 +332,126 @@ export default function WatchlistDetailScreen() {
);
}
// TV layout with ScrollView + flexWrap
if (Platform.isTV) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
>
{/* Header */}
<View
style={{
alignItems: "center",
marginBottom: 32,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
}}
>
{watchlist.description && (
<Text
style={{
fontSize: TVTypography.body,
color: "#9CA3AF",
marginBottom: 16,
textAlign: "center",
}}
>
{watchlist.description}
</Text>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 24,
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons name='film-outline' size={20} color='#9ca3af' />
<Text
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons
name={
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
}
size={20}
color='#9ca3af'
/>
<Text
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text
style={{ fontSize: TVTypography.callout, color: "#737373" }}
>
{t("watchlists.by_owner")}
</Text>
)}
</View>
</View>
{/* Grid with flexWrap */}
{!items || items.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text
style={{
fontSize: TVTypography.body,
color: "#9CA3AF",
textAlign: "center",
marginTop: 16,
}}
>
{t("watchlists.empty_watchlist")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{items.map((item, index) => renderTVItem(item, index))}
</View>
)}
</ScrollView>
);
}
// Mobile layout with FlashList
return (
<FlashList
key={orientation}
@@ -340,14 +464,13 @@ export default function WatchlistDetailScreen() {
keyExtractor={keyExtractor}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={Platform.isTV ? renderTVItem : renderItem}
renderItem={renderItem}
ItemSeparatorComponent={() => (
<View
style={{

View File

@@ -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={{

View File

@@ -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}

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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>

View File

@@ -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}

View File

@@ -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,
]}

View File

@@ -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={{

View File

@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["GlassPosterModule"]
}
}

View File

@@ -0,0 +1,8 @@
// Glass Poster - Native SwiftUI glass effect for tvOS 26+
export * from "./src/GlassPoster.types";
export {
default as GlassPosterModule,
isGlassEffectAvailable,
} from "./src/GlassPosterModule";
export { default as GlassPosterView } from "./src/GlassPosterView";

View File

@@ -0,0 +1,23 @@
Pod::Spec.new do |s|
s.name = 'GlassPoster'
s.version = '1.0.0'
s.summary = 'Native SwiftUI glass effect poster for tvOS'
s.description = 'Provides Liquid Glass effect poster cards for tvOS 26+'
s.author = 'Streamyfin'
s.homepage = 'https://github.com/streamyfin/streamyfin'
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_VERSION' => '5.9'
}
s.source_files = "*.{h,m,mm,swift}"
end

View File

@@ -0,0 +1,91 @@
import ExpoModulesCore
import SwiftUI
import UIKit
/// ExpoView wrapper that hosts the SwiftUI GlassPosterView
class GlassPosterExpoView: ExpoView {
private var hostingController: UIHostingController<GlassPosterView>?
private var posterView: GlassPosterView
// Stored dimensions for intrinsic content size
private var posterWidth: CGFloat = 260
private var posterAspectRatio: CGFloat = 10.0 / 15.0
// Event dispatchers
let onLoad = EventDispatcher()
let onError = EventDispatcher()
required init(appContext: AppContext? = nil) {
self.posterView = GlassPosterView()
super.init(appContext: appContext)
setupHostingController()
}
private func setupHostingController() {
let hostingController = UIHostingController(rootView: posterView)
hostingController.view.backgroundColor = .clear
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor)
])
self.hostingController = hostingController
}
private func updateHostingController() {
hostingController?.rootView = posterView
}
// Override intrinsic content size for proper React Native layout
override var intrinsicContentSize: CGSize {
let height = posterWidth / posterAspectRatio
return CGSize(width: posterWidth, height: height)
}
// MARK: - Property Setters
func setImageUrl(_ url: String?) {
posterView.imageUrl = url
updateHostingController()
}
func setAspectRatio(_ ratio: Double) {
posterView.aspectRatio = ratio
posterAspectRatio = CGFloat(ratio)
invalidateIntrinsicContentSize()
updateHostingController()
}
func setWidth(_ width: Double) {
posterView.width = width
posterWidth = CGFloat(width)
invalidateIntrinsicContentSize()
updateHostingController()
}
func setCornerRadius(_ radius: Double) {
posterView.cornerRadius = radius
updateHostingController()
}
func setProgress(_ progress: Double) {
posterView.progress = progress
updateHostingController()
}
func setShowWatchedIndicator(_ show: Bool) {
posterView.showWatchedIndicator = show
updateHostingController()
}
func setIsFocused(_ focused: Bool) {
posterView.isFocused = focused
updateHostingController()
}
}

View File

@@ -0,0 +1,50 @@
import ExpoModulesCore
public class GlassPosterModule: Module {
public func definition() -> ModuleDefinition {
Name("GlassPoster")
// Check if glass effect is available (tvOS 26+)
Function("isGlassEffectAvailable") { () -> Bool in
#if os(tvOS)
if #available(tvOS 26.0, *) {
return true
}
#endif
return false
}
// Native view component
View(GlassPosterExpoView.self) {
Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in
view.setImageUrl(url)
}
Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in
view.setAspectRatio(ratio)
}
Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in
view.setCornerRadius(radius)
}
Prop("progress") { (view: GlassPosterExpoView, progress: Double) in
view.setProgress(progress)
}
Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in
view.setShowWatchedIndicator(show)
}
Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in
view.setIsFocused(focused)
}
Prop("width") { (view: GlassPosterExpoView, width: Double) in
view.setWidth(width)
}
Events("onLoad", "onError")
}
}
}

View File

@@ -0,0 +1,195 @@
import SwiftUI
/// SwiftUI view with tvOS 26 Liquid Glass effect
struct GlassPosterView: View {
var imageUrl: String? = nil
var aspectRatio: Double = 10.0 / 15.0
var cornerRadius: Double = 24
var progress: Double = 0
var showWatchedIndicator: Bool = false
var isFocused: Bool = false
var width: Double = 260
// Internal focus state for tvOS
@FocusState private var isInternallyFocused: Bool
// Combined focus state (external prop OR internal focus)
private var isCurrentlyFocused: Bool {
isFocused || isInternallyFocused
}
// Calculated height based on width and aspect ratio
private var height: Double {
width / aspectRatio
}
var body: some View {
#if os(tvOS)
if #available(tvOS 26.0, *) {
glassContent
} else {
fallbackContent
}
#else
fallbackContent
#endif
}
// MARK: - tvOS 26+ Glass Effect
#if os(tvOS)
@available(tvOS 26.0, *)
private var glassContent: some View {
return ZStack {
// Image content
imageContent
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
// Progress bar overlay
if progress > 0 {
progressOverlay
}
// Watched indicator
if showWatchedIndicator {
watchedIndicatorOverlay
}
}
.frame(width: width, height: height)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.focusable()
.focused($isInternallyFocused)
.scaleEffect(isCurrentlyFocused ? 1.08 : 1.0)
.animation(.easeOut(duration: 0.15), value: isCurrentlyFocused)
}
#endif
// MARK: - Fallback for older tvOS versions
private var fallbackContent: some View {
ZStack {
// Main image
imageContent
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
// Subtle overlay for depth
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.ultraThinMaterial.opacity(0.15))
// Progress bar overlay
if progress > 0 {
progressOverlay
}
// Watched indicator
if showWatchedIndicator {
watchedIndicatorOverlay
}
}
.frame(width: width, height: height)
.scaleEffect(isFocused ? 1.08 : 1.0)
.animation(.easeOut(duration: 0.15), value: isFocused)
}
// MARK: - Shared Components
private var imageContent: some View {
Group {
if let urlString = imageUrl, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
placeholderView
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
placeholderView
@unknown default:
placeholderView
}
}
} else {
placeholderView
}
}
}
private var placeholderView: some View {
Rectangle()
.fill(Color.gray.opacity(0.3))
}
private var progressOverlay: some View {
VStack {
Spacer()
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background track
Rectangle()
.fill(Color.white.opacity(0.3))
.frame(height: 4)
// Progress fill
Rectangle()
.fill(Color.white)
.frame(width: geometry.size.width * CGFloat(progress / 100), height: 4)
}
}
.frame(height: 4)
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
private var watchedIndicatorOverlay: some View {
VStack {
HStack {
Spacer()
ZStack {
Circle()
.fill(Color.white.opacity(0.9))
.frame(width: 28, height: 28)
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.black)
}
.padding(8)
}
Spacer()
}
}
}
// MARK: - Preview
#if DEBUG
struct GlassPosterView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 20) {
GlassPosterView(
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
aspectRatio: 10.0 / 15.0,
cornerRadius: 24,
progress: 45,
showWatchedIndicator: false,
isFocused: true,
width: 260
)
GlassPosterView(
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
aspectRatio: 16.0 / 9.0,
cornerRadius: 24,
progress: 75,
showWatchedIndicator: true,
isFocused: false,
width: 400
)
}
.padding()
.background(Color.black)
}
}
#endif

View File

@@ -0,0 +1,26 @@
import type { StyleProp, ViewStyle } from "react-native";
export interface GlassPosterViewProps {
/** URL of the image to display */
imageUrl: string | null;
/** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */
aspectRatio: number;
/** Corner radius in points. Default: 24 */
cornerRadius: number;
/** Progress percentage (0-100). Shows progress bar at bottom when > 0 */
progress: number;
/** Whether to show the watched checkmark indicator */
showWatchedIndicator: boolean;
/** Whether the poster is currently focused (for scale animation) */
isFocused: boolean;
/** Width of the poster in points. Required for proper sizing. */
width: number;
/** Style for the container view */
style?: StyleProp<ViewStyle>;
/** Called when the image loads successfully */
onLoad?: () => void;
/** Called when image loading fails */
onError?: (error: string) => void;
}
export type GlassPosterModuleEvents = Record<string, never>;

View File

@@ -0,0 +1,36 @@
import { NativeModule, requireNativeModule } from "expo";
import { Platform } from "react-native";
import type { GlassPosterModuleEvents } from "./GlassPoster.types";
declare class GlassPosterModuleType extends NativeModule<GlassPosterModuleEvents> {
isGlassEffectAvailable(): boolean;
}
// Only load the native module on tvOS
let GlassPosterNativeModule: GlassPosterModuleType | null = null;
if (Platform.OS === "ios" && Platform.isTV) {
try {
GlassPosterNativeModule =
requireNativeModule<GlassPosterModuleType>("GlassPoster");
} catch {
// Module not available, will use fallback
}
}
/**
* Check if the native glass effect is available (tvOS 26+)
*/
export function isGlassEffectAvailable(): boolean {
if (!GlassPosterNativeModule) {
return false;
}
try {
return GlassPosterNativeModule.isGlassEffectAvailable();
} catch {
return false;
}
}
export default GlassPosterNativeModule;

View File

@@ -0,0 +1,46 @@
import { requireNativeView } from "expo";
import * as React from "react";
import { Platform, View } from "react-native";
import type { GlassPosterViewProps } from "./GlassPoster.types";
import { isGlassEffectAvailable } from "./GlassPosterModule";
// Only require the native view on tvOS
let NativeGlassPosterView: React.ComponentType<GlassPosterViewProps> | null =
null;
if (Platform.OS === "ios" && Platform.isTV) {
try {
NativeGlassPosterView =
requireNativeView<GlassPosterViewProps>("GlassPoster");
} catch {
// Module not available
}
}
/**
* GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+
*
* On tvOS 26+: Renders with native Liquid Glass effect
* On older tvOS: Renders with subtle glass-like material effect
* On other platforms: Returns null (use existing poster components)
*/
const GlassPosterView: React.FC<GlassPosterViewProps> = (props) => {
// Only render on tvOS
if (!Platform.isTV || Platform.OS !== "ios") {
return null;
}
// Use native view if available
if (NativeGlassPosterView) {
return <NativeGlassPosterView {...props} />;
}
// Fallback: return empty view (caller should handle this)
return <View style={props.style} />;
};
export default GlassPosterView;
// Re-export availability check for convenience
export { isGlassEffectAvailable };

View File

@@ -0,0 +1,6 @@
export * from "./GlassPoster.types";
export {
default as GlassPosterModule,
isGlassEffectAvailable,
} from "./GlassPosterModule";
export { default as GlassPosterView } from "./GlassPosterView";

View File

@@ -7,7 +7,9 @@ export type {
DownloadStartedEvent,
} from "./background-downloader";
export { default as BackgroundDownloader } from "./background-downloader";
// Glass Poster (tvOS 26+)
export type { GlassPosterViewProps } from "./glass-poster";
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
// MPV Player (iOS + Android)
export type {
AudioTrack as MpvAudioTrack,