expo-tvos-search 1.2.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.
@@ -0,0 +1,507 @@
1
+ import ExpoModulesCore
2
+ import SwiftUI
3
+
4
+ #if os(tvOS)
5
+
6
+ struct SearchResultItem: Identifiable, Equatable {
7
+ let id: String
8
+ let title: String
9
+ let subtitle: String?
10
+ let imageUrl: String?
11
+ }
12
+
13
+ /// ObservableObject that holds state for the search view.
14
+ /// This allows updating properties without recreating the entire view hierarchy.
15
+ class SearchViewModel: ObservableObject {
16
+ @Published var results: [SearchResultItem] = []
17
+ @Published var isLoading: Bool = false
18
+ @Published var searchText: String = ""
19
+
20
+ var onSearch: ((String) -> Void)?
21
+ var onSelectItem: ((String) -> Void)?
22
+ var columns: Int = 5
23
+ var placeholder: String = "Search movies and videos..."
24
+
25
+ // Card styling options (configurable from JS)
26
+ var showTitle: Bool = false
27
+ var showSubtitle: Bool = false
28
+ var showFocusBorder: Bool = false
29
+ var topInset: CGFloat = 0 // Extra top padding for tab bar
30
+
31
+ // Title overlay options (configurable from JS)
32
+ var showTitleOverlay: Bool = true
33
+ var enableMarquee: Bool = true
34
+ var marqueeDelay: Double = 1.5
35
+
36
+ // State text options (configurable from JS)
37
+ var emptyStateText: String = "Search for movies and videos"
38
+ var searchingText: String = "Searching..."
39
+ var noResultsText: String = "No results found"
40
+ var noResultsHintText: String = "Try a different search term"
41
+ }
42
+
43
+ struct TvosSearchContentView: View {
44
+ @ObservedObject var viewModel: SearchViewModel
45
+
46
+ private var gridColumns: [GridItem] {
47
+ Array(repeating: GridItem(.flexible(), spacing: 40), count: viewModel.columns)
48
+ }
49
+
50
+ var body: some View {
51
+ NavigationView {
52
+ ZStack {
53
+ Group {
54
+ if viewModel.results.isEmpty && viewModel.searchText.isEmpty {
55
+ emptyStateView
56
+ } else if viewModel.results.isEmpty && !viewModel.searchText.isEmpty {
57
+ if viewModel.isLoading {
58
+ searchingStateView
59
+ } else {
60
+ noResultsView
61
+ }
62
+ } else {
63
+ resultsGridView
64
+ }
65
+ }
66
+
67
+ // Loading overlay when loading with results
68
+ if viewModel.isLoading && !viewModel.results.isEmpty {
69
+ loadingOverlay
70
+ }
71
+ }
72
+ .searchable(text: $viewModel.searchText, prompt: viewModel.placeholder)
73
+ .onChange(of: viewModel.searchText) { newValue in
74
+ viewModel.onSearch?(newValue)
75
+ }
76
+ }
77
+ .padding(.top, viewModel.topInset)
78
+ .ignoresSafeArea(.all, edges: .top)
79
+ }
80
+
81
+ private var emptyStateView: some View {
82
+ VStack(spacing: 20) {
83
+ Image(systemName: "magnifyingglass")
84
+ .font(.system(size: 80))
85
+ .foregroundColor(.secondary)
86
+ Text(viewModel.emptyStateText)
87
+ .font(.headline)
88
+ .foregroundColor(.secondary)
89
+ }
90
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
91
+ }
92
+
93
+ private var searchingStateView: some View {
94
+ VStack(spacing: 20) {
95
+ ProgressView()
96
+ .scaleEffect(1.5)
97
+ Text(viewModel.searchingText)
98
+ .font(.headline)
99
+ .foregroundColor(.secondary)
100
+ }
101
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
102
+ }
103
+
104
+ private var noResultsView: some View {
105
+ VStack(spacing: 20) {
106
+ Image(systemName: "film.stack")
107
+ .font(.system(size: 80))
108
+ .foregroundColor(.secondary)
109
+ Text(viewModel.noResultsText)
110
+ .font(.headline)
111
+ .foregroundColor(.secondary)
112
+ Text(viewModel.noResultsHintText)
113
+ .font(.subheadline)
114
+ .foregroundColor(.secondary)
115
+ }
116
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
117
+ }
118
+
119
+ private var loadingOverlay: some View {
120
+ VStack {
121
+ HStack {
122
+ Spacer()
123
+ ProgressView()
124
+ .padding(16)
125
+ .background(Color.black.opacity(0.6))
126
+ .clipShape(RoundedRectangle(cornerRadius: 12))
127
+ }
128
+ .padding(.trailing, 60)
129
+ .padding(.top, 20)
130
+ Spacer()
131
+ }
132
+ }
133
+
134
+ private var resultsGridView: some View {
135
+ ScrollView {
136
+ LazyVGrid(columns: gridColumns, spacing: 50) {
137
+ ForEach(viewModel.results) { item in
138
+ SearchResultCard(
139
+ item: item,
140
+ showTitle: viewModel.showTitle,
141
+ showSubtitle: viewModel.showSubtitle,
142
+ showFocusBorder: viewModel.showFocusBorder,
143
+ showTitleOverlay: viewModel.showTitleOverlay,
144
+ enableMarquee: viewModel.enableMarquee,
145
+ marqueeDelay: viewModel.marqueeDelay,
146
+ onSelect: { viewModel.onSelectItem?(item.id) }
147
+ )
148
+ }
149
+ }
150
+ .padding(.horizontal, 60)
151
+ .padding(.vertical, 40)
152
+ }
153
+ }
154
+ }
155
+
156
+ struct SearchResultCard: View {
157
+ let item: SearchResultItem
158
+ let showTitle: Bool
159
+ let showSubtitle: Bool
160
+ let showFocusBorder: Bool
161
+ let showTitleOverlay: Bool
162
+ let enableMarquee: Bool
163
+ let marqueeDelay: Double
164
+ let onSelect: () -> Void
165
+ @FocusState private var isFocused: Bool
166
+
167
+ private let placeholderColor = Color(white: 0.2)
168
+ private let focusedBorderColor = Color(red: 1, green: 0.765, blue: 0.07) // #FFC312
169
+
170
+ // Fixed card dimensions for consistent grid layout
171
+ // Width calculated for 5 columns: (1920 - 120 padding - 160 spacing) / 5 ≈ 280
172
+ private let cardWidth: CGFloat = 280
173
+ private var cardHeight: CGFloat { cardWidth * 1.5 } // 2:3 aspect ratio
174
+
175
+ // Title overlay constants
176
+ private let overlayGradientHeight: CGFloat = 30
177
+ private let titleBarHeight: CGFloat = 36
178
+ private let overlayOpacity: Double = 0.8
179
+
180
+ var body: some View {
181
+ Button(action: onSelect) {
182
+ VStack(alignment: .leading, spacing: showTitle || showSubtitle ? 12 : 0) {
183
+ ZStack(alignment: .bottom) {
184
+ // Card image content
185
+ ZStack {
186
+ placeholderColor
187
+
188
+ if let imageUrl = item.imageUrl, let url = URL(string: imageUrl) {
189
+ AsyncImage(url: url) { phase in
190
+ switch phase {
191
+ case .empty:
192
+ ProgressView()
193
+ case .success(let image):
194
+ image
195
+ .resizable()
196
+ .aspectRatio(contentMode: .fill)
197
+ .frame(width: cardWidth, height: cardHeight)
198
+ case .failure:
199
+ placeholderIcon
200
+ @unknown default:
201
+ EmptyView()
202
+ }
203
+ }
204
+ .frame(width: cardWidth, height: cardHeight)
205
+ } else {
206
+ placeholderIcon
207
+ }
208
+ }
209
+ .frame(width: cardWidth, height: cardHeight)
210
+ .clipped()
211
+
212
+ // Title overlay (gradient + title bar)
213
+ if showTitleOverlay {
214
+ VStack(spacing: 0) {
215
+ // Gradient fade
216
+ LinearGradient(
217
+ colors: [.clear, .black.opacity(overlayOpacity)],
218
+ startPoint: .top,
219
+ endPoint: .bottom
220
+ )
221
+ .frame(height: overlayGradientHeight)
222
+
223
+ // Title bar
224
+ HStack {
225
+ if enableMarquee {
226
+ MarqueeText(
227
+ item.title,
228
+ font: .callout,
229
+ leftFade: 8,
230
+ rightFade: 8,
231
+ startDelay: marqueeDelay,
232
+ animate: isFocused
233
+ )
234
+ .foregroundColor(.white)
235
+ } else {
236
+ Text(item.title)
237
+ .font(.callout)
238
+ .foregroundColor(.white)
239
+ .lineLimit(1)
240
+ }
241
+ }
242
+ .padding(.horizontal, 12)
243
+ .frame(width: cardWidth, height: titleBarHeight, alignment: .leading)
244
+ .background(Color.black.opacity(overlayOpacity))
245
+ }
246
+ }
247
+ }
248
+ .frame(width: cardWidth, height: cardHeight)
249
+ .clipShape(RoundedRectangle(cornerRadius: 12))
250
+ .overlay(
251
+ RoundedRectangle(cornerRadius: 12)
252
+ .stroke(showFocusBorder && isFocused ? focusedBorderColor : Color.clear, lineWidth: 4)
253
+ )
254
+
255
+ if showTitle || showSubtitle {
256
+ VStack(alignment: .leading, spacing: 4) {
257
+ if showTitle {
258
+ Text(item.title)
259
+ .font(.callout)
260
+ .fontWeight(.medium)
261
+ .lineLimit(2)
262
+ .multilineTextAlignment(.leading)
263
+ .foregroundColor(.primary)
264
+ }
265
+
266
+ if showSubtitle, let subtitle = item.subtitle {
267
+ Text(subtitle)
268
+ .font(.caption)
269
+ .foregroundColor(.secondary)
270
+ .lineLimit(1)
271
+ }
272
+ }
273
+ .frame(width: cardWidth, alignment: .leading)
274
+ }
275
+ }
276
+ }
277
+ .buttonStyle(.card)
278
+ .focused($isFocused)
279
+ }
280
+
281
+ private var placeholderIcon: some View {
282
+ Image(systemName: "film")
283
+ .font(.system(size: 50))
284
+ .foregroundColor(.secondary)
285
+ }
286
+ }
287
+
288
+ class ExpoTvosSearchView: ExpoView {
289
+ private var hostingController: UIHostingController<TvosSearchContentView>?
290
+ private let viewModel = SearchViewModel()
291
+
292
+ var columns: Int = 5 {
293
+ didSet {
294
+ viewModel.columns = columns
295
+ }
296
+ }
297
+
298
+ var placeholder: String = "Search movies and videos..." {
299
+ didSet {
300
+ viewModel.placeholder = placeholder
301
+ }
302
+ }
303
+
304
+ var isLoading: Bool = false {
305
+ didSet {
306
+ viewModel.isLoading = isLoading
307
+ }
308
+ }
309
+
310
+ var showTitle: Bool = false {
311
+ didSet {
312
+ viewModel.showTitle = showTitle
313
+ }
314
+ }
315
+
316
+ var showSubtitle: Bool = false {
317
+ didSet {
318
+ viewModel.showSubtitle = showSubtitle
319
+ }
320
+ }
321
+
322
+ var showFocusBorder: Bool = false {
323
+ didSet {
324
+ viewModel.showFocusBorder = showFocusBorder
325
+ }
326
+ }
327
+
328
+ var topInset: CGFloat = 0 {
329
+ didSet {
330
+ viewModel.topInset = topInset
331
+ }
332
+ }
333
+
334
+ var showTitleOverlay: Bool = true {
335
+ didSet {
336
+ viewModel.showTitleOverlay = showTitleOverlay
337
+ }
338
+ }
339
+
340
+ var enableMarquee: Bool = true {
341
+ didSet {
342
+ viewModel.enableMarquee = enableMarquee
343
+ }
344
+ }
345
+
346
+ var marqueeDelay: Double = 1.5 {
347
+ didSet {
348
+ viewModel.marqueeDelay = marqueeDelay
349
+ }
350
+ }
351
+
352
+ var emptyStateText: String = "Search for movies and videos" {
353
+ didSet {
354
+ viewModel.emptyStateText = emptyStateText
355
+ }
356
+ }
357
+
358
+ var searchingText: String = "Searching..." {
359
+ didSet {
360
+ viewModel.searchingText = searchingText
361
+ }
362
+ }
363
+
364
+ var noResultsText: String = "No results found" {
365
+ didSet {
366
+ viewModel.noResultsText = noResultsText
367
+ }
368
+ }
369
+
370
+ var noResultsHintText: String = "Try a different search term" {
371
+ didSet {
372
+ viewModel.noResultsHintText = noResultsHintText
373
+ }
374
+ }
375
+
376
+ let onSearch = EventDispatcher()
377
+ let onSelectItem = EventDispatcher()
378
+
379
+ required init(appContext: AppContext? = nil) {
380
+ super.init(appContext: appContext)
381
+ setupView()
382
+ }
383
+
384
+ deinit {
385
+ // Clean up hosting controller and view model references to prevent memory leaks
386
+ hostingController?.view.removeFromSuperview()
387
+ hostingController = nil
388
+ viewModel.onSearch = nil
389
+ viewModel.onSelectItem = nil
390
+ }
391
+
392
+ private func setupView() {
393
+ // Configure viewModel callbacks
394
+ viewModel.onSearch = { [weak self] query in
395
+ self?.onSearch(["query": query])
396
+ }
397
+ viewModel.onSelectItem = { [weak self] id in
398
+ self?.onSelectItem(["id": id])
399
+ }
400
+
401
+ // Create hosting controller once
402
+ let contentView = TvosSearchContentView(viewModel: viewModel)
403
+ let controller = UIHostingController(rootView: contentView)
404
+ controller.view.backgroundColor = .clear
405
+ hostingController = controller
406
+
407
+ addSubview(controller.view)
408
+ controller.view.translatesAutoresizingMaskIntoConstraints = false
409
+ NSLayoutConstraint.activate([
410
+ controller.view.topAnchor.constraint(equalTo: topAnchor),
411
+ controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
412
+ controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
413
+ controller.view.trailingAnchor.constraint(equalTo: trailingAnchor)
414
+ ])
415
+ }
416
+
417
+ func updateResults(_ results: [[String: Any]]) {
418
+ var validResults: [SearchResultItem] = []
419
+ var skippedCount = 0
420
+
421
+ for dict in results {
422
+ guard let id = dict["id"] as? String, !id.isEmpty,
423
+ let title = dict["title"] as? String, !title.isEmpty else {
424
+ skippedCount += 1
425
+ continue
426
+ }
427
+
428
+ // Validate and sanitize imageUrl if present
429
+ var validatedImageUrl: String? = nil
430
+ if let imageUrl = dict["imageUrl"] as? String, !imageUrl.isEmpty {
431
+ // Accept HTTP/HTTPS URLs only, reject other schemes for security
432
+ if let url = URL(string: imageUrl),
433
+ let scheme = url.scheme?.lowercased(),
434
+ scheme == "http" || scheme == "https" {
435
+ validatedImageUrl = imageUrl
436
+ }
437
+ }
438
+
439
+ // Limit string lengths to prevent memory issues
440
+ let maxIdLength = 500
441
+ let maxTitleLength = 500
442
+ let maxSubtitleLength = 500
443
+
444
+ validResults.append(SearchResultItem(
445
+ id: String(id.prefix(maxIdLength)),
446
+ title: String(title.prefix(maxTitleLength)),
447
+ subtitle: (dict["subtitle"] as? String).map { String($0.prefix(maxSubtitleLength)) },
448
+ imageUrl: validatedImageUrl
449
+ ))
450
+ }
451
+
452
+ #if DEBUG
453
+ if skippedCount > 0 {
454
+ print("[expo-tvos-search] Skipped \(skippedCount) invalid result(s) missing required id or title")
455
+ }
456
+ #endif
457
+
458
+ viewModel.results = validResults
459
+ }
460
+ }
461
+
462
+ #else
463
+
464
+ // Fallback for non-tvOS platforms (iOS)
465
+ class ExpoTvosSearchView: ExpoView {
466
+ var columns: Int = 5
467
+ var placeholder: String = "Search movies and videos..."
468
+ var isLoading: Bool = false
469
+ var showTitle: Bool = false
470
+ var showSubtitle: Bool = false
471
+ var showFocusBorder: Bool = false
472
+ var topInset: CGFloat = 0
473
+ var showTitleOverlay: Bool = true
474
+ var enableMarquee: Bool = true
475
+ var marqueeDelay: Double = 1.5
476
+ var emptyStateText: String = "Search for movies and videos"
477
+ var searchingText: String = "Searching..."
478
+ var noResultsText: String = "No results found"
479
+ var noResultsHintText: String = "Try a different search term"
480
+
481
+ let onSearch = EventDispatcher()
482
+ let onSelectItem = EventDispatcher()
483
+
484
+ required init(appContext: AppContext? = nil) {
485
+ super.init(appContext: appContext)
486
+ setupFallbackView()
487
+ }
488
+
489
+ private func setupFallbackView() {
490
+ let label = UILabel()
491
+ label.text = "TvOS Search View is only available on Apple TV"
492
+ label.textAlignment = .center
493
+ label.textColor = .secondaryLabel
494
+ label.translatesAutoresizingMaskIntoConstraints = false
495
+ addSubview(label)
496
+ NSLayoutConstraint.activate([
497
+ label.centerXAnchor.constraint(equalTo: centerXAnchor),
498
+ label.centerYAnchor.constraint(equalTo: centerYAnchor)
499
+ ])
500
+ }
501
+
502
+ func updateResults(_ results: [[String: Any]]) {
503
+ // No-op on non-tvOS
504
+ }
505
+ }
506
+
507
+ #endif
@@ -0,0 +1,41 @@
1
+ import Foundation
2
+
3
+ /// Calculator for marquee text animation parameters.
4
+ /// Extracted from MarqueeText view to enable unit testing.
5
+ struct MarqueeAnimationCalculator {
6
+ /// Minimum scroll speed to prevent division by zero
7
+ private static let minPixelsPerSecond: CGFloat = 1.0
8
+
9
+ let spacing: CGFloat
10
+ let pixelsPerSecond: CGFloat
11
+
12
+ init(spacing: CGFloat = 40, pixelsPerSecond: CGFloat = 30) {
13
+ // Ensure non-negative spacing
14
+ self.spacing = max(0, spacing)
15
+ // Ensure minimum scroll speed to prevent division by zero
16
+ self.pixelsPerSecond = max(Self.minPixelsPerSecond, pixelsPerSecond)
17
+ }
18
+
19
+ /// Determines if the text needs to scroll based on its width vs container width.
20
+ /// - Parameters:
21
+ /// - textWidth: The measured width of the text content
22
+ /// - containerWidth: The available container width
23
+ /// - Returns: `true` if text is wider than container and container has valid width
24
+ func shouldScroll(textWidth: CGFloat, containerWidth: CGFloat) -> Bool {
25
+ textWidth > containerWidth && containerWidth > 0
26
+ }
27
+
28
+ /// Calculates the total scroll distance including spacing between repeated text.
29
+ /// - Parameter textWidth: The measured width of the text content
30
+ /// - Returns: Total distance the text needs to scroll (always non-negative)
31
+ func scrollDistance(textWidth: CGFloat) -> CGFloat {
32
+ max(0, textWidth) + spacing
33
+ }
34
+
35
+ /// Calculates animation duration based on scroll distance and scroll speed.
36
+ /// - Parameter distance: The total scroll distance
37
+ /// - Returns: Duration in seconds for the scroll animation (always non-negative)
38
+ func animationDuration(for distance: CGFloat) -> Double {
39
+ Double(max(0, distance)) / pixelsPerSecond
40
+ }
41
+ }