diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
index 3f0734fa..4f1bbfbd 100644
--- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
+++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx
@@ -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 (
@@ -843,15 +845,32 @@ const Page = () => {
// TV return with filter bar
return (
-
- {/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
+ {
+ // 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 */}
@@ -918,45 +937,40 @@ const Page = () => {
/>
- {/* Grid - using FlatList instead of FlashList to fix focus issues */}
-
-
- {t("library.no_results")}
-
-
- }
- 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={() => (
-
- )}
- />
-
+ {/* Grid with flexWrap */}
+ {flatData.length === 0 ? (
+
+
+ {t("library.no_results")}
+
+
+ ) : (
+
+ {flatData.map((item) => renderTVItem(item))}
+
+ )}
+
+ {/* Loading indicator */}
+ {isFetching && (
+
+
+
+ )}
+
);
};
diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
index b8a31190..2ee4592c 100644
--- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
+++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
@@ -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 }) => (
-
+
{item.Name}
-
+
{item.ProductionYear}
@@ -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 (
@@ -328,6 +332,126 @@ export default function WatchlistDetailScreen() {
);
}
+ // TV layout with ScrollView + flexWrap
+ if (Platform.isTV) {
+ return (
+
+ {/* Header */}
+
+ {watchlist.description && (
+
+ {watchlist.description}
+
+ )}
+
+
+
+
+ {items?.length ?? 0}{" "}
+ {(items?.length ?? 0) === 1
+ ? t("watchlists.item")
+ : t("watchlists.items")}
+
+
+
+
+
+ {watchlist.isPublic
+ ? t("watchlists.public")
+ : t("watchlists.private")}
+
+
+ {!isOwner && (
+
+ {t("watchlists.by_owner")}
+
+ )}
+
+
+
+ {/* Grid with flexWrap */}
+ {!items || items.length === 0 ? (
+
+
+
+ {t("watchlists.empty_watchlist")}
+
+
+ ) : (
+
+ {items.map((item, index) => renderTVItem(item, index))}
+
+ )}
+
+ );
+ }
+
+ // Mobile layout with FlashList
return (
}
- renderItem={Platform.isTV ? renderTVItem : renderItem}
+ renderItem={renderItem}
ItemSeparatorComponent={() => (
= ({
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 (
= ({
);
}
+ if (useGlass) {
+ return (
+
+
+ {showPlayButton && (
+
+
+
+ )}
+
+ );
+ }
+
+ // Fallback for older tvOS versions
return (
= 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 = 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 = React.memo(
onPress(item);
}, [onPress, item]);
+ // Use glass poster for tvOS 26+
+ if (useGlass) {
+ return (
+
+
+
+ );
+ }
+
+ // Fallback for non-tvOS or older tvOS
return (
= ({
}, [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 (
+
+ );
+ }
+
+ // Fallback for older tvOS versions
return (
= ({ item }) => {
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
+ // Use glass effect on tvOS 26+
+ const useGlass = isGlassEffectAvailable();
+
+ if (useGlass) {
+ return (
+
+ );
+ }
+
+ // Fallback for older tvOS versions
return (
{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
- fontSize: 16,
+ fontSize: TVTypography.callout,
}}
numberOfLines={1}
>
@@ -222,7 +223,7 @@ export const TVSearchPage: React.FC = ({
}}
>
{/* Search Input */}
-
+
= ({
= ({
>
{t("search.no_results_found_for")}
-
+
"{debouncedSearch}"
diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx
index 9f2152c5..695c64cc 100644
--- a/components/search/TVSearchSection.tsx
+++ b/components/search/TVSearchSection.tsx
@@ -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 }) => {
{item.Type === "Episode" ? (
<>
-
+
{item.Name}
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
@@ -36,53 +44,92 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
) : item.Type === "MusicArtist" ? (
{item.Name}
) : item.Type === "MusicAlbum" ? (
<>
-
+
{item.Name}
{item.AlbumArtist || item.Artists?.join(", ")}
>
) : item.Type === "Audio" ? (
<>
-
+
{item.Name}
{item.Artists?.join(", ") || item.AlbumArtist}
>
) : item.Type === "Playlist" ? (
<>
-
+
{item.Name}
-
+
{item.ChildCount} tracks
>
) : item.Type === "Person" ? (
-
+
{item.Name}
) : (
<>
-
+
{item.Name}
-
+
{item.ProductionYear}
>
@@ -311,11 +358,12 @@ export const TVSearchSection: React.FC = ({
{/* Section Header */}
{title}
diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx
index 3ae0e214..fe2ab9f6 100644
--- a/components/tv/TVFocusablePoster.tsx
+++ b/components/tv/TVFocusablePoster.tsx
@@ -70,8 +70,8 @@ export const TVFocusablePoster: React.FC = ({
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,
]}
diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx
index eb69f7f8..81b9772b 100644
--- a/components/tv/TVSeriesSeasonCard.tsx
+++ b/components/tv/TVSeriesSeasonCard.tsx
@@ -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 = ({
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 (
+
+ );
+ }
+
+ return (
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+ };
+
return (
= ({
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,
},
]}
>
-
- {imageUrl ? (
-
- ) : (
-
-
-
- )}
-
+ {renderPoster()}
'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
diff --git a/modules/glass-poster/ios/GlassPosterExpoView.swift b/modules/glass-poster/ios/GlassPosterExpoView.swift
new file mode 100644
index 00000000..1c334190
--- /dev/null
+++ b/modules/glass-poster/ios/GlassPosterExpoView.swift
@@ -0,0 +1,91 @@
+import ExpoModulesCore
+import SwiftUI
+import UIKit
+
+/// ExpoView wrapper that hosts the SwiftUI GlassPosterView
+class GlassPosterExpoView: ExpoView {
+ private var hostingController: UIHostingController?
+ 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()
+ }
+}
diff --git a/modules/glass-poster/ios/GlassPosterModule.swift b/modules/glass-poster/ios/GlassPosterModule.swift
new file mode 100644
index 00000000..3b9b9b19
--- /dev/null
+++ b/modules/glass-poster/ios/GlassPosterModule.swift
@@ -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")
+ }
+ }
+}
diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift
new file mode 100644
index 00000000..77c5efb8
--- /dev/null
+++ b/modules/glass-poster/ios/GlassPosterView.swift
@@ -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
diff --git a/modules/glass-poster/src/GlassPoster.types.ts b/modules/glass-poster/src/GlassPoster.types.ts
new file mode 100644
index 00000000..8878779b
--- /dev/null
+++ b/modules/glass-poster/src/GlassPoster.types.ts
@@ -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;
+ /** Called when the image loads successfully */
+ onLoad?: () => void;
+ /** Called when image loading fails */
+ onError?: (error: string) => void;
+}
+
+export type GlassPosterModuleEvents = Record;
diff --git a/modules/glass-poster/src/GlassPosterModule.ts b/modules/glass-poster/src/GlassPosterModule.ts
new file mode 100644
index 00000000..58ac4a25
--- /dev/null
+++ b/modules/glass-poster/src/GlassPosterModule.ts
@@ -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 {
+ isGlassEffectAvailable(): boolean;
+}
+
+// Only load the native module on tvOS
+let GlassPosterNativeModule: GlassPosterModuleType | null = null;
+
+if (Platform.OS === "ios" && Platform.isTV) {
+ try {
+ GlassPosterNativeModule =
+ requireNativeModule("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;
diff --git a/modules/glass-poster/src/GlassPosterView.tsx b/modules/glass-poster/src/GlassPosterView.tsx
new file mode 100644
index 00000000..0ec104f5
--- /dev/null
+++ b/modules/glass-poster/src/GlassPosterView.tsx
@@ -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 | null =
+ null;
+
+if (Platform.OS === "ios" && Platform.isTV) {
+ try {
+ NativeGlassPosterView =
+ requireNativeView("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 = (props) => {
+ // Only render on tvOS
+ if (!Platform.isTV || Platform.OS !== "ios") {
+ return null;
+ }
+
+ // Use native view if available
+ if (NativeGlassPosterView) {
+ return ;
+ }
+
+ // Fallback: return empty view (caller should handle this)
+ return ;
+};
+
+export default GlassPosterView;
+
+// Re-export availability check for convenience
+export { isGlassEffectAvailable };
diff --git a/modules/glass-poster/src/index.ts b/modules/glass-poster/src/index.ts
new file mode 100644
index 00000000..eee2be16
--- /dev/null
+++ b/modules/glass-poster/src/index.ts
@@ -0,0 +1,6 @@
+export * from "./GlassPoster.types";
+export {
+ default as GlassPosterModule,
+ isGlassEffectAvailable,
+} from "./GlassPosterModule";
+export { default as GlassPosterView } from "./GlassPosterView";
diff --git a/modules/index.ts b/modules/index.ts
index e026be73..d93e9077 100644
--- a/modules/index.ts
+++ b/modules/index.ts
@@ -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,