@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,1032 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from 'react';
3
+ import { Animated, Image, PanResponder, Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View, } from 'react-native';
4
+ import { LinearGradient } from 'expo-linear-gradient';
5
+ const emptyChapters = [];
6
+ const emptyKeyMoments = [];
7
+ const emptyCaptionTracks = [];
8
+ const robotImages = {
9
+ summary: require('../assets/MuxRobot_03.gif'),
10
+ chapters: require('../assets/MuxRobot_02.gif'),
11
+ moments: require('../assets/MuxRobot_05.gif'),
12
+ };
13
+ const defaultTheme = {
14
+ accentColor: '#FA50B5',
15
+ backgroundColor: 'transparent',
16
+ buttonBackgroundColor: 'rgba(20, 28, 38, 0.28)',
17
+ buttonTextColor: '#f8fbff',
18
+ buttonSize: 48,
19
+ playButtonSize: 72,
20
+ fullscreenButtonSize: 26,
21
+ progressTrackColor: '#FA50B5',
22
+ bufferedTrackColor: 'rgba(248, 251, 255, 0.28)',
23
+ trackColor: 'rgba(248, 251, 255, 0.16)',
24
+ trackHeight: 4,
25
+ textColor: '#f8fbff',
26
+ seekSeconds: 10,
27
+ };
28
+ const frostBorderColor = 'rgba(255, 255, 255, 0.18)';
29
+ const frostBorderWidth = StyleSheet.hairlineWidth * 2;
30
+ export function MuxVideoControls({ player, status, shouldPlay, theme, robots, allowsFullscreen = false, isFullscreen = false, onToggleFullscreen, generatedSummary, generatedChapters, generatedKeyMoments, onGeneratedSummaryChange, onGeneratedChaptersChange, onGeneratedKeyMomentsChange, }) {
31
+ const controlsTheme = React.useMemo(() => ({ ...defaultTheme, ...theme }), [theme]);
32
+ const [hidden, setHidden] = React.useState(false);
33
+ const [interactionTick, setInteractionTick] = React.useState(0);
34
+ const [trackWidth, setTrackWidth] = React.useState(0);
35
+ const [containerWidth, setContainerWidth] = React.useState(0);
36
+ const [containerHeight, setContainerHeight] = React.useState(0);
37
+ const [containerPageX, setContainerPageX] = React.useState(0);
38
+ const [scrubbing, setScrubbing] = React.useState(false);
39
+ const [scrubTime, setScrubTime] = React.useState(0);
40
+ const [pendingTarget, setPendingTarget] = React.useState(null);
41
+ const [activeRobotsPanel, setActiveRobotsPanel] = React.useState(null);
42
+ const [robotsLoading, setRobotsLoading] = React.useState(null);
43
+ const [robotsError, setRobotsError] = React.useState(null);
44
+ const [captionsOpen, setCaptionsOpen] = React.useState(false);
45
+ const robotsRequestRef = React.useRef(0);
46
+ const controlsRootRef = React.useRef(null);
47
+ const opacity = React.useRef(new Animated.Value(1)).current;
48
+ const { width: windowWidth } = useWindowDimensions();
49
+ const duration = Number.isFinite(status.duration) ? status.duration : 0;
50
+ const playerTime = clamp(status.currentTime, 0, duration || 0);
51
+ const displayTime = scrubbing
52
+ ? clamp(scrubTime, 0, duration || 0)
53
+ : pendingTarget !== null
54
+ ? clamp(pendingTarget, 0, duration || 0)
55
+ : playerTime;
56
+ React.useEffect(() => {
57
+ if (pendingTarget === null) {
58
+ return;
59
+ }
60
+ if (Math.abs(playerTime - pendingTarget) < 0.5) {
61
+ setPendingTarget(null);
62
+ return;
63
+ }
64
+ const timeout = setTimeout(() => setPendingTarget(null), 1500);
65
+ return () => clearTimeout(timeout);
66
+ }, [pendingTarget, playerTime]);
67
+ const bufferedPosition = clamp(status.bufferedPosition, 0, duration || 0);
68
+ const progress = duration > 0 ? displayTime / duration : 0;
69
+ const buffered = duration > 0 ? bufferedPosition / duration : 0;
70
+ const isPlaying = status.status === 'playing' || shouldPlay;
71
+ const robotsEnabled = robots != null && robots.enabled !== false;
72
+ const robotsAssetId = robots?.assetId;
73
+ const summary = generatedSummary ?? robots?.summary;
74
+ const chapters = generatedChapters ?? robots?.chapters ?? emptyChapters;
75
+ const keyMoments = generatedKeyMoments ?? robots?.keyMoments ?? emptyKeyMoments;
76
+ const captionTracks = status.captionTracks ?? emptyCaptionTracks;
77
+ const selectedCaptionTrackId = status.selectedCaptionTrackId ?? null;
78
+ const canSummarize = robotsEnabled && (!!summary || (!!robotsAssetId && !!robots?.onSummarize));
79
+ const canGenerateChapters = robotsEnabled && (chapters.length > 0 || (!!robotsAssetId && !!robots?.onGenerateChapters));
80
+ const canFindKeyMoments = robotsEnabled && (keyMoments.length > 0 || (!!robotsAssetId && !!robots?.onFindKeyMoments));
81
+ const hasRobotsActions = canSummarize || canGenerateChapters || canFindKeyMoments;
82
+ const isRobotsFocused = activeRobotsPanel !== null;
83
+ const hasCaptionTracks = captionTracks.length > 0;
84
+ React.useEffect(() => {
85
+ robotsRequestRef.current += 1;
86
+ setActiveRobotsPanel(null);
87
+ setRobotsLoading(null);
88
+ setRobotsError(null);
89
+ }, [robotsAssetId]);
90
+ React.useEffect(() => {
91
+ if (!hasCaptionTracks) {
92
+ setCaptionsOpen(false);
93
+ }
94
+ }, [hasCaptionTracks]);
95
+ const visibleChapters = React.useMemo(() => chapters
96
+ .filter(chapter => chapter.title && chapter.startTime >= 0 && (duration <= 0 || chapter.startTime <= duration))
97
+ .slice()
98
+ .sort((a, b) => a.startTime - b.startTime), [chapters, duration]);
99
+ const visibleKeyMoments = React.useMemo(() => keyMoments
100
+ .filter(moment => moment.title && moment.startTime >= 0 && moment.endTime > moment.startTime)
101
+ .slice()
102
+ .sort((a, b) => a.startTime - b.startTime), [keyMoments]);
103
+ const activeChapter = React.useMemo(() => {
104
+ if (visibleChapters.length === 0) {
105
+ return undefined;
106
+ }
107
+ let current;
108
+ for (const chapter of visibleChapters) {
109
+ if (chapter.startTime > displayTime) {
110
+ break;
111
+ }
112
+ current = chapter;
113
+ }
114
+ return current;
115
+ }, [displayTime, visibleChapters]);
116
+ const measureControlsRoot = React.useCallback(() => {
117
+ controlsRootRef.current?.measureInWindow((x, _y, width) => {
118
+ if (Number.isFinite(x)) {
119
+ setContainerPageX(x);
120
+ }
121
+ if (Number.isFinite(width) && width > 0) {
122
+ setContainerWidth(width);
123
+ }
124
+ });
125
+ }, []);
126
+ const handleControlsLayout = React.useCallback((event) => {
127
+ const { width, height } = event.nativeEvent.layout;
128
+ setContainerWidth(width);
129
+ setContainerHeight(height);
130
+ measureControlsRoot();
131
+ }, [measureControlsRoot]);
132
+ React.useEffect(() => {
133
+ measureControlsRoot();
134
+ }, [measureControlsRoot, windowWidth]);
135
+ const viewportWidth = windowWidth > 0 ? windowWidth : containerWidth;
136
+ const offscreenLeftInset = Math.max(0, -containerPageX);
137
+ const offscreenRightInset = viewportWidth > 0 && containerWidth > 0
138
+ ? Math.max(0, containerPageX + containerWidth - viewportWidth)
139
+ : 0;
140
+ const isPortraitControls = containerWidth > 0 && containerHeight > 0 && containerHeight / containerWidth > 1.25;
141
+ const visibleControlsWidth = containerWidth > 0
142
+ ? Math.max(0, containerWidth - offscreenLeftInset - offscreenRightInset)
143
+ : 0;
144
+ const isCompactHeight = containerHeight > 0 && containerHeight < 240;
145
+ const isNarrowControls = isCompactHeight ||
146
+ isPortraitControls ||
147
+ (visibleControlsWidth > 0 && visibleControlsWidth < 360);
148
+ const baseHorizontalInset = visibleControlsWidth > 0 && visibleControlsWidth < 340 ? 10 : isPortraitControls ? 12 : 16;
149
+ const timelineLeftInset = baseHorizontalInset + offscreenLeftInset;
150
+ const timelineRightInset = baseHorizontalInset + offscreenRightInset;
151
+ const centerHorizontalPadding = isNarrowControls ? 10 : 16;
152
+ const centerHorizontalGap = isNarrowControls ? 18 : 28;
153
+ const trackHorizontalPadding = isNarrowControls ? 8 : 10;
154
+ const trackVerticalPadding = isNarrowControls ? 8 : 10;
155
+ const timePillHorizontalPadding = isNarrowControls ? 8 : 10;
156
+ const timeFontSize = isNarrowControls ? 11 : 12;
157
+ const buttonSize = Math.max(36, isNarrowControls ? 40 : controlsTheme.buttonSize);
158
+ const playButtonSize = Math.max(buttonSize, isCompactHeight ? 52 : isNarrowControls ? 56 : controlsTheme.playButtonSize);
159
+ const centerVerticalGap = isCompactHeight ? 8 : isPortraitControls ? 14 : 20;
160
+ const fullscreenButtonSize = Math.max(22, isNarrowControls ? 24 : controlsTheme.fullscreenButtonSize);
161
+ const trackHeight = Math.max(3, controlsTheme.trackHeight);
162
+ const fadeOut = React.useCallback(() => {
163
+ Animated.timing(opacity, {
164
+ toValue: 0,
165
+ duration: 200,
166
+ useNativeDriver: true,
167
+ }).start(({ finished }) => {
168
+ if (finished) {
169
+ setHidden(true);
170
+ }
171
+ });
172
+ }, [opacity]);
173
+ const fadeIn = React.useCallback(() => {
174
+ setHidden(false);
175
+ Animated.timing(opacity, {
176
+ toValue: 1,
177
+ duration: 200,
178
+ useNativeDriver: true,
179
+ }).start();
180
+ }, [opacity]);
181
+ const keepAlive = React.useCallback(() => {
182
+ setInteractionTick(t => t + 1);
183
+ }, []);
184
+ const dismissRobotsPanel = React.useCallback(() => {
185
+ robotsRequestRef.current += 1;
186
+ setActiveRobotsPanel(null);
187
+ setRobotsLoading(null);
188
+ setRobotsError(null);
189
+ keepAlive();
190
+ }, [keepAlive]);
191
+ const dismissCaptionsPanel = React.useCallback(() => {
192
+ setCaptionsOpen(false);
193
+ keepAlive();
194
+ }, [keepAlive]);
195
+ const handleBackgroundTap = React.useCallback(() => {
196
+ if (captionsOpen) {
197
+ dismissCaptionsPanel();
198
+ return;
199
+ }
200
+ if (activeRobotsPanel || robotsLoading) {
201
+ dismissRobotsPanel();
202
+ return;
203
+ }
204
+ if (hidden) {
205
+ fadeIn();
206
+ setInteractionTick(t => t + 1);
207
+ }
208
+ else {
209
+ fadeOut();
210
+ }
211
+ }, [activeRobotsPanel, captionsOpen, dismissCaptionsPanel, dismissRobotsPanel, fadeIn, fadeOut, hidden, robotsLoading]);
212
+ React.useEffect(() => {
213
+ if (hidden || scrubbing || activeRobotsPanel || robotsLoading || captionsOpen) {
214
+ return;
215
+ }
216
+ const timer = setTimeout(fadeOut, 3000);
217
+ return () => clearTimeout(timer);
218
+ }, [hidden, scrubbing, activeRobotsPanel, robotsLoading, captionsOpen, interactionTick, fadeOut]);
219
+ const trackRef = React.useRef(null);
220
+ const scrubStateRef = React.useRef({
221
+ trackWidth: 0,
222
+ trackPageX: 0,
223
+ duration: 0,
224
+ lastSeekAt: 0,
225
+ currentTime: 0,
226
+ wasPlaying: false,
227
+ });
228
+ const measureTrack = React.useCallback(() => {
229
+ trackRef.current?.measureInWindow((x, _y, width) => {
230
+ if (Number.isFinite(x)) {
231
+ scrubStateRef.current.trackPageX = x;
232
+ }
233
+ if (Number.isFinite(width) && width > 0) {
234
+ scrubStateRef.current.trackWidth = width;
235
+ }
236
+ });
237
+ }, []);
238
+ const onTrackLayout = React.useCallback((event) => {
239
+ setTrackWidth(event.nativeEvent.layout.width);
240
+ measureTrack();
241
+ }, [measureTrack]);
242
+ React.useEffect(() => {
243
+ scrubStateRef.current.trackWidth = trackWidth;
244
+ scrubStateRef.current.duration = duration;
245
+ }, [trackWidth, duration]);
246
+ const computeTimeFromX = React.useCallback((x) => {
247
+ const { trackWidth: width, duration: dur } = scrubStateRef.current;
248
+ if (width <= 0 || dur <= 0) {
249
+ return 0;
250
+ }
251
+ return clamp(x / width, 0, 1) * dur;
252
+ }, []);
253
+ const isPlayingRef = React.useRef(isPlaying);
254
+ isPlayingRef.current = isPlaying;
255
+ const finishScrub = React.useCallback((commitX) => {
256
+ if (scrubStateRef.current.duration > 0) {
257
+ const localX = commitX ?? -1;
258
+ const target = localX >= 0
259
+ ? computeTimeFromX(localX)
260
+ : scrubStateRef.current.currentTime;
261
+ scrubStateRef.current.currentTime = target;
262
+ setPendingTarget(target);
263
+ console.log('[MuxControls] scrub release seekTo=', target);
264
+ player
265
+ .seekTo(target)
266
+ .then(() => console.log('[MuxControls] scrub seekTo OK'))
267
+ .catch(err => console.log('[MuxControls] scrub seekTo ERROR', err));
268
+ }
269
+ setScrubbing(false);
270
+ setInteractionTick(prev => prev + 1);
271
+ }, [computeTimeFromX, player]);
272
+ const panResponder = React.useMemo(() => PanResponder.create({
273
+ onStartShouldSetPanResponder: () => true,
274
+ onStartShouldSetPanResponderCapture: () => true,
275
+ onMoveShouldSetPanResponder: () => true,
276
+ onMoveShouldSetPanResponderCapture: () => true,
277
+ onPanResponderTerminationRequest: () => false,
278
+ onShouldBlockNativeResponder: () => true,
279
+ onPanResponderGrant: (_evt, gestureState) => {
280
+ if (scrubStateRef.current.duration <= 0) {
281
+ return;
282
+ }
283
+ measureTrack();
284
+ const localX = gestureState.x0 - scrubStateRef.current.trackPageX;
285
+ const t = computeTimeFromX(localX);
286
+ scrubStateRef.current.currentTime = t;
287
+ setScrubbing(true);
288
+ setScrubTime(t);
289
+ console.log('[MuxControls] scrub grant x0=', gestureState.x0, 'trackPageX=', scrubStateRef.current.trackPageX, 'trackWidth=', scrubStateRef.current.trackWidth, 'duration=', scrubStateRef.current.duration, 't=', t);
290
+ },
291
+ onPanResponderMove: (_evt, gestureState) => {
292
+ if (scrubStateRef.current.duration <= 0) {
293
+ return;
294
+ }
295
+ const localX = gestureState.moveX - scrubStateRef.current.trackPageX;
296
+ const t = computeTimeFromX(localX);
297
+ scrubStateRef.current.currentTime = t;
298
+ setScrubTime(t);
299
+ },
300
+ onPanResponderRelease: (_evt, gestureState) => {
301
+ const localX = gestureState.moveX - scrubStateRef.current.trackPageX;
302
+ finishScrub(localX);
303
+ },
304
+ onPanResponderTerminate: (_evt, gestureState) => {
305
+ const localX = gestureState.moveX - scrubStateRef.current.trackPageX;
306
+ finishScrub(localX);
307
+ },
308
+ }), [computeTimeFromX, finishScrub, measureTrack]);
309
+ const seekSecondsRef = React.useRef(controlsTheme.seekSeconds);
310
+ seekSecondsRef.current = controlsTheme.seekSeconds;
311
+ const seekBack = React.useCallback(() => {
312
+ console.log('[MuxControls] seekBack pressed, seconds=', seekSecondsRef.current);
313
+ keepAlive();
314
+ player
315
+ .seekBy(-seekSecondsRef.current)
316
+ .then(() => console.log('[MuxControls] seekBack OK'))
317
+ .catch(err => console.log('[MuxControls] seekBack ERROR', err));
318
+ }, [keepAlive, player]);
319
+ const seekForward = React.useCallback(() => {
320
+ console.log('[MuxControls] seekForward pressed, seconds=', seekSecondsRef.current);
321
+ keepAlive();
322
+ player
323
+ .seekBy(seekSecondsRef.current)
324
+ .then(() => console.log('[MuxControls] seekForward OK'))
325
+ .catch(err => console.log('[MuxControls] seekForward ERROR', err));
326
+ }, [keepAlive, player]);
327
+ const togglePlayback = React.useCallback(() => {
328
+ keepAlive();
329
+ if (isPlayingRef.current) {
330
+ runPlayerCommand(player.pause());
331
+ return;
332
+ }
333
+ runPlayerCommand(player.play());
334
+ }, [keepAlive, player]);
335
+ const handleToggleFullscreen = React.useCallback(() => {
336
+ keepAlive();
337
+ setCaptionsOpen(false);
338
+ onToggleFullscreen?.();
339
+ }, [keepAlive, onToggleFullscreen]);
340
+ const toggleCaptionsPanel = React.useCallback(() => {
341
+ keepAlive();
342
+ setCaptionsOpen(open => !open);
343
+ }, [keepAlive]);
344
+ const toggleRobotsPanel = React.useCallback((panel) => {
345
+ keepAlive();
346
+ setRobotsError(null);
347
+ if (activeRobotsPanel === panel) {
348
+ setActiveRobotsPanel(null);
349
+ return;
350
+ }
351
+ if (robotsLoading !== null) {
352
+ robotsRequestRef.current += 1;
353
+ setRobotsLoading(null);
354
+ }
355
+ setActiveRobotsPanel(panel);
356
+ }, [activeRobotsPanel, keepAlive, robotsLoading]);
357
+ const loadSummary = React.useCallback(() => {
358
+ if (activeRobotsPanel === 'summary' || robotsLoading === 'summary') {
359
+ dismissRobotsPanel();
360
+ return;
361
+ }
362
+ if (summary || !robotsAssetId || !robots?.onSummarize) {
363
+ toggleRobotsPanel('summary');
364
+ return;
365
+ }
366
+ keepAlive();
367
+ const requestId = robotsRequestRef.current + 1;
368
+ robotsRequestRef.current = requestId;
369
+ setActiveRobotsPanel('summary');
370
+ setRobotsLoading('summary');
371
+ setRobotsError(null);
372
+ robots
373
+ .onSummarize({
374
+ assetId: robotsAssetId,
375
+ currentTime: displayTime,
376
+ duration,
377
+ })
378
+ .then(result => {
379
+ if (robotsRequestRef.current !== requestId) {
380
+ return;
381
+ }
382
+ onGeneratedSummaryChange?.(result);
383
+ setActiveRobotsPanel('summary');
384
+ })
385
+ .catch(error => {
386
+ if (robotsRequestRef.current !== requestId) {
387
+ return;
388
+ }
389
+ setRobotsError(getRobotsErrorMessage(error, 'Summary is not available yet.'));
390
+ setActiveRobotsPanel('summary');
391
+ })
392
+ .finally(() => {
393
+ if (robotsRequestRef.current === requestId) {
394
+ setRobotsLoading(null);
395
+ }
396
+ });
397
+ }, [activeRobotsPanel, dismissRobotsPanel, displayTime, duration, keepAlive, robots, robotsAssetId, robotsLoading, summary, toggleRobotsPanel]);
398
+ const loadChapters = React.useCallback(() => {
399
+ if (activeRobotsPanel === 'chapters' || robotsLoading === 'chapters') {
400
+ dismissRobotsPanel();
401
+ return;
402
+ }
403
+ if (chapters.length > 0 || !robotsAssetId || !robots?.onGenerateChapters) {
404
+ toggleRobotsPanel('chapters');
405
+ return;
406
+ }
407
+ keepAlive();
408
+ const requestId = robotsRequestRef.current + 1;
409
+ robotsRequestRef.current = requestId;
410
+ setActiveRobotsPanel('chapters');
411
+ setRobotsLoading('chapters');
412
+ setRobotsError(null);
413
+ robots
414
+ .onGenerateChapters({
415
+ assetId: robotsAssetId,
416
+ currentTime: displayTime,
417
+ duration,
418
+ })
419
+ .then(result => {
420
+ if (robotsRequestRef.current !== requestId) {
421
+ return;
422
+ }
423
+ onGeneratedChaptersChange?.(result);
424
+ setActiveRobotsPanel('chapters');
425
+ })
426
+ .catch(error => {
427
+ if (robotsRequestRef.current !== requestId) {
428
+ return;
429
+ }
430
+ setRobotsError(getRobotsErrorMessage(error, 'Chapters are not available yet.'));
431
+ setActiveRobotsPanel('chapters');
432
+ })
433
+ .finally(() => {
434
+ if (robotsRequestRef.current === requestId) {
435
+ setRobotsLoading(null);
436
+ }
437
+ });
438
+ }, [activeRobotsPanel, chapters.length, dismissRobotsPanel, displayTime, duration, keepAlive, robots, robotsAssetId, robotsLoading, toggleRobotsPanel]);
439
+ const loadKeyMoments = React.useCallback(() => {
440
+ if (activeRobotsPanel === 'moments' || robotsLoading === 'moments') {
441
+ dismissRobotsPanel();
442
+ return;
443
+ }
444
+ if (keyMoments.length > 0 || !robotsAssetId || !robots?.onFindKeyMoments) {
445
+ toggleRobotsPanel('moments');
446
+ return;
447
+ }
448
+ keepAlive();
449
+ const requestId = robotsRequestRef.current + 1;
450
+ robotsRequestRef.current = requestId;
451
+ setActiveRobotsPanel('moments');
452
+ setRobotsLoading('moments');
453
+ setRobotsError(null);
454
+ robots
455
+ .onFindKeyMoments({
456
+ assetId: robotsAssetId,
457
+ currentTime: displayTime,
458
+ duration,
459
+ })
460
+ .then(result => {
461
+ if (robotsRequestRef.current !== requestId) {
462
+ return;
463
+ }
464
+ onGeneratedKeyMomentsChange?.(result);
465
+ setActiveRobotsPanel('moments');
466
+ })
467
+ .catch(error => {
468
+ if (robotsRequestRef.current !== requestId) {
469
+ return;
470
+ }
471
+ setRobotsError(getRobotsErrorMessage(error, 'Key moments are not available yet.'));
472
+ setActiveRobotsPanel('moments');
473
+ })
474
+ .finally(() => {
475
+ if (robotsRequestRef.current === requestId) {
476
+ setRobotsLoading(null);
477
+ }
478
+ });
479
+ }, [activeRobotsPanel, dismissRobotsPanel, displayTime, duration, keepAlive, keyMoments.length, robots, robotsAssetId, robotsLoading, toggleRobotsPanel]);
480
+ const seekToRobotsTime = React.useCallback((target) => {
481
+ keepAlive();
482
+ setRobotsError(null);
483
+ setPendingTarget(target);
484
+ player.seekTo(target).catch(() => {
485
+ // Seeking from generated navigation is best-effort.
486
+ });
487
+ }, [keepAlive, player]);
488
+ const selectCaptionTrack = React.useCallback((trackId) => {
489
+ keepAlive();
490
+ setCaptionsOpen(false);
491
+ player.setCaptionTrack(trackId).catch(() => {
492
+ // Caption selection failures are non-fatal and native status will correct UI state.
493
+ });
494
+ }, [keepAlive, player]);
495
+ const defaultPanelMaxHeight = containerHeight > 0
496
+ ? Math.max(96, Math.min(168, Math.floor(containerHeight * 0.45)))
497
+ : 168;
498
+ const summaryPanelMaxHeight = containerHeight > 0
499
+ ? Math.max(140, Math.min(280, Math.floor(containerHeight * 0.65)))
500
+ : 260;
501
+ const panelMaxHeight = activeRobotsPanel === 'summary' ? summaryPanelMaxHeight : defaultPanelMaxHeight;
502
+ return (_jsx(View, { ref: controlsRootRef, pointerEvents: "box-none", style: [StyleSheet.absoluteFill, styles.controlsRoot], onLayout: handleControlsLayout, children: hidden ? (_jsx(Pressable, { accessibilityLabel: "Show video controls", accessibilityRole: "button", onPress: handleBackgroundTap, style: StyleSheet.absoluteFill })) : (_jsxs(Animated.View, { pointerEvents: "box-none", style: [StyleSheet.absoluteFill, { opacity }], children: [_jsx(Pressable, { accessibilityLabel: activeRobotsPanel ? 'Hide Mux Robots results' : 'Hide video controls', accessibilityRole: "button", onPress: handleBackgroundTap, style: StyleSheet.absoluteFill }), _jsx(LinearGradient, { colors: ['rgba(0,0,0,0.55)', 'rgba(0,0,0,0)'], pointerEvents: "none", style: styles.gradientTop }), _jsx(LinearGradient, { colors: ['rgba(0,0,0,0)', 'rgba(0,0,0,0.7)'], pointerEvents: "none", style: styles.gradientBottom }), _jsxs(View, { pointerEvents: "box-none", style: [
503
+ styles.centerWrap,
504
+ {
505
+ gap: centerVerticalGap,
506
+ left: offscreenLeftInset,
507
+ paddingHorizontal: centerHorizontalPadding,
508
+ right: offscreenRightInset,
509
+ },
510
+ ], children: [hasRobotsActions ? (_jsxs(View, { style: styles.robotsArea, children: [_jsxs(ScrollView, { horizontal: true, bounces: false, showsHorizontalScrollIndicator: false, contentContainerStyle: styles.robotsButtonRow, style: styles.robotsButtonScroller, children: [canSummarize ? (_jsx(RobotsActionButton, { active: activeRobotsPanel === 'summary', backgroundColor: controlsTheme.buttonBackgroundColor, label: "Summary", onPress: loadSummary, robotSource: robotImages.summary, textColor: controlsTheme.buttonTextColor })) : null, canGenerateChapters ? (_jsx(RobotsActionButton, { active: activeRobotsPanel === 'chapters', backgroundColor: controlsTheme.buttonBackgroundColor, label: "Chapters", onPress: loadChapters, robotSource: robotImages.chapters, textColor: controlsTheme.buttonTextColor })) : null, canFindKeyMoments ? (_jsx(RobotsActionButton, { active: activeRobotsPanel === 'moments', backgroundColor: controlsTheme.buttonBackgroundColor, label: "Moments", onPress: loadKeyMoments, robotSource: robotImages.moments, textColor: controlsTheme.buttonTextColor })) : null] }), activeRobotsPanel ? (_jsx(RobotsPanelView, { activePanel: activeRobotsPanel, backgroundColor: controlsTheme.buttonBackgroundColor, chapters: visibleChapters, error: robotsError, keyMoments: visibleKeyMoments, loading: robotsLoading === activeRobotsPanel, maxHeight: panelMaxHeight, onSeek: seekToRobotsTime, summary: summary, textColor: controlsTheme.textColor })) : null] })) : null, _jsxs(View, { pointerEvents: isRobotsFocused ? 'none' : 'box-none', style: [
511
+ styles.centerCluster,
512
+ { gap: centerHorizontalGap },
513
+ isRobotsFocused && styles.centerClusterHidden,
514
+ ], children: [_jsx(IconButton, { accessibilityLabel: `Skip back ${controlsTheme.seekSeconds} seconds`, backgroundColor: controlsTheme.buttonBackgroundColor, onPress: seekBack, size: buttonSize, children: _jsx(SkipIcon, { color: controlsTheme.buttonTextColor, direction: "back", seconds: controlsTheme.seekSeconds, size: buttonSize }) }), _jsx(IconButton, { accessibilityLabel: isPlaying ? 'Pause' : 'Play', backgroundColor: controlsTheme.buttonBackgroundColor, onPress: togglePlayback, size: playButtonSize, children: isPlaying ? (_jsx(PauseIcon, { color: controlsTheme.buttonTextColor, size: playButtonSize })) : (_jsx(PlayIcon, { color: controlsTheme.buttonTextColor, size: playButtonSize })) }), _jsx(IconButton, { accessibilityLabel: `Skip forward ${controlsTheme.seekSeconds} seconds`, backgroundColor: controlsTheme.buttonBackgroundColor, onPress: seekForward, size: buttonSize, children: _jsx(SkipIcon, { color: controlsTheme.buttonTextColor, direction: "forward", seconds: controlsTheme.seekSeconds, size: buttonSize }) })] })] }), !isRobotsFocused ? (_jsxs(View, { pointerEvents: "box-none", style: [
515
+ styles.timeline,
516
+ { left: timelineLeftInset, right: timelineRightInset },
517
+ ], children: [activeChapter ? (_jsx(View, { pointerEvents: "none", style: styles.chapterPillRow, children: _jsx(View, { style: [
518
+ styles.chapterPill,
519
+ { backgroundColor: controlsTheme.buttonBackgroundColor },
520
+ ], children: _jsx(Text, { numberOfLines: 1, style: [styles.chapterPillText, { color: controlsTheme.textColor }], children: activeChapter.title }) }) })) : null, _jsx(View, { accessibilityLabel: "Seek video", accessibilityRole: "adjustable", style: [
521
+ styles.trackHitArea,
522
+ {
523
+ backgroundColor: controlsTheme.buttonBackgroundColor,
524
+ paddingHorizontal: trackHorizontalPadding,
525
+ paddingVertical: trackVerticalPadding,
526
+ },
527
+ ], ...panResponder.panHandlers, children: _jsxs(View, { ref: trackRef, onLayout: onTrackLayout, style: [
528
+ styles.track,
529
+ {
530
+ backgroundColor: controlsTheme.trackColor,
531
+ height: scrubbing ? trackHeight + 2 : trackHeight,
532
+ borderRadius: trackHeight,
533
+ },
534
+ ], children: [_jsx(View, { style: [
535
+ styles.trackFill,
536
+ {
537
+ backgroundColor: controlsTheme.bufferedTrackColor,
538
+ borderRadius: trackHeight,
539
+ width: `${buffered * 100}%`,
540
+ },
541
+ ] }), _jsx(View, { style: [
542
+ styles.trackFill,
543
+ {
544
+ backgroundColor: controlsTheme.progressTrackColor,
545
+ borderRadius: trackHeight,
546
+ width: `${progress * 100}%`,
547
+ },
548
+ ] }), duration > 0
549
+ ? visibleKeyMoments.map(moment => {
550
+ const start = clamp(moment.startTime / duration, 0, 1);
551
+ const end = clamp(moment.endTime / duration, 0, 1);
552
+ return (_jsx(View, { pointerEvents: "none", style: [
553
+ styles.momentRange,
554
+ {
555
+ backgroundColor: controlsTheme.accentColor,
556
+ left: `${start * 100}%`,
557
+ width: `${Math.max(0, end - start) * 100}%`,
558
+ },
559
+ ] }, `${moment.startTime}-${moment.endTime}-${moment.title}`));
560
+ })
561
+ : null, duration > 0
562
+ ? visibleChapters.slice(1).map(chapter => (_jsx(View, { pointerEvents: "none", style: [
563
+ styles.chapterMarker,
564
+ {
565
+ backgroundColor: controlsTheme.buttonTextColor,
566
+ left: `${clamp(chapter.startTime / duration, 0, 1) * 100}%`,
567
+ },
568
+ ] }, `${chapter.startTime}-${chapter.title}`)))
569
+ : null] }) }), _jsxs(View, { style: styles.timeRow, children: [_jsx(View, { style: [
570
+ styles.timePill,
571
+ {
572
+ backgroundColor: controlsTheme.buttonBackgroundColor,
573
+ paddingHorizontal: timePillHorizontalPadding,
574
+ },
575
+ ], children: _jsxs(Text, { adjustsFontSizeToFit: true, minimumFontScale: 0.85, numberOfLines: 1, style: [
576
+ styles.timeText,
577
+ { color: controlsTheme.textColor, fontSize: timeFontSize },
578
+ ], children: [formatTime(displayTime), " / ", formatTime(duration)] }) }), _jsxs(View, { style: styles.timeControls, children: [hasCaptionTracks ? (_jsxs(View, { style: styles.captionControlWrap, children: [captionsOpen ? (_jsx(CaptionTracksPanel, { backgroundColor: controlsTheme.buttonBackgroundColor, onSelect: selectCaptionTrack, selectedTrackId: selectedCaptionTrackId, textColor: controlsTheme.textColor, tracks: captionTracks })) : null, _jsx(IconButton, { accessibilityLabel: captionsOpen ? 'Hide captions options' : 'Show captions options', backgroundColor: controlsTheme.buttonBackgroundColor, onPress: toggleCaptionsPanel, size: fullscreenButtonSize, children: _jsx(CaptionsIcon, { active: selectedCaptionTrackId !== null, color: controlsTheme.buttonTextColor, size: fullscreenButtonSize }) })] })) : null, allowsFullscreen && onToggleFullscreen ? (_jsx(IconButton, { accessibilityLabel: isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen', backgroundColor: controlsTheme.buttonBackgroundColor, onPress: handleToggleFullscreen, size: fullscreenButtonSize, children: _jsx(FullscreenIcon, { color: controlsTheme.buttonTextColor, expanded: isFullscreen, size: fullscreenButtonSize }) })) : null] })] })] })) : null] })) }));
579
+ }
580
+ const IconButton = React.memo(function IconButton({ accessibilityLabel, backgroundColor, children, onPress, size, }) {
581
+ return (_jsx(Pressable, { accessibilityLabel: accessibilityLabel, accessibilityRole: "button", hitSlop: 8, onPress: onPress, style: ({ pressed }) => [
582
+ {
583
+ alignItems: 'center',
584
+ backgroundColor,
585
+ borderColor: frostBorderColor,
586
+ borderRadius: size / 2,
587
+ borderWidth: frostBorderWidth,
588
+ height: size,
589
+ justifyContent: 'center',
590
+ width: size,
591
+ },
592
+ pressed && styles.pressed,
593
+ ], children: children }));
594
+ });
595
+ const RobotsActionButton = React.memo(function RobotsActionButton({ active, backgroundColor, disabled = false, label, onPress, robotSource, textColor, }) {
596
+ return (_jsxs(Pressable, { accessibilityLabel: `Show ${label.toLowerCase()} video AI results`, accessibilityRole: "button", disabled: disabled, hitSlop: 6, onPress: onPress, style: ({ pressed }) => [
597
+ styles.robotsButton,
598
+ {
599
+ backgroundColor,
600
+ borderColor: active ? textColor : frostBorderColor,
601
+ opacity: disabled ? 0.65 : 1,
602
+ },
603
+ pressed && styles.pressed,
604
+ ], children: [_jsx(Image, { source: robotSource, style: styles.robotsButtonImage }), _jsx(Text, { style: [styles.robotsButtonText, { color: textColor }], children: label })] }));
605
+ });
606
+ function RobotsPanelView({ activePanel, backgroundColor, chapters, error, keyMoments, loading, maxHeight, onSeek, summary, textColor, }) {
607
+ const title = activePanel === 'summary'
608
+ ? 'Summary'
609
+ : activePanel === 'chapters'
610
+ ? 'Chapters'
611
+ : 'Key Moments';
612
+ return (_jsxs(View, { style: [styles.robotsPanel, { backgroundColor, maxHeight }], children: [activePanel === 'summary' ? null : (_jsx(Text, { style: [styles.robotsPanelTitle, { color: textColor }], children: title })), loading ? (_jsx(Text, { style: [styles.robotsPanelText, { color: textColor }], children: "Generating results..." })) : error ? (_jsx(Text, { style: [styles.robotsPanelText, { color: textColor }], children: error })) : activePanel === 'summary' ? (_jsx(SummaryPanel, { summary: summary, textColor: textColor })) : activePanel === 'chapters' ? (_jsx(ChapterPanel, { chapters: chapters, onSeek: onSeek, textColor: textColor })) : (_jsx(KeyMomentsPanel, { keyMoments: keyMoments, onSeek: onSeek, textColor: textColor }))] }));
613
+ }
614
+ function SummaryPanel({ summary, textColor, }) {
615
+ if (!summary) {
616
+ return _jsx(Text, { style: [styles.robotsPanelText, { color: textColor }], children: "No summary yet." });
617
+ }
618
+ return (_jsxs(View, { style: styles.robotsPanelBody, children: [_jsx(Text, { numberOfLines: 2, style: [styles.summaryTitle, { color: textColor }], children: summary.title }), _jsx(Text, { numberOfLines: 10, style: [styles.robotsPanelText, { color: textColor }], children: summary.description })] }));
619
+ }
620
+ function ChapterPanel({ chapters, onSeek, textColor, }) {
621
+ if (chapters.length === 0) {
622
+ return _jsx(Text, { style: [styles.robotsPanelText, { color: textColor }], children: "No chapters yet." });
623
+ }
624
+ return (_jsx(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, style: styles.robotsScroll, children: chapters.map(chapter => (_jsxs(Pressable, { accessibilityLabel: `Seek to chapter ${chapter.title}`, accessibilityRole: "button", onPress: () => onSeek(chapter.startTime), style: ({ pressed }) => [styles.robotsListItem, pressed && styles.pressed], children: [_jsx(Text, { style: [styles.robotsItemTime, { color: textColor }], children: formatTime(chapter.startTime) }), _jsx(Text, { numberOfLines: 2, style: [styles.robotsItemTitle, { color: textColor }], children: chapter.title })] }, `${chapter.startTime}-${chapter.title}`))) }));
625
+ }
626
+ function KeyMomentsPanel({ keyMoments, onSeek, textColor, }) {
627
+ if (keyMoments.length === 0) {
628
+ return _jsx(Text, { style: [styles.robotsPanelText, { color: textColor }], children: "No key moments yet." });
629
+ }
630
+ return (_jsx(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, style: styles.robotsScroll, children: keyMoments.map(moment => (_jsxs(Pressable, { accessibilityLabel: `Seek to key moment ${moment.title}`, accessibilityRole: "button", onPress: () => onSeek(moment.startTime), style: ({ pressed }) => [styles.robotsListItem, pressed && styles.pressed], children: [_jsxs(Text, { style: [styles.robotsItemTime, { color: textColor }], children: [formatTime(moment.startTime), " - ", formatTime(moment.endTime)] }), _jsx(Text, { numberOfLines: 2, style: [styles.robotsItemTitle, { color: textColor }], children: moment.title }), moment.description ? (_jsx(Text, { numberOfLines: 2, style: [styles.robotsItemDescription, { color: textColor }], children: moment.description })) : null] }, `${moment.startTime}-${moment.endTime}-${moment.title}`))) }));
631
+ }
632
+ function CaptionTracksPanel({ backgroundColor, onSelect, selectedTrackId, textColor, tracks, }) {
633
+ return (_jsxs(View, { style: [styles.captionPanel, { backgroundColor }], children: [_jsx(Text, { style: [styles.captionPanelTitle, { color: textColor }], children: "Captions" }), _jsx(CaptionTrackOption, { active: selectedTrackId === null, label: "Off", onPress: () => onSelect(null), textColor: textColor }), tracks.map(track => (_jsx(CaptionTrackOption, { active: selectedTrackId === track.id, label: track.label || track.language || 'Caption track', meta: track.language, onPress: () => onSelect(track.id), textColor: textColor }, track.id)))] }));
634
+ }
635
+ function CaptionTrackOption({ active, label, meta, onPress, textColor, }) {
636
+ return (_jsxs(Pressable, { accessibilityLabel: `Select captions ${label}`, accessibilityRole: "button", accessibilityState: { selected: active }, onPress: onPress, style: ({ pressed }) => [
637
+ styles.captionOption,
638
+ active && styles.captionOptionActive,
639
+ pressed && styles.pressed,
640
+ ], children: [_jsx(Text, { numberOfLines: 1, style: [styles.captionOptionText, { color: textColor }], children: label }), meta ? (_jsx(Text, { numberOfLines: 1, style: [styles.captionOptionMeta, { color: textColor }], children: meta })) : null] }));
641
+ }
642
+ function PlayIcon({ color, size }) {
643
+ const half = size * 0.18;
644
+ return (_jsx(View, { pointerEvents: "none", style: {
645
+ borderBottomColor: 'transparent',
646
+ borderBottomWidth: half,
647
+ borderLeftColor: color,
648
+ borderLeftWidth: half * 1.15,
649
+ borderTopColor: 'transparent',
650
+ borderTopWidth: half,
651
+ height: 0,
652
+ marginLeft: half * 0.3,
653
+ width: 0,
654
+ } }));
655
+ }
656
+ function PauseIcon({ color, size }) {
657
+ const barWidth = size * 0.1;
658
+ const barHeight = size * 0.36;
659
+ return (_jsxs(View, { pointerEvents: "none", style: { alignItems: 'center', flexDirection: 'row', gap: barWidth * 1.1 }, children: [_jsx(View, { style: { backgroundColor: color, borderRadius: 2, height: barHeight, width: barWidth } }), _jsx(View, { style: { backgroundColor: color, borderRadius: 2, height: barHeight, width: barWidth } })] }));
660
+ }
661
+ function SkipIcon({ color, direction, seconds, size, }) {
662
+ return (_jsxs(View, { pointerEvents: "none", style: { alignItems: 'center', justifyContent: 'center' }, children: [_jsx(Text, { allowFontScaling: false, style: {
663
+ color,
664
+ fontSize: Math.round(size * 0.5),
665
+ fontWeight: '700',
666
+ lineHeight: Math.round(size * 0.5),
667
+ }, children: direction === 'back' ? '↺' : '↻' }), _jsx(Text, { allowFontScaling: false, style: {
668
+ color,
669
+ fontSize: Math.round(size * 0.26),
670
+ fontWeight: '800',
671
+ marginTop: 1,
672
+ }, children: seconds })] }));
673
+ }
674
+ function CaptionsIcon({ active, color, size, }) {
675
+ return (_jsx(View, { pointerEvents: "none", style: [
676
+ styles.captionsIconFrame,
677
+ {
678
+ borderColor: color,
679
+ height: Math.round(size * 0.58),
680
+ width: Math.round(size * 0.78),
681
+ },
682
+ ], children: _jsx(Text, { allowFontScaling: false, style: [
683
+ styles.captionsIconText,
684
+ {
685
+ color,
686
+ fontSize: Math.max(8, Math.round(size * 0.24)),
687
+ opacity: active ? 1 : 0.72,
688
+ },
689
+ ], children: "CC" }) }));
690
+ }
691
+ function FullscreenIcon({ color, expanded, size, }) {
692
+ const stroke = Math.max(2, Math.round(size * 0.1));
693
+ const arm = Math.round(size * 0.36);
694
+ const inset = expanded ? Math.round(size * 0.24) : Math.round(size * 0.2);
695
+ const cornerStyle = (vertical, horizontal) => ({
696
+ [vertical]: inset,
697
+ [horizontal]: inset,
698
+ });
699
+ const armStyle = (vertical, horizontal, orientation) => ({
700
+ position: 'absolute',
701
+ backgroundColor: color,
702
+ borderRadius: stroke,
703
+ width: orientation === 'horizontal' ? arm : stroke,
704
+ height: orientation === 'horizontal' ? stroke : arm,
705
+ [vertical]: 0,
706
+ [horizontal]: 0,
707
+ });
708
+ const renderBracket = (key, vertical, horizontal) => (_jsxs(View, { style: { position: 'absolute', ...cornerStyle(vertical, horizontal) }, children: [_jsx(View, { style: armStyle(vertical, horizontal, 'horizontal') }), _jsx(View, { style: armStyle(vertical, horizontal, 'vertical') })] }, key));
709
+ return (_jsx(View, { pointerEvents: "none", style: { height: size, width: size }, children: expanded
710
+ ? [
711
+ renderBracket('tr', 'top', 'right'),
712
+ renderBracket('bl', 'bottom', 'left'),
713
+ ]
714
+ : [
715
+ renderBracket('tl', 'top', 'left'),
716
+ renderBracket('br', 'bottom', 'right'),
717
+ ] }));
718
+ }
719
+ function formatTime(seconds) {
720
+ if (!Number.isFinite(seconds) || seconds <= 0) {
721
+ return '0:00';
722
+ }
723
+ const totalSeconds = Math.floor(seconds);
724
+ const minutes = Math.floor(totalSeconds / 60);
725
+ const remainingSeconds = totalSeconds % 60;
726
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
727
+ }
728
+ function runPlayerCommand(command) {
729
+ command.catch(() => {
730
+ // Command failures are already reflected through player status/source events.
731
+ });
732
+ }
733
+ function getRobotsErrorMessage(error, fallback) {
734
+ return error instanceof Error && error.message ? error.message : fallback;
735
+ }
736
+ function clamp(value, min, max) {
737
+ if (!Number.isFinite(value)) {
738
+ return min;
739
+ }
740
+ return Math.min(max, Math.max(min, value));
741
+ }
742
+ const styles = StyleSheet.create({
743
+ controlsRoot: {
744
+ overflow: 'hidden',
745
+ },
746
+ centerWrap: {
747
+ bottom: 0,
748
+ left: 0,
749
+ position: 'absolute',
750
+ right: 0,
751
+ top: 0,
752
+ alignItems: 'center',
753
+ gap: 20,
754
+ justifyContent: 'center',
755
+ overflow: 'hidden',
756
+ paddingBottom: 88,
757
+ paddingHorizontal: 16,
758
+ },
759
+ gradientTop: {
760
+ height: '32%',
761
+ left: 0,
762
+ position: 'absolute',
763
+ right: 0,
764
+ top: 0,
765
+ },
766
+ gradientBottom: {
767
+ bottom: 0,
768
+ height: '40%',
769
+ left: 0,
770
+ position: 'absolute',
771
+ right: 0,
772
+ },
773
+ centerCluster: {
774
+ alignItems: 'center',
775
+ flexDirection: 'row',
776
+ },
777
+ centerClusterHidden: {
778
+ opacity: 0,
779
+ },
780
+ timeline: {
781
+ bottom: 6,
782
+ gap: 4,
783
+ left: 16,
784
+ minWidth: 0,
785
+ position: 'absolute',
786
+ right: 16,
787
+ },
788
+ trackHitArea: {
789
+ borderColor: frostBorderColor,
790
+ borderRadius: 999,
791
+ borderWidth: frostBorderWidth,
792
+ justifyContent: 'center',
793
+ minWidth: 0,
794
+ paddingHorizontal: 10,
795
+ paddingVertical: 10,
796
+ },
797
+ track: {
798
+ overflow: 'hidden',
799
+ width: '100%',
800
+ },
801
+ trackFill: {
802
+ bottom: 0,
803
+ left: 0,
804
+ position: 'absolute',
805
+ top: 0,
806
+ },
807
+ momentRange: {
808
+ bottom: 0,
809
+ opacity: 0.45,
810
+ position: 'absolute',
811
+ top: 0,
812
+ },
813
+ chapterMarker: {
814
+ bottom: 0,
815
+ opacity: 0.85,
816
+ position: 'absolute',
817
+ top: 0,
818
+ width: 2,
819
+ },
820
+ timeRow: {
821
+ alignItems: 'center',
822
+ flexDirection: 'row',
823
+ justifyContent: 'space-between',
824
+ minWidth: 0,
825
+ },
826
+ timeControls: {
827
+ alignItems: 'center',
828
+ flexDirection: 'row',
829
+ flexShrink: 0,
830
+ gap: 8,
831
+ justifyContent: 'flex-end',
832
+ },
833
+ captionControlWrap: {
834
+ position: 'relative',
835
+ },
836
+ captionPanel: {
837
+ borderColor: frostBorderColor,
838
+ borderRadius: 14,
839
+ borderWidth: frostBorderWidth,
840
+ bottom: 34,
841
+ gap: 6,
842
+ minWidth: 176,
843
+ padding: 8,
844
+ position: 'absolute',
845
+ right: 0,
846
+ },
847
+ captionPanelTitle: {
848
+ fontSize: 12,
849
+ fontWeight: '800',
850
+ paddingHorizontal: 6,
851
+ },
852
+ captionOption: {
853
+ backgroundColor: 'rgba(248, 251, 255, 0.08)',
854
+ borderColor: 'transparent',
855
+ borderRadius: 10,
856
+ borderWidth: StyleSheet.hairlineWidth,
857
+ paddingHorizontal: 8,
858
+ paddingVertical: 7,
859
+ },
860
+ captionOptionActive: {
861
+ borderColor: frostBorderColor,
862
+ backgroundColor: 'rgba(248, 251, 255, 0.16)',
863
+ },
864
+ captionOptionText: {
865
+ fontSize: 12,
866
+ fontWeight: '800',
867
+ },
868
+ captionOptionMeta: {
869
+ fontSize: 10,
870
+ fontWeight: '700',
871
+ marginTop: 1,
872
+ opacity: 0.72,
873
+ },
874
+ timePill: {
875
+ borderColor: frostBorderColor,
876
+ borderRadius: 999,
877
+ borderWidth: frostBorderWidth,
878
+ flexShrink: 1,
879
+ minWidth: 0,
880
+ paddingHorizontal: 10,
881
+ paddingVertical: 4,
882
+ },
883
+ timeText: {
884
+ fontSize: 12,
885
+ fontWeight: '700',
886
+ },
887
+ chapterPillRow: {
888
+ alignItems: 'flex-start',
889
+ flexDirection: 'row',
890
+ },
891
+ chapterPill: {
892
+ borderColor: frostBorderColor,
893
+ borderRadius: 999,
894
+ borderWidth: frostBorderWidth,
895
+ maxWidth: 140,
896
+ paddingHorizontal: 8,
897
+ paddingVertical: 2,
898
+ },
899
+ chapterPillText: {
900
+ fontSize: 10,
901
+ fontWeight: '700',
902
+ },
903
+ robotsArea: {
904
+ alignItems: 'center',
905
+ gap: 6,
906
+ maxWidth: 420,
907
+ width: '100%',
908
+ },
909
+ robotsButtonRow: {
910
+ alignItems: 'center',
911
+ flexDirection: 'row',
912
+ flexGrow: 1,
913
+ justifyContent: 'center',
914
+ paddingHorizontal: 4,
915
+ paddingVertical: 1,
916
+ },
917
+ robotsButtonScroller: {
918
+ maxWidth: '100%',
919
+ width: '100%',
920
+ },
921
+ robotsButton: {
922
+ alignItems: 'center',
923
+ borderRadius: 999,
924
+ borderWidth: 1,
925
+ flexDirection: 'row',
926
+ gap: 4,
927
+ marginHorizontal: 3,
928
+ paddingHorizontal: 8,
929
+ paddingVertical: 5,
930
+ },
931
+ robotsButtonImage: {
932
+ height: 18,
933
+ resizeMode: 'contain',
934
+ width: 18,
935
+ },
936
+ robotsButtonText: {
937
+ fontSize: 11,
938
+ fontWeight: '800',
939
+ },
940
+ robotsPanel: {
941
+ borderColor: frostBorderColor,
942
+ borderRadius: 16,
943
+ borderWidth: frostBorderWidth,
944
+ gap: 6,
945
+ left: 0,
946
+ marginTop: 6,
947
+ overflow: 'hidden',
948
+ padding: 10,
949
+ position: 'absolute',
950
+ right: 0,
951
+ top: '100%',
952
+ },
953
+ robotsPanelTitle: {
954
+ fontSize: 13,
955
+ fontWeight: '800',
956
+ },
957
+ robotsPanelBody: {
958
+ gap: 6,
959
+ },
960
+ robotsPanelText: {
961
+ fontSize: 12,
962
+ fontWeight: '600',
963
+ lineHeight: 16,
964
+ opacity: 0.92,
965
+ },
966
+ summaryTitle: {
967
+ fontSize: 14,
968
+ fontWeight: '800',
969
+ lineHeight: 18,
970
+ },
971
+ tagRow: {
972
+ flexDirection: 'row',
973
+ flexWrap: 'wrap',
974
+ gap: 6,
975
+ },
976
+ tagPill: {
977
+ backgroundColor: 'rgba(248, 251, 255, 0.08)',
978
+ borderColor: frostBorderColor,
979
+ borderRadius: 999,
980
+ borderWidth: StyleSheet.hairlineWidth,
981
+ maxWidth: 120,
982
+ paddingHorizontal: 8,
983
+ paddingVertical: 3,
984
+ },
985
+ tagText: {
986
+ fontSize: 11,
987
+ fontWeight: '700',
988
+ },
989
+ robotsScroll: {
990
+ maxHeight: 92,
991
+ },
992
+ robotsListItem: {
993
+ backgroundColor: 'rgba(248, 251, 255, 0.08)',
994
+ borderColor: frostBorderColor,
995
+ borderRadius: 12,
996
+ borderWidth: StyleSheet.hairlineWidth,
997
+ marginRight: 8,
998
+ padding: 8,
999
+ width: 152,
1000
+ },
1001
+ robotsItemTime: {
1002
+ fontSize: 11,
1003
+ fontWeight: '800',
1004
+ marginBottom: 2,
1005
+ opacity: 0.78,
1006
+ },
1007
+ robotsItemTitle: {
1008
+ fontSize: 12,
1009
+ fontWeight: '800',
1010
+ lineHeight: 16,
1011
+ },
1012
+ robotsItemDescription: {
1013
+ fontSize: 11,
1014
+ fontWeight: '600',
1015
+ lineHeight: 14,
1016
+ marginTop: 3,
1017
+ opacity: 0.82,
1018
+ },
1019
+ captionsIconFrame: {
1020
+ alignItems: 'center',
1021
+ borderRadius: 4,
1022
+ borderWidth: 2,
1023
+ justifyContent: 'center',
1024
+ },
1025
+ captionsIconText: {
1026
+ fontWeight: '900',
1027
+ letterSpacing: 0.4,
1028
+ },
1029
+ pressed: {
1030
+ opacity: 0.6,
1031
+ },
1032
+ });