expo-tvos-search 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,49 +1,12 @@
1
1
  import ExpoModulesCore
2
2
  import SwiftUI
3
3
 
4
- #if os(tvOS)
5
-
6
- /// Custom shape for cards with selectively rounded corners
7
- /// Provides backwards compatibility for tvOS versions before 16.0
8
- struct SelectiveRoundedRectangle: Shape {
9
- var topLeadingRadius: CGFloat
10
- var topTrailingRadius: CGFloat
11
- var bottomLeadingRadius: CGFloat
12
- var bottomTrailingRadius: CGFloat
13
-
14
- func path(in rect: CGRect) -> Path {
15
- var path = Path()
16
-
17
- let tl = min(topLeadingRadius, min(rect.width, rect.height) / 2)
18
- let tr = min(topTrailingRadius, min(rect.width, rect.height) / 2)
19
- let bl = min(bottomLeadingRadius, min(rect.width, rect.height) / 2)
20
- let br = min(bottomTrailingRadius, min(rect.width, rect.height) / 2)
21
-
22
- path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
23
- path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
24
- path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr),
25
- radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
26
- path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
27
- path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br),
28
- radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
29
- path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
30
- path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl),
31
- radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
32
- path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
33
- path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl),
34
- radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
35
- path.closeSubpath()
36
-
37
- return path
38
- }
39
- }
4
+ // React Native tvOS notification names for controlling gesture handler behavior
5
+ // These match the constants in RCTTVRemoteHandler.h
6
+ private let RCTTVDisableGestureHandlersCancelTouchesNotification = Notification.Name("RCTTVDisableGestureHandlersCancelTouchesNotification")
7
+ private let RCTTVEnableGestureHandlersCancelTouchesNotification = Notification.Name("RCTTVEnableGestureHandlersCancelTouchesNotification")
40
8
 
41
- struct SearchResultItem: Identifiable, Equatable {
42
- let id: String
43
- let title: String
44
- let subtitle: String?
45
- let imageUrl: String?
46
- }
9
+ #if os(tvOS)
47
10
 
48
11
  /// ObservableObject that holds state for the search view.
49
12
  /// This allows updating properties without recreating the entire view hierarchy.
@@ -54,323 +17,79 @@ class SearchViewModel: ObservableObject {
54
17
 
55
18
  var onSearch: ((String) -> Void)?
56
19
  var onSelectItem: ((String) -> Void)?
57
- var columns: Int = 5
58
- var placeholder: String = "Search movies and videos..."
20
+ @Published var columns: Int = 5
21
+ @Published var placeholder: String = "Search..."
59
22
 
60
23
  // Card styling options (configurable from JS)
61
- var showTitle: Bool = false
62
- var showSubtitle: Bool = false
63
- var showFocusBorder: Bool = false
64
- var topInset: CGFloat = 0 // Extra top padding for tab bar
24
+ @Published var showTitle: Bool = false
25
+ @Published var showSubtitle: Bool = false
26
+ @Published var showFocusBorder: Bool = false
27
+ @Published var topInset: CGFloat = 0 // Extra top padding for tab bar
65
28
 
66
29
  // Title overlay options (configurable from JS)
67
- var showTitleOverlay: Bool = true
68
- var enableMarquee: Bool = true
69
- var marqueeDelay: Double = 1.5
30
+ @Published var showTitleOverlay: Bool = true
31
+ @Published var enableMarquee: Bool = true
32
+ @Published var marqueeDelay: Double = 1.5
70
33
 
71
34
  // State text options (configurable from JS)
72
- var emptyStateText: String = "Search for movies and videos"
73
- var searchingText: String = "Searching..."
74
- var noResultsText: String = "No results found"
75
- var noResultsHintText: String = "Try a different search term"
35
+ @Published var emptyStateText: String = "Search your library"
36
+ @Published var searchingText: String = "Searching..."
37
+ @Published var noResultsText: String = "No results found"
38
+ @Published var noResultsHintText: String = "Try a different search term"
76
39
 
77
40
  // Color customization options (configurable from JS)
78
- var textColor: Color? = nil
79
- var accentColor: Color = Color(red: 1, green: 0.765, blue: 0.07) // #FFC312 (gold)
41
+ @Published var textColor: Color? = nil
42
+ @Published var accentColor: Color = Color(red: 1, green: 0.765, blue: 0.07) // #FFC312 (gold)
80
43
 
81
44
  // Card dimension options (configurable from JS)
82
- var cardWidth: CGFloat = 280
83
- var cardHeight: CGFloat = 420
45
+ @Published var cardWidth: CGFloat = 280
46
+ @Published var cardHeight: CGFloat = 420
84
47
 
85
48
  // Image display options (configurable from JS)
86
- var imageContentMode: ContentMode = .fill
49
+ @Published var imageContentMode: ContentMode = .fill
87
50
 
88
51
  // Layout spacing options (configurable from JS)
89
- var cardMargin: CGFloat = 40 // Spacing between cards
90
- var cardPadding: CGFloat = 16 // Padding inside cards
91
- var overlayTitleSize: CGFloat = 20 // Font size for overlay title
52
+ @Published var cardMargin: CGFloat = 40 // Spacing between cards
53
+ @Published var cardPadding: CGFloat = 16 // Padding inside cards
54
+ @Published var overlayTitleSize: CGFloat = 20 // Font size for overlay title
92
55
  }
93
56
 
94
- struct TvosSearchContentView: View {
95
- @ObservedObject var viewModel: SearchViewModel
96
-
97
- private var gridColumns: [GridItem] {
98
- Array(repeating: GridItem(.flexible(), spacing: viewModel.cardMargin), count: viewModel.columns)
99
- }
100
-
101
- var body: some View {
102
- NavigationView {
103
- ZStack {
104
- Group {
105
- if viewModel.results.isEmpty && viewModel.searchText.isEmpty {
106
- emptyStateView
107
- } else if viewModel.results.isEmpty && !viewModel.searchText.isEmpty {
108
- if viewModel.isLoading {
109
- searchingStateView
110
- } else {
111
- noResultsView
112
- }
113
- } else {
114
- resultsGridView
115
- }
116
- }
117
-
118
- // Loading overlay when loading with results
119
- if viewModel.isLoading && !viewModel.results.isEmpty {
120
- loadingOverlay
121
- }
122
- }
123
- .searchable(text: $viewModel.searchText, prompt: viewModel.placeholder)
124
- .onChange(of: viewModel.searchText) { newValue in
125
- viewModel.onSearch?(newValue)
126
- }
127
- }
128
- .padding(.top, viewModel.topInset)
129
- .ignoresSafeArea(.all, edges: .top)
130
- }
131
-
132
- private var emptyStateView: some View {
133
- VStack(spacing: 20) {
134
- Image(systemName: "magnifyingglass")
135
- .font(.system(size: 80))
136
- .foregroundColor(viewModel.textColor ?? .secondary)
137
- Text(viewModel.emptyStateText)
138
- .font(.headline)
139
- .foregroundColor(viewModel.textColor ?? .secondary)
140
- }
141
- .frame(maxWidth: .infinity, maxHeight: .infinity)
142
- }
143
-
144
- private var searchingStateView: some View {
145
- VStack(spacing: 20) {
146
- ProgressView()
147
- .scaleEffect(1.5)
148
- Text(viewModel.searchingText)
149
- .font(.headline)
150
- .foregroundColor(viewModel.textColor ?? .secondary)
151
- }
152
- .frame(maxWidth: .infinity, maxHeight: .infinity)
153
- }
154
-
155
- private var noResultsView: some View {
156
- VStack(spacing: 20) {
157
- Image(systemName: "film.stack")
158
- .font(.system(size: 80))
159
- .foregroundColor(viewModel.textColor ?? .secondary)
160
- Text(viewModel.noResultsText)
161
- .font(.headline)
162
- .foregroundColor(viewModel.textColor ?? .secondary)
163
- Text(viewModel.noResultsHintText)
164
- .font(.subheadline)
165
- .foregroundColor(viewModel.textColor ?? .secondary)
166
- }
167
- .frame(maxWidth: .infinity, maxHeight: .infinity)
168
- }
169
-
170
- private var loadingOverlay: some View {
171
- VStack {
172
- HStack {
173
- Spacer()
174
- ProgressView()
175
- .padding(16)
176
- .background(Color.black.opacity(0.6))
177
- .clipShape(RoundedRectangle(cornerRadius: 12))
178
- }
179
- .padding(.trailing, 60)
180
- .padding(.top, 20)
181
- Spacer()
182
- }
183
- }
184
-
185
- private var resultsGridView: some View {
186
- ScrollView {
187
- LazyVGrid(columns: gridColumns, spacing: viewModel.cardMargin) {
188
- ForEach(viewModel.results) { item in
189
- SearchResultCard(
190
- item: item,
191
- showTitle: viewModel.showTitle,
192
- showSubtitle: viewModel.showSubtitle,
193
- showFocusBorder: viewModel.showFocusBorder,
194
- showTitleOverlay: viewModel.showTitleOverlay,
195
- enableMarquee: viewModel.enableMarquee,
196
- marqueeDelay: viewModel.marqueeDelay,
197
- textColor: viewModel.textColor,
198
- accentColor: viewModel.accentColor,
199
- cardWidth: viewModel.cardWidth,
200
- cardHeight: viewModel.cardHeight,
201
- imageContentMode: viewModel.imageContentMode,
202
- cardPadding: viewModel.cardPadding,
203
- overlayTitleSize: viewModel.overlayTitleSize,
204
- onSelect: { viewModel.onSelectItem?(item.id) }
205
- )
206
- }
207
- }
208
- .padding(.horizontal, 60)
209
- .padding(.vertical, 40)
210
- }
211
- }
212
- }
57
+ class ExpoTvosSearchView: ExpoView {
58
+ private var hostingController: UIHostingController<TvosSearchContentView>?
59
+ private var viewModel = SearchViewModel()
213
60
 
214
- struct SearchResultCard: View {
215
- let item: SearchResultItem
216
- let showTitle: Bool
217
- let showSubtitle: Bool
218
- let showFocusBorder: Bool
219
- let showTitleOverlay: Bool
220
- let enableMarquee: Bool
221
- let marqueeDelay: Double
222
- let textColor: Color?
223
- let accentColor: Color
224
- let cardWidth: CGFloat
225
- let cardHeight: CGFloat
226
- let imageContentMode: ContentMode
227
- let cardPadding: CGFloat
228
- let overlayTitleSize: CGFloat
229
- let onSelect: () -> Void
230
- @FocusState private var isFocused: Bool
231
-
232
- private let placeholderColor = Color(white: 0.2)
233
-
234
- // Title overlay constants
235
- private var overlayHeight: CGFloat { cardHeight * 0.25 } // 25% of card
236
-
237
- var body: some View {
238
- Button(action: onSelect) {
239
- VStack(alignment: .leading, spacing: showTitle || showSubtitle ? 12 : 0) {
240
- ZStack(alignment: .bottom) {
241
- // Card image content
242
- ZStack {
243
- placeholderColor
244
-
245
- if let imageUrl = item.imageUrl, let url = URL(string: imageUrl) {
246
- AsyncImage(url: url) { phase in
247
- switch phase {
248
- case .empty:
249
- ProgressView()
250
- case .success(let image):
251
- image
252
- .resizable()
253
- .aspectRatio(contentMode: imageContentMode)
254
- .frame(width: cardWidth, height: cardHeight)
255
- case .failure:
256
- placeholderIcon
257
- @unknown default:
258
- EmptyView()
259
- }
260
- }
261
- .frame(width: cardWidth, height: cardHeight)
262
- } else {
263
- placeholderIcon
264
- }
265
- }
266
- .frame(width: cardWidth, height: cardHeight)
267
- .clipped()
268
-
269
- // Title overlay with native material blur
270
- if showTitleOverlay {
271
- ZStack {
272
- Rectangle()
273
- .fill(.ultraThinMaterial)
274
- .frame(width: cardWidth, height: overlayHeight)
275
-
276
- if enableMarquee {
277
- MarqueeText(
278
- item.title,
279
- font: .system(size: overlayTitleSize, weight: .semibold),
280
- leftFade: 12,
281
- rightFade: 12,
282
- startDelay: marqueeDelay,
283
- animate: isFocused
284
- )
285
- .foregroundColor(.white)
286
- .padding(.horizontal, cardPadding)
287
- } else {
288
- Text(item.title)
289
- .font(.system(size: overlayTitleSize, weight: .semibold))
290
- .foregroundColor(.white)
291
- .lineLimit(2)
292
- .multilineTextAlignment(.center)
293
- .padding(.horizontal, cardPadding)
294
- }
295
- }
296
- .frame(width: cardWidth, height: overlayHeight)
297
- }
298
- }
299
- .frame(width: cardWidth, height: cardHeight)
300
- .clipShape(
301
- SelectiveRoundedRectangle(
302
- topLeadingRadius: 12,
303
- topTrailingRadius: 12,
304
- bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
305
- bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
306
- )
307
- )
308
- .overlay(
309
- SelectiveRoundedRectangle(
310
- topLeadingRadius: 12,
311
- topTrailingRadius: 12,
312
- bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
313
- bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
314
- )
315
- .stroke(showFocusBorder && isFocused ? accentColor : Color.clear, lineWidth: 4)
316
- )
317
-
318
- if showTitle || showSubtitle {
319
- VStack(alignment: .leading, spacing: 4) {
320
- if showTitle {
321
- Text(item.title)
322
- .font(.callout)
323
- .fontWeight(.medium)
324
- .lineLimit(2)
325
- .multilineTextAlignment(.leading)
326
- .foregroundColor(.primary)
327
- }
328
-
329
- if showSubtitle, let subtitle = item.subtitle {
330
- Text(subtitle)
331
- .font(.caption)
332
- .foregroundColor(textColor ?? .secondary)
333
- .lineLimit(1)
334
- }
335
- }
336
- .padding(cardPadding)
337
- .frame(width: cardWidth, alignment: .leading)
338
- }
339
- }
340
- }
341
- .buttonStyle(.card)
342
- .focused($isFocused)
343
- }
61
+ /// Maximum length for string fields (id, title, subtitle) to prevent memory issues.
62
+ private static let maxStringFieldLength = 500
344
63
 
345
- private var placeholderIcon: some View {
346
- ZStack {
347
- Circle()
348
- .fill(Color.white.opacity(0.1))
349
- .frame(width: 120, height: 120)
64
+ /// Maximum number of results to process to prevent memory exhaustion.
65
+ private static let maxResultsCount = 500
350
66
 
351
- Image(systemName: "photo")
352
- .font(.system(size: 60, weight: .light))
353
- .foregroundColor(.white.opacity(0.7))
354
- }
355
- }
356
- }
67
+ // Track if we've disabled RN gesture handlers for keyboard input
68
+ private var gestureHandlersDisabled = false
357
69
 
358
- class ExpoTvosSearchView: ExpoView {
359
- private var hostingController: UIHostingController<TvosSearchContentView>?
360
- private let viewModel = SearchViewModel()
70
+ // Store references to disabled gesture recognizers so we can re-enable them
71
+ private var disabledGestureRecognizers: [UIGestureRecognizer] = []
361
72
 
73
+ // Validation is handled by ExpoTvosSearchModule
362
74
  var columns: Int = 5 {
363
75
  didSet {
364
76
  viewModel.columns = columns
365
77
  }
366
78
  }
367
79
 
368
- var placeholder: String = "Search movies and videos..." {
80
+ var placeholder: String = "Search..." {
369
81
  didSet {
370
82
  viewModel.placeholder = placeholder
371
83
  }
372
84
  }
373
85
 
86
+ var searchTextProp: String? = nil {
87
+ didSet {
88
+ guard let text = searchTextProp, text != viewModel.searchText else { return }
89
+ viewModel.searchText = text
90
+ }
91
+ }
92
+
374
93
  var isLoading: Bool = false {
375
94
  didSet {
376
95
  viewModel.isLoading = isLoading
@@ -419,7 +138,7 @@ class ExpoTvosSearchView: ExpoView {
419
138
  }
420
139
  }
421
140
 
422
- var emptyStateText: String = "Search for movies and videos" {
141
+ var emptyStateText: String = "Search your library" {
423
142
  didSet {
424
143
  viewModel.emptyStateText = emptyStateText
425
144
  }
@@ -474,10 +193,8 @@ class ExpoTvosSearchView: ExpoView {
474
193
  var imageContentMode: String = "fill" {
475
194
  didSet {
476
195
  switch imageContentMode.lowercased() {
477
- case "fit":
196
+ case "fit", "contain":
478
197
  viewModel.imageContentMode = .fit
479
- case "contain":
480
- viewModel.imageContentMode = .fit // SwiftUI uses .fit for contain
481
198
  default:
482
199
  viewModel.imageContentMode = .fill
483
200
  }
@@ -506,6 +223,8 @@ class ExpoTvosSearchView: ExpoView {
506
223
  let onSelectItem = EventDispatcher()
507
224
  let onError = EventDispatcher()
508
225
  let onValidationWarning = EventDispatcher()
226
+ let onSearchFieldFocused = EventDispatcher()
227
+ let onSearchFieldBlurred = EventDispatcher()
509
228
 
510
229
  required init(appContext: AppContext? = nil) {
511
230
  super.init(appContext: appContext)
@@ -513,14 +232,29 @@ class ExpoTvosSearchView: ExpoView {
513
232
  }
514
233
 
515
234
  deinit {
516
- // Clean up hosting controller and view model references to prevent memory leaks
517
- hostingController?.view.removeFromSuperview()
518
- hostingController = nil
519
- viewModel.onSearch = nil
520
- viewModel.onSelectItem = nil
235
+ // Remove notification observers explicitly (also auto-removed on dealloc, but explicit is safer)
236
+ NotificationCenter.default.removeObserver(self)
237
+
238
+ // Re-enable any disabled gesture recognizers (only needed on real hardware)
239
+ #if !targetEnvironment(simulator)
240
+ enableParentGestureRecognizers()
241
+ #endif
242
+
243
+ // Post notification to re-enable cancelsTouchesInView if needed
244
+ if gestureHandlersDisabled {
245
+ NotificationCenter.default.post(
246
+ name: RCTTVEnableGestureHandlersCancelTouchesNotification,
247
+ object: nil
248
+ )
249
+ }
521
250
  }
522
251
 
523
252
  private func setupView() {
253
+ let contentView = TvosSearchContentView(viewModel: viewModel)
254
+ let controller = UIHostingController(rootView: contentView)
255
+ controller.view.backgroundColor = .clear
256
+ hostingController = controller
257
+
524
258
  // Configure viewModel callbacks
525
259
  viewModel.onSearch = { [weak self] query in
526
260
  self?.onSearch(["query": query])
@@ -529,12 +263,8 @@ class ExpoTvosSearchView: ExpoView {
529
263
  self?.onSelectItem(["id": id])
530
264
  }
531
265
 
532
- // Create hosting controller once
533
- let contentView = TvosSearchContentView(viewModel: viewModel)
534
- let controller = UIHostingController(rootView: contentView)
535
- controller.view.backgroundColor = .clear
536
- hostingController = controller
537
-
266
+ // Add hosting controller view with constraints
267
+ guard let controller = hostingController else { return }
538
268
  addSubview(controller.view)
539
269
  controller.view.translatesAutoresizingMaskIntoConstraints = false
540
270
  NSLayoutConstraint.activate([
@@ -543,15 +273,138 @@ class ExpoTvosSearchView: ExpoView {
543
273
  controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
544
274
  controller.view.trailingAnchor.constraint(equalTo: trailingAnchor)
545
275
  ])
276
+
277
+ // Observe text field editing to detect when search keyboard is active
278
+ NotificationCenter.default.addObserver(
279
+ self,
280
+ selector: #selector(handleTextFieldDidBeginEditing),
281
+ name: UITextField.textDidBeginEditingNotification,
282
+ object: nil
283
+ )
284
+ NotificationCenter.default.addObserver(
285
+ self,
286
+ selector: #selector(handleTextFieldDidEndEditing),
287
+ name: UITextField.textDidEndEditingNotification,
288
+ object: nil
289
+ )
290
+ }
291
+
292
+ @objc private func handleTextFieldDidBeginEditing(_ notification: Notification) {
293
+ guard let textField = notification.object as? UITextField,
294
+ let hostingView = hostingController?.view,
295
+ textField.isDescendant(of: hostingView) else {
296
+ return
297
+ }
298
+
299
+ // Skip if already disabled
300
+ guard !gestureHandlersDisabled else { return }
301
+ gestureHandlersDisabled = true
302
+
303
+ // Post notification to RN to stop cancelling touches
304
+ NotificationCenter.default.post(
305
+ name: RCTTVDisableGestureHandlersCancelTouchesNotification,
306
+ object: nil
307
+ )
308
+
309
+ // Only disable parent gesture recognizers on real hardware.
310
+ // On the Simulator, the RCT notification alone is sufficient and
311
+ // disabling gesture recognizers interferes with keyboard input
312
+ // (Mac keyboard events are delivered as UIPress events through the
313
+ // responder chain, which breaks when recognizers are disabled).
314
+ #if !targetEnvironment(simulator)
315
+ disableParentGestureRecognizers()
316
+ #endif
317
+
318
+ // Fire JS event
319
+ onSearchFieldFocused([:])
320
+ }
321
+
322
+ @objc private func handleTextFieldDidEndEditing(_ notification: Notification) {
323
+ guard let textField = notification.object as? UITextField,
324
+ let hostingView = hostingController?.view,
325
+ textField.isDescendant(of: hostingView) else {
326
+ return
327
+ }
328
+
329
+ // Skip if not disabled
330
+ guard gestureHandlersDisabled else { return }
331
+ gestureHandlersDisabled = false
332
+
333
+ // Re-enable gesture recognizers (only needed on real hardware)
334
+ #if !targetEnvironment(simulator)
335
+ enableParentGestureRecognizers()
336
+ #endif
337
+
338
+ // Post notification to RN
339
+ NotificationCenter.default.post(
340
+ name: RCTTVEnableGestureHandlersCancelTouchesNotification,
341
+ object: nil
342
+ )
343
+
344
+ // Fire JS event
345
+ onSearchFieldBlurred([:])
346
+ }
347
+
348
+ // MARK: - Validation Warning Helper
349
+
350
+ /// Emits a validation warning event with optional debug-only context
351
+ private func emitWarning(type: String, message: String, context: String? = nil, debugContext: String? = nil) {
352
+ #if DEBUG
353
+ let ctx = debugContext ?? context ?? "validation completed"
354
+ #else
355
+ let ctx = context ?? "validation completed"
356
+ #endif
357
+ onValidationWarning(["type": type, "message": message, "context": ctx])
358
+ }
359
+
360
+ // MARK: - Gesture Recognizer Management
361
+
362
+ /// Walks up the view hierarchy and disables tap/long-press gesture recognizers.
363
+ /// Swipe/pan recognizers are kept enabled for keyboard navigation.
364
+ private func disableParentGestureRecognizers() {
365
+ disabledGestureRecognizers.removeAll()
366
+
367
+ var currentView: UIView? = self.superview
368
+ while let view = currentView {
369
+ for recognizer in view.gestureRecognizers ?? [] {
370
+ // Only disable tap and long press recognizers
371
+ let isTapOrPress = recognizer is UITapGestureRecognizer ||
372
+ recognizer is UILongPressGestureRecognizer
373
+ if isTapOrPress && recognizer.isEnabled {
374
+ recognizer.isEnabled = false
375
+ disabledGestureRecognizers.append(recognizer)
376
+ }
377
+ }
378
+ currentView = view.superview
379
+ }
380
+ }
381
+
382
+ /// Re-enables all gesture recognizers that were previously disabled.
383
+ private func enableParentGestureRecognizers() {
384
+ for recognizer in disabledGestureRecognizers {
385
+ recognizer.isEnabled = true
386
+ }
387
+ disabledGestureRecognizers.removeAll()
546
388
  }
547
389
 
548
390
  func updateResults(_ results: [[String: Any]]) {
391
+ // Limit results to prevent memory exhaustion from malicious/buggy input
392
+ let limitedResults = results.prefix(Self.maxResultsCount)
393
+ let truncatedResultsCount = results.count - limitedResults.count
394
+
549
395
  var validResults: [SearchResultItem] = []
550
396
  var skippedCount = 0
551
397
  var urlValidationFailures = 0
398
+ var httpUrlCount = 0
552
399
  var truncatedFields = 0
553
400
 
554
- for (index, dict) in results.enumerated() {
401
+ #if DEBUG
402
+ if truncatedResultsCount > 0 {
403
+ print("[expo-tvos-search] Truncated \(truncatedResultsCount) results (max \(Self.maxResultsCount) allowed)")
404
+ }
405
+ #endif
406
+
407
+ for (index, dict) in limitedResults.enumerated() {
555
408
  // Validate required fields
556
409
  guard let id = dict["id"] as? String, !id.isEmpty else {
557
410
  skippedCount += 1
@@ -572,29 +425,32 @@ class ExpoTvosSearchView: ExpoView {
572
425
  // Validate and sanitize imageUrl if present
573
426
  var validatedImageUrl: String? = nil
574
427
  if let imageUrl = dict["imageUrl"] as? String, !imageUrl.isEmpty {
575
- // Accept HTTP/HTTPS URLs only, reject other schemes for security
428
+ // Accept HTTP/HTTPS URLs and data: URIs, reject other schemes for security
576
429
  if let url = URL(string: imageUrl),
577
430
  let scheme = url.scheme?.lowercased(),
578
- scheme == "http" || scheme == "https" {
431
+ scheme == "http" || scheme == "https" || scheme == "data" {
579
432
  validatedImageUrl = imageUrl
433
+ // Warn about insecure HTTP URLs (HTTPS recommended)
434
+ if scheme == "http" {
435
+ httpUrlCount += 1
436
+ #if DEBUG
437
+ print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): using insecure HTTP URL. HTTPS is recommended for security.")
438
+ #endif
439
+ }
580
440
  } else {
581
441
  urlValidationFailures += 1
582
442
  #if DEBUG
583
- print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): invalid imageUrl '\(imageUrl)'. Only HTTP/HTTPS URLs are supported for security reasons.")
443
+ print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): invalid imageUrl '\(imageUrl)'. Only HTTP/HTTPS URLs and data: URIs are supported.")
584
444
  #endif
585
445
  }
586
446
  }
587
447
 
588
- // Limit string lengths to prevent memory issues
589
- let maxIdLength = 500
590
- let maxTitleLength = 500
591
- let maxSubtitleLength = 500
592
-
593
448
  // Track if any fields were truncated
594
- let idTruncated = id.count > maxIdLength
595
- let titleTruncated = title.count > maxTitleLength
449
+ let maxLen = Self.maxStringFieldLength
596
450
  let subtitle = dict["subtitle"] as? String
597
- let subtitleTruncated = (subtitle?.count ?? 0) > maxSubtitleLength
451
+ let idTruncated = id.count > maxLen
452
+ let titleTruncated = title.count > maxLen
453
+ let subtitleTruncated = (subtitle?.count ?? 0) > maxLen
598
454
 
599
455
  if idTruncated || titleTruncated || subtitleTruncated {
600
456
  truncatedFields += 1
@@ -608,9 +464,9 @@ class ExpoTvosSearchView: ExpoView {
608
464
  }
609
465
 
610
466
  validResults.append(SearchResultItem(
611
- id: String(id.prefix(maxIdLength)),
612
- title: String(title.prefix(maxTitleLength)),
613
- subtitle: subtitle.map { String($0.prefix(maxSubtitleLength)) },
467
+ id: String(id.prefix(maxLen)),
468
+ title: String(title.prefix(maxLen)),
469
+ subtitle: subtitle.map { String($0.prefix(maxLen)) },
614
470
  imageUrl: validatedImageUrl
615
471
  ))
616
472
  }
@@ -618,58 +474,47 @@ class ExpoTvosSearchView: ExpoView {
618
474
  // Log summary of validation issues and emit warnings
619
475
  #if DEBUG
620
476
  if skippedCount > 0 {
621
- print("[expo-tvos-search] ⚠️ Skipped \(skippedCount) result(s) due to missing required fields (id or title)")
477
+ print("[expo-tvos-search] Skipped \(skippedCount) result(s) due to missing required fields (id or title)")
622
478
  }
623
479
  if urlValidationFailures > 0 {
624
- print("[expo-tvos-search] ⚠️ \(urlValidationFailures) image URL(s) failed validation (non-HTTP/HTTPS or malformed)")
480
+ print("[expo-tvos-search] \(urlValidationFailures) image URL(s) failed validation (non-HTTP/HTTPS or malformed)")
481
+ }
482
+ if httpUrlCount > 0 {
483
+ print("[expo-tvos-search] \(httpUrlCount) image URL(s) use insecure HTTP. HTTPS is recommended.")
625
484
  }
626
485
  if truncatedFields > 0 {
627
- print("[expo-tvos-search] ℹ️ Truncated \(truncatedFields) result(s) with fields exceeding maximum length (500 chars)")
486
+ print("[expo-tvos-search] Truncated \(truncatedFields) result(s) with fields exceeding maximum length (500 chars)")
628
487
  }
629
488
  if validResults.count > 0 {
630
- print("[expo-tvos-search] Processed \(validResults.count) valid result(s)")
489
+ print("[expo-tvos-search] Processed \(validResults.count) valid result(s)")
631
490
  }
632
491
  #endif
633
492
 
634
493
  // Emit validation warnings for production monitoring
494
+ if truncatedResultsCount > 0 {
495
+ emitWarning(type: "results_truncated",
496
+ message: "Truncated \(truncatedResultsCount) result(s) exceeding maximum of \(Self.maxResultsCount)",
497
+ context: "Consider implementing pagination")
498
+ }
635
499
  if skippedCount > 0 {
636
- #if DEBUG
637
- let skipContext = "validResults=\(validResults.count), skipped=\(skippedCount)"
638
- #else
639
- let skipContext = "validation completed"
640
- #endif
641
-
642
- onValidationWarning([
643
- "type": "validation_failed",
644
- "message": "Skipped \(skippedCount) result(s) due to missing required fields",
645
- "context": skipContext
646
- ])
500
+ emitWarning(type: "validation_failed",
501
+ message: "Skipped \(skippedCount) result(s) due to missing required fields",
502
+ debugContext: "validResults=\(validResults.count), skipped=\(skippedCount)")
647
503
  }
648
504
  if urlValidationFailures > 0 {
649
- #if DEBUG
650
- let urlContext = "Non-HTTP/HTTPS or malformed URLs"
651
- #else
652
- let urlContext = "validation completed"
653
- #endif
654
-
655
- onValidationWarning([
656
- "type": "url_invalid",
657
- "message": "\(urlValidationFailures) image URL(s) failed validation",
658
- "context": urlContext
659
- ])
505
+ emitWarning(type: "url_invalid",
506
+ message: "\(urlValidationFailures) image URL(s) failed validation",
507
+ debugContext: "Non-HTTP/HTTPS or malformed URLs")
508
+ }
509
+ if httpUrlCount > 0 {
510
+ emitWarning(type: "url_insecure",
511
+ message: "\(httpUrlCount) image URL(s) use insecure HTTP. HTTPS is recommended.",
512
+ context: "Consider using HTTPS URLs")
660
513
  }
661
514
  if truncatedFields > 0 {
662
- #if DEBUG
663
- let truncContext = "Check id, title, or subtitle field lengths"
664
- #else
665
- let truncContext = "validation completed"
666
- #endif
667
-
668
- onValidationWarning([
669
- "type": "field_truncated",
670
- "message": "Truncated \(truncatedFields) result(s) with fields exceeding 500 characters",
671
- "context": truncContext
672
- ])
515
+ emitWarning(type: "field_truncated",
516
+ message: "Truncated \(truncatedFields) result(s) with fields exceeding 500 characters",
517
+ debugContext: "Check id, title, or subtitle field lengths")
673
518
  }
674
519
 
675
520
  // Ensure UI updates happen on main thread
@@ -682,34 +527,13 @@ class ExpoTvosSearchView: ExpoView {
682
527
  // MARK: - Color Extension for Hex String Parsing
683
528
  extension Color {
684
529
  /// Initialize a Color from a hex string (e.g., "#FFFFFF", "#FF5733", "FFC312")
685
- /// Returns nil if the string cannot be parsed as a valid hex color
530
+ /// Returns nil if the string cannot be parsed as a valid hex color.
531
+ /// Parsing logic is in HexColorParser for testability.
686
532
  init?(hex: String) {
687
- let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
688
- var int: UInt64 = 0
689
-
690
- guard Scanner(string: hex).scanHexInt64(&int) else {
691
- return nil
692
- }
693
-
694
- let a, r, g, b: UInt64
695
- switch hex.count {
696
- case 3: // RGB (12-bit)
697
- (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
698
- case 6: // RGB (24-bit)
699
- (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
700
- case 8: // ARGB (32-bit)
701
- (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
702
- default:
533
+ guard let rgba = HexColorParser.parse(hex) else {
703
534
  return nil
704
535
  }
705
-
706
- self.init(
707
- .sRGB,
708
- red: Double(r) / 255,
709
- green: Double(g) / 255,
710
- blue: Double(b) / 255,
711
- opacity: Double(a) / 255
712
- )
536
+ self.init(.sRGB, red: rgba.red, green: rgba.green, blue: rgba.blue, opacity: rgba.alpha)
713
537
  }
714
538
  }
715
539
 
@@ -718,7 +542,8 @@ extension Color {
718
542
  // Fallback for non-tvOS platforms (iOS)
719
543
  class ExpoTvosSearchView: ExpoView {
720
544
  var columns: Int = 5
721
- var placeholder: String = "Search movies and videos..."
545
+ var placeholder: String = "Search..."
546
+ var searchTextProp: String? = nil
722
547
  var isLoading: Bool = false
723
548
  var showTitle: Bool = false
724
549
  var showSubtitle: Bool = false
@@ -727,7 +552,7 @@ class ExpoTvosSearchView: ExpoView {
727
552
  var showTitleOverlay: Bool = true
728
553
  var enableMarquee: Bool = true
729
554
  var marqueeDelay: Double = 1.5
730
- var emptyStateText: String = "Search for movies and videos"
555
+ var emptyStateText: String = "Search your library"
731
556
  var searchingText: String = "Searching..."
732
557
  var noResultsText: String = "No results found"
733
558
  var noResultsHintText: String = "Try a different search term"
@@ -740,10 +565,14 @@ class ExpoTvosSearchView: ExpoView {
740
565
  var cardPadding: CGFloat = 16
741
566
  var overlayTitleSize: CGFloat = 20
742
567
 
568
+ // Event dispatchers required by ExpoTvosSearchModule's Event() registration.
569
+ // Intentionally no-ops on non-tvOS — the fallback view never fires events.
743
570
  let onSearch = EventDispatcher()
744
571
  let onSelectItem = EventDispatcher()
745
572
  let onError = EventDispatcher()
746
573
  let onValidationWarning = EventDispatcher()
574
+ let onSearchFieldFocused = EventDispatcher()
575
+ let onSearchFieldBlurred = EventDispatcher()
747
576
 
748
577
  required init(appContext: AppContext? = nil) {
749
578
  super.init(appContext: appContext)