mega-stream-proxy 0.1.9

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +518 -0
  3. package/android/build.gradle +31 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/dist/Player/native.d.ts +3 -0
  6. package/dist/Player/native.js +3 -0
  7. package/dist/Player/playerAndroid/MegaVideoPlayerAndroid.d.ts +47 -0
  8. package/dist/Player/playerAndroid/MegaVideoPlayerAndroid.js +624 -0
  9. package/dist/Player/playerAndroid/PlayerControlsAndroid.d.ts +54 -0
  10. package/dist/Player/playerAndroid/PlayerControlsAndroid.js +370 -0
  11. package/dist/Player/playerAndroid/SubtitleDisplayAndroid.d.ts +21 -0
  12. package/dist/Player/playerAndroid/SubtitleDisplayAndroid.js +55 -0
  13. package/dist/Player/playerAndroid/index.d.ts +3 -0
  14. package/dist/Player/playerAndroid/index.js +3 -0
  15. package/dist/Player/playerweb/MegaVideoPlayer.d.ts +92 -0
  16. package/dist/Player/playerweb/MegaVideoPlayer.js +368 -0
  17. package/dist/Player/playerweb/PlayerControls.d.ts +52 -0
  18. package/dist/Player/playerweb/PlayerControls.js +346 -0
  19. package/dist/Player/playerweb/SubtitleDisplay.d.ts +15 -0
  20. package/dist/Player/playerweb/SubtitleDisplay.js +32 -0
  21. package/dist/Player/playerweb/index.d.ts +3 -0
  22. package/dist/Player/playerweb/index.js +3 -0
  23. package/dist/Player/web.d.ts +3 -0
  24. package/dist/Player/web.js +3 -0
  25. package/dist/accounts/async-storage.d.ts +10 -0
  26. package/dist/accounts/async-storage.js +46 -0
  27. package/dist/accounts/index.d.ts +4 -0
  28. package/dist/accounts/index.js +3 -0
  29. package/dist/accounts/local-storage.d.ts +3 -0
  30. package/dist/accounts/local-storage.js +63 -0
  31. package/dist/accounts/manager.d.ts +39 -0
  32. package/dist/accounts/manager.js +149 -0
  33. package/dist/accounts/types.d.ts +35 -0
  34. package/dist/accounts/types.js +1 -0
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +4 -0
  37. package/dist/megaFile.d.ts +7 -0
  38. package/dist/megaFile.js +31 -0
  39. package/dist/proxy/native.d.ts +25 -0
  40. package/dist/proxy/native.js +336 -0
  41. package/dist/proxy/sw/bundle.d.ts +21 -0
  42. package/dist/proxy/sw/bundle.js +32 -0
  43. package/dist/proxy/sw/megajs.browser.js +27 -0
  44. package/dist/proxy/sw/sw.js +212 -0
  45. package/dist/proxy/types.d.ts +15 -0
  46. package/dist/proxy/types.js +2 -0
  47. package/dist/proxy/web.d.ts +24 -0
  48. package/dist/proxy/web.js +231 -0
  49. package/dist/ranges.d.ts +30 -0
  50. package/dist/ranges.js +63 -0
  51. package/dist/stream.d.ts +9 -0
  52. package/dist/stream.js +100 -0
  53. package/dist/subtitles/index.d.ts +9 -0
  54. package/dist/subtitles/index.js +2 -0
  55. package/dist/subtitles/load.d.ts +18 -0
  56. package/dist/subtitles/load.js +58 -0
  57. package/dist/subtitles/parse.d.ts +19 -0
  58. package/dist/subtitles/parse.js +95 -0
  59. package/dist/swDefine.d.ts +5 -0
  60. package/dist/swDefine.js +5 -0
  61. package/package.json +112 -0
  62. package/src/Player/playerAndroid/MegaVideoPlayerAndroid.tsx +825 -0
  63. package/src/Player/playerAndroid/PlayerControlsAndroid.tsx +824 -0
  64. package/src/Player/playerAndroid/SubtitleDisplayAndroid.tsx +80 -0
  65. package/src/Player/playerAndroid/index.ts +7 -0
  66. package/src/Player/playerweb/MegaVideoPlayer.tsx +551 -0
  67. package/src/Player/playerweb/PlayerControls.tsx +798 -0
  68. package/src/Player/playerweb/SubtitleDisplay.tsx +48 -0
  69. package/src/Player/playerweb/index.ts +3 -0
  70. package/src/accounts/async-storage.ts +55 -0
  71. package/src/accounts/index.ts +9 -0
  72. package/src/accounts/local-storage.ts +66 -0
  73. package/src/accounts/manager.ts +174 -0
  74. package/src/accounts/types.ts +38 -0
  75. package/src/index.ts +10 -0
  76. package/src/megaFile.ts +48 -0
  77. package/src/proxy/native.ts +396 -0
  78. package/src/proxy/sw/bundle.ts +37 -0
  79. package/src/proxy/sw/megajs.browser.js +27 -0
  80. package/src/proxy/sw/sw.js +212 -0
  81. package/src/proxy/types.ts +17 -0
  82. package/src/proxy/web.ts +266 -0
  83. package/src/ranges.ts +72 -0
  84. package/src/stream.ts +117 -0
  85. package/src/subtitles/index.ts +15 -0
  86. package/src/subtitles/load.ts +83 -0
  87. package/src/subtitles/parse.ts +105 -0
  88. package/src/swDefine.ts +5 -0
@@ -0,0 +1,824 @@
1
+ import { useMemo, useState } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ PanResponder,
5
+ Pressable,
6
+ StyleSheet,
7
+ Text,
8
+ TouchableOpacity,
9
+ View,
10
+ } from "react-native";
11
+ import { Ionicons } from "@expo/vector-icons";
12
+
13
+ export type PlayerControlsAndroidProps = {
14
+ playing: boolean;
15
+ buffering: boolean;
16
+ currentTime: number;
17
+ duration: number;
18
+ muted: boolean;
19
+ title?: string;
20
+ visible: boolean;
21
+ subtitlesOn: boolean;
22
+ volume: number;
23
+ /** subtitle language labels (when >1 the CC switcher is shown) */
24
+ subtitleTracks?: string[];
25
+ /** currently active subtitle language */
26
+ subtitleTrack?: string;
27
+ /** select a subtitle language from the list directly */
28
+ onSelectSubtitleTrack?: (lang: string) => void;
29
+ /** current subtitle font size (px) */
30
+ subtitleFontSize?: number;
31
+ /** adjust the subtitle font size (px) */
32
+ onChangeSubtitleFontSize?: (px: number) => void;
33
+ /** 0..1 — how much of the whole merged episode has been downloaded. Renders
34
+ * a second, thinner line under the playhead track. */
35
+ loadProgress?: number;
36
+ /** resolution labels in display order — the FIRST is the default source
37
+ * (usually "HD: <url>"), e.g. ["Default", "1080p", "720p"]. Shown in the
38
+ * settings popup. */
39
+ resolutions?: string[];
40
+ /** currently active resolution label */
41
+ resolution?: string;
42
+ /** user picked a resolution label — resolve it to a URL and update `uri`. */
43
+ onSelectResolution?: (label: string) => void;
44
+ /** [start, end] seconds (merged timeline) of the intro — highlighted on the
45
+ * track + shows a "Skip Intro" button while the playhead is inside it. */
46
+ introRegion?: [number, number];
47
+ /** [start, end] seconds (merged timeline) of the outro */
48
+ outroRegion?: [number, number];
49
+ onSkipIntro?: () => void;
50
+ onSkipOutro?: () => void;
51
+ onToggleVisible: () => void;
52
+ onPlayPause: () => void;
53
+ onSkip: (deltaSeconds: number) => void;
54
+ onSeek: (seconds: number) => void;
55
+ onToggleMute: () => void;
56
+ onToggleSubtitles: () => void;
57
+ onVolume: (v: number) => void;
58
+ onFullscreen: () => void;
59
+ /** current fullscreen state — switches the expand/contract icon */
60
+ fullscreen?: boolean;
61
+ /** any user interaction with the bar/menus — reset the auto-hide timer */
62
+ onInteract?: () => void;
63
+ width?: number;
64
+ height?: number;
65
+ };
66
+
67
+ const fmt = (s: number) => {
68
+ if (!Number.isFinite(s) || s < 0) s = 0;
69
+ const h = Math.floor(s / 3600);
70
+ const m = Math.floor((s % 3600) / 60);
71
+ const sec = Math.floor(s % 60);
72
+ return `${h.toString().padStart(2, "0")}:${m
73
+ .toString()
74
+ .padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
75
+ };
76
+
77
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
78
+
79
+ const SIZE_MIN = 10;
80
+ const SIZE_MAX = 34;
81
+
82
+ export function PlayerControlsAndroid(props: PlayerControlsAndroidProps) {
83
+ const {
84
+ playing,
85
+ buffering,
86
+ currentTime,
87
+ duration,
88
+ muted,
89
+ title,
90
+ visible,
91
+ subtitlesOn,
92
+ volume,
93
+ subtitleTracks,
94
+ subtitleTrack,
95
+ onSelectSubtitleTrack,
96
+ subtitleFontSize,
97
+ onChangeSubtitleFontSize,
98
+ loadProgress,
99
+ resolutions,
100
+ resolution,
101
+ onSelectResolution,
102
+ introRegion,
103
+ outroRegion,
104
+ onSkipIntro,
105
+ onSkipOutro,
106
+ onToggleVisible,
107
+ onPlayPause,
108
+ onSkip,
109
+ onSeek,
110
+ onToggleMute,
111
+ onToggleSubtitles,
112
+ onVolume,
113
+ onFullscreen,
114
+ fullscreen = false,
115
+ onInteract,
116
+ width,
117
+ height,
118
+ } = props;
119
+
120
+ const hasSubs = (subtitleTracks?.length ?? 0) > 0;
121
+ const showSubsSection = hasSubs || !!onChangeSubtitleFontSize;
122
+ // empty resolution = the implicit default (first row, usually "HD: <url>")
123
+ const activeRes = (resolution && resolution.trim()) || resolutions?.[0] || "";
124
+
125
+ const [trackW, setTrackW] = useState(0);
126
+ const [scrubPct, setScrubPct] = useState<number | null>(null);
127
+ const [volW, setVolW] = useState(0);
128
+ const [sizeW, setSizeW] = useState(0);
129
+ const [settingsOpen, setSettingsOpen] = useState(false);
130
+
131
+ const pct = duration > 0 ? clamp01(currentTime / duration) : 0;
132
+ const volPct = clamp01(volume);
133
+ const inIntro =
134
+ !!introRegion &&
135
+ introRegion[1] > introRegion[0] &&
136
+ currentTime >= introRegion[0] &&
137
+ currentTime <= introRegion[1] &&
138
+ !!onSkipIntro;
139
+ const inOutro =
140
+ !!outroRegion &&
141
+ outroRegion[1] > outroRegion[0] &&
142
+ currentTime >= outroRegion[0] &&
143
+ currentTime <= outroRegion[1] &&
144
+ !!onSkipOutro;
145
+ const currentSize = subtitleFontSize ?? 14;
146
+ const sizePct = clamp01((currentSize - SIZE_MIN) / (SIZE_MAX - SIZE_MIN));
147
+ const shownPct = scrubPct ?? pct;
148
+ const volIcon = (
149
+ muted || volume === 0
150
+ ? "volume-mute"
151
+ : volume < 0.5
152
+ ? "volume-low"
153
+ : "volume-high"
154
+ ) as "volume-mute" | "volume-low" | "volume-high";
155
+
156
+ const pan = useMemo(
157
+ () =>
158
+ PanResponder.create({
159
+ onStartShouldSetPanResponder: () => true,
160
+ onMoveShouldSetPanResponder: () => true,
161
+ onPanResponderGrant: (e) => {
162
+ onInteract?.();
163
+ setScrubPct(
164
+ trackW > 0 ? clamp01(e.nativeEvent.locationX / trackW) : 0,
165
+ );
166
+ },
167
+ onPanResponderMove: (e) =>
168
+ setScrubPct(
169
+ trackW > 0 ? clamp01(e.nativeEvent.locationX / trackW) : 0,
170
+ ),
171
+ onPanResponderRelease: (e) => {
172
+ const p = trackW > 0 ? clamp01(e.nativeEvent.locationX / trackW) : 0;
173
+ setScrubPct(null);
174
+ onInteract?.();
175
+ onSeek(p * duration);
176
+ },
177
+ }),
178
+ [trackW, duration, onSeek, onInteract],
179
+ );
180
+
181
+ const volPan = useMemo(
182
+ () =>
183
+ PanResponder.create({
184
+ onStartShouldSetPanResponder: () => true,
185
+ onMoveShouldSetPanResponder: () => true,
186
+ onPanResponderGrant: (e) => {
187
+ onInteract?.();
188
+ onVolume(volW > 0 ? clamp01(e.nativeEvent.locationX / volW) : 1);
189
+ },
190
+ onPanResponderMove: (e) =>
191
+ onVolume(volW > 0 ? clamp01(e.nativeEvent.locationX / volW) : 1),
192
+ }),
193
+ [volW, onVolume, onInteract],
194
+ );
195
+
196
+ const sizePan = useMemo(
197
+ () =>
198
+ PanResponder.create({
199
+ onStartShouldSetPanResponder: () => true,
200
+ onMoveShouldSetPanResponder: () => true,
201
+ onPanResponderGrant: (e) => {
202
+ if (!onChangeSubtitleFontSize) return;
203
+ const p = sizeW > 0 ? clamp01(e.nativeEvent.locationX / sizeW) : 0;
204
+ onChangeSubtitleFontSize(
205
+ Math.round(SIZE_MIN + p * (SIZE_MAX - SIZE_MIN)),
206
+ );
207
+ },
208
+ onPanResponderMove: (e) => {
209
+ if (!onChangeSubtitleFontSize) return;
210
+ const p = sizeW > 0 ? clamp01(e.nativeEvent.locationX / sizeW) : 0;
211
+ onChangeSubtitleFontSize(
212
+ Math.round(SIZE_MIN + p * (SIZE_MAX - SIZE_MIN)),
213
+ );
214
+ },
215
+ }),
216
+ [sizeW, onChangeSubtitleFontSize],
217
+ );
218
+
219
+ const interact = () => onInteract?.();
220
+
221
+ return (
222
+ <View
223
+ style={[
224
+ styles.root,
225
+ StyleSheet.absoluteFill,
226
+ { width: width ? width : "100%", height: height ? height : "100%" },
227
+ ]}
228
+ >
229
+ {/* tap surface toggles the chrome */}
230
+ <TouchableOpacity
231
+ style={StyleSheet.absoluteFill}
232
+ activeOpacity={1}
233
+ onPress={onToggleVisible}
234
+ />
235
+
236
+ {buffering && (
237
+ <View style={styles.centerBadge} pointerEvents="none">
238
+ <ActivityIndicator color="#e50914" size="large" />
239
+ </View>
240
+ )}
241
+
242
+ {settingsOpen && (
243
+ <Pressable
244
+ style={StyleSheet.absoluteFill}
245
+ onPress={() => setSettingsOpen(false)}
246
+ />
247
+ )}
248
+
249
+ {visible && (
250
+ <>
251
+ {/* top gradient-ish bar */}
252
+ <View style={[styles.bar, styles.top]} pointerEvents="box-none">
253
+ {!!title && (
254
+ <Text numberOfLines={1} style={styles.title}>
255
+ {title}
256
+ </Text>
257
+ )}
258
+ </View>
259
+
260
+ {/* center buttons */}
261
+ <View
262
+ style={[
263
+ styles.centerRow,
264
+ StyleSheet.absoluteFill,
265
+ { width: width },
266
+ ]}
267
+ pointerEvents="box-none"
268
+ >
269
+ <TouchableOpacity
270
+ style={styles.skipBtn}
271
+ onPress={() => onSkip(-10)}
272
+ >
273
+ <Ionicons name="play-back" size={30} color="#fff" />
274
+ </TouchableOpacity>
275
+ <TouchableOpacity style={styles.ppBtn} onPress={onPlayPause}>
276
+ <Ionicons
277
+ name={
278
+ buffering ? "ellipsis-horizontal" : playing ? "pause" : "play"
279
+ }
280
+ size={38}
281
+ color="#fff"
282
+ />
283
+ </TouchableOpacity>
284
+ <TouchableOpacity style={styles.skipBtn} onPress={() => onSkip(10)}>
285
+ <Ionicons name="play-forward" size={30} color="#fff" />
286
+ </TouchableOpacity>
287
+ </View>
288
+
289
+ {/* bottom bar */}
290
+ <View style={[styles.bar, styles.bottom]} pointerEvents="box-none">
291
+ <Text style={styles.time}>
292
+ {fmt(scrubPct !== null ? scrubPct * duration : currentTime)}
293
+ </Text>
294
+
295
+ <View
296
+ style={styles.trackWrap}
297
+ onLayout={(e) => setTrackW(e.nativeEvent.layout.width)}
298
+ {...pan.panHandlers}
299
+ >
300
+ <View style={styles.track}>
301
+ {loadProgress != null && loadProgress > 0 && (
302
+ <View
303
+ style={[
304
+ styles.loadLine,
305
+ { width: `${clamp01(loadProgress) * 100}%` },
306
+ ]}
307
+ />
308
+ )}
309
+ {introRegion &&
310
+ duration > 0 &&
311
+ introRegion[1] > introRegion[0] && (
312
+ <View
313
+ style={[
314
+ styles.introHighlight,
315
+ {
316
+ left: `${clamp01(introRegion[0] / duration) * 100}%`,
317
+ width: `${clamp01((introRegion[1] - introRegion[0]) / duration) * 100}%`,
318
+ },
319
+ ]}
320
+ />
321
+ )}
322
+ {outroRegion &&
323
+ duration > 0 &&
324
+ outroRegion[1] > outroRegion[0] && (
325
+ <View
326
+ style={[
327
+ styles.outroHighlight,
328
+ {
329
+ left: `${clamp01(outroRegion[0] / duration) * 100}%`,
330
+ width: `${clamp01((outroRegion[1] - outroRegion[0]) / duration) * 100}%`,
331
+ },
332
+ ]}
333
+ />
334
+ )}
335
+ <View style={[styles.fill, { width: `${shownPct * 100}%` }]} />
336
+ </View>
337
+ <View
338
+ style={[
339
+ styles.thumb,
340
+ { left: `${shownPct * 100}%` },
341
+ scrubPct === null && styles.thumbIdle,
342
+ ]}
343
+ />
344
+ </View>
345
+
346
+ <Text style={styles.time}>{fmt(duration)}</Text>
347
+
348
+ {/* settings popup: subtitles + volume + resolution */}
349
+ <View style={styles.settingsWrap}>
350
+ <TouchableOpacity
351
+ style={[styles.iconBtn, settingsOpen && styles.iconBtnOn]}
352
+ onPress={() => {
353
+ setSettingsOpen((v) => !v);
354
+ interact();
355
+ }}
356
+ hitSlop={6}
357
+ >
358
+ <Ionicons
359
+ name="settings-outline"
360
+ size={17}
361
+ color={settingsOpen ? "#7dd3fc" : "#fff"}
362
+ />
363
+ </TouchableOpacity>
364
+
365
+ {settingsOpen && (
366
+ <View style={styles.settingsMenu}>
367
+ <Text style={styles.settingsTitle}>Settings</Text>
368
+
369
+ {/* ---- Subtitles ---- */}
370
+ {showSubsSection && (
371
+ <View style={styles.section}>
372
+ <View style={styles.sectionHead}>
373
+ <Ionicons
374
+ name="chatbox-ellipses-outline"
375
+ size={15}
376
+ color="#9aa0aa"
377
+ />
378
+ <Text style={styles.sectionLabel}>Subtitles</Text>
379
+ <TouchableOpacity
380
+ onPress={() => {
381
+ onToggleSubtitles();
382
+ interact();
383
+ }}
384
+ hitSlop={6}
385
+ style={{ marginLeft: "auto" }}
386
+ >
387
+ <View
388
+ style={[
389
+ styles.switchTrack,
390
+ subtitlesOn && styles.switchTrackOn,
391
+ ]}
392
+ >
393
+ <View
394
+ style={[
395
+ styles.switchThumb,
396
+ subtitlesOn && styles.switchThumbOn,
397
+ ]}
398
+ />
399
+ </View>
400
+ </TouchableOpacity>
401
+ </View>
402
+
403
+ {hasSubs && (
404
+ <View style={styles.menuList}>
405
+ {subtitleTracks?.map((lang) => {
406
+ const active = lang === subtitleTrack;
407
+ return (
408
+ <TouchableOpacity
409
+ key={lang}
410
+ style={[
411
+ styles.menuItem,
412
+ active && styles.menuItemOn,
413
+ ]}
414
+ onPress={() => {
415
+ setSettingsOpen(false);
416
+ interact();
417
+ onSelectSubtitleTrack?.(lang);
418
+ }}
419
+ >
420
+ <Text
421
+ numberOfLines={1}
422
+ style={[
423
+ styles.menuItemText,
424
+ active && styles.menuItemTextOn,
425
+ ]}
426
+ >
427
+ {lang}
428
+ </Text>
429
+ {active ? (
430
+ <Ionicons
431
+ name="checkmark"
432
+ size={14}
433
+ color="#fff"
434
+ />
435
+ ) : null}
436
+ </TouchableOpacity>
437
+ );
438
+ })}
439
+ </View>
440
+ )}
441
+
442
+ {!!onChangeSubtitleFontSize && (
443
+ <View style={styles.sliderRow}>
444
+ <TouchableOpacity
445
+ style={styles.minusBtn}
446
+ onPress={() =>
447
+ onChangeSubtitleFontSize(
448
+ Math.max(SIZE_MIN, Math.round(currentSize - 2)),
449
+ )
450
+ }
451
+ >
452
+ <Ionicons name="remove" size={16} color="#fff" />
453
+ </TouchableOpacity>
454
+
455
+ <View
456
+ style={styles.sliderTrack}
457
+ onLayout={(e) =>
458
+ setSizeW(e.nativeEvent.layout.width)
459
+ }
460
+ {...sizePan.panHandlers}
461
+ >
462
+ <View
463
+ style={[
464
+ styles.sliderFill,
465
+ { width: `${sizePct * 100}%` },
466
+ ]}
467
+ />
468
+ </View>
469
+
470
+ <TouchableOpacity
471
+ style={styles.minusBtn}
472
+ onPress={() =>
473
+ onChangeSubtitleFontSize(
474
+ Math.min(SIZE_MAX, Math.round(currentSize + 2)),
475
+ )
476
+ }
477
+ >
478
+ <Ionicons name="add" size={16} color="#fff" />
479
+ </TouchableOpacity>
480
+ <Text style={styles.sliderValue}>
481
+ {currentSize}px
482
+ </Text>
483
+ </View>
484
+ )}
485
+ </View>
486
+ )}
487
+
488
+ {/* ---- Volume ---- */}
489
+ <View style={styles.section}>
490
+ <View style={styles.sectionHead}>
491
+ <Ionicons
492
+ name="volume-medium-outline"
493
+ size={15}
494
+ color="#9aa0aa"
495
+ />
496
+ <Text style={styles.sectionLabel}>Volume</Text>
497
+ <TouchableOpacity
498
+ onPress={() => {
499
+ onToggleMute();
500
+ interact();
501
+ }}
502
+ hitSlop={6}
503
+ style={{ marginLeft: "auto" }}
504
+ >
505
+ <Ionicons
506
+ name={volIcon}
507
+ size={17}
508
+ color={muted ? "#e50914" : "#fff"}
509
+ />
510
+ </TouchableOpacity>
511
+ <Text style={styles.sliderValue}>
512
+ {Math.round(volPct * 100)}
513
+ </Text>
514
+ </View>
515
+ <View style={styles.sliderRow}>
516
+ <View
517
+ style={styles.sliderTrack}
518
+ onLayout={(e) => setVolW(e.nativeEvent.layout.width)}
519
+ {...volPan.panHandlers}
520
+ >
521
+ <View
522
+ style={[
523
+ styles.sliderFill,
524
+ { width: `${volPct * 100}%` },
525
+ ]}
526
+ />
527
+ </View>
528
+ </View>
529
+ </View>
530
+
531
+ {/* ---- Resolution ---- */}
532
+ {(resolutions?.length ?? 0) > 0 && (
533
+ <View style={styles.section}>
534
+ <View style={styles.sectionHead}>
535
+ <Ionicons
536
+ name="resize-outline"
537
+ size={15}
538
+ color="#9aa0aa"
539
+ />
540
+ <Text style={styles.sectionLabel}>Resolution</Text>
541
+ </View>
542
+ <View style={styles.menuList}>
543
+ {resolutions?.map((label) => {
544
+ const active = label === activeRes;
545
+ return (
546
+ <TouchableOpacity
547
+ key={label}
548
+ style={[
549
+ styles.menuItem,
550
+ active && styles.menuItemOn,
551
+ ]}
552
+ onPress={() => {
553
+ setSettingsOpen(false);
554
+ interact();
555
+ onSelectResolution?.(label);
556
+ }}
557
+ >
558
+ <Text
559
+ numberOfLines={1}
560
+ style={[
561
+ styles.menuItemText,
562
+ active && styles.menuItemTextOn,
563
+ ]}
564
+ >
565
+ {label}
566
+ </Text>
567
+ {active ? (
568
+ <Ionicons
569
+ name="checkmark"
570
+ size={14}
571
+ color="#fff"
572
+ />
573
+ ) : null}
574
+ </TouchableOpacity>
575
+ );
576
+ })}
577
+ </View>
578
+ </View>
579
+ )}
580
+ </View>
581
+ )}
582
+ </View>
583
+
584
+ {/* fullscreen toggle */}
585
+ <TouchableOpacity
586
+ onPress={() => {
587
+ onFullscreen();
588
+ interact();
589
+ }}
590
+ hitSlop={8}
591
+ >
592
+ <Ionicons
593
+ name={fullscreen ? "contract" : "expand"}
594
+ size={18}
595
+ color="#fff"
596
+ />
597
+ </TouchableOpacity>
598
+ </View>
599
+ </>
600
+ )}
601
+
602
+ {visible && inIntro && (
603
+ <TouchableOpacity style={styles.skipRegionBtn} onPress={onSkipIntro}>
604
+ <Text style={styles.skipRegionText}>Skip Intro ›</Text>
605
+ </TouchableOpacity>
606
+ )}
607
+ {visible && inOutro && (
608
+ <TouchableOpacity style={styles.skipRegionBtn} onPress={onSkipOutro}>
609
+ <Text style={styles.skipRegionText}>Skip Outro ›</Text>
610
+ </TouchableOpacity>
611
+ )}
612
+ </View>
613
+ );
614
+ }
615
+
616
+ const styles = StyleSheet.create({
617
+ root: { zIndex: 2 },
618
+
619
+ centerBadge: {
620
+ justifyContent: "center",
621
+ alignItems: "center",
622
+ width: "100%",
623
+ height: "100%",
624
+ },
625
+
626
+ bar: {
627
+ position: "absolute",
628
+ left: 0,
629
+ right: 0,
630
+ flexDirection: "row",
631
+ alignItems: "center",
632
+ paddingHorizontal: 12,
633
+ backgroundColor: "rgba(0,0,0,0.45)",
634
+ gap: 8,
635
+ zIndex: 5,
636
+ },
637
+ top: { top: 0, height: 42 },
638
+ bottom: { bottom: 0, height: 44 },
639
+
640
+ title: { color: "#fff", fontWeight: "700", fontSize: 13, flex: 1 },
641
+
642
+ centerRow: {
643
+ justifyContent: "center",
644
+ alignItems: "center",
645
+ flexDirection: "row",
646
+ gap: 34,
647
+ },
648
+ ppBtn: {
649
+ width: 64,
650
+ height: 64,
651
+ borderRadius: 32,
652
+ backgroundColor: "rgba(229,9,20,0.92)",
653
+ justifyContent: "center",
654
+ alignItems: "center",
655
+ },
656
+ skipBtn: { opacity: 0.9 },
657
+
658
+ time: {
659
+ color: "#ddd",
660
+ fontSize: 11,
661
+ fontVariant: ["tabular-nums"],
662
+ minWidth: 58,
663
+ },
664
+ trackWrap: { flex: 1, paddingVertical: 12, justifyContent: "center" },
665
+ track: {
666
+ height: 4,
667
+ borderRadius: 2,
668
+ backgroundColor: "#3a3f47",
669
+ overflow: "hidden",
670
+ },
671
+ fill: { height: 4, borderRadius: 2, backgroundColor: "#e50914" },
672
+ loadLine: {
673
+ position: "absolute",
674
+ left: 0,
675
+ top: 0,
676
+ height: 4,
677
+ borderRadius: 2,
678
+ backgroundColor: "rgba(255,255,255,0.35)",
679
+ },
680
+ introHighlight: {
681
+ position: "absolute",
682
+ top: 0,
683
+ height: 4,
684
+ backgroundColor: "rgba(0,229,255,0.55)",
685
+ },
686
+ outroHighlight: {
687
+ position: "absolute",
688
+ top: 0,
689
+ height: 4,
690
+ backgroundColor: "rgba(255,0,200,0.55)",
691
+ },
692
+ skipRegionBtn: {
693
+ position: "absolute",
694
+ right: 16,
695
+ bottom: 60,
696
+ flexDirection: "row",
697
+ alignItems: "center",
698
+ backgroundColor: "rgba(0,0,0,0.6)",
699
+ borderWidth: 1,
700
+ borderColor: "rgba(255,255,255,0.25)",
701
+ borderRadius: 20,
702
+ paddingHorizontal: 14,
703
+ paddingVertical: 8,
704
+ },
705
+ skipRegionText: { color: "#fff", fontWeight: "700", fontSize: 13 },
706
+ thumb: {
707
+ position: "absolute",
708
+ top: 12 - 7 + 2,
709
+ marginLeft: -6,
710
+ width: 12,
711
+ height: 12,
712
+ borderRadius: 6,
713
+ backgroundColor: "#e50914",
714
+ },
715
+ thumbIdle: { opacity: 0.85 },
716
+
717
+ iconBtn: {
718
+ width: 26,
719
+ height: 26,
720
+ borderRadius: 4,
721
+ alignItems: "center",
722
+ justifyContent: "center",
723
+ },
724
+ iconBtnOn: { backgroundColor: "rgba(125,211,252,0.18)" },
725
+
726
+ settingsWrap: { position: "relative", zIndex: 20 },
727
+ settingsMenu: {
728
+ position: "absolute",
729
+ bottom: 30,
730
+ right: 0,
731
+ zIndex: 30,
732
+ width: 250,
733
+ maxHeight: 420,
734
+ backgroundColor: "#14161c",
735
+ borderWidth: 1,
736
+ borderColor: "#2a2f3a",
737
+ borderRadius: 8,
738
+ overflow: "hidden",
739
+ paddingBottom: 6,
740
+ elevation: 12,
741
+ shadowColor: "#000",
742
+ shadowOpacity: 0.5,
743
+ shadowRadius: 10,
744
+ shadowOffset: { width: 0, height: 3 },
745
+ },
746
+ settingsTitle: {
747
+ color: "#9aa0aa",
748
+ fontSize: 11,
749
+ fontWeight: "800",
750
+ textTransform: "uppercase",
751
+ letterSpacing: 1,
752
+ paddingHorizontal: 12,
753
+ paddingTop: 10,
754
+ paddingBottom: 6,
755
+ borderBottomWidth: 1,
756
+ borderBottomColor: "#262a34",
757
+ },
758
+
759
+ section: { paddingHorizontal: 6, paddingTop: 8 },
760
+ sectionHead: {
761
+ flexDirection: "row",
762
+ alignItems: "center",
763
+ gap: 6,
764
+ paddingHorizontal: 6,
765
+ paddingBottom: 2,
766
+ },
767
+ sectionLabel: { color: "#cbd5e1", fontSize: 12, fontWeight: "700" },
768
+
769
+ menuList: { paddingVertical: 4 },
770
+ menuItem: {
771
+ flexDirection: "row",
772
+ alignItems: "center",
773
+ justifyContent: "space-between",
774
+ gap: 10,
775
+ paddingHorizontal: 14,
776
+ paddingVertical: 6,
777
+ borderRadius: 6,
778
+ },
779
+ menuItemOn: { backgroundColor: "rgba(125,211,252,0.15)" },
780
+ menuItemText: { color: "#cbd5e1", fontSize: 13, fontWeight: "600" },
781
+ menuItemTextOn: { color: "#7dd3fc", fontWeight: "800" },
782
+
783
+ sliderRow: {
784
+ flexDirection: "row",
785
+ alignItems: "center",
786
+ gap: 10,
787
+ paddingHorizontal: 10,
788
+ paddingVertical: 8,
789
+ },
790
+ minusBtn: {
791
+ width: 24,
792
+ height: 24,
793
+ borderRadius: 4,
794
+ alignItems: "center",
795
+ justifyContent: "center",
796
+ backgroundColor: "#2a2f3a",
797
+ },
798
+ sliderTrack: { flex: 1, height: 22, justifyContent: "center" },
799
+ sliderFill: { height: 4, borderRadius: 2, backgroundColor: "#7dd3fc" },
800
+ sliderValue: {
801
+ color: "#9aa0aa",
802
+ fontSize: 11,
803
+ fontWeight: "700",
804
+ minWidth: 32,
805
+ textAlign: "right",
806
+ },
807
+
808
+ switchTrack: {
809
+ width: 34,
810
+ height: 20,
811
+ borderRadius: 10,
812
+ backgroundColor: "#3a3f47",
813
+ justifyContent: "center",
814
+ paddingHorizontal: 2,
815
+ },
816
+ switchTrackOn: { backgroundColor: "rgba(229,9,20,0.85)" },
817
+ switchThumb: {
818
+ width: 16,
819
+ height: 16,
820
+ borderRadius: 8,
821
+ backgroundColor: "#fff",
822
+ },
823
+ switchThumbOn: { alignSelf: "flex-end" },
824
+ });