mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-18 03:40:23 +01:00
perf(tv): optimize focus animations and disable native glass effect
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
|||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -134,27 +134,16 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
// Track focus within section for item focus/blur callbacks
|
|
||||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
const [_focusedCount, setFocusedCount] = useState(0);
|
|
||||||
|
|
||||||
|
// Pass through focus callbacks without tracking internal state
|
||||||
const handleItemFocus = useCallback(
|
const handleItemFocus = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
setFocusedCount((c) => c + 1);
|
|
||||||
onItemFocus?.(item);
|
onItemFocus?.(item);
|
||||||
},
|
},
|
||||||
[onItemFocus],
|
[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 } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
@@ -234,7 +223,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
onLongPress={() => showItemActions(item)}
|
onLongPress={() => showItemActions(item)}
|
||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={() => handleItemFocus(item)}
|
onFocus={() => handleItemFocus(item)}
|
||||||
onBlur={handleItemBlur}
|
|
||||||
width={itemWidth}
|
width={itemWidth}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -247,7 +235,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
handleItemPress,
|
handleItemPress,
|
||||||
showItemActions,
|
showItemActions,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
handleItemBlur,
|
|
||||||
ITEM_GAP,
|
ITEM_GAP,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -370,8 +357,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
onPress={handleSeeAllPress}
|
onPress={handleSeeAllPress}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onFocus={handleSeeAllFocus}
|
|
||||||
onBlur={handleItemBlur}
|
|
||||||
typography={typography}
|
typography={typography}
|
||||||
posterSizes={posterSizes}
|
posterSizes={posterSizes}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
|||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
||||||
shadowColor: "#FFFFFF",
|
shadowColor: "#FFFFFF",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
borderWidth: focused ? 3 : 0,
|
borderWidth: 2,
|
||||||
borderColor: "#fff",
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
|
|||||||
@@ -397,6 +397,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
aspectRatio,
|
aspectRatio,
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -432,6 +434,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#FFFFFF" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
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
|
/// ExpoView wrapper that hosts the SwiftUI GlassPosterView
|
||||||
class GlassPosterExpoView: ExpoView {
|
class GlassPosterExpoView: ExpoView {
|
||||||
private var hostingController: UIHostingController<GlassPosterView>?
|
private var hostingController: UIHostingController<GlassPosterViewWrapper>?
|
||||||
private var posterView: GlassPosterView
|
private let state = GlassPosterState()
|
||||||
|
|
||||||
// Stored dimensions for intrinsic content size
|
// Stored dimensions for intrinsic content size
|
||||||
private var posterWidth: CGFloat = 260
|
private var posterWidth: CGFloat = 260
|
||||||
@@ -16,13 +28,13 @@ class GlassPosterExpoView: ExpoView {
|
|||||||
let onError = EventDispatcher()
|
let onError = EventDispatcher()
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
required init(appContext: AppContext? = nil) {
|
||||||
self.posterView = GlassPosterView()
|
|
||||||
super.init(appContext: appContext)
|
super.init(appContext: appContext)
|
||||||
setupHostingController()
|
setupHostingController()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func 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.backgroundColor = .clear
|
||||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
@@ -38,10 +50,6 @@ class GlassPosterExpoView: ExpoView {
|
|||||||
self.hostingController = hostingController
|
self.hostingController = hostingController
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateHostingController() {
|
|
||||||
hostingController?.rootView = posterView
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override intrinsic content size for proper React Native layout
|
// Override intrinsic content size for proper React Native layout
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
let height = posterWidth / posterAspectRatio
|
let height = posterWidth / posterAspectRatio
|
||||||
@@ -49,43 +57,38 @@ class GlassPosterExpoView: ExpoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Property Setters
|
// 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?) {
|
func setImageUrl(_ url: String?) {
|
||||||
posterView.imageUrl = url
|
state.imageUrl = url
|
||||||
updateHostingController()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAspectRatio(_ ratio: Double) {
|
func setAspectRatio(_ ratio: Double) {
|
||||||
posterView.aspectRatio = ratio
|
state.aspectRatio = ratio
|
||||||
posterAspectRatio = CGFloat(ratio)
|
posterAspectRatio = CGFloat(ratio)
|
||||||
invalidateIntrinsicContentSize()
|
invalidateIntrinsicContentSize()
|
||||||
updateHostingController()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setWidth(_ width: Double) {
|
func setWidth(_ width: Double) {
|
||||||
posterView.width = width
|
state.width = width
|
||||||
posterWidth = CGFloat(width)
|
posterWidth = CGFloat(width)
|
||||||
invalidateIntrinsicContentSize()
|
invalidateIntrinsicContentSize()
|
||||||
updateHostingController()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCornerRadius(_ radius: Double) {
|
func setCornerRadius(_ radius: Double) {
|
||||||
posterView.cornerRadius = radius
|
state.cornerRadius = radius
|
||||||
updateHostingController()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setProgress(_ progress: Double) {
|
func setProgress(_ progress: Double) {
|
||||||
posterView.progress = progress
|
state.progress = progress
|
||||||
updateHostingController()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setShowWatchedIndicator(_ show: Bool) {
|
func setShowWatchedIndicator(_ show: Bool) {
|
||||||
posterView.showWatchedIndicator = show
|
state.showWatchedIndicator = show
|
||||||
updateHostingController()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setIsFocused(_ focused: Bool) {
|
func setIsFocused(_ focused: Bool) {
|
||||||
posterView.isFocused = focused
|
state.isFocused = focused
|
||||||
updateHostingController()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
import SwiftUI
|
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
|
/// SwiftUI view with tvOS 26 Liquid Glass effect
|
||||||
struct GlassPosterView: View {
|
struct GlassPosterView: View {
|
||||||
var imageUrl: String? = nil
|
var imageUrl: String? = nil
|
||||||
@@ -35,7 +54,7 @@ struct GlassPosterView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - tvOS 26+ Glass Effect
|
// MARK: - tvOS 26+ Content (glass effect disabled for now)
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@available(tvOS 26.0, *)
|
@available(tvOS 26.0, *)
|
||||||
@@ -45,6 +64,10 @@ struct GlassPosterView: View {
|
|||||||
imageContent
|
imageContent
|
||||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
.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
|
// Progress bar overlay
|
||||||
if progress > 0 {
|
if progress > 0 {
|
||||||
progressOverlay
|
progressOverlay
|
||||||
@@ -56,7 +79,6 @@ struct GlassPosterView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: width, height: height)
|
.frame(width: width, height: height)
|
||||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
|
||||||
.focusable()
|
.focusable()
|
||||||
.focused($isInternallyFocused)
|
.focused($isInternallyFocused)
|
||||||
.scaleEffect(isCurrentlyFocused ? 1.05 : 1.0)
|
.scaleEffect(isCurrentlyFocused ? 1.05 : 1.0)
|
||||||
@@ -72,9 +94,9 @@ struct GlassPosterView: View {
|
|||||||
imageContent
|
imageContent
|
||||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
|
||||||
// Subtle overlay for depth
|
// White border on focus
|
||||||
RoundedRectangle(cornerRadius: cornerRadius)
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
.fill(.ultraThinMaterial.opacity(0.15))
|
.stroke(Color.white, lineWidth: isFocused ? 4 : 0)
|
||||||
|
|
||||||
// Progress bar overlay
|
// Progress bar overlay
|
||||||
if progress > 0 {
|
if progress > 0 {
|
||||||
|
|||||||
@@ -21,16 +21,23 @@ if (Platform.OS === "ios" && Platform.isTV) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the native glass effect is available (tvOS 26+)
|
* 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 {
|
export function isGlassEffectAvailable(): boolean {
|
||||||
if (!GlassPosterNativeModule) {
|
// Glass effect disabled - using JS-based focus effects instead
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
try {
|
// Original implementation (re-enable when glass effect is optimized):
|
||||||
return GlassPosterNativeModule.isGlassEffectAvailable();
|
// if (!GlassPosterNativeModule) {
|
||||||
} catch {
|
// return false;
|
||||||
return false;
|
// }
|
||||||
}
|
// try {
|
||||||
|
// return GlassPosterNativeModule.isGlassEffectAvailable();
|
||||||
|
// } catch {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GlassPosterNativeModule;
|
export default GlassPosterNativeModule;
|
||||||
|
|||||||
Reference in New Issue
Block a user