diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index 0b4e194b..c3e1aa34 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -6,7 +6,7 @@ import { useInfiniteQuery, } from "@tanstack/react-query"; import { useSegments } from "expo-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -134,27 +134,16 @@ export const InfiniteScrollingCollectionList: React.FC = ({ const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; - // Track focus within section for item focus/blur callbacks const flatListRef = useRef>(null); - const [_focusedCount, setFocusedCount] = useState(0); + // Pass through focus callbacks without tracking internal state const handleItemFocus = useCallback( (item: BaseItemDto) => { - setFocusedCount((c) => c + 1); onItemFocus?.(item); }, [onItemFocus], ); - const handleItemBlur = useCallback(() => { - setFocusedCount((c) => Math.max(0, c - 1)); - }, []); - - // Focus handler for See All card (doesn't need item parameter) - const handleSeeAllFocus = useCallback(() => { - setFocusedCount((c) => c + 1); - }, []); - const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({ queryKey: queryKey, @@ -234,7 +223,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ onLongPress={() => showItemActions(item)} hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} - onBlur={handleItemBlur} width={itemWidth} /> @@ -247,7 +235,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ handleItemPress, showItemActions, handleItemFocus, - handleItemBlur, ITEM_GAP, ], ); @@ -370,8 +357,6 @@ export const InfiniteScrollingCollectionList: React.FC = ({ onPress={handleSeeAllPress} orientation={orientation} disabled={disabled} - onFocus={handleSeeAllFocus} - onBlur={handleItemBlur} typography={typography} posterSizes={posterSizes} /> diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 0c318905..8118245b 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -157,6 +157,8 @@ const HeroCard: React.FC = React.memo( borderRadius: 24, overflow: "hidden", transform: [{ scale }], + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", shadowColor: "#FFFFFF", shadowOffset: { width: 0, height: 0 }, shadowOpacity: focused ? 0.6 : 0, diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx index 29817512..fba7ce1c 100644 --- a/components/tv/TVActorCard.tsx +++ b/components/tv/TVActorCard.tsx @@ -56,8 +56,8 @@ export const TVActorCard = React.forwardRef( overflow: "hidden", backgroundColor: "rgba(255,255,255,0.1)", marginBottom: 14, - borderWidth: focused ? 3 : 0, - borderColor: "#fff", + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", }} > {imageUrl ? ( diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index c8259838..6cb4fa82 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -397,6 +397,8 @@ export const TVPosterCard: React.FC = ({ aspectRatio, borderRadius: 24, backgroundColor: "#1a1a1a", + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", }} /> ); @@ -432,6 +434,8 @@ export const TVPosterCard: React.FC = ({ borderRadius: 24, overflow: "hidden", backgroundColor: "#1a1a1a", + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", }} > = ({ borderRadius: 24, overflow: "hidden", backgroundColor: "rgba(255,255,255,0.1)", + borderWidth: 2, + borderColor: focused ? "#FFFFFF" : "transparent", }} > {imageUrl ? ( diff --git a/modules/glass-poster/ios/GlassPosterExpoView.swift b/modules/glass-poster/ios/GlassPosterExpoView.swift index 1c334190..d2d654c5 100644 --- a/modules/glass-poster/ios/GlassPosterExpoView.swift +++ b/modules/glass-poster/ios/GlassPosterExpoView.swift @@ -1,11 +1,23 @@ import ExpoModulesCore import SwiftUI import UIKit +import Combine + +/// Observable state that SwiftUI can watch for changes without rebuilding the entire view +class GlassPosterState: ObservableObject { + @Published var imageUrl: String? = nil + @Published var aspectRatio: Double = 10.0 / 15.0 + @Published var cornerRadius: Double = 24 + @Published var progress: Double = 0 + @Published var showWatchedIndicator: Bool = false + @Published var isFocused: Bool = false + @Published var width: Double = 260 +} /// ExpoView wrapper that hosts the SwiftUI GlassPosterView class GlassPosterExpoView: ExpoView { - private var hostingController: UIHostingController? - private var posterView: GlassPosterView + private var hostingController: UIHostingController? + private let state = GlassPosterState() // Stored dimensions for intrinsic content size private var posterWidth: CGFloat = 260 @@ -16,13 +28,13 @@ class GlassPosterExpoView: ExpoView { let onError = EventDispatcher() required init(appContext: AppContext? = nil) { - self.posterView = GlassPosterView() super.init(appContext: appContext) setupHostingController() } private func setupHostingController() { - let hostingController = UIHostingController(rootView: posterView) + let wrapper = GlassPosterViewWrapper(state: state) + let hostingController = UIHostingController(rootView: wrapper) hostingController.view.backgroundColor = .clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false @@ -38,10 +50,6 @@ class GlassPosterExpoView: ExpoView { 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 @@ -49,43 +57,38 @@ class GlassPosterExpoView: ExpoView { } // MARK: - Property Setters + // These now update the observable state object directly. + // SwiftUI observes state changes and only re-renders affected views. func setImageUrl(_ url: String?) { - posterView.imageUrl = url - updateHostingController() + state.imageUrl = url } func setAspectRatio(_ ratio: Double) { - posterView.aspectRatio = ratio + state.aspectRatio = ratio posterAspectRatio = CGFloat(ratio) invalidateIntrinsicContentSize() - updateHostingController() } func setWidth(_ width: Double) { - posterView.width = width + state.width = width posterWidth = CGFloat(width) invalidateIntrinsicContentSize() - updateHostingController() } func setCornerRadius(_ radius: Double) { - posterView.cornerRadius = radius - updateHostingController() + state.cornerRadius = radius } func setProgress(_ progress: Double) { - posterView.progress = progress - updateHostingController() + state.progress = progress } func setShowWatchedIndicator(_ show: Bool) { - posterView.showWatchedIndicator = show - updateHostingController() + state.showWatchedIndicator = show } func setIsFocused(_ focused: Bool) { - posterView.isFocused = focused - updateHostingController() + state.isFocused = focused } } diff --git a/modules/glass-poster/ios/GlassPosterView.swift b/modules/glass-poster/ios/GlassPosterView.swift index 3efa9d4c..8c8e4f5f 100644 --- a/modules/glass-poster/ios/GlassPosterView.swift +++ b/modules/glass-poster/ios/GlassPosterView.swift @@ -1,5 +1,24 @@ import SwiftUI +/// Wrapper view that observes state changes from GlassPosterState +/// This allows SwiftUI to efficiently update only the changed properties +/// instead of rebuilding the entire view hierarchy on every prop change. +struct GlassPosterViewWrapper: View { + @ObservedObject var state: GlassPosterState + + var body: some View { + GlassPosterView( + imageUrl: state.imageUrl, + aspectRatio: state.aspectRatio, + cornerRadius: state.cornerRadius, + progress: state.progress, + showWatchedIndicator: state.showWatchedIndicator, + isFocused: state.isFocused, + width: state.width + ) + } +} + /// SwiftUI view with tvOS 26 Liquid Glass effect struct GlassPosterView: View { var imageUrl: String? = nil @@ -35,7 +54,7 @@ struct GlassPosterView: View { #endif } - // MARK: - tvOS 26+ Glass Effect + // MARK: - tvOS 26+ Content (glass effect disabled for now) #if os(tvOS) @available(tvOS 26.0, *) @@ -45,6 +64,10 @@ struct GlassPosterView: View { imageContent .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + // White border on focus + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white, lineWidth: isCurrentlyFocused ? 4 : 0) + // Progress bar overlay if progress > 0 { progressOverlay @@ -56,7 +79,6 @@ struct GlassPosterView: View { } } .frame(width: width, height: height) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .focusable() .focused($isInternallyFocused) .scaleEffect(isCurrentlyFocused ? 1.05 : 1.0) @@ -72,9 +94,9 @@ struct GlassPosterView: View { imageContent .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - // Subtle overlay for depth + // White border on focus RoundedRectangle(cornerRadius: cornerRadius) - .fill(.ultraThinMaterial.opacity(0.15)) + .stroke(Color.white, lineWidth: isFocused ? 4 : 0) // Progress bar overlay if progress > 0 { diff --git a/modules/glass-poster/src/GlassPosterModule.ts b/modules/glass-poster/src/GlassPosterModule.ts index 58ac4a25..20c2714f 100644 --- a/modules/glass-poster/src/GlassPosterModule.ts +++ b/modules/glass-poster/src/GlassPosterModule.ts @@ -21,16 +21,23 @@ if (Platform.OS === "ios" && Platform.isTV) { /** * Check if the native glass effect is available (tvOS 26+) + * NOTE: Glass effect is currently disabled for performance reasons. + * The native module rebuilds views on every focus change which causes lag. + * Re-enable by uncommenting the native module check below. */ export function isGlassEffectAvailable(): boolean { - if (!GlassPosterNativeModule) { - return false; - } - try { - return GlassPosterNativeModule.isGlassEffectAvailable(); - } catch { - return false; - } + // Glass effect disabled - using JS-based focus effects instead + return false; + + // Original implementation (re-enable when glass effect is optimized): + // if (!GlassPosterNativeModule) { + // return false; + // } + // try { + // return GlassPosterNativeModule.isGlassEffectAvailable(); + // } catch { + // return false; + // } } export default GlassPosterNativeModule;