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,