@mux/mux-react-native-player 0.1.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.
Files changed (56) hide show
  1. package/MuxReactNativePlayer.podspec +37 -0
  2. package/README.md +134 -0
  3. package/android/build.gradle +33 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/mux/reactnativeplayer/MuxReactNativePlayerModule.kt +135 -0
  6. package/android/src/main/java/com/mux/reactnativeplayer/MuxVideoRecords.kt +174 -0
  7. package/android/src/main/java/com/mux/reactnativeplayer/MuxVideoView.kt +452 -0
  8. package/android/src/main/res/layout/mux_video_player_view.xml +6 -0
  9. package/assets/MuxRobot_02.gif +0 -0
  10. package/assets/MuxRobot_02@2x.gif +0 -0
  11. package/assets/MuxRobot_03.gif +0 -0
  12. package/assets/MuxRobot_03@2x.gif +0 -0
  13. package/assets/MuxRobot_04.gif +0 -0
  14. package/assets/MuxRobot_04@2x.gif +0 -0
  15. package/assets/MuxRobot_05.gif +0 -0
  16. package/assets/MuxRobot_05@2x.gif +0 -0
  17. package/build/MuxVideoControls.d.ts +21 -0
  18. package/build/MuxVideoControls.d.ts.map +1 -0
  19. package/build/MuxVideoControls.js +1032 -0
  20. package/build/MuxVideoPlayer.d.ts +59 -0
  21. package/build/MuxVideoPlayer.d.ts.map +1 -0
  22. package/build/MuxVideoPlayer.js +265 -0
  23. package/build/MuxVideoView.d.ts +39 -0
  24. package/build/MuxVideoView.d.ts.map +1 -0
  25. package/build/MuxVideoView.js +254 -0
  26. package/build/NativeMuxVideoView.d.ts +5 -0
  27. package/build/NativeMuxVideoView.d.ts.map +1 -0
  28. package/build/NativeMuxVideoView.js +4 -0
  29. package/build/index.d.ts +6 -0
  30. package/build/index.d.ts.map +1 -0
  31. package/build/index.js +3 -0
  32. package/build/normalizeSource.d.ts +7 -0
  33. package/build/normalizeSource.d.ts.map +1 -0
  34. package/build/normalizeSource.js +76 -0
  35. package/build/screenOrientation.d.ts +3 -0
  36. package/build/screenOrientation.d.ts.map +1 -0
  37. package/build/screenOrientation.js +38 -0
  38. package/build/types.d.ts +170 -0
  39. package/build/types.d.ts.map +1 -0
  40. package/build/types.js +1 -0
  41. package/expo-module.config.json +13 -0
  42. package/ios/MuxReactNativePlayerModule.swift +139 -0
  43. package/ios/MuxVideoRecords.swift +212 -0
  44. package/ios/MuxVideoView.swift +502 -0
  45. package/package.json +69 -0
  46. package/plugin/index.d.ts +11 -0
  47. package/plugin/index.js +1 -0
  48. package/plugin/withMuxReactNativePlayer.js +203 -0
  49. package/src/MuxVideoControls.tsx +1772 -0
  50. package/src/MuxVideoPlayer.ts +338 -0
  51. package/src/MuxVideoView.tsx +412 -0
  52. package/src/NativeMuxVideoView.ts +15 -0
  53. package/src/index.ts +32 -0
  54. package/src/normalizeSource.ts +101 -0
  55. package/src/screenOrientation.ts +46 -0
  56. package/src/types.ts +228 -0
@@ -0,0 +1,1772 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Animated,
4
+ Image,
5
+ LayoutChangeEvent,
6
+ PanResponder,
7
+ Pressable,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ useWindowDimensions,
12
+ View,
13
+ } from 'react-native';
14
+ import type { ImageSourcePropType } from 'react-native';
15
+ import { LinearGradient } from 'expo-linear-gradient';
16
+
17
+ import type {
18
+ MuxPlayerStatus,
19
+ MuxVideoCaptionTrack,
20
+ MuxVideoChapter,
21
+ MuxVideoControlsTheme,
22
+ MuxVideoKeyMoment,
23
+ MuxVideoRobotsConfig,
24
+ MuxVideoSummary,
25
+ } from './types';
26
+ import { MuxVideoPlayer } from './MuxVideoPlayer';
27
+
28
+ type RequiredControlsTheme = Required<MuxVideoControlsTheme>;
29
+ type RobotsPanel = 'summary' | 'chapters' | 'moments';
30
+
31
+ type MuxVideoControlsProps = {
32
+ player: MuxVideoPlayer;
33
+ status: MuxPlayerStatus;
34
+ shouldPlay: boolean;
35
+ theme?: MuxVideoControlsTheme;
36
+ robots?: MuxVideoRobotsConfig;
37
+ allowsFullscreen?: boolean;
38
+ isFullscreen?: boolean;
39
+ onToggleFullscreen?: () => void;
40
+ generatedSummary?: MuxVideoSummary;
41
+ generatedChapters?: MuxVideoChapter[];
42
+ generatedKeyMoments?: MuxVideoKeyMoment[];
43
+ onGeneratedSummaryChange?: (summary: MuxVideoSummary | undefined) => void;
44
+ onGeneratedChaptersChange?: (chapters: MuxVideoChapter[] | undefined) => void;
45
+ onGeneratedKeyMomentsChange?: (keyMoments: MuxVideoKeyMoment[] | undefined) => void;
46
+ };
47
+
48
+ const emptyChapters: MuxVideoChapter[] = [];
49
+ const emptyKeyMoments: MuxVideoKeyMoment[] = [];
50
+ const emptyCaptionTracks: MuxVideoCaptionTrack[] = [];
51
+ const robotImages = {
52
+ summary: require('../assets/MuxRobot_03.gif'),
53
+ chapters: require('../assets/MuxRobot_02.gif'),
54
+ moments: require('../assets/MuxRobot_05.gif'),
55
+ } as const;
56
+
57
+ const defaultTheme: RequiredControlsTheme = {
58
+ accentColor: '#FA50B5',
59
+ backgroundColor: 'transparent',
60
+ buttonBackgroundColor: 'rgba(20, 28, 38, 0.28)',
61
+ buttonTextColor: '#f8fbff',
62
+ buttonSize: 48,
63
+ playButtonSize: 72,
64
+ fullscreenButtonSize: 26,
65
+ progressTrackColor: '#FA50B5',
66
+ bufferedTrackColor: 'rgba(248, 251, 255, 0.28)',
67
+ trackColor: 'rgba(248, 251, 255, 0.16)',
68
+ trackHeight: 4,
69
+ textColor: '#f8fbff',
70
+ seekSeconds: 10,
71
+ };
72
+
73
+ const frostBorderColor = 'rgba(255, 255, 255, 0.18)';
74
+ const frostBorderWidth = StyleSheet.hairlineWidth * 2;
75
+
76
+ export function MuxVideoControls({
77
+ player,
78
+ status,
79
+ shouldPlay,
80
+ theme,
81
+ robots,
82
+ allowsFullscreen = false,
83
+ isFullscreen = false,
84
+ onToggleFullscreen,
85
+ generatedSummary,
86
+ generatedChapters,
87
+ generatedKeyMoments,
88
+ onGeneratedSummaryChange,
89
+ onGeneratedChaptersChange,
90
+ onGeneratedKeyMomentsChange,
91
+ }: MuxVideoControlsProps) {
92
+ const controlsTheme = React.useMemo<RequiredControlsTheme>(
93
+ () => ({ ...defaultTheme, ...theme }),
94
+ [theme]
95
+ );
96
+
97
+ const [hidden, setHidden] = React.useState(false);
98
+ const [interactionTick, setInteractionTick] = React.useState(0);
99
+ const [trackWidth, setTrackWidth] = React.useState(0);
100
+ const [containerWidth, setContainerWidth] = React.useState(0);
101
+ const [containerHeight, setContainerHeight] = React.useState(0);
102
+ const [containerPageX, setContainerPageX] = React.useState(0);
103
+ const [scrubbing, setScrubbing] = React.useState(false);
104
+ const [scrubTime, setScrubTime] = React.useState(0);
105
+ const [pendingTarget, setPendingTarget] = React.useState<number | null>(null);
106
+ const [activeRobotsPanel, setActiveRobotsPanel] = React.useState<RobotsPanel | null>(null);
107
+ const [robotsLoading, setRobotsLoading] = React.useState<RobotsPanel | null>(null);
108
+ const [robotsError, setRobotsError] = React.useState<string | null>(null);
109
+ const [captionsOpen, setCaptionsOpen] = React.useState(false);
110
+ const robotsRequestRef = React.useRef(0);
111
+ const controlsRootRef = React.useRef<View>(null);
112
+ const opacity = React.useRef(new Animated.Value(1)).current;
113
+ const { width: windowWidth } = useWindowDimensions();
114
+ const duration = Number.isFinite(status.duration) ? status.duration : 0;
115
+ const playerTime = clamp(status.currentTime, 0, duration || 0);
116
+ const displayTime = scrubbing
117
+ ? clamp(scrubTime, 0, duration || 0)
118
+ : pendingTarget !== null
119
+ ? clamp(pendingTarget, 0, duration || 0)
120
+ : playerTime;
121
+
122
+ React.useEffect(() => {
123
+ if (pendingTarget === null) {
124
+ return;
125
+ }
126
+ if (Math.abs(playerTime - pendingTarget) < 0.5) {
127
+ setPendingTarget(null);
128
+ return;
129
+ }
130
+ const timeout = setTimeout(() => setPendingTarget(null), 1500);
131
+ return () => clearTimeout(timeout);
132
+ }, [pendingTarget, playerTime]);
133
+ const bufferedPosition = clamp(status.bufferedPosition, 0, duration || 0);
134
+ const progress = duration > 0 ? displayTime / duration : 0;
135
+ const buffered = duration > 0 ? bufferedPosition / duration : 0;
136
+ const isPlaying = status.status === 'playing' || shouldPlay;
137
+ const robotsEnabled = robots != null && robots.enabled !== false;
138
+ const robotsAssetId = robots?.assetId;
139
+ const summary = generatedSummary ?? robots?.summary;
140
+ const chapters = generatedChapters ?? robots?.chapters ?? emptyChapters;
141
+ const keyMoments = generatedKeyMoments ?? robots?.keyMoments ?? emptyKeyMoments;
142
+ const captionTracks = status.captionTracks ?? emptyCaptionTracks;
143
+ const selectedCaptionTrackId = status.selectedCaptionTrackId ?? null;
144
+ const canSummarize = robotsEnabled && (!!summary || (!!robotsAssetId && !!robots?.onSummarize));
145
+ const canGenerateChapters = robotsEnabled && (chapters.length > 0 || (!!robotsAssetId && !!robots?.onGenerateChapters));
146
+ const canFindKeyMoments = robotsEnabled && (keyMoments.length > 0 || (!!robotsAssetId && !!robots?.onFindKeyMoments));
147
+ const hasRobotsActions = canSummarize || canGenerateChapters || canFindKeyMoments;
148
+ const isRobotsFocused = activeRobotsPanel !== null;
149
+ const hasCaptionTracks = captionTracks.length > 0;
150
+
151
+ React.useEffect(() => {
152
+ robotsRequestRef.current += 1;
153
+ setActiveRobotsPanel(null);
154
+ setRobotsLoading(null);
155
+ setRobotsError(null);
156
+ }, [robotsAssetId]);
157
+
158
+ React.useEffect(() => {
159
+ if (!hasCaptionTracks) {
160
+ setCaptionsOpen(false);
161
+ }
162
+ }, [hasCaptionTracks]);
163
+
164
+ const visibleChapters = React.useMemo(
165
+ () =>
166
+ chapters
167
+ .filter(chapter => chapter.title && chapter.startTime >= 0 && (duration <= 0 || chapter.startTime <= duration))
168
+ .slice()
169
+ .sort((a, b) => a.startTime - b.startTime),
170
+ [chapters, duration]
171
+ );
172
+
173
+ const visibleKeyMoments = React.useMemo(
174
+ () =>
175
+ keyMoments
176
+ .filter(moment => moment.title && moment.startTime >= 0 && moment.endTime > moment.startTime)
177
+ .slice()
178
+ .sort((a, b) => a.startTime - b.startTime),
179
+ [keyMoments]
180
+ );
181
+
182
+ const activeChapter = React.useMemo(() => {
183
+ if (visibleChapters.length === 0) {
184
+ return undefined;
185
+ }
186
+ let current: MuxVideoChapter | undefined;
187
+ for (const chapter of visibleChapters) {
188
+ if (chapter.startTime > displayTime) {
189
+ break;
190
+ }
191
+ current = chapter;
192
+ }
193
+ return current;
194
+ }, [displayTime, visibleChapters]);
195
+
196
+ const measureControlsRoot = React.useCallback(() => {
197
+ controlsRootRef.current?.measureInWindow((x, _y, width) => {
198
+ if (Number.isFinite(x)) {
199
+ setContainerPageX(x);
200
+ }
201
+ if (Number.isFinite(width) && width > 0) {
202
+ setContainerWidth(width);
203
+ }
204
+ });
205
+ }, []);
206
+
207
+ const handleControlsLayout = React.useCallback(
208
+ (event: LayoutChangeEvent) => {
209
+ const { width, height } = event.nativeEvent.layout;
210
+ setContainerWidth(width);
211
+ setContainerHeight(height);
212
+ measureControlsRoot();
213
+ },
214
+ [measureControlsRoot]
215
+ );
216
+
217
+ React.useEffect(() => {
218
+ measureControlsRoot();
219
+ }, [measureControlsRoot, windowWidth]);
220
+
221
+ const viewportWidth = windowWidth > 0 ? windowWidth : containerWidth;
222
+ const offscreenLeftInset = Math.max(0, -containerPageX);
223
+ const offscreenRightInset =
224
+ viewportWidth > 0 && containerWidth > 0
225
+ ? Math.max(0, containerPageX + containerWidth - viewportWidth)
226
+ : 0;
227
+ const isPortraitControls =
228
+ containerWidth > 0 && containerHeight > 0 && containerHeight / containerWidth > 1.25;
229
+ const visibleControlsWidth =
230
+ containerWidth > 0
231
+ ? Math.max(0, containerWidth - offscreenLeftInset - offscreenRightInset)
232
+ : 0;
233
+ const isCompactHeight = containerHeight > 0 && containerHeight < 240;
234
+ const isNarrowControls =
235
+ isCompactHeight ||
236
+ isPortraitControls ||
237
+ (visibleControlsWidth > 0 && visibleControlsWidth < 360);
238
+ const baseHorizontalInset =
239
+ visibleControlsWidth > 0 && visibleControlsWidth < 340 ? 10 : isPortraitControls ? 12 : 16;
240
+ const timelineLeftInset = baseHorizontalInset + offscreenLeftInset;
241
+ const timelineRightInset = baseHorizontalInset + offscreenRightInset;
242
+ const centerHorizontalPadding = isNarrowControls ? 10 : 16;
243
+ const centerHorizontalGap = isNarrowControls ? 18 : 28;
244
+ const trackHorizontalPadding = isNarrowControls ? 8 : 10;
245
+ const trackVerticalPadding = isNarrowControls ? 8 : 10;
246
+ const timePillHorizontalPadding = isNarrowControls ? 8 : 10;
247
+ const timeFontSize = isNarrowControls ? 11 : 12;
248
+ const buttonSize = Math.max(36, isNarrowControls ? 40 : controlsTheme.buttonSize);
249
+ const playButtonSize = Math.max(
250
+ buttonSize,
251
+ isCompactHeight ? 52 : isNarrowControls ? 56 : controlsTheme.playButtonSize
252
+ );
253
+ const centerVerticalGap = isCompactHeight ? 8 : isPortraitControls ? 14 : 20;
254
+ const fullscreenButtonSize = Math.max(22, isNarrowControls ? 24 : controlsTheme.fullscreenButtonSize);
255
+ const trackHeight = Math.max(3, controlsTheme.trackHeight);
256
+
257
+ const fadeOut = React.useCallback(() => {
258
+ Animated.timing(opacity, {
259
+ toValue: 0,
260
+ duration: 200,
261
+ useNativeDriver: true,
262
+ }).start(({ finished }) => {
263
+ if (finished) {
264
+ setHidden(true);
265
+ }
266
+ });
267
+ }, [opacity]);
268
+
269
+ const fadeIn = React.useCallback(() => {
270
+ setHidden(false);
271
+ Animated.timing(opacity, {
272
+ toValue: 1,
273
+ duration: 200,
274
+ useNativeDriver: true,
275
+ }).start();
276
+ }, [opacity]);
277
+
278
+ const keepAlive = React.useCallback(() => {
279
+ setInteractionTick(t => t + 1);
280
+ }, []);
281
+
282
+ const dismissRobotsPanel = React.useCallback(() => {
283
+ robotsRequestRef.current += 1;
284
+ setActiveRobotsPanel(null);
285
+ setRobotsLoading(null);
286
+ setRobotsError(null);
287
+ keepAlive();
288
+ }, [keepAlive]);
289
+
290
+ const dismissCaptionsPanel = React.useCallback(() => {
291
+ setCaptionsOpen(false);
292
+ keepAlive();
293
+ }, [keepAlive]);
294
+
295
+ const handleBackgroundTap = React.useCallback(() => {
296
+ if (captionsOpen) {
297
+ dismissCaptionsPanel();
298
+ return;
299
+ }
300
+
301
+ if (activeRobotsPanel || robotsLoading) {
302
+ dismissRobotsPanel();
303
+ return;
304
+ }
305
+
306
+ if (hidden) {
307
+ fadeIn();
308
+ setInteractionTick(t => t + 1);
309
+ } else {
310
+ fadeOut();
311
+ }
312
+ }, [activeRobotsPanel, captionsOpen, dismissCaptionsPanel, dismissRobotsPanel, fadeIn, fadeOut, hidden, robotsLoading]);
313
+
314
+ React.useEffect(() => {
315
+ if (hidden || scrubbing || activeRobotsPanel || robotsLoading || captionsOpen) {
316
+ return;
317
+ }
318
+ const timer = setTimeout(fadeOut, 3000);
319
+ return () => clearTimeout(timer);
320
+ }, [hidden, scrubbing, activeRobotsPanel, robotsLoading, captionsOpen, interactionTick, fadeOut]);
321
+
322
+ const trackRef = React.useRef<View>(null);
323
+ const scrubStateRef = React.useRef({
324
+ trackWidth: 0,
325
+ trackPageX: 0,
326
+ duration: 0,
327
+ lastSeekAt: 0,
328
+ currentTime: 0,
329
+ wasPlaying: false,
330
+ });
331
+
332
+ const measureTrack = React.useCallback(() => {
333
+ trackRef.current?.measureInWindow((x, _y, width) => {
334
+ if (Number.isFinite(x)) {
335
+ scrubStateRef.current.trackPageX = x;
336
+ }
337
+ if (Number.isFinite(width) && width > 0) {
338
+ scrubStateRef.current.trackWidth = width;
339
+ }
340
+ });
341
+ }, []);
342
+
343
+ const onTrackLayout = React.useCallback(
344
+ (event: LayoutChangeEvent) => {
345
+ setTrackWidth(event.nativeEvent.layout.width);
346
+ measureTrack();
347
+ },
348
+ [measureTrack]
349
+ );
350
+
351
+ React.useEffect(() => {
352
+ scrubStateRef.current.trackWidth = trackWidth;
353
+ scrubStateRef.current.duration = duration;
354
+ }, [trackWidth, duration]);
355
+
356
+ const computeTimeFromX = React.useCallback((x: number): number => {
357
+ const { trackWidth: width, duration: dur } = scrubStateRef.current;
358
+ if (width <= 0 || dur <= 0) {
359
+ return 0;
360
+ }
361
+ return clamp(x / width, 0, 1) * dur;
362
+ }, []);
363
+
364
+ const isPlayingRef = React.useRef(isPlaying);
365
+ isPlayingRef.current = isPlaying;
366
+
367
+ const finishScrub = React.useCallback(
368
+ (commitX: number | null) => {
369
+ if (scrubStateRef.current.duration > 0) {
370
+ const localX = commitX ?? -1;
371
+ const target =
372
+ localX >= 0
373
+ ? computeTimeFromX(localX)
374
+ : scrubStateRef.current.currentTime;
375
+ scrubStateRef.current.currentTime = target;
376
+ setPendingTarget(target);
377
+ console.log('[MuxControls] scrub release seekTo=', target);
378
+ player
379
+ .seekTo(target)
380
+ .then(() => console.log('[MuxControls] scrub seekTo OK'))
381
+ .catch(err => console.log('[MuxControls] scrub seekTo ERROR', err));
382
+ }
383
+ setScrubbing(false);
384
+ setInteractionTick(prev => prev + 1);
385
+ },
386
+ [computeTimeFromX, player]
387
+ );
388
+
389
+ const panResponder = React.useMemo(
390
+ () =>
391
+ PanResponder.create({
392
+ onStartShouldSetPanResponder: () => true,
393
+ onStartShouldSetPanResponderCapture: () => true,
394
+ onMoveShouldSetPanResponder: () => true,
395
+ onMoveShouldSetPanResponderCapture: () => true,
396
+ onPanResponderTerminationRequest: () => false,
397
+ onShouldBlockNativeResponder: () => true,
398
+ onPanResponderGrant: (_evt, gestureState) => {
399
+ if (scrubStateRef.current.duration <= 0) {
400
+ return;
401
+ }
402
+ measureTrack();
403
+ const localX = gestureState.x0 - scrubStateRef.current.trackPageX;
404
+ const t = computeTimeFromX(localX);
405
+ scrubStateRef.current.currentTime = t;
406
+ setScrubbing(true);
407
+ setScrubTime(t);
408
+ console.log(
409
+ '[MuxControls] scrub grant x0=',
410
+ gestureState.x0,
411
+ 'trackPageX=',
412
+ scrubStateRef.current.trackPageX,
413
+ 'trackWidth=',
414
+ scrubStateRef.current.trackWidth,
415
+ 'duration=',
416
+ scrubStateRef.current.duration,
417
+ 't=',
418
+ t
419
+ );
420
+ },
421
+ onPanResponderMove: (_evt, gestureState) => {
422
+ if (scrubStateRef.current.duration <= 0) {
423
+ return;
424
+ }
425
+ const localX = gestureState.moveX - scrubStateRef.current.trackPageX;
426
+ const t = computeTimeFromX(localX);
427
+ scrubStateRef.current.currentTime = t;
428
+ setScrubTime(t);
429
+ },
430
+ onPanResponderRelease: (_evt, gestureState) => {
431
+ const localX = gestureState.moveX - scrubStateRef.current.trackPageX;
432
+ finishScrub(localX);
433
+ },
434
+ onPanResponderTerminate: (_evt, gestureState) => {
435
+ const localX = gestureState.moveX - scrubStateRef.current.trackPageX;
436
+ finishScrub(localX);
437
+ },
438
+ }),
439
+ [computeTimeFromX, finishScrub, measureTrack]
440
+ );
441
+
442
+ const seekSecondsRef = React.useRef(controlsTheme.seekSeconds);
443
+ seekSecondsRef.current = controlsTheme.seekSeconds;
444
+
445
+ const seekBack = React.useCallback(() => {
446
+ console.log('[MuxControls] seekBack pressed, seconds=', seekSecondsRef.current);
447
+ keepAlive();
448
+ player
449
+ .seekBy(-seekSecondsRef.current)
450
+ .then(() => console.log('[MuxControls] seekBack OK'))
451
+ .catch(err => console.log('[MuxControls] seekBack ERROR', err));
452
+ }, [keepAlive, player]);
453
+
454
+ const seekForward = React.useCallback(() => {
455
+ console.log('[MuxControls] seekForward pressed, seconds=', seekSecondsRef.current);
456
+ keepAlive();
457
+ player
458
+ .seekBy(seekSecondsRef.current)
459
+ .then(() => console.log('[MuxControls] seekForward OK'))
460
+ .catch(err => console.log('[MuxControls] seekForward ERROR', err));
461
+ }, [keepAlive, player]);
462
+
463
+ const togglePlayback = React.useCallback(() => {
464
+ keepAlive();
465
+ if (isPlayingRef.current) {
466
+ runPlayerCommand(player.pause());
467
+ return;
468
+ }
469
+ runPlayerCommand(player.play());
470
+ }, [keepAlive, player]);
471
+
472
+ const handleToggleFullscreen = React.useCallback(() => {
473
+ keepAlive();
474
+ setCaptionsOpen(false);
475
+ onToggleFullscreen?.();
476
+ }, [keepAlive, onToggleFullscreen]);
477
+
478
+ const toggleCaptionsPanel = React.useCallback(() => {
479
+ keepAlive();
480
+ setCaptionsOpen(open => !open);
481
+ }, [keepAlive]);
482
+
483
+ const toggleRobotsPanel = React.useCallback(
484
+ (panel: RobotsPanel) => {
485
+ keepAlive();
486
+ setRobotsError(null);
487
+ if (activeRobotsPanel === panel) {
488
+ setActiveRobotsPanel(null);
489
+ return;
490
+ }
491
+ if (robotsLoading !== null) {
492
+ robotsRequestRef.current += 1;
493
+ setRobotsLoading(null);
494
+ }
495
+ setActiveRobotsPanel(panel);
496
+ },
497
+ [activeRobotsPanel, keepAlive, robotsLoading]
498
+ );
499
+
500
+ const loadSummary = React.useCallback(() => {
501
+ if (activeRobotsPanel === 'summary' || robotsLoading === 'summary') {
502
+ dismissRobotsPanel();
503
+ return;
504
+ }
505
+ if (summary || !robotsAssetId || !robots?.onSummarize) {
506
+ toggleRobotsPanel('summary');
507
+ return;
508
+ }
509
+ keepAlive();
510
+ const requestId = robotsRequestRef.current + 1;
511
+ robotsRequestRef.current = requestId;
512
+ setActiveRobotsPanel('summary');
513
+ setRobotsLoading('summary');
514
+ setRobotsError(null);
515
+ robots
516
+ .onSummarize({
517
+ assetId: robotsAssetId,
518
+ currentTime: displayTime,
519
+ duration,
520
+ })
521
+ .then(result => {
522
+ if (robotsRequestRef.current !== requestId) {
523
+ return;
524
+ }
525
+ onGeneratedSummaryChange?.(result);
526
+ setActiveRobotsPanel('summary');
527
+ })
528
+ .catch(error => {
529
+ if (robotsRequestRef.current !== requestId) {
530
+ return;
531
+ }
532
+ setRobotsError(getRobotsErrorMessage(error, 'Summary is not available yet.'));
533
+ setActiveRobotsPanel('summary');
534
+ })
535
+ .finally(() => {
536
+ if (robotsRequestRef.current === requestId) {
537
+ setRobotsLoading(null);
538
+ }
539
+ });
540
+ }, [activeRobotsPanel, dismissRobotsPanel, displayTime, duration, keepAlive, robots, robotsAssetId, robotsLoading, summary, toggleRobotsPanel]);
541
+
542
+ const loadChapters = React.useCallback(() => {
543
+ if (activeRobotsPanel === 'chapters' || robotsLoading === 'chapters') {
544
+ dismissRobotsPanel();
545
+ return;
546
+ }
547
+ if (chapters.length > 0 || !robotsAssetId || !robots?.onGenerateChapters) {
548
+ toggleRobotsPanel('chapters');
549
+ return;
550
+ }
551
+ keepAlive();
552
+ const requestId = robotsRequestRef.current + 1;
553
+ robotsRequestRef.current = requestId;
554
+ setActiveRobotsPanel('chapters');
555
+ setRobotsLoading('chapters');
556
+ setRobotsError(null);
557
+ robots
558
+ .onGenerateChapters({
559
+ assetId: robotsAssetId,
560
+ currentTime: displayTime,
561
+ duration,
562
+ })
563
+ .then(result => {
564
+ if (robotsRequestRef.current !== requestId) {
565
+ return;
566
+ }
567
+ onGeneratedChaptersChange?.(result);
568
+ setActiveRobotsPanel('chapters');
569
+ })
570
+ .catch(error => {
571
+ if (robotsRequestRef.current !== requestId) {
572
+ return;
573
+ }
574
+ setRobotsError(getRobotsErrorMessage(error, 'Chapters are not available yet.'));
575
+ setActiveRobotsPanel('chapters');
576
+ })
577
+ .finally(() => {
578
+ if (robotsRequestRef.current === requestId) {
579
+ setRobotsLoading(null);
580
+ }
581
+ });
582
+ }, [activeRobotsPanel, chapters.length, dismissRobotsPanel, displayTime, duration, keepAlive, robots, robotsAssetId, robotsLoading, toggleRobotsPanel]);
583
+
584
+ const loadKeyMoments = React.useCallback(() => {
585
+ if (activeRobotsPanel === 'moments' || robotsLoading === 'moments') {
586
+ dismissRobotsPanel();
587
+ return;
588
+ }
589
+ if (keyMoments.length > 0 || !robotsAssetId || !robots?.onFindKeyMoments) {
590
+ toggleRobotsPanel('moments');
591
+ return;
592
+ }
593
+ keepAlive();
594
+ const requestId = robotsRequestRef.current + 1;
595
+ robotsRequestRef.current = requestId;
596
+ setActiveRobotsPanel('moments');
597
+ setRobotsLoading('moments');
598
+ setRobotsError(null);
599
+ robots
600
+ .onFindKeyMoments({
601
+ assetId: robotsAssetId,
602
+ currentTime: displayTime,
603
+ duration,
604
+ })
605
+ .then(result => {
606
+ if (robotsRequestRef.current !== requestId) {
607
+ return;
608
+ }
609
+ onGeneratedKeyMomentsChange?.(result);
610
+ setActiveRobotsPanel('moments');
611
+ })
612
+ .catch(error => {
613
+ if (robotsRequestRef.current !== requestId) {
614
+ return;
615
+ }
616
+ setRobotsError(getRobotsErrorMessage(error, 'Key moments are not available yet.'));
617
+ setActiveRobotsPanel('moments');
618
+ })
619
+ .finally(() => {
620
+ if (robotsRequestRef.current === requestId) {
621
+ setRobotsLoading(null);
622
+ }
623
+ });
624
+ }, [activeRobotsPanel, dismissRobotsPanel, displayTime, duration, keepAlive, keyMoments.length, robots, robotsAssetId, robotsLoading, toggleRobotsPanel]);
625
+
626
+ const seekToRobotsTime = React.useCallback(
627
+ (target: number) => {
628
+ keepAlive();
629
+ setRobotsError(null);
630
+ setPendingTarget(target);
631
+ player.seekTo(target).catch(() => {
632
+ // Seeking from generated navigation is best-effort.
633
+ });
634
+ },
635
+ [keepAlive, player]
636
+ );
637
+
638
+ const selectCaptionTrack = React.useCallback(
639
+ (trackId: string | null) => {
640
+ keepAlive();
641
+ setCaptionsOpen(false);
642
+ player.setCaptionTrack(trackId).catch(() => {
643
+ // Caption selection failures are non-fatal and native status will correct UI state.
644
+ });
645
+ },
646
+ [keepAlive, player]
647
+ );
648
+
649
+ const defaultPanelMaxHeight = containerHeight > 0
650
+ ? Math.max(96, Math.min(168, Math.floor(containerHeight * 0.45)))
651
+ : 168;
652
+ const summaryPanelMaxHeight = containerHeight > 0
653
+ ? Math.max(140, Math.min(280, Math.floor(containerHeight * 0.65)))
654
+ : 260;
655
+ const panelMaxHeight =
656
+ activeRobotsPanel === 'summary' ? summaryPanelMaxHeight : defaultPanelMaxHeight;
657
+
658
+ return (
659
+ <View
660
+ ref={controlsRootRef}
661
+ pointerEvents="box-none"
662
+ style={[StyleSheet.absoluteFill, styles.controlsRoot]}
663
+ onLayout={handleControlsLayout}
664
+ >
665
+ {hidden ? (
666
+ <Pressable
667
+ accessibilityLabel="Show video controls"
668
+ accessibilityRole="button"
669
+ onPress={handleBackgroundTap}
670
+ style={StyleSheet.absoluteFill}
671
+ />
672
+ ) : (
673
+ <Animated.View
674
+ pointerEvents="box-none"
675
+ style={[StyleSheet.absoluteFill, { opacity }]}
676
+ >
677
+ <Pressable
678
+ accessibilityLabel={activeRobotsPanel ? 'Hide Mux Robots results' : 'Hide video controls'}
679
+ accessibilityRole="button"
680
+ onPress={handleBackgroundTap}
681
+ style={StyleSheet.absoluteFill}
682
+ />
683
+ <LinearGradient
684
+ colors={['rgba(0,0,0,0.55)', 'rgba(0,0,0,0)']}
685
+ pointerEvents="none"
686
+ style={styles.gradientTop}
687
+ />
688
+ <LinearGradient
689
+ colors={['rgba(0,0,0,0)', 'rgba(0,0,0,0.7)']}
690
+ pointerEvents="none"
691
+ style={styles.gradientBottom}
692
+ />
693
+ <View
694
+ pointerEvents="box-none"
695
+ style={[
696
+ styles.centerWrap,
697
+ {
698
+ gap: centerVerticalGap,
699
+ left: offscreenLeftInset,
700
+ paddingHorizontal: centerHorizontalPadding,
701
+ right: offscreenRightInset,
702
+ },
703
+ ]}
704
+ >
705
+ {hasRobotsActions ? (
706
+ <View style={styles.robotsArea}>
707
+ <ScrollView
708
+ horizontal
709
+ bounces={false}
710
+ showsHorizontalScrollIndicator={false}
711
+ contentContainerStyle={styles.robotsButtonRow}
712
+ style={styles.robotsButtonScroller}
713
+ >
714
+ {canSummarize ? (
715
+ <RobotsActionButton
716
+ active={activeRobotsPanel === 'summary'}
717
+ backgroundColor={controlsTheme.buttonBackgroundColor}
718
+ label="Summary"
719
+ onPress={loadSummary}
720
+ robotSource={robotImages.summary}
721
+ textColor={controlsTheme.buttonTextColor}
722
+ />
723
+ ) : null}
724
+ {canGenerateChapters ? (
725
+ <RobotsActionButton
726
+ active={activeRobotsPanel === 'chapters'}
727
+ backgroundColor={controlsTheme.buttonBackgroundColor}
728
+ label="Chapters"
729
+ onPress={loadChapters}
730
+ robotSource={robotImages.chapters}
731
+ textColor={controlsTheme.buttonTextColor}
732
+ />
733
+ ) : null}
734
+ {canFindKeyMoments ? (
735
+ <RobotsActionButton
736
+ active={activeRobotsPanel === 'moments'}
737
+ backgroundColor={controlsTheme.buttonBackgroundColor}
738
+ label="Moments"
739
+ onPress={loadKeyMoments}
740
+ robotSource={robotImages.moments}
741
+ textColor={controlsTheme.buttonTextColor}
742
+ />
743
+ ) : null}
744
+ </ScrollView>
745
+ {activeRobotsPanel ? (
746
+ <RobotsPanelView
747
+ activePanel={activeRobotsPanel}
748
+ backgroundColor={controlsTheme.buttonBackgroundColor}
749
+ chapters={visibleChapters}
750
+ error={robotsError}
751
+ keyMoments={visibleKeyMoments}
752
+ loading={robotsLoading === activeRobotsPanel}
753
+ maxHeight={panelMaxHeight}
754
+ onSeek={seekToRobotsTime}
755
+ summary={summary}
756
+ textColor={controlsTheme.textColor}
757
+ />
758
+ ) : null}
759
+ </View>
760
+ ) : null}
761
+ <View
762
+ pointerEvents={isRobotsFocused ? 'none' : 'box-none'}
763
+ style={[
764
+ styles.centerCluster,
765
+ { gap: centerHorizontalGap },
766
+ isRobotsFocused && styles.centerClusterHidden,
767
+ ]}
768
+ >
769
+ <IconButton
770
+ accessibilityLabel={`Skip back ${controlsTheme.seekSeconds} seconds`}
771
+ backgroundColor={controlsTheme.buttonBackgroundColor}
772
+ onPress={seekBack}
773
+ size={buttonSize}
774
+ >
775
+ <SkipIcon
776
+ color={controlsTheme.buttonTextColor}
777
+ direction="back"
778
+ seconds={controlsTheme.seekSeconds}
779
+ size={buttonSize}
780
+ />
781
+ </IconButton>
782
+ <IconButton
783
+ accessibilityLabel={isPlaying ? 'Pause' : 'Play'}
784
+ backgroundColor={controlsTheme.buttonBackgroundColor}
785
+ onPress={togglePlayback}
786
+ size={playButtonSize}
787
+ >
788
+ {isPlaying ? (
789
+ <PauseIcon color={controlsTheme.buttonTextColor} size={playButtonSize} />
790
+ ) : (
791
+ <PlayIcon color={controlsTheme.buttonTextColor} size={playButtonSize} />
792
+ )}
793
+ </IconButton>
794
+ <IconButton
795
+ accessibilityLabel={`Skip forward ${controlsTheme.seekSeconds} seconds`}
796
+ backgroundColor={controlsTheme.buttonBackgroundColor}
797
+ onPress={seekForward}
798
+ size={buttonSize}
799
+ >
800
+ <SkipIcon
801
+ color={controlsTheme.buttonTextColor}
802
+ direction="forward"
803
+ seconds={controlsTheme.seekSeconds}
804
+ size={buttonSize}
805
+ />
806
+ </IconButton>
807
+ </View>
808
+ </View>
809
+
810
+ {!isRobotsFocused ? (
811
+ <View
812
+ pointerEvents="box-none"
813
+ style={[
814
+ styles.timeline,
815
+ { left: timelineLeftInset, right: timelineRightInset },
816
+ ]}
817
+ >
818
+ {activeChapter ? (
819
+ <View pointerEvents="none" style={styles.chapterPillRow}>
820
+ <View
821
+ style={[
822
+ styles.chapterPill,
823
+ { backgroundColor: controlsTheme.buttonBackgroundColor },
824
+ ]}
825
+ >
826
+ <Text
827
+ numberOfLines={1}
828
+ style={[styles.chapterPillText, { color: controlsTheme.textColor }]}
829
+ >
830
+ {activeChapter.title}
831
+ </Text>
832
+ </View>
833
+ </View>
834
+ ) : null}
835
+ <View
836
+ accessibilityLabel="Seek video"
837
+ accessibilityRole="adjustable"
838
+ style={[
839
+ styles.trackHitArea,
840
+ {
841
+ backgroundColor: controlsTheme.buttonBackgroundColor,
842
+ paddingHorizontal: trackHorizontalPadding,
843
+ paddingVertical: trackVerticalPadding,
844
+ },
845
+ ]}
846
+ {...panResponder.panHandlers}
847
+ >
848
+ <View
849
+ ref={trackRef}
850
+ onLayout={onTrackLayout}
851
+ style={[
852
+ styles.track,
853
+ {
854
+ backgroundColor: controlsTheme.trackColor,
855
+ height: scrubbing ? trackHeight + 2 : trackHeight,
856
+ borderRadius: trackHeight,
857
+ },
858
+ ]}
859
+ >
860
+ <View
861
+ style={[
862
+ styles.trackFill,
863
+ {
864
+ backgroundColor: controlsTheme.bufferedTrackColor,
865
+ borderRadius: trackHeight,
866
+ width: `${buffered * 100}%`,
867
+ },
868
+ ]}
869
+ />
870
+ <View
871
+ style={[
872
+ styles.trackFill,
873
+ {
874
+ backgroundColor: controlsTheme.progressTrackColor,
875
+ borderRadius: trackHeight,
876
+ width: `${progress * 100}%`,
877
+ },
878
+ ]}
879
+ />
880
+ {duration > 0
881
+ ? visibleKeyMoments.map(moment => {
882
+ const start = clamp(moment.startTime / duration, 0, 1);
883
+ const end = clamp(moment.endTime / duration, 0, 1);
884
+ return (
885
+ <View
886
+ key={`${moment.startTime}-${moment.endTime}-${moment.title}`}
887
+ pointerEvents="none"
888
+ style={[
889
+ styles.momentRange,
890
+ {
891
+ backgroundColor: controlsTheme.accentColor,
892
+ left: `${start * 100}%`,
893
+ width: `${Math.max(0, end - start) * 100}%`,
894
+ },
895
+ ]}
896
+ />
897
+ );
898
+ })
899
+ : null}
900
+ {duration > 0
901
+ ? visibleChapters.slice(1).map(chapter => (
902
+ <View
903
+ key={`${chapter.startTime}-${chapter.title}`}
904
+ pointerEvents="none"
905
+ style={[
906
+ styles.chapterMarker,
907
+ {
908
+ backgroundColor: controlsTheme.buttonTextColor,
909
+ left: `${clamp(chapter.startTime / duration, 0, 1) * 100}%`,
910
+ },
911
+ ]}
912
+ />
913
+ ))
914
+ : null}
915
+ </View>
916
+ </View>
917
+ <View style={styles.timeRow}>
918
+ <View
919
+ style={[
920
+ styles.timePill,
921
+ {
922
+ backgroundColor: controlsTheme.buttonBackgroundColor,
923
+ paddingHorizontal: timePillHorizontalPadding,
924
+ },
925
+ ]}
926
+ >
927
+ <Text
928
+ adjustsFontSizeToFit
929
+ minimumFontScale={0.85}
930
+ numberOfLines={1}
931
+ style={[
932
+ styles.timeText,
933
+ { color: controlsTheme.textColor, fontSize: timeFontSize },
934
+ ]}
935
+ >
936
+ {formatTime(displayTime)} / {formatTime(duration)}
937
+ </Text>
938
+ </View>
939
+ <View style={styles.timeControls}>
940
+ {hasCaptionTracks ? (
941
+ <View style={styles.captionControlWrap}>
942
+ {captionsOpen ? (
943
+ <CaptionTracksPanel
944
+ backgroundColor={controlsTheme.buttonBackgroundColor}
945
+ onSelect={selectCaptionTrack}
946
+ selectedTrackId={selectedCaptionTrackId}
947
+ textColor={controlsTheme.textColor}
948
+ tracks={captionTracks}
949
+ />
950
+ ) : null}
951
+ <IconButton
952
+ accessibilityLabel={captionsOpen ? 'Hide captions options' : 'Show captions options'}
953
+ backgroundColor={controlsTheme.buttonBackgroundColor}
954
+ onPress={toggleCaptionsPanel}
955
+ size={fullscreenButtonSize}
956
+ >
957
+ <CaptionsIcon
958
+ active={selectedCaptionTrackId !== null}
959
+ color={controlsTheme.buttonTextColor}
960
+ size={fullscreenButtonSize}
961
+ />
962
+ </IconButton>
963
+ </View>
964
+ ) : null}
965
+ {allowsFullscreen && onToggleFullscreen ? (
966
+ <IconButton
967
+ accessibilityLabel={
968
+ isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'
969
+ }
970
+ backgroundColor={controlsTheme.buttonBackgroundColor}
971
+ onPress={handleToggleFullscreen}
972
+ size={fullscreenButtonSize}
973
+ >
974
+ <FullscreenIcon
975
+ color={controlsTheme.buttonTextColor}
976
+ expanded={isFullscreen}
977
+ size={fullscreenButtonSize}
978
+ />
979
+ </IconButton>
980
+ ) : null}
981
+ </View>
982
+ </View>
983
+ </View>
984
+ ) : null}
985
+ </Animated.View>
986
+ )}
987
+ </View>
988
+ );
989
+ }
990
+
991
+ const IconButton = React.memo(function IconButton({
992
+ accessibilityLabel,
993
+ backgroundColor,
994
+ children,
995
+ onPress,
996
+ size,
997
+ }: {
998
+ accessibilityLabel: string;
999
+ backgroundColor: string;
1000
+ children: React.ReactNode;
1001
+ onPress: () => void;
1002
+ size: number;
1003
+ }) {
1004
+ return (
1005
+ <Pressable
1006
+ accessibilityLabel={accessibilityLabel}
1007
+ accessibilityRole="button"
1008
+ hitSlop={8}
1009
+ onPress={onPress}
1010
+ style={({ pressed }) => [
1011
+ {
1012
+ alignItems: 'center',
1013
+ backgroundColor,
1014
+ borderColor: frostBorderColor,
1015
+ borderRadius: size / 2,
1016
+ borderWidth: frostBorderWidth,
1017
+ height: size,
1018
+ justifyContent: 'center',
1019
+ width: size,
1020
+ },
1021
+ pressed && styles.pressed,
1022
+ ]}
1023
+ >
1024
+ {children}
1025
+ </Pressable>
1026
+ );
1027
+ });
1028
+
1029
+ const RobotsActionButton = React.memo(function RobotsActionButton({
1030
+ active,
1031
+ backgroundColor,
1032
+ disabled = false,
1033
+ label,
1034
+ onPress,
1035
+ robotSource,
1036
+ textColor,
1037
+ }: {
1038
+ active: boolean;
1039
+ backgroundColor: string;
1040
+ disabled?: boolean;
1041
+ label: string;
1042
+ onPress: () => void;
1043
+ robotSource: ImageSourcePropType;
1044
+ textColor: string;
1045
+ }) {
1046
+ return (
1047
+ <Pressable
1048
+ accessibilityLabel={`Show ${label.toLowerCase()} video AI results`}
1049
+ accessibilityRole="button"
1050
+ disabled={disabled}
1051
+ hitSlop={6}
1052
+ onPress={onPress}
1053
+ style={({ pressed }) => [
1054
+ styles.robotsButton,
1055
+ {
1056
+ backgroundColor,
1057
+ borderColor: active ? textColor : frostBorderColor,
1058
+ opacity: disabled ? 0.65 : 1,
1059
+ },
1060
+ pressed && styles.pressed,
1061
+ ]}
1062
+ >
1063
+ <Image source={robotSource} style={styles.robotsButtonImage} />
1064
+ <Text style={[styles.robotsButtonText, { color: textColor }]}>{label}</Text>
1065
+ </Pressable>
1066
+ );
1067
+ });
1068
+
1069
+ function RobotsPanelView({
1070
+ activePanel,
1071
+ backgroundColor,
1072
+ chapters,
1073
+ error,
1074
+ keyMoments,
1075
+ loading,
1076
+ maxHeight,
1077
+ onSeek,
1078
+ summary,
1079
+ textColor,
1080
+ }: {
1081
+ activePanel: RobotsPanel;
1082
+ backgroundColor: string;
1083
+ chapters: MuxVideoChapter[];
1084
+ error: string | null;
1085
+ keyMoments: MuxVideoKeyMoment[];
1086
+ loading: boolean;
1087
+ maxHeight: number;
1088
+ onSeek: (time: number) => void;
1089
+ summary?: MuxVideoSummary;
1090
+ textColor: string;
1091
+ }) {
1092
+ const title =
1093
+ activePanel === 'summary'
1094
+ ? 'Summary'
1095
+ : activePanel === 'chapters'
1096
+ ? 'Chapters'
1097
+ : 'Key Moments';
1098
+
1099
+ return (
1100
+ <View style={[styles.robotsPanel, { backgroundColor, maxHeight }]}>
1101
+ {activePanel === 'summary' ? null : (
1102
+ <Text style={[styles.robotsPanelTitle, { color: textColor }]}>{title}</Text>
1103
+ )}
1104
+ {loading ? (
1105
+ <Text style={[styles.robotsPanelText, { color: textColor }]}>Generating results...</Text>
1106
+ ) : error ? (
1107
+ <Text style={[styles.robotsPanelText, { color: textColor }]}>{error}</Text>
1108
+ ) : activePanel === 'summary' ? (
1109
+ <SummaryPanel summary={summary} textColor={textColor} />
1110
+ ) : activePanel === 'chapters' ? (
1111
+ <ChapterPanel chapters={chapters} onSeek={onSeek} textColor={textColor} />
1112
+ ) : (
1113
+ <KeyMomentsPanel keyMoments={keyMoments} onSeek={onSeek} textColor={textColor} />
1114
+ )}
1115
+ </View>
1116
+ );
1117
+ }
1118
+
1119
+ function SummaryPanel({
1120
+ summary,
1121
+ textColor,
1122
+ }: {
1123
+ summary?: MuxVideoSummary;
1124
+ textColor: string;
1125
+ }) {
1126
+ if (!summary) {
1127
+ return <Text style={[styles.robotsPanelText, { color: textColor }]}>No summary yet.</Text>;
1128
+ }
1129
+ return (
1130
+ <View style={styles.robotsPanelBody}>
1131
+ <Text numberOfLines={2} style={[styles.summaryTitle, { color: textColor }]}>
1132
+ {summary.title}
1133
+ </Text>
1134
+ <Text numberOfLines={10} style={[styles.robotsPanelText, { color: textColor }]}>
1135
+ {summary.description}
1136
+ </Text>
1137
+ </View>
1138
+ );
1139
+ }
1140
+
1141
+ function ChapterPanel({
1142
+ chapters,
1143
+ onSeek,
1144
+ textColor,
1145
+ }: {
1146
+ chapters: MuxVideoChapter[];
1147
+ onSeek: (time: number) => void;
1148
+ textColor: string;
1149
+ }) {
1150
+ if (chapters.length === 0) {
1151
+ return <Text style={[styles.robotsPanelText, { color: textColor }]}>No chapters yet.</Text>;
1152
+ }
1153
+ return (
1154
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.robotsScroll}>
1155
+ {chapters.map(chapter => (
1156
+ <Pressable
1157
+ accessibilityLabel={`Seek to chapter ${chapter.title}`}
1158
+ accessibilityRole="button"
1159
+ key={`${chapter.startTime}-${chapter.title}`}
1160
+ onPress={() => onSeek(chapter.startTime)}
1161
+ style={({ pressed }) => [styles.robotsListItem, pressed && styles.pressed]}
1162
+ >
1163
+ <Text style={[styles.robotsItemTime, { color: textColor }]}>
1164
+ {formatTime(chapter.startTime)}
1165
+ </Text>
1166
+ <Text numberOfLines={2} style={[styles.robotsItemTitle, { color: textColor }]}>
1167
+ {chapter.title}
1168
+ </Text>
1169
+ </Pressable>
1170
+ ))}
1171
+ </ScrollView>
1172
+ );
1173
+ }
1174
+
1175
+ function KeyMomentsPanel({
1176
+ keyMoments,
1177
+ onSeek,
1178
+ textColor,
1179
+ }: {
1180
+ keyMoments: MuxVideoKeyMoment[];
1181
+ onSeek: (time: number) => void;
1182
+ textColor: string;
1183
+ }) {
1184
+ if (keyMoments.length === 0) {
1185
+ return <Text style={[styles.robotsPanelText, { color: textColor }]}>No key moments yet.</Text>;
1186
+ }
1187
+ return (
1188
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.robotsScroll}>
1189
+ {keyMoments.map(moment => (
1190
+ <Pressable
1191
+ accessibilityLabel={`Seek to key moment ${moment.title}`}
1192
+ accessibilityRole="button"
1193
+ key={`${moment.startTime}-${moment.endTime}-${moment.title}`}
1194
+ onPress={() => onSeek(moment.startTime)}
1195
+ style={({ pressed }) => [styles.robotsListItem, pressed && styles.pressed]}
1196
+ >
1197
+ <Text style={[styles.robotsItemTime, { color: textColor }]}>
1198
+ {formatTime(moment.startTime)} - {formatTime(moment.endTime)}
1199
+ </Text>
1200
+ <Text numberOfLines={2} style={[styles.robotsItemTitle, { color: textColor }]}>
1201
+ {moment.title}
1202
+ </Text>
1203
+ {moment.description ? (
1204
+ <Text numberOfLines={2} style={[styles.robotsItemDescription, { color: textColor }]}>
1205
+ {moment.description}
1206
+ </Text>
1207
+ ) : null}
1208
+ </Pressable>
1209
+ ))}
1210
+ </ScrollView>
1211
+ );
1212
+ }
1213
+
1214
+ function CaptionTracksPanel({
1215
+ backgroundColor,
1216
+ onSelect,
1217
+ selectedTrackId,
1218
+ textColor,
1219
+ tracks,
1220
+ }: {
1221
+ backgroundColor: string;
1222
+ onSelect: (trackId: string | null) => void;
1223
+ selectedTrackId: string | null;
1224
+ textColor: string;
1225
+ tracks: MuxVideoCaptionTrack[];
1226
+ }) {
1227
+ return (
1228
+ <View style={[styles.captionPanel, { backgroundColor }]}>
1229
+ <Text style={[styles.captionPanelTitle, { color: textColor }]}>Captions</Text>
1230
+ <CaptionTrackOption
1231
+ active={selectedTrackId === null}
1232
+ label="Off"
1233
+ onPress={() => onSelect(null)}
1234
+ textColor={textColor}
1235
+ />
1236
+ {tracks.map(track => (
1237
+ <CaptionTrackOption
1238
+ active={selectedTrackId === track.id}
1239
+ key={track.id}
1240
+ label={track.label || track.language || 'Caption track'}
1241
+ meta={track.language}
1242
+ onPress={() => onSelect(track.id)}
1243
+ textColor={textColor}
1244
+ />
1245
+ ))}
1246
+ </View>
1247
+ );
1248
+ }
1249
+
1250
+ function CaptionTrackOption({
1251
+ active,
1252
+ label,
1253
+ meta,
1254
+ onPress,
1255
+ textColor,
1256
+ }: {
1257
+ active: boolean;
1258
+ label: string;
1259
+ meta?: string;
1260
+ onPress: () => void;
1261
+ textColor: string;
1262
+ }) {
1263
+ return (
1264
+ <Pressable
1265
+ accessibilityLabel={`Select captions ${label}`}
1266
+ accessibilityRole="button"
1267
+ accessibilityState={{ selected: active }}
1268
+ onPress={onPress}
1269
+ style={({ pressed }) => [
1270
+ styles.captionOption,
1271
+ active && styles.captionOptionActive,
1272
+ pressed && styles.pressed,
1273
+ ]}
1274
+ >
1275
+ <Text numberOfLines={1} style={[styles.captionOptionText, { color: textColor }]}>
1276
+ {label}
1277
+ </Text>
1278
+ {meta ? (
1279
+ <Text numberOfLines={1} style={[styles.captionOptionMeta, { color: textColor }]}>
1280
+ {meta}
1281
+ </Text>
1282
+ ) : null}
1283
+ </Pressable>
1284
+ );
1285
+ }
1286
+
1287
+ function PlayIcon({ color, size }: { color: string; size: number }) {
1288
+ const half = size * 0.18;
1289
+ return (
1290
+ <View
1291
+ pointerEvents="none"
1292
+ style={{
1293
+ borderBottomColor: 'transparent',
1294
+ borderBottomWidth: half,
1295
+ borderLeftColor: color,
1296
+ borderLeftWidth: half * 1.15,
1297
+ borderTopColor: 'transparent',
1298
+ borderTopWidth: half,
1299
+ height: 0,
1300
+ marginLeft: half * 0.3,
1301
+ width: 0,
1302
+ }}
1303
+ />
1304
+ );
1305
+ }
1306
+
1307
+ function PauseIcon({ color, size }: { color: string; size: number }) {
1308
+ const barWidth = size * 0.1;
1309
+ const barHeight = size * 0.36;
1310
+ return (
1311
+ <View pointerEvents="none" style={{ alignItems: 'center', flexDirection: 'row', gap: barWidth * 1.1 }}>
1312
+ <View style={{ backgroundColor: color, borderRadius: 2, height: barHeight, width: barWidth }} />
1313
+ <View style={{ backgroundColor: color, borderRadius: 2, height: barHeight, width: barWidth }} />
1314
+ </View>
1315
+ );
1316
+ }
1317
+
1318
+ function SkipIcon({
1319
+ color,
1320
+ direction,
1321
+ seconds,
1322
+ size,
1323
+ }: {
1324
+ color: string;
1325
+ direction: 'back' | 'forward';
1326
+ seconds: number;
1327
+ size: number;
1328
+ }) {
1329
+ return (
1330
+ <View pointerEvents="none" style={{ alignItems: 'center', justifyContent: 'center' }}>
1331
+ <Text
1332
+ allowFontScaling={false}
1333
+ style={{
1334
+ color,
1335
+ fontSize: Math.round(size * 0.5),
1336
+ fontWeight: '700',
1337
+ lineHeight: Math.round(size * 0.5),
1338
+ }}
1339
+ >
1340
+ {direction === 'back' ? '↺' : '↻'}
1341
+ </Text>
1342
+ <Text
1343
+ allowFontScaling={false}
1344
+ style={{
1345
+ color,
1346
+ fontSize: Math.round(size * 0.26),
1347
+ fontWeight: '800',
1348
+ marginTop: 1,
1349
+ }}
1350
+ >
1351
+ {seconds}
1352
+ </Text>
1353
+ </View>
1354
+ );
1355
+ }
1356
+
1357
+ function CaptionsIcon({
1358
+ active,
1359
+ color,
1360
+ size,
1361
+ }: {
1362
+ active: boolean;
1363
+ color: string;
1364
+ size: number;
1365
+ }) {
1366
+ return (
1367
+ <View
1368
+ pointerEvents="none"
1369
+ style={[
1370
+ styles.captionsIconFrame,
1371
+ {
1372
+ borderColor: color,
1373
+ height: Math.round(size * 0.58),
1374
+ width: Math.round(size * 0.78),
1375
+ },
1376
+ ]}
1377
+ >
1378
+ <Text
1379
+ allowFontScaling={false}
1380
+ style={[
1381
+ styles.captionsIconText,
1382
+ {
1383
+ color,
1384
+ fontSize: Math.max(8, Math.round(size * 0.24)),
1385
+ opacity: active ? 1 : 0.72,
1386
+ },
1387
+ ]}
1388
+ >
1389
+ CC
1390
+ </Text>
1391
+ </View>
1392
+ );
1393
+ }
1394
+
1395
+ function FullscreenIcon({
1396
+ color,
1397
+ expanded,
1398
+ size,
1399
+ }: {
1400
+ color: string;
1401
+ expanded: boolean;
1402
+ size: number;
1403
+ }) {
1404
+ const stroke = Math.max(2, Math.round(size * 0.1));
1405
+ const arm = Math.round(size * 0.36);
1406
+ const inset = expanded ? Math.round(size * 0.24) : Math.round(size * 0.2);
1407
+ const cornerStyle = (
1408
+ vertical: 'top' | 'bottom',
1409
+ horizontal: 'left' | 'right'
1410
+ ) => ({
1411
+ [vertical]: inset,
1412
+ [horizontal]: inset,
1413
+ });
1414
+ const armStyle = (
1415
+ vertical: 'top' | 'bottom',
1416
+ horizontal: 'left' | 'right',
1417
+ orientation: 'horizontal' | 'vertical'
1418
+ ) => ({
1419
+ position: 'absolute' as const,
1420
+ backgroundColor: color,
1421
+ borderRadius: stroke,
1422
+ width: orientation === 'horizontal' ? arm : stroke,
1423
+ height: orientation === 'horizontal' ? stroke : arm,
1424
+ [vertical]: 0,
1425
+ [horizontal]: 0,
1426
+ });
1427
+ const renderBracket = (
1428
+ key: string,
1429
+ vertical: 'top' | 'bottom',
1430
+ horizontal: 'left' | 'right'
1431
+ ) => (
1432
+ <View
1433
+ key={key}
1434
+ style={{ position: 'absolute', ...cornerStyle(vertical, horizontal) }}
1435
+ >
1436
+ <View style={armStyle(vertical, horizontal, 'horizontal')} />
1437
+ <View style={armStyle(vertical, horizontal, 'vertical')} />
1438
+ </View>
1439
+ );
1440
+ return (
1441
+ <View pointerEvents="none" style={{ height: size, width: size }}>
1442
+ {expanded
1443
+ ? [
1444
+ renderBracket('tr', 'top', 'right'),
1445
+ renderBracket('bl', 'bottom', 'left'),
1446
+ ]
1447
+ : [
1448
+ renderBracket('tl', 'top', 'left'),
1449
+ renderBracket('br', 'bottom', 'right'),
1450
+ ]}
1451
+ </View>
1452
+ );
1453
+ }
1454
+
1455
+ function formatTime(seconds: number): string {
1456
+ if (!Number.isFinite(seconds) || seconds <= 0) {
1457
+ return '0:00';
1458
+ }
1459
+ const totalSeconds = Math.floor(seconds);
1460
+ const minutes = Math.floor(totalSeconds / 60);
1461
+ const remainingSeconds = totalSeconds % 60;
1462
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
1463
+ }
1464
+
1465
+ function runPlayerCommand(command: Promise<void>): void {
1466
+ command.catch(() => {
1467
+ // Command failures are already reflected through player status/source events.
1468
+ });
1469
+ }
1470
+
1471
+ function getRobotsErrorMessage(error: unknown, fallback: string): string {
1472
+ return error instanceof Error && error.message ? error.message : fallback;
1473
+ }
1474
+
1475
+ function clamp(value: number, min: number, max: number): number {
1476
+ if (!Number.isFinite(value)) {
1477
+ return min;
1478
+ }
1479
+ return Math.min(max, Math.max(min, value));
1480
+ }
1481
+
1482
+ const styles = StyleSheet.create({
1483
+ controlsRoot: {
1484
+ overflow: 'hidden',
1485
+ },
1486
+ centerWrap: {
1487
+ bottom: 0,
1488
+ left: 0,
1489
+ position: 'absolute',
1490
+ right: 0,
1491
+ top: 0,
1492
+ alignItems: 'center',
1493
+ gap: 20,
1494
+ justifyContent: 'center',
1495
+ overflow: 'hidden',
1496
+ paddingBottom: 88,
1497
+ paddingHorizontal: 16,
1498
+ },
1499
+ gradientTop: {
1500
+ height: '32%',
1501
+ left: 0,
1502
+ position: 'absolute',
1503
+ right: 0,
1504
+ top: 0,
1505
+ },
1506
+ gradientBottom: {
1507
+ bottom: 0,
1508
+ height: '40%',
1509
+ left: 0,
1510
+ position: 'absolute',
1511
+ right: 0,
1512
+ },
1513
+ centerCluster: {
1514
+ alignItems: 'center',
1515
+ flexDirection: 'row',
1516
+ },
1517
+ centerClusterHidden: {
1518
+ opacity: 0,
1519
+ },
1520
+ timeline: {
1521
+ bottom: 6,
1522
+ gap: 4,
1523
+ left: 16,
1524
+ minWidth: 0,
1525
+ position: 'absolute',
1526
+ right: 16,
1527
+ },
1528
+ trackHitArea: {
1529
+ borderColor: frostBorderColor,
1530
+ borderRadius: 999,
1531
+ borderWidth: frostBorderWidth,
1532
+ justifyContent: 'center',
1533
+ minWidth: 0,
1534
+ paddingHorizontal: 10,
1535
+ paddingVertical: 10,
1536
+ },
1537
+ track: {
1538
+ overflow: 'hidden',
1539
+ width: '100%',
1540
+ },
1541
+ trackFill: {
1542
+ bottom: 0,
1543
+ left: 0,
1544
+ position: 'absolute',
1545
+ top: 0,
1546
+ },
1547
+ momentRange: {
1548
+ bottom: 0,
1549
+ opacity: 0.45,
1550
+ position: 'absolute',
1551
+ top: 0,
1552
+ },
1553
+ chapterMarker: {
1554
+ bottom: 0,
1555
+ opacity: 0.85,
1556
+ position: 'absolute',
1557
+ top: 0,
1558
+ width: 2,
1559
+ },
1560
+ timeRow: {
1561
+ alignItems: 'center',
1562
+ flexDirection: 'row',
1563
+ justifyContent: 'space-between',
1564
+ minWidth: 0,
1565
+ },
1566
+ timeControls: {
1567
+ alignItems: 'center',
1568
+ flexDirection: 'row',
1569
+ flexShrink: 0,
1570
+ gap: 8,
1571
+ justifyContent: 'flex-end',
1572
+ },
1573
+ captionControlWrap: {
1574
+ position: 'relative',
1575
+ },
1576
+ captionPanel: {
1577
+ borderColor: frostBorderColor,
1578
+ borderRadius: 14,
1579
+ borderWidth: frostBorderWidth,
1580
+ bottom: 34,
1581
+ gap: 6,
1582
+ minWidth: 176,
1583
+ padding: 8,
1584
+ position: 'absolute',
1585
+ right: 0,
1586
+ },
1587
+ captionPanelTitle: {
1588
+ fontSize: 12,
1589
+ fontWeight: '800',
1590
+ paddingHorizontal: 6,
1591
+ },
1592
+ captionOption: {
1593
+ backgroundColor: 'rgba(248, 251, 255, 0.08)',
1594
+ borderColor: 'transparent',
1595
+ borderRadius: 10,
1596
+ borderWidth: StyleSheet.hairlineWidth,
1597
+ paddingHorizontal: 8,
1598
+ paddingVertical: 7,
1599
+ },
1600
+ captionOptionActive: {
1601
+ borderColor: frostBorderColor,
1602
+ backgroundColor: 'rgba(248, 251, 255, 0.16)',
1603
+ },
1604
+ captionOptionText: {
1605
+ fontSize: 12,
1606
+ fontWeight: '800',
1607
+ },
1608
+ captionOptionMeta: {
1609
+ fontSize: 10,
1610
+ fontWeight: '700',
1611
+ marginTop: 1,
1612
+ opacity: 0.72,
1613
+ },
1614
+ timePill: {
1615
+ borderColor: frostBorderColor,
1616
+ borderRadius: 999,
1617
+ borderWidth: frostBorderWidth,
1618
+ flexShrink: 1,
1619
+ minWidth: 0,
1620
+ paddingHorizontal: 10,
1621
+ paddingVertical: 4,
1622
+ },
1623
+ timeText: {
1624
+ fontSize: 12,
1625
+ fontWeight: '700',
1626
+ },
1627
+ chapterPillRow: {
1628
+ alignItems: 'flex-start',
1629
+ flexDirection: 'row',
1630
+ },
1631
+ chapterPill: {
1632
+ borderColor: frostBorderColor,
1633
+ borderRadius: 999,
1634
+ borderWidth: frostBorderWidth,
1635
+ maxWidth: 140,
1636
+ paddingHorizontal: 8,
1637
+ paddingVertical: 2,
1638
+ },
1639
+ chapterPillText: {
1640
+ fontSize: 10,
1641
+ fontWeight: '700',
1642
+ },
1643
+ robotsArea: {
1644
+ alignItems: 'center',
1645
+ gap: 6,
1646
+ maxWidth: 420,
1647
+ width: '100%',
1648
+ },
1649
+ robotsButtonRow: {
1650
+ alignItems: 'center',
1651
+ flexDirection: 'row',
1652
+ flexGrow: 1,
1653
+ justifyContent: 'center',
1654
+ paddingHorizontal: 4,
1655
+ paddingVertical: 1,
1656
+ },
1657
+ robotsButtonScroller: {
1658
+ maxWidth: '100%',
1659
+ width: '100%',
1660
+ },
1661
+ robotsButton: {
1662
+ alignItems: 'center',
1663
+ borderRadius: 999,
1664
+ borderWidth: 1,
1665
+ flexDirection: 'row',
1666
+ gap: 4,
1667
+ marginHorizontal: 3,
1668
+ paddingHorizontal: 8,
1669
+ paddingVertical: 5,
1670
+ },
1671
+ robotsButtonImage: {
1672
+ height: 18,
1673
+ resizeMode: 'contain',
1674
+ width: 18,
1675
+ },
1676
+ robotsButtonText: {
1677
+ fontSize: 11,
1678
+ fontWeight: '800',
1679
+ },
1680
+ robotsPanel: {
1681
+ borderColor: frostBorderColor,
1682
+ borderRadius: 16,
1683
+ borderWidth: frostBorderWidth,
1684
+ gap: 6,
1685
+ left: 0,
1686
+ marginTop: 6,
1687
+ overflow: 'hidden',
1688
+ padding: 10,
1689
+ position: 'absolute',
1690
+ right: 0,
1691
+ top: '100%',
1692
+ },
1693
+ robotsPanelTitle: {
1694
+ fontSize: 13,
1695
+ fontWeight: '800',
1696
+ },
1697
+ robotsPanelBody: {
1698
+ gap: 6,
1699
+ },
1700
+ robotsPanelText: {
1701
+ fontSize: 12,
1702
+ fontWeight: '600',
1703
+ lineHeight: 16,
1704
+ opacity: 0.92,
1705
+ },
1706
+ summaryTitle: {
1707
+ fontSize: 14,
1708
+ fontWeight: '800',
1709
+ lineHeight: 18,
1710
+ },
1711
+ tagRow: {
1712
+ flexDirection: 'row',
1713
+ flexWrap: 'wrap',
1714
+ gap: 6,
1715
+ },
1716
+ tagPill: {
1717
+ backgroundColor: 'rgba(248, 251, 255, 0.08)',
1718
+ borderColor: frostBorderColor,
1719
+ borderRadius: 999,
1720
+ borderWidth: StyleSheet.hairlineWidth,
1721
+ maxWidth: 120,
1722
+ paddingHorizontal: 8,
1723
+ paddingVertical: 3,
1724
+ },
1725
+ tagText: {
1726
+ fontSize: 11,
1727
+ fontWeight: '700',
1728
+ },
1729
+ robotsScroll: {
1730
+ maxHeight: 92,
1731
+ },
1732
+ robotsListItem: {
1733
+ backgroundColor: 'rgba(248, 251, 255, 0.08)',
1734
+ borderColor: frostBorderColor,
1735
+ borderRadius: 12,
1736
+ borderWidth: StyleSheet.hairlineWidth,
1737
+ marginRight: 8,
1738
+ padding: 8,
1739
+ width: 152,
1740
+ },
1741
+ robotsItemTime: {
1742
+ fontSize: 11,
1743
+ fontWeight: '800',
1744
+ marginBottom: 2,
1745
+ opacity: 0.78,
1746
+ },
1747
+ robotsItemTitle: {
1748
+ fontSize: 12,
1749
+ fontWeight: '800',
1750
+ lineHeight: 16,
1751
+ },
1752
+ robotsItemDescription: {
1753
+ fontSize: 11,
1754
+ fontWeight: '600',
1755
+ lineHeight: 14,
1756
+ marginTop: 3,
1757
+ opacity: 0.82,
1758
+ },
1759
+ captionsIconFrame: {
1760
+ alignItems: 'center',
1761
+ borderRadius: 4,
1762
+ borderWidth: 2,
1763
+ justifyContent: 'center',
1764
+ },
1765
+ captionsIconText: {
1766
+ fontWeight: '900',
1767
+ letterSpacing: 0.4,
1768
+ },
1769
+ pressed: {
1770
+ opacity: 0.6,
1771
+ },
1772
+ });