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.
- package/LICENSE +21 -0
- package/README.md +197 -0
- package/build/index.d.ts +215 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +81 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoTvosSearch.podspec +16 -0
- package/ios/ExpoTvosSearchModule.swift +84 -0
- package/ios/ExpoTvosSearchView.swift +507 -0
- package/ios/MarqueeAnimationCalculator.swift +41 -0
- package/ios/MarqueeText.swift +168 -0
- package/ios/Tests/MarqueeAnimationCalculatorTests.swift +103 -0
- package/ios/Tests/SearchResultItemTests.swift +200 -0
- package/ios/Tests/SearchViewModelTests.swift +202 -0
- package/package.json +73 -0
- package/src/__tests__/__mocks__/expo-modules-core.ts +21 -0
- package/src/__tests__/__mocks__/react-native.ts +41 -0
- package/src/__tests__/events.test.ts +126 -0
- package/src/__tests__/index.test.tsx +124 -0
- package/src/__tests__/setup.ts +49 -0
- package/src/index.tsx +261 -0
|
@@ -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
|
+
}
|