expo-tvos-search 1.2.3 → 1.3.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.
@@ -12,17 +12,32 @@ public class ExpoTvosSearchModule: Module {
12
12
  Name("ExpoTvosSearch")
13
13
 
14
14
  View(ExpoTvosSearchView.self) {
15
- Events("onSearch", "onSelectItem")
15
+ Events("onSearch", "onSelectItem", "onError", "onValidationWarning")
16
16
 
17
17
  Prop("results") { (view: ExpoTvosSearchView, results: [[String: Any]]) in
18
18
  // Limit results array size to prevent memory issues
19
19
  let limitedResults = Array(results.prefix(Self.maxResults))
20
+ if results.count > Self.maxResults {
21
+ view.onValidationWarning([
22
+ "type": "value_clamped",
23
+ "message": "Results array truncated from \(results.count) to \(Self.maxResults) items",
24
+ "context": "maxResults=\(Self.maxResults)"
25
+ ])
26
+ }
20
27
  view.updateResults(limitedResults)
21
28
  }
22
29
 
23
30
  Prop("columns") { (view: ExpoTvosSearchView, columns: Int) in
24
31
  // Clamp columns between min and max for safe grid layout
25
- view.columns = min(max(Self.minColumns, columns), Self.maxColumns)
32
+ let clampedValue = min(max(Self.minColumns, columns), Self.maxColumns)
33
+ if clampedValue != columns {
34
+ view.onValidationWarning([
35
+ "type": "value_clamped",
36
+ "message": "columns value \(columns) was clamped to range [\(Self.minColumns), \(Self.maxColumns)]",
37
+ "context": "columns=\(clampedValue)"
38
+ ])
39
+ }
40
+ view.columns = clampedValue
26
41
  }
27
42
 
28
43
  Prop("placeholder") { (view: ExpoTvosSearchView, placeholder: String) in
@@ -48,7 +63,15 @@ public class ExpoTvosSearchModule: Module {
48
63
 
49
64
  Prop("topInset") { (view: ExpoTvosSearchView, topInset: Double) in
50
65
  // Clamp to non-negative values (max 500 points reasonable for any screen)
51
- view.topInset = CGFloat(min(max(0, topInset), 500))
66
+ let clampedValue = min(max(0, topInset), 500)
67
+ if clampedValue != topInset {
68
+ view.onValidationWarning([
69
+ "type": "value_clamped",
70
+ "message": "topInset value \(topInset) was clamped to range [0, 500]",
71
+ "context": "topInset=\(clampedValue)"
72
+ ])
73
+ }
74
+ view.topInset = CGFloat(clampedValue)
52
75
  }
53
76
 
54
77
  Prop("showTitleOverlay") { (view: ExpoTvosSearchView, show: Bool) in
@@ -61,7 +84,15 @@ public class ExpoTvosSearchModule: Module {
61
84
 
62
85
  Prop("marqueeDelay") { (view: ExpoTvosSearchView, delay: Double) in
63
86
  // Clamp between 0 and maxMarqueeDelay seconds
64
- view.marqueeDelay = min(max(0, delay), Self.maxMarqueeDelay)
87
+ let clampedValue = min(max(0, delay), Self.maxMarqueeDelay)
88
+ if clampedValue != delay {
89
+ view.onValidationWarning([
90
+ "type": "value_clamped",
91
+ "message": "marqueeDelay value \(delay) was clamped to range [0, \(Self.maxMarqueeDelay)]",
92
+ "context": "marqueeDelay=\(clampedValue)"
93
+ ])
94
+ }
95
+ view.marqueeDelay = clampedValue
65
96
  }
66
97
 
67
98
  Prop("emptyStateText") { (view: ExpoTvosSearchView, text: String) in
@@ -79,6 +110,38 @@ public class ExpoTvosSearchModule: Module {
79
110
  Prop("noResultsHintText") { (view: ExpoTvosSearchView, text: String) in
80
111
  view.noResultsHintText = String(text.prefix(Self.maxStringLength))
81
112
  }
113
+
114
+ Prop("textColor") { (view: ExpoTvosSearchView, colorHex: String?) in
115
+ view.textColor = colorHex
116
+ }
117
+
118
+ Prop("accentColor") { (view: ExpoTvosSearchView, colorHex: String) in
119
+ view.accentColor = colorHex
120
+ }
121
+
122
+ Prop("cardWidth") { (view: ExpoTvosSearchView, width: Double) in
123
+ view.cardWidth = CGFloat(max(50, min(1000, width))) // Clamp to reasonable range
124
+ }
125
+
126
+ Prop("cardHeight") { (view: ExpoTvosSearchView, height: Double) in
127
+ view.cardHeight = CGFloat(max(50, min(1000, height))) // Clamp to reasonable range
128
+ }
129
+
130
+ Prop("imageContentMode") { (view: ExpoTvosSearchView, mode: String) in
131
+ view.imageContentMode = mode
132
+ }
133
+
134
+ Prop("cardMargin") { (view: ExpoTvosSearchView, margin: Double) in
135
+ view.cardMargin = CGFloat(max(0, min(200, margin))) // Clamp to reasonable range
136
+ }
137
+
138
+ Prop("cardPadding") { (view: ExpoTvosSearchView, padding: Double) in
139
+ view.cardPadding = CGFloat(max(0, min(100, padding))) // Clamp to reasonable range
140
+ }
141
+
142
+ Prop("overlayTitleSize") { (view: ExpoTvosSearchView, size: Double) in
143
+ view.overlayTitleSize = CGFloat(max(8, min(72, size))) // Clamp to reasonable font size range
144
+ }
82
145
  }
83
146
  }
84
147
  }
@@ -3,6 +3,41 @@ import SwiftUI
3
3
 
4
4
  #if os(tvOS)
5
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
+ }
40
+
6
41
  struct SearchResultItem: Identifiable, Equatable {
7
42
  let id: String
8
43
  let title: String
@@ -38,13 +73,29 @@ class SearchViewModel: ObservableObject {
38
73
  var searchingText: String = "Searching..."
39
74
  var noResultsText: String = "No results found"
40
75
  var noResultsHintText: String = "Try a different search term"
76
+
77
+ // 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)
80
+
81
+ // Card dimension options (configurable from JS)
82
+ var cardWidth: CGFloat = 280
83
+ var cardHeight: CGFloat = 420
84
+
85
+ // Image display options (configurable from JS)
86
+ var imageContentMode: ContentMode = .fill
87
+
88
+ // 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
41
92
  }
42
93
 
43
94
  struct TvosSearchContentView: View {
44
95
  @ObservedObject var viewModel: SearchViewModel
45
96
 
46
97
  private var gridColumns: [GridItem] {
47
- Array(repeating: GridItem(.flexible(), spacing: 40), count: viewModel.columns)
98
+ Array(repeating: GridItem(.flexible(), spacing: viewModel.cardMargin), count: viewModel.columns)
48
99
  }
49
100
 
50
101
  var body: some View {
@@ -82,10 +133,10 @@ struct TvosSearchContentView: View {
82
133
  VStack(spacing: 20) {
83
134
  Image(systemName: "magnifyingglass")
84
135
  .font(.system(size: 80))
85
- .foregroundColor(.secondary)
136
+ .foregroundColor(viewModel.textColor ?? .secondary)
86
137
  Text(viewModel.emptyStateText)
87
138
  .font(.headline)
88
- .foregroundColor(.secondary)
139
+ .foregroundColor(viewModel.textColor ?? .secondary)
89
140
  }
90
141
  .frame(maxWidth: .infinity, maxHeight: .infinity)
91
142
  }
@@ -96,7 +147,7 @@ struct TvosSearchContentView: View {
96
147
  .scaleEffect(1.5)
97
148
  Text(viewModel.searchingText)
98
149
  .font(.headline)
99
- .foregroundColor(.secondary)
150
+ .foregroundColor(viewModel.textColor ?? .secondary)
100
151
  }
101
152
  .frame(maxWidth: .infinity, maxHeight: .infinity)
102
153
  }
@@ -105,13 +156,13 @@ struct TvosSearchContentView: View {
105
156
  VStack(spacing: 20) {
106
157
  Image(systemName: "film.stack")
107
158
  .font(.system(size: 80))
108
- .foregroundColor(.secondary)
159
+ .foregroundColor(viewModel.textColor ?? .secondary)
109
160
  Text(viewModel.noResultsText)
110
161
  .font(.headline)
111
- .foregroundColor(.secondary)
162
+ .foregroundColor(viewModel.textColor ?? .secondary)
112
163
  Text(viewModel.noResultsHintText)
113
164
  .font(.subheadline)
114
- .foregroundColor(.secondary)
165
+ .foregroundColor(viewModel.textColor ?? .secondary)
115
166
  }
116
167
  .frame(maxWidth: .infinity, maxHeight: .infinity)
117
168
  }
@@ -133,7 +184,7 @@ struct TvosSearchContentView: View {
133
184
 
134
185
  private var resultsGridView: some View {
135
186
  ScrollView {
136
- LazyVGrid(columns: gridColumns, spacing: 50) {
187
+ LazyVGrid(columns: gridColumns, spacing: viewModel.cardMargin) {
137
188
  ForEach(viewModel.results) { item in
138
189
  SearchResultCard(
139
190
  item: item,
@@ -143,6 +194,13 @@ struct TvosSearchContentView: View {
143
194
  showTitleOverlay: viewModel.showTitleOverlay,
144
195
  enableMarquee: viewModel.enableMarquee,
145
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,
146
204
  onSelect: { viewModel.onSelectItem?(item.id) }
147
205
  )
148
206
  }
@@ -161,21 +219,20 @@ struct SearchResultCard: View {
161
219
  let showTitleOverlay: Bool
162
220
  let enableMarquee: Bool
163
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
164
229
  let onSelect: () -> Void
165
230
  @FocusState private var isFocused: Bool
166
231
 
167
232
  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
233
 
175
234
  // Title overlay constants
176
- private let overlayGradientHeight: CGFloat = 30
177
- private let titleBarHeight: CGFloat = 36
178
- private let overlayOpacity: Double = 0.8
235
+ private var overlayHeight: CGFloat { cardHeight * 0.25 } // 25% of card
179
236
 
180
237
  var body: some View {
181
238
  Button(action: onSelect) {
@@ -193,7 +250,7 @@ struct SearchResultCard: View {
193
250
  case .success(let image):
194
251
  image
195
252
  .resizable()
196
- .aspectRatio(contentMode: .fill)
253
+ .aspectRatio(contentMode: imageContentMode)
197
254
  .frame(width: cardWidth, height: cardHeight)
198
255
  case .failure:
199
256
  placeholderIcon
@@ -209,47 +266,53 @@ struct SearchResultCard: View {
209
266
  .frame(width: cardWidth, height: cardHeight)
210
267
  .clipped()
211
268
 
212
- // Title overlay (gradient + title bar)
269
+ // Title overlay with native material blur
213
270
  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
- )
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))
234
290
  .foregroundColor(.white)
235
- } else {
236
- Text(item.title)
237
- .font(.callout)
238
- .foregroundColor(.white)
239
- .lineLimit(1)
240
- }
291
+ .lineLimit(2)
292
+ .multilineTextAlignment(.center)
293
+ .padding(.horizontal, cardPadding)
241
294
  }
242
- .padding(.horizontal, 12)
243
- .frame(width: cardWidth, height: titleBarHeight, alignment: .leading)
244
- .background(Color.black.opacity(overlayOpacity))
245
295
  }
296
+ .frame(width: cardWidth, height: overlayHeight)
246
297
  }
247
298
  }
248
299
  .frame(width: cardWidth, height: cardHeight)
249
- .clipShape(RoundedRectangle(cornerRadius: 12))
300
+ .clipShape(
301
+ SelectiveRoundedRectangle(
302
+ topLeadingRadius: 12,
303
+ topTrailingRadius: 12,
304
+ bottomLeadingRadius: (showTitle || showSubtitle) ? 0 : 12,
305
+ bottomTrailingRadius: (showTitle || showSubtitle) ? 0 : 12
306
+ )
307
+ )
250
308
  .overlay(
251
- RoundedRectangle(cornerRadius: 12)
252
- .stroke(showFocusBorder && isFocused ? focusedBorderColor : Color.clear, lineWidth: 4)
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)
253
316
  )
254
317
 
255
318
  if showTitle || showSubtitle {
@@ -266,10 +329,11 @@ struct SearchResultCard: View {
266
329
  if showSubtitle, let subtitle = item.subtitle {
267
330
  Text(subtitle)
268
331
  .font(.caption)
269
- .foregroundColor(.secondary)
332
+ .foregroundColor(textColor ?? .secondary)
270
333
  .lineLimit(1)
271
334
  }
272
335
  }
336
+ .padding(cardPadding)
273
337
  .frame(width: cardWidth, alignment: .leading)
274
338
  }
275
339
  }
@@ -279,9 +343,15 @@ struct SearchResultCard: View {
279
343
  }
280
344
 
281
345
  private var placeholderIcon: some View {
282
- Image(systemName: "film")
283
- .font(.system(size: 50))
284
- .foregroundColor(.secondary)
346
+ ZStack {
347
+ Circle()
348
+ .fill(Color.white.opacity(0.1))
349
+ .frame(width: 120, height: 120)
350
+
351
+ Image(systemName: "photo")
352
+ .font(.system(size: 60, weight: .light))
353
+ .foregroundColor(.white.opacity(0.7))
354
+ }
285
355
  }
286
356
  }
287
357
 
@@ -373,8 +443,69 @@ class ExpoTvosSearchView: ExpoView {
373
443
  }
374
444
  }
375
445
 
446
+ var textColor: String? = nil {
447
+ didSet {
448
+ if let hexColor = textColor {
449
+ viewModel.textColor = Color(hex: hexColor)
450
+ } else {
451
+ viewModel.textColor = nil
452
+ }
453
+ }
454
+ }
455
+
456
+ var accentColor: String = "#FFC312" {
457
+ didSet {
458
+ viewModel.accentColor = Color(hex: accentColor) ?? Color(red: 1, green: 0.765, blue: 0.07)
459
+ }
460
+ }
461
+
462
+ var cardWidth: CGFloat = 280 {
463
+ didSet {
464
+ viewModel.cardWidth = cardWidth
465
+ }
466
+ }
467
+
468
+ var cardHeight: CGFloat = 420 {
469
+ didSet {
470
+ viewModel.cardHeight = cardHeight
471
+ }
472
+ }
473
+
474
+ var imageContentMode: String = "fill" {
475
+ didSet {
476
+ switch imageContentMode.lowercased() {
477
+ case "fit":
478
+ viewModel.imageContentMode = .fit
479
+ case "contain":
480
+ viewModel.imageContentMode = .fit // SwiftUI uses .fit for contain
481
+ default:
482
+ viewModel.imageContentMode = .fill
483
+ }
484
+ }
485
+ }
486
+
487
+ var cardMargin: CGFloat = 40 {
488
+ didSet {
489
+ viewModel.cardMargin = cardMargin
490
+ }
491
+ }
492
+
493
+ var cardPadding: CGFloat = 16 {
494
+ didSet {
495
+ viewModel.cardPadding = cardPadding
496
+ }
497
+ }
498
+
499
+ var overlayTitleSize: CGFloat = 20 {
500
+ didSet {
501
+ viewModel.overlayTitleSize = overlayTitleSize
502
+ }
503
+ }
504
+
376
505
  let onSearch = EventDispatcher()
377
506
  let onSelectItem = EventDispatcher()
507
+ let onError = EventDispatcher()
508
+ let onValidationWarning = EventDispatcher()
378
509
 
379
510
  required init(appContext: AppContext? = nil) {
380
511
  super.init(appContext: appContext)
@@ -417,11 +548,24 @@ class ExpoTvosSearchView: ExpoView {
417
548
  func updateResults(_ results: [[String: Any]]) {
418
549
  var validResults: [SearchResultItem] = []
419
550
  var skippedCount = 0
551
+ var urlValidationFailures = 0
552
+ var truncatedFields = 0
420
553
 
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 {
554
+ for (index, dict) in results.enumerated() {
555
+ // Validate required fields
556
+ guard let id = dict["id"] as? String, !id.isEmpty else {
424
557
  skippedCount += 1
558
+ #if DEBUG
559
+ print("[expo-tvos-search] Result at index \(index) skipped: missing or empty 'id' field")
560
+ #endif
561
+ continue
562
+ }
563
+
564
+ guard let title = dict["title"] as? String, !title.isEmpty else {
565
+ skippedCount += 1
566
+ #if DEBUG
567
+ print("[expo-tvos-search] Result at index \(index) (id: '\(id)') skipped: missing or empty 'title' field")
568
+ #endif
425
569
  continue
426
570
  }
427
571
 
@@ -433,6 +577,11 @@ class ExpoTvosSearchView: ExpoView {
433
577
  let scheme = url.scheme?.lowercased(),
434
578
  scheme == "http" || scheme == "https" {
435
579
  validatedImageUrl = imageUrl
580
+ } else {
581
+ urlValidationFailures += 1
582
+ #if DEBUG
583
+ print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): invalid imageUrl '\(imageUrl)'. Only HTTP/HTTPS URLs are supported for security reasons.")
584
+ #endif
436
585
  }
437
586
  }
438
587
 
@@ -441,21 +590,126 @@ class ExpoTvosSearchView: ExpoView {
441
590
  let maxTitleLength = 500
442
591
  let maxSubtitleLength = 500
443
592
 
593
+ // Track if any fields were truncated
594
+ let idTruncated = id.count > maxIdLength
595
+ let titleTruncated = title.count > maxTitleLength
596
+ let subtitle = dict["subtitle"] as? String
597
+ let subtitleTruncated = (subtitle?.count ?? 0) > maxSubtitleLength
598
+
599
+ if idTruncated || titleTruncated || subtitleTruncated {
600
+ truncatedFields += 1
601
+ #if DEBUG
602
+ var truncatedList: [String] = []
603
+ if idTruncated { truncatedList.append("id (\(id.count) chars)") }
604
+ if titleTruncated { truncatedList.append("title (\(title.count) chars)") }
605
+ if subtitleTruncated { truncatedList.append("subtitle (\(subtitle?.count ?? 0) chars)") }
606
+ print("[expo-tvos-search] Result '\(title)' (id: '\(id)'): truncated fields: \(truncatedList.joined(separator: ", "))")
607
+ #endif
608
+ }
609
+
444
610
  validResults.append(SearchResultItem(
445
611
  id: String(id.prefix(maxIdLength)),
446
612
  title: String(title.prefix(maxTitleLength)),
447
- subtitle: (dict["subtitle"] as? String).map { String($0.prefix(maxSubtitleLength)) },
613
+ subtitle: subtitle.map { String($0.prefix(maxSubtitleLength)) },
448
614
  imageUrl: validatedImageUrl
449
615
  ))
450
616
  }
451
617
 
618
+ // Log summary of validation issues and emit warnings
452
619
  #if DEBUG
453
620
  if skippedCount > 0 {
454
- print("[expo-tvos-search] Skipped \(skippedCount) invalid result(s) missing required id or title")
621
+ print("[expo-tvos-search] ⚠️ Skipped \(skippedCount) result(s) due to missing required fields (id or title)")
622
+ }
623
+ if urlValidationFailures > 0 {
624
+ print("[expo-tvos-search] ⚠️ \(urlValidationFailures) image URL(s) failed validation (non-HTTP/HTTPS or malformed)")
625
+ }
626
+ if truncatedFields > 0 {
627
+ print("[expo-tvos-search] ℹ️ Truncated \(truncatedFields) result(s) with fields exceeding maximum length (500 chars)")
628
+ }
629
+ if validResults.count > 0 {
630
+ print("[expo-tvos-search] ✓ Processed \(validResults.count) valid result(s)")
455
631
  }
456
632
  #endif
457
633
 
458
- viewModel.results = validResults
634
+ // Emit validation warnings for production monitoring
635
+ 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
+ ])
647
+ }
648
+ 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
+ ])
660
+ }
661
+ 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
+ ])
673
+ }
674
+
675
+ // Ensure UI updates happen on main thread
676
+ DispatchQueue.main.async { [weak self] in
677
+ self?.viewModel.results = validResults
678
+ }
679
+ }
680
+ }
681
+
682
+ // MARK: - Color Extension for Hex String Parsing
683
+ extension Color {
684
+ /// 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
686
+ 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:
703
+ return nil
704
+ }
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
+ )
459
713
  }
460
714
  }
461
715
 
@@ -477,9 +731,19 @@ class ExpoTvosSearchView: ExpoView {
477
731
  var searchingText: String = "Searching..."
478
732
  var noResultsText: String = "No results found"
479
733
  var noResultsHintText: String = "Try a different search term"
734
+ var textColor: String? = nil
735
+ var accentColor: String = "#FFC312"
736
+ var cardWidth: CGFloat = 280
737
+ var cardHeight: CGFloat = 420
738
+ var imageContentMode: String = "fill"
739
+ var cardMargin: CGFloat = 40
740
+ var cardPadding: CGFloat = 16
741
+ var overlayTitleSize: CGFloat = 20
480
742
 
481
743
  let onSearch = EventDispatcher()
482
744
  let onSelectItem = EventDispatcher()
745
+ let onError = EventDispatcher()
746
+ let onValidationWarning = EventDispatcher()
483
747
 
484
748
  required init(appContext: AppContext? = nil) {
485
749
  super.init(appContext: appContext)
@@ -41,7 +41,7 @@ struct MarqueeText: View {
41
41
 
42
42
  var body: some View {
43
43
  GeometryReader { geometry in
44
- ZStack(alignment: .leading) {
44
+ ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
45
45
  // Hidden text to measure actual width
46
46
  Text(text)
47
47
  .font(font)
@@ -69,7 +69,7 @@ struct MarqueeText: View {
69
69
  }
70
70
  }
71
71
  }
72
- .frame(width: geometry.size.width, alignment: .leading)
72
+ .frame(width: geometry.size.width, height: geometry.size.height, alignment: Alignment(horizontal: .leading, vertical: .center))
73
73
  .clipped()
74
74
  .mask(fadeMask)
75
75
  .onPreferenceChange(TextWidthKey.self) { width in