From e044859aaf0166f0272bfc91a0587969f6e9951f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 30 May 2026 16:54:35 +0200 Subject: [PATCH] feat(tv): native tvOS search field via SwiftUI .searchable Add a local `tv-search` Expo module that hosts SwiftUI's `.searchable` in a UIHostingController (adapted from expo-tvos-search, minus its native results grid). It emits typed text to React Native so the existing search pipeline and custom TV results grid are reused. Handles the RN-tvOS remote gesture release needed for keyboard input on device. Wire it into TVSearchPage as a sticky header above the scrollable results, with a TVFocusGuideView bridge so focus can move from the tab bar into the native search field. --- components/search/TVSearchPage.tsx | 246 +++++++++++--------- modules/tv-search/expo-module.config.json | 6 + modules/tv-search/index.ts | 2 + modules/tv-search/ios/TvSearch.podspec | 22 ++ modules/tv-search/ios/TvSearchModule.swift | 15 ++ modules/tv-search/ios/TvSearchView.swift | 206 ++++++++++++++++ modules/tv-search/package.json | 10 + modules/tv-search/src/TvSearchView.tsx | 22 ++ modules/tv-search/src/TvSearchView.types.ts | 12 + 9 files changed, 429 insertions(+), 112 deletions(-) create mode 100644 modules/tv-search/expo-module.config.json create mode 100644 modules/tv-search/index.ts create mode 100644 modules/tv-search/ios/TvSearch.podspec create mode 100644 modules/tv-search/ios/TvSearchModule.swift create mode 100644 modules/tv-search/ios/TvSearchView.swift create mode 100644 modules/tv-search/package.json create mode 100644 modules/tv-search/src/TvSearchView.tsx create mode 100644 modules/tv-search/src/TvSearchView.types.ts diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index 69c7fc216..00ca4e1a6 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -1,13 +1,13 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useAtom } from "jotai"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ScrollView, View } from "react-native"; +import { ScrollView, TVFocusGuideView, View } from "react-native"; 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 { useScaledTVTypography } from "@/constants/TVTypography"; +import { TvSearchView } from "@/modules/tv-search"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; @@ -22,6 +22,10 @@ import { TVSearchTabBadges } from "./TVSearchTabBadges"; const HORIZONTAL_PADDING = 60; const TOP_PADDING = 100; +// Height of the native search bar itself. The tvOS grid keyboard presents as +// its own overlay when the field is focused, so we only reserve the bar height +// here — not the whole keyboard. Tunable once seen on device. +const SEARCH_AREA_HEIGHT = 250; const SECTION_GAP = 10; const SCALE_PADDING = 20; @@ -124,7 +128,6 @@ interface TVSearchPageProps { } export const TVSearchPage: React.FC = ({ - search, setSearch, debouncedSearch, movies, @@ -157,6 +160,9 @@ export const TVSearchPage: React.FC = ({ const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [api] = useAtom(apiAtom); + // Ref to the native search view, used as a TVFocusGuideView destination so + // focus can be routed into it from the tab bar above. + const [searchViewRef, setSearchViewRef] = useState(null); // Image URL getter for music items const getImageUrl = useMemo(() => { @@ -215,125 +221,141 @@ export const TVSearchPage: React.FC = ({ const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults; return ( - - {/* Search Input */} + + {/* Sticky header: search field stays pinned while results scroll below. */} - - + {/* Focus bridge: routes a "down" press from the tab bar above into the + native search view (RN-tvOS won't traverse into the native container + on its own). */} + {searchViewRef && ( + + )} - {/* Search Type Tab Badges */} - {showDiscover && ( - - + setSearch(e.nativeEvent.text)} /> - )} + - {/* Loading State */} - {currentLoading && ( - - - - - )} - - {/* Library Search Results */} - {isLibraryMode && !loading && ( - - {sections.map((section, index) => ( - + {/* Search Type Tab Badges */} + {showDiscover && ( + + - ))} - - )} + + )} - {/* Jellyseerr/Discover Search Results */} - {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && ( - {})} - onTvPress={onJellyseerrTvPress || (() => {})} - onPersonPress={onJellyseerrPersonPress || (() => {})} - /> - )} + {/* Loading State */} + {currentLoading && ( + + + + + )} - {/* Discover Content (when no search query in Discover mode) */} - {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && ( - - )} + {/* Library Search Results */} + {isLibraryMode && !loading && ( + + {sections.map((section, index) => ( + + ))} + + )} - {/* No Results State */} - {!currentLoading && currentNoResults && debouncedSearch.length > 0 && ( - - - {t("search.no_results_found_for")} - - - "{debouncedSearch}" - - - )} - + {/* Jellyseerr/Discover Search Results */} + {isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && ( + {})} + onTvPress={onJellyseerrTvPress || (() => {})} + onPersonPress={onJellyseerrPersonPress || (() => {})} + /> + )} + + {/* Discover Content (when no search query in Discover mode) */} + {isDiscoverMode && + !jellyseerrLoading && + debouncedSearch.length === 0 && ( + + )} + + {/* No Results State */} + {!currentLoading && currentNoResults && debouncedSearch.length > 0 && ( + + + {t("search.no_results_found_for")} + + + "{debouncedSearch}" + + + )} + + ); }; diff --git a/modules/tv-search/expo-module.config.json b/modules/tv-search/expo-module.config.json new file mode 100644 index 000000000..b73df1517 --- /dev/null +++ b/modules/tv-search/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["TvSearchModule"] + } +} diff --git a/modules/tv-search/index.ts b/modules/tv-search/index.ts new file mode 100644 index 000000000..5184d1129 --- /dev/null +++ b/modules/tv-search/index.ts @@ -0,0 +1,2 @@ +export { default as TvSearchView } from "./src/TvSearchView"; +export * from "./src/TvSearchView.types"; diff --git a/modules/tv-search/ios/TvSearch.podspec b/modules/tv-search/ios/TvSearch.podspec new file mode 100644 index 000000000..db0bfefa4 --- /dev/null +++ b/modules/tv-search/ios/TvSearch.podspec @@ -0,0 +1,22 @@ +Pod::Spec.new do |s| + s.name = 'TvSearch' + s.version = '1.0.0' + s.summary = 'Native tvOS search field with text change events' + s.description = 'Hosts SwiftUI .searchable inside a UIHostingController so React Native can render its own results grid while using the native tvOS search bar and grid keyboard.' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { + :tvos => '15.1' + } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift}" +end diff --git a/modules/tv-search/ios/TvSearchModule.swift b/modules/tv-search/ios/TvSearchModule.swift new file mode 100644 index 000000000..65b026c8d --- /dev/null +++ b/modules/tv-search/ios/TvSearchModule.swift @@ -0,0 +1,15 @@ +import ExpoModulesCore + +public class TvSearchModule: Module { + public func definition() -> ModuleDefinition { + Name("TvSearchModule") + + View(TvSearchView.self) { + Events("onChangeText") + + Prop("placeholder") { (view: TvSearchView, value: String?) in + view.setPlaceholder(value ?? "") + } + } + } +} diff --git a/modules/tv-search/ios/TvSearchView.swift b/modules/tv-search/ios/TvSearchView.swift new file mode 100644 index 000000000..7fd44f718 --- /dev/null +++ b/modules/tv-search/ios/TvSearchView.swift @@ -0,0 +1,206 @@ +import ExpoModulesCore +import SwiftUI + +// React Native tvOS notification names for controlling gesture handler behavior. +// These match the constants in RCTTVRemoteHandler.h and are what make keyboard +// input actually reach the native search field on tvOS. +private let RCTTVDisableGestureHandlersCancelTouchesNotification = Notification.Name( + "RCTTVDisableGestureHandlersCancelTouchesNotification") +private let RCTTVEnableGestureHandlersCancelTouchesNotification = Notification.Name( + "RCTTVEnableGestureHandlersCancelTouchesNotification") + +#if os(tvOS) + + /// Holds the search state. ObservableObject so we can update placeholder/text + /// without recreating the SwiftUI hierarchy. + class TvSearchViewModel: ObservableObject { + @Published var searchText: String = "" + @Published var placeholder: String = "Search..." + @Published var accentColor: Color = .white + var onSearch: ((String) -> Void)? + } + + /// SwiftUI content hosting `.searchable`. This mirrors expo-tvos-search's + /// structure — `.searchable` attached inside a `NavigationView` (REQUIRED: + /// `.searchable` only renders a search bar in a navigation context) — but with + /// the results grid REMOVED. The body is just transparent filler so the search + /// field + native grid keyboard render; results are drawn by React Native + /// below this native view instead. + struct TvSearchContentView: View { + @ObservedObject var viewModel: TvSearchViewModel + + var body: some View { + NavigationView { + // Transparent filler gives `.searchable` something to attach to and + // lets the native search bar/keyboard own the space. + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .searchable(text: $viewModel.searchText, prompt: viewModel.placeholder) + .onChange(of: viewModel.searchText) { newValue in + viewModel.onSearch?(newValue) + } + } + .tint(viewModel.accentColor) + } + } + + class TvSearchView: ExpoView { + private var hostingController: UIHostingController? + private let viewModel = TvSearchViewModel() + private var gestureHandlersDisabled = false + private var disabledGestureRecognizers: [UIGestureRecognizer] = [] + + let onChangeText = EventDispatcher() + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + setupView() + } + + deinit { + NotificationCenter.default.removeObserver(self) + hostingController?.willMove(toParent: nil) + hostingController?.removeFromParent() + #if !targetEnvironment(simulator) + enableParentGestureRecognizers() + #endif + if gestureHandlersDisabled { + NotificationCenter.default.post( + name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil) + } + } + + func setPlaceholder(_ value: String) { + viewModel.placeholder = value + } + + private func setupView() { + viewModel.onSearch = { [weak self] query in + self?.onChangeText(["text": query]) + } + + let controller = UIHostingController(rootView: TvSearchContentView(viewModel: viewModel)) + controller.view.backgroundColor = .clear + hostingController = controller + + addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: topAnchor), + controller.view.bottomAnchor.constraint(equalTo: bottomAnchor), + controller.view.leadingAnchor.constraint(equalTo: leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + if let parentVC = parentViewController() { + parentVC.addChild(controller) + controller.didMove(toParent: parentVC) + } + + // Detect when the search keyboard becomes active so we can release RN's + // remote gesture handling (otherwise keystrokes never reach the field). + NotificationCenter.default.addObserver( + self, selector: #selector(handleTextFieldDidBeginEditing), + name: UITextField.textDidBeginEditingNotification, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleTextFieldDidEndEditing), + name: UITextField.textDidEndEditingNotification, object: nil) + } + + // MARK: - View controller containment + + /// SwiftUI needs proper appearance lifecycle events for `.searchable` to + /// register with tvOS's focus system, so we manage child VC containment as + /// the view enters/leaves the window. + override func didMoveToWindow() { + super.didMoveToWindow() + guard let controller = hostingController else { return } + if window != nil { + if controller.parent == nil, let parentVC = parentViewController() { + parentVC.addChild(controller) + controller.didMove(toParent: parentVC) + } + } else { + controller.willMove(toParent: nil) + controller.removeFromParent() + } + } + + private func parentViewController() -> UIViewController? { + var responder: UIResponder? = self + while let next = responder?.next { + if let vc = next as? UIViewController { return vc } + responder = next + } + return nil + } + + // MARK: - Keyboard / gesture handling + + @objc private func handleTextFieldDidBeginEditing(_ notification: Notification) { + guard let textField = notification.object as? UITextField, + let hostingView = hostingController?.view, + textField.isDescendant(of: hostingView) + else { return } + + guard !gestureHandlersDisabled else { return } + gestureHandlersDisabled = true + + NotificationCenter.default.post( + name: RCTTVDisableGestureHandlersCancelTouchesNotification, object: nil) + + #if !targetEnvironment(simulator) + disableParentGestureRecognizers() + #endif + } + + @objc private func handleTextFieldDidEndEditing(_ notification: Notification) { + guard let textField = notification.object as? UITextField, + let hostingView = hostingController?.view, + textField.isDescendant(of: hostingView) + else { return } + + guard gestureHandlersDisabled else { return } + gestureHandlersDisabled = false + + #if !targetEnvironment(simulator) + enableParentGestureRecognizers() + #endif + + NotificationCenter.default.post( + name: RCTTVEnableGestureHandlersCancelTouchesNotification, object: nil) + } + + private func disableParentGestureRecognizers() { + disabledGestureRecognizers.removeAll() + var currentView: UIView? = superview + while let view = currentView { + for recognizer in view.gestureRecognizers ?? [] { + let isTapOrPress = + recognizer is UITapGestureRecognizer || recognizer is UILongPressGestureRecognizer + if isTapOrPress && recognizer.isEnabled { + recognizer.isEnabled = false + disabledGestureRecognizers.append(recognizer) + } + } + currentView = view.superview + } + } + + private func enableParentGestureRecognizers() { + for recognizer in disabledGestureRecognizers { + recognizer.isEnabled = true + } + disabledGestureRecognizers.removeAll() + } + } + +#else + + // Fallback for non-tvOS platforms (iOS). + class TvSearchView: ExpoView { + let onChangeText = EventDispatcher() + func setPlaceholder(_ value: String) {} + } + +#endif diff --git a/modules/tv-search/package.json b/modules/tv-search/package.json new file mode 100644 index 000000000..c4ac53271 --- /dev/null +++ b/modules/tv-search/package.json @@ -0,0 +1,10 @@ +{ + "name": "tv-search", + "version": "0.1.0", + "description": "Native tvOS search field (SwiftUI .searchable) emitting typed text to React Native", + "main": "index.ts", + "platforms": [ + "apple" + ], + "devDependencies": {} +} diff --git a/modules/tv-search/src/TvSearchView.tsx b/modules/tv-search/src/TvSearchView.tsx new file mode 100644 index 000000000..aa1a81d29 --- /dev/null +++ b/modules/tv-search/src/TvSearchView.tsx @@ -0,0 +1,22 @@ +import { requireNativeView } from "expo"; +import * as React from "react"; +import type { View } from "react-native"; + +import type { TvSearchViewProps } from "./TvSearchView.types"; + +const NativeView: React.ComponentType< + TvSearchViewProps & React.RefAttributes +> = requireNativeView("TvSearchModule"); + +/** + * Forwards its ref to the underlying native view so it can be used as a + * `TVFocusGuideView` `destinations` target for routing focus into the native + * search bar. + */ +const TvSearchView = React.forwardRef((props, ref) => { + return ; +}); + +TvSearchView.displayName = "TvSearchView"; + +export default TvSearchView; diff --git a/modules/tv-search/src/TvSearchView.types.ts b/modules/tv-search/src/TvSearchView.types.ts new file mode 100644 index 000000000..011dbbcd0 --- /dev/null +++ b/modules/tv-search/src/TvSearchView.types.ts @@ -0,0 +1,12 @@ +import type { ViewProps } from "react-native"; + +export interface TvSearchTextChangeEvent { + nativeEvent: { text: string }; +} + +export interface TvSearchViewProps extends ViewProps { + /** Placeholder shown in the native search bar. */ + placeholder?: string; + /** Fired as the user types in the native search bar. */ + onChangeText?: (event: TvSearchTextChangeEvent) => void; +}