@venomousone/rn-videokit 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 (145) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +248 -0
  3. package/lib/module/components/VideoKit.js +347 -0
  4. package/lib/module/components/VideoKit.js.map +1 -0
  5. package/lib/module/components/controls/FullscreenButton.js +38 -0
  6. package/lib/module/components/controls/FullscreenButton.js.map +1 -0
  7. package/lib/module/components/controls/Icon.js +71 -0
  8. package/lib/module/components/controls/Icon.js.map +1 -0
  9. package/lib/module/components/controls/PlayPauseButton.js +33 -0
  10. package/lib/module/components/controls/PlayPauseButton.js.map +1 -0
  11. package/lib/module/components/controls/Scrubber.js +146 -0
  12. package/lib/module/components/controls/Scrubber.js.map +1 -0
  13. package/lib/module/components/controls/SpeedButton.js +96 -0
  14. package/lib/module/components/controls/SpeedButton.js.map +1 -0
  15. package/lib/module/components/controls/TimeDisplay.js +28 -0
  16. package/lib/module/components/controls/TimeDisplay.js.map +1 -0
  17. package/lib/module/components/controls/VolumeButton.js +31 -0
  18. package/lib/module/components/controls/VolumeButton.js.map +1 -0
  19. package/lib/module/components/core/VideoPlayer.js +114 -0
  20. package/lib/module/components/core/VideoPlayer.js.map +1 -0
  21. package/lib/module/components/core/VideoPlayerContext.js +119 -0
  22. package/lib/module/components/core/VideoPlayerContext.js.map +1 -0
  23. package/lib/module/components/core/index.js +5 -0
  24. package/lib/module/components/core/index.js.map +1 -0
  25. package/lib/module/components/index.js +14 -0
  26. package/lib/module/components/index.js.map +1 -0
  27. package/lib/module/components/overlays/BufferingOverlay.js +24 -0
  28. package/lib/module/components/overlays/BufferingOverlay.js.map +1 -0
  29. package/lib/module/components/overlays/DoubleTapSeek.js +95 -0
  30. package/lib/module/components/overlays/DoubleTapSeek.js.map +1 -0
  31. package/lib/module/components/overlays/ErrorOverlay.js +60 -0
  32. package/lib/module/components/overlays/ErrorOverlay.js.map +1 -0
  33. package/lib/module/components/overlays/GestureIndicator.js +118 -0
  34. package/lib/module/components/overlays/GestureIndicator.js.map +1 -0
  35. package/lib/module/components/overlays/LoadingPoster.js +22 -0
  36. package/lib/module/components/overlays/LoadingPoster.js.map +1 -0
  37. package/lib/module/hooks/index.js +6 -0
  38. package/lib/module/hooks/index.js.map +1 -0
  39. package/lib/module/hooks/useVideoBrightness.js +33 -0
  40. package/lib/module/hooks/useVideoBrightness.js.map +1 -0
  41. package/lib/module/hooks/useVideoControls.js +64 -0
  42. package/lib/module/hooks/useVideoControls.js.map +1 -0
  43. package/lib/module/hooks/useVideoOrientation.js +24 -0
  44. package/lib/module/hooks/useVideoOrientation.js.map +1 -0
  45. package/lib/module/hooks/useVideoPlayer.js +59 -0
  46. package/lib/module/hooks/useVideoPlayer.js.map +1 -0
  47. package/lib/module/hooks/useVideoVolume.js +42 -0
  48. package/lib/module/hooks/useVideoVolume.js.map +1 -0
  49. package/lib/module/index.js +7 -0
  50. package/lib/module/index.js.map +1 -0
  51. package/lib/module/package.json +1 -0
  52. package/lib/module/types/index.js +4 -0
  53. package/lib/module/types/index.js.map +1 -0
  54. package/lib/module/utils/clamp.js +8 -0
  55. package/lib/module/utils/clamp.js.map +1 -0
  56. package/lib/module/utils/formatTime.js +12 -0
  57. package/lib/module/utils/formatTime.js.map +1 -0
  58. package/lib/module/utils/index.js +5 -0
  59. package/lib/module/utils/index.js.map +1 -0
  60. package/lib/typescript/package.json +1 -0
  61. package/lib/typescript/src/components/VideoKit.d.ts +3 -0
  62. package/lib/typescript/src/components/VideoKit.d.ts.map +1 -0
  63. package/lib/typescript/src/components/controls/FullscreenButton.d.ts +6 -0
  64. package/lib/typescript/src/components/controls/FullscreenButton.d.ts.map +1 -0
  65. package/lib/typescript/src/components/controls/Icon.d.ts +10 -0
  66. package/lib/typescript/src/components/controls/Icon.d.ts.map +1 -0
  67. package/lib/typescript/src/components/controls/PlayPauseButton.d.ts +2 -0
  68. package/lib/typescript/src/components/controls/PlayPauseButton.d.ts.map +1 -0
  69. package/lib/typescript/src/components/controls/Scrubber.d.ts +7 -0
  70. package/lib/typescript/src/components/controls/Scrubber.d.ts.map +1 -0
  71. package/lib/typescript/src/components/controls/SpeedButton.d.ts +2 -0
  72. package/lib/typescript/src/components/controls/SpeedButton.d.ts.map +1 -0
  73. package/lib/typescript/src/components/controls/TimeDisplay.d.ts +2 -0
  74. package/lib/typescript/src/components/controls/TimeDisplay.d.ts.map +1 -0
  75. package/lib/typescript/src/components/controls/VolumeButton.d.ts +2 -0
  76. package/lib/typescript/src/components/controls/VolumeButton.d.ts.map +1 -0
  77. package/lib/typescript/src/components/core/VideoPlayer.d.ts +14 -0
  78. package/lib/typescript/src/components/core/VideoPlayer.d.ts.map +1 -0
  79. package/lib/typescript/src/components/core/VideoPlayerContext.d.ts +48 -0
  80. package/lib/typescript/src/components/core/VideoPlayerContext.d.ts.map +1 -0
  81. package/lib/typescript/src/components/core/index.d.ts +3 -0
  82. package/lib/typescript/src/components/core/index.d.ts.map +1 -0
  83. package/lib/typescript/src/components/index.d.ts +6 -0
  84. package/lib/typescript/src/components/index.d.ts.map +1 -0
  85. package/lib/typescript/src/components/overlays/BufferingOverlay.d.ts +2 -0
  86. package/lib/typescript/src/components/overlays/BufferingOverlay.d.ts.map +1 -0
  87. package/lib/typescript/src/components/overlays/DoubleTapSeek.d.ts +5 -0
  88. package/lib/typescript/src/components/overlays/DoubleTapSeek.d.ts.map +1 -0
  89. package/lib/typescript/src/components/overlays/ErrorOverlay.d.ts +6 -0
  90. package/lib/typescript/src/components/overlays/ErrorOverlay.d.ts.map +1 -0
  91. package/lib/typescript/src/components/overlays/GestureIndicator.d.ts +9 -0
  92. package/lib/typescript/src/components/overlays/GestureIndicator.d.ts.map +1 -0
  93. package/lib/typescript/src/components/overlays/LoadingPoster.d.ts +6 -0
  94. package/lib/typescript/src/components/overlays/LoadingPoster.d.ts.map +1 -0
  95. package/lib/typescript/src/hooks/index.d.ts +4 -0
  96. package/lib/typescript/src/hooks/index.d.ts.map +1 -0
  97. package/lib/typescript/src/hooks/useVideoBrightness.d.ts +5 -0
  98. package/lib/typescript/src/hooks/useVideoBrightness.d.ts.map +1 -0
  99. package/lib/typescript/src/hooks/useVideoControls.d.ts +11 -0
  100. package/lib/typescript/src/hooks/useVideoControls.d.ts.map +1 -0
  101. package/lib/typescript/src/hooks/useVideoOrientation.d.ts +6 -0
  102. package/lib/typescript/src/hooks/useVideoOrientation.d.ts.map +1 -0
  103. package/lib/typescript/src/hooks/useVideoPlayer.d.ts +7 -0
  104. package/lib/typescript/src/hooks/useVideoPlayer.d.ts.map +1 -0
  105. package/lib/typescript/src/hooks/useVideoVolume.d.ts +6 -0
  106. package/lib/typescript/src/hooks/useVideoVolume.d.ts.map +1 -0
  107. package/lib/typescript/src/index.d.ts +6 -0
  108. package/lib/typescript/src/index.d.ts.map +1 -0
  109. package/lib/typescript/src/types/index.d.ts +96 -0
  110. package/lib/typescript/src/types/index.d.ts.map +1 -0
  111. package/lib/typescript/src/utils/clamp.d.ts +2 -0
  112. package/lib/typescript/src/utils/clamp.d.ts.map +1 -0
  113. package/lib/typescript/src/utils/formatTime.d.ts +2 -0
  114. package/lib/typescript/src/utils/formatTime.d.ts.map +1 -0
  115. package/lib/typescript/src/utils/index.d.ts +3 -0
  116. package/lib/typescript/src/utils/index.d.ts.map +1 -0
  117. package/package.json +191 -0
  118. package/src/components/VideoKit.tsx +415 -0
  119. package/src/components/controls/FullscreenButton.tsx +29 -0
  120. package/src/components/controls/Icon.tsx +71 -0
  121. package/src/components/controls/PlayPauseButton.tsx +25 -0
  122. package/src/components/controls/Scrubber.tsx +157 -0
  123. package/src/components/controls/SpeedButton.tsx +86 -0
  124. package/src/components/controls/TimeDisplay.tsx +21 -0
  125. package/src/components/controls/VolumeButton.tsx +23 -0
  126. package/src/components/core/VideoPlayer.tsx +148 -0
  127. package/src/components/core/VideoPlayerContext.tsx +133 -0
  128. package/src/components/core/index.ts +5 -0
  129. package/src/components/index.ts +25 -0
  130. package/src/components/overlays/BufferingOverlay.tsx +21 -0
  131. package/src/components/overlays/DoubleTapSeek.tsx +91 -0
  132. package/src/components/overlays/ErrorOverlay.tsx +49 -0
  133. package/src/components/overlays/GestureIndicator.tsx +114 -0
  134. package/src/components/overlays/LoadingPoster.tsx +21 -0
  135. package/src/hooks/index.ts +3 -0
  136. package/src/hooks/useVideoBrightness.ts +34 -0
  137. package/src/hooks/useVideoControls.ts +65 -0
  138. package/src/hooks/useVideoOrientation.ts +22 -0
  139. package/src/hooks/useVideoPlayer.ts +69 -0
  140. package/src/hooks/useVideoVolume.ts +36 -0
  141. package/src/index.ts +15 -0
  142. package/src/types/index.ts +137 -0
  143. package/src/utils/clamp.ts +4 -0
  144. package/src/utils/formatTime.ts +9 -0
  145. package/src/utils/index.ts +2 -0
@@ -0,0 +1,415 @@
1
+ import { useCallback, useEffect, useRef, useMemo } from 'react';
2
+ import {
3
+ StyleSheet,
4
+ View,
5
+ StatusBar,
6
+ Modal,
7
+ useWindowDimensions,
8
+ } from 'react-native';
9
+ import { GestureDetector, Gesture } from 'react-native-gesture-handler';
10
+ import Animated, {
11
+ useAnimatedStyle,
12
+ useSharedValue,
13
+ withTiming,
14
+ } from 'react-native-reanimated';
15
+ import { scheduleOnRN } from 'react-native-worklets';
16
+
17
+ import {
18
+ useVideoStoreApi,
19
+ VideoPlayerProvider,
20
+ } from './core/VideoPlayerContext';
21
+ import { VideoPlayer } from './core/VideoPlayer';
22
+
23
+ import { useVideoControls } from '../hooks/useVideoControls';
24
+ import { useVideoOrientation } from '../hooks/useVideoOrientation';
25
+ import {
26
+ useVideoPlayerContext,
27
+ useVideoStore,
28
+ } from './core/VideoPlayerContext';
29
+ import { useVideoBrightness } from '../hooks/useVideoBrightness';
30
+
31
+ import { PlayPauseButton } from './controls/PlayPauseButton';
32
+ import { Scrubber } from './controls/Scrubber';
33
+ import { TimeDisplay } from './controls/TimeDisplay';
34
+ import { VolumeButton } from './controls/VolumeButton';
35
+ import { FullscreenButton } from './controls/FullscreenButton';
36
+ import { SpeedButton } from './controls/SpeedButton';
37
+
38
+ import { BufferingOverlay } from './overlays/BufferingOverlay';
39
+ import { ErrorOverlay } from './overlays/ErrorOverlay';
40
+ import { LoadingPoster } from './overlays/LoadingPoster';
41
+ import {
42
+ DoubleTapSeek,
43
+ type DoubleTapSeekHandle,
44
+ } from './overlays/DoubleTapSeek';
45
+
46
+ import type { VideoKitProps } from '../types';
47
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
48
+ import { useVideoVolume } from '../hooks/useVideoVolume';
49
+ import { VolumeManager } from 'react-native-volume-manager';
50
+
51
+ // ─── Inner component (has access to context) ─────────────────────────────────
52
+ function VideoKitInner({
53
+ source,
54
+ poster,
55
+ autoPlay = false,
56
+ loop = false,
57
+ onProgress,
58
+ onEnd,
59
+ onError,
60
+ onBuffer,
61
+ onFullscreenChange,
62
+ autoHideDelay = 3000,
63
+ doubleTapSeekSeconds = 10,
64
+ style,
65
+ testID,
66
+ safeAreaInsets,
67
+ controls,
68
+ muted,
69
+ }: VideoKitProps) {
70
+ const { videoRef } = useVideoPlayerContext();
71
+ const doubleTapRef = useRef<DoubleTapSeekHandle>(null);
72
+ const layoutWidth = useSharedValue(0);
73
+ const isFullscreen = useVideoStore((s) => s.isFullscreen);
74
+ const { height } = useWindowDimensions();
75
+
76
+ useVideoOrientation();
77
+
78
+ const { showControls, keepControlsVisible, toggleControls } =
79
+ useVideoControls(autoHideDelay);
80
+ const { adjustBrightness } = useVideoBrightness();
81
+ const { adjustVolume } = useVideoVolume();
82
+ const controlsVisible = useVideoStore((s) => s.controlsVisible);
83
+ const {
84
+ currentTime,
85
+ duration,
86
+ setSpeed,
87
+ setStatus,
88
+ setError,
89
+ setMuted,
90
+ setFullscreen,
91
+ } = useVideoStoreApi().getState();
92
+ const resolvedControls = useMemo(() => {
93
+ if (controls === false) return null;
94
+ if (controls === true || controls === undefined) {
95
+ return {
96
+ playPause: true,
97
+ scrubber: true,
98
+ time: true,
99
+ volume: true,
100
+ fullscreen: true,
101
+ speed: true,
102
+ };
103
+ }
104
+ return {
105
+ playPause: true,
106
+ scrubber: true,
107
+ time: true,
108
+ volume: true,
109
+ fullscreen: true,
110
+ speed: true,
111
+ ...controls,
112
+ };
113
+ }, [controls]);
114
+
115
+ // ── Seek helper (JS thread) ──────────────────────────────────────────────
116
+ const seekBy = useCallback(
117
+ (seconds: number) => {
118
+ const next = Math.max(0, Math.min(currentTime + seconds, duration));
119
+ videoRef?.current?.seek(next);
120
+ },
121
+ [videoRef, currentTime, duration]
122
+ );
123
+
124
+ const handleDoubleTap = useCallback(
125
+ (side: 'left' | 'right') => {
126
+ doubleTapRef.current?.show(side, doubleTapSeekSeconds);
127
+ seekBy(side === 'left' ? -doubleTapSeekSeconds : doubleTapSeekSeconds);
128
+ showControls();
129
+ },
130
+ [doubleTapSeekSeconds, seekBy, showControls]
131
+ );
132
+
133
+ const handleLongPressStart = useCallback(() => {
134
+ setSpeed(2);
135
+ }, [setSpeed]);
136
+
137
+ const handleLongPressEnd = useCallback(() => {
138
+ setSpeed(1);
139
+ }, [setSpeed]);
140
+
141
+ // ── Gestures ────────────────────────────────────────────────────────────
142
+
143
+ const panGesture = Gesture.Pan()
144
+
145
+ .minDistance(10)
146
+ .onEnd((e) => {
147
+ 'worklet';
148
+ // vertical swipe — not a brightness/volume gesture
149
+ if (Math.abs(e.translationY) > Math.abs(e.translationX) * 1.5) {
150
+ const isSignificantSwipe = Math.abs(e.translationY) > 60;
151
+ if (isSignificantSwipe) {
152
+ if (e.translationY < 0) {
153
+ // swipe up → enter fullscreen
154
+ scheduleOnRN(() => setFullscreen(true));
155
+ } else if (e.translationY > 0) {
156
+ // swipe down → exit fullscreen
157
+ scheduleOnRN(() => setFullscreen(false));
158
+ }
159
+ }
160
+ }
161
+ })
162
+ .activeOffsetY([-10, 10]) // only vertical pans
163
+ .failOffsetX([-10, 10]) // horizontal movement cancels it
164
+ .onUpdate((e) => {
165
+ 'worklet';
166
+ const delta = -(e.translationY / height) / 3; // negative because swipe up = brighter
167
+ const isLeftSide = e.x < layoutWidth.value / 2;
168
+
169
+ if (isLeftSide) {
170
+ scheduleOnRN(adjustBrightness, delta);
171
+ } else {
172
+ scheduleOnRN(adjustVolume, delta);
173
+ }
174
+ });
175
+
176
+ const doubleTap = Gesture.Tap()
177
+ .numberOfTaps(2)
178
+ .maxDuration(250)
179
+ .onEnd((e) => {
180
+ 'worklet';
181
+
182
+ const leftZone = layoutWidth.value * 0.35;
183
+ const rightZone = layoutWidth.value * 0.65;
184
+
185
+ if (e.x < leftZone) {
186
+ scheduleOnRN(handleDoubleTap, 'left');
187
+ } else if (e.x > rightZone) {
188
+ scheduleOnRN(handleDoubleTap, 'right');
189
+ } else {
190
+ // scheduleOnRN(toggleControls);
191
+ }
192
+ });
193
+
194
+ const singleTap = Gesture.Tap()
195
+ // .maxDuration(250)
196
+ .onEnd(() => {
197
+ 'worklet';
198
+ scheduleOnRN(toggleControls);
199
+ });
200
+
201
+ const longPress = Gesture.LongPress()
202
+ .minDuration(400)
203
+ .onStart(() => {
204
+ 'worklet';
205
+ scheduleOnRN(handleLongPressStart);
206
+ })
207
+ .onEnd(() => {
208
+ 'worklet';
209
+ scheduleOnRN(handleLongPressEnd);
210
+ });
211
+
212
+ const taps = Gesture.Exclusive(doubleTap, singleTap);
213
+
214
+ const composed = Gesture.Simultaneous(taps, longPress, panGesture);
215
+
216
+ // ── Controls animated opacity ────────────────────────────────────────────
217
+ const controlsStyle = useAnimatedStyle(() => ({
218
+ opacity: withTiming(controlsVisible ? 1 : 0, { duration: 200 }),
219
+ }));
220
+
221
+ const handleRetry = useCallback(() => {
222
+ setStatus('loading');
223
+ setError(null);
224
+ }, [setStatus, setError]);
225
+
226
+ const insets = isFullscreen
227
+ ? {
228
+ paddingBottom: 8 + (safeAreaInsets?.bottom ?? 0),
229
+ paddingLeft: 12 + (safeAreaInsets?.left ?? 0),
230
+ paddingRight: 12 + (safeAreaInsets?.right ?? 0),
231
+ }
232
+ : undefined;
233
+
234
+ const playerContent = (
235
+ <View
236
+ style={[
237
+ styles.container,
238
+ isFullscreen
239
+ ? {
240
+ ...styles.fullscreenContainer,
241
+ width: '100%',
242
+ height: '100%',
243
+ }
244
+ : style,
245
+ {
246
+ overflow: isFullscreen ? 'visible' : 'hidden',
247
+ },
248
+ ]}
249
+ testID={testID}
250
+ onLayout={(e) => {
251
+ layoutWidth.value = e.nativeEvent.layout.width;
252
+ }}
253
+ >
254
+ {/* ── Raw video ── */}
255
+ <VideoPlayer
256
+ source={source}
257
+ autoPlay={autoPlay}
258
+ loop={loop}
259
+ poster={poster}
260
+ onProgress={onProgress}
261
+ onEnd={onEnd}
262
+ onError={onError}
263
+ onBuffer={onBuffer}
264
+ />
265
+
266
+ {/* ── Gesture + overlay layer ── */}
267
+ <GestureDetector gesture={composed}>
268
+ <Animated.View
269
+ style={[
270
+ StyleSheet.absoluteFill,
271
+ {
272
+ // backgroundColor: 'purple',
273
+ },
274
+ ]}
275
+ collapsable={false}
276
+ />
277
+ </GestureDetector>
278
+
279
+ {/* Overlays */}
280
+ <LoadingPoster uri={poster} />
281
+ <BufferingOverlay />
282
+ <ErrorOverlay onRetry={handleRetry} />
283
+ <DoubleTapSeek ref={doubleTapRef} />
284
+
285
+ {/* Full-screen dim overlay — fades with controls */}
286
+ <Animated.View
287
+ style={[StyleSheet.absoluteFill, styles.dimOverlay, controlsStyle]}
288
+ pointerEvents="none"
289
+ />
290
+
291
+ {/* Controls — fade in/out */}
292
+ <Animated.View
293
+ style={[
294
+ {
295
+ position: 'absolute',
296
+ bottom: 0,
297
+ left: 0,
298
+ right: 0,
299
+ },
300
+ controlsStyle,
301
+ ]}
302
+ pointerEvents={controlsVisible ? 'auto' : 'none'}
303
+ >
304
+ {/* Bottom gradient area */}
305
+ <View style={[styles.bottomControls, insets]}>
306
+ {resolvedControls?.scrubber && (
307
+ <Scrubber
308
+ onScrubStart={keepControlsVisible}
309
+ onScrubEnd={showControls}
310
+ />
311
+ )}
312
+ <View style={styles.buttonRow}>
313
+ {resolvedControls?.playPause && <PlayPauseButton />}
314
+ {resolvedControls?.time && <TimeDisplay />}
315
+ <View style={styles.spacer} />
316
+ {resolvedControls?.speed && <SpeedButton />}
317
+ {resolvedControls?.volume && <VolumeButton />}
318
+ {resolvedControls?.fullscreen && (
319
+ <FullscreenButton
320
+ onFullscreenChange={(v) => {
321
+ StatusBar.setHidden(v, 'fade');
322
+ onFullscreenChange?.(v);
323
+ }}
324
+ />
325
+ )}
326
+ </View>
327
+ </View>
328
+ </Animated.View>
329
+ </View>
330
+ );
331
+
332
+ // In VideoKit.tsx, on mount:
333
+ useEffect(() => {
334
+ VolumeManager.showNativeVolumeUI({ enabled: false });
335
+ return () => {
336
+ VolumeManager.showNativeVolumeUI({ enabled: true });
337
+ };
338
+ }, []);
339
+
340
+ //on mount and when prop changes
341
+ useEffect(() => {
342
+ setMuted(muted ?? false);
343
+ }, [muted, setMuted]);
344
+
345
+ return isFullscreen ? (
346
+ <Modal
347
+ visible
348
+ presentationStyle="fullScreen"
349
+ hardwareAccelerated
350
+ navigationBarTranslucent
351
+ statusBarTranslucent
352
+ supportedOrientations={['landscape', 'landscape-left', 'landscape-right']}
353
+ onDismiss={() => {
354
+ setFullscreen(false);
355
+ onFullscreenChange?.(false);
356
+ }}
357
+ onRequestClose={() => {
358
+ setFullscreen(false);
359
+ onFullscreenChange?.(false);
360
+ StatusBar.setHidden(false, 'fade');
361
+ }}
362
+ onShow={() => {}}
363
+ style={{
364
+ margin: 0,
365
+ }}
366
+ >
367
+ <GestureHandlerRootView style={{ flex: 1 }}>
368
+ {playerContent}
369
+ </GestureHandlerRootView>
370
+ </Modal>
371
+ ) : (
372
+ <>
373
+ {/* placeholder so layout doesn't collapse */}
374
+ {/* <View style={[styles.container, style]} /> */}
375
+ {playerContent}
376
+ </>
377
+ );
378
+ }
379
+
380
+ // ─── Public export (wraps with providers) ─────────────────────────────────────
381
+ export function VideoKit(props: VideoKitProps) {
382
+ return (
383
+ <VideoPlayerProvider>
384
+ <VideoKitInner {...props} />
385
+ </VideoPlayerProvider>
386
+ );
387
+ }
388
+
389
+ const styles = StyleSheet.create({
390
+ container: {
391
+ backgroundColor: '#000',
392
+ position: 'relative',
393
+ },
394
+ dimOverlay: {
395
+ backgroundColor: 'rgba(0,0,0,0.45)',
396
+ },
397
+ bottomControls: {
398
+ bottom: 0,
399
+ left: 0,
400
+ right: 0,
401
+ paddingBottom: 8,
402
+ paddingHorizontal: 12,
403
+ },
404
+ buttonRow: {
405
+ flexDirection: 'row',
406
+ alignItems: 'center',
407
+ gap: 12,
408
+ marginTop: 4,
409
+ },
410
+ fullscreenContainer: {
411
+ flex: 1,
412
+ backgroundColor: '#000',
413
+ },
414
+ spacer: { flex: 1 },
415
+ });
@@ -0,0 +1,29 @@
1
+ import { StyleSheet, TouchableOpacity } from 'react-native';
2
+ import { useVideoStore } from '../core/VideoPlayerContext';
3
+ import { Icon } from './Icon';
4
+
5
+ interface Props {
6
+ onFullscreenChange?: (isFullscreen: boolean) => void;
7
+ }
8
+
9
+ export function FullscreenButton({ onFullscreenChange }: Props) {
10
+ const isFullscreen = useVideoStore((s) => s.isFullscreen);
11
+ const { setFullscreen } = useVideoStore((s) => s);
12
+
13
+ const toggle = () => {
14
+ const next = !isFullscreen;
15
+ setFullscreen(next);
16
+ onFullscreenChange?.(next);
17
+ };
18
+
19
+ return (
20
+ <TouchableOpacity onPress={toggle} style={styles.btn} hitSlop={12}>
21
+ <Icon name={isFullscreen ? 'fullscreen-exit' : 'fullscreen'} size={22} />
22
+ </TouchableOpacity>
23
+ );
24
+ }
25
+
26
+ const styles = StyleSheet.create({
27
+ btn: { padding: 4 },
28
+ icon: { fontSize: 18, color: '#fff' },
29
+ });
@@ -0,0 +1,71 @@
1
+ import React from 'react';
2
+ import Svg, { Path, Polygon, Rect } from 'react-native-svg';
3
+
4
+ type IconName =
5
+ | 'play'
6
+ | 'pause'
7
+ | 'volume'
8
+ | 'mute'
9
+ | 'fullscreen'
10
+ | 'fullscreen-exit';
11
+
12
+ interface Props {
13
+ name: IconName;
14
+ size?: number;
15
+ color?: string;
16
+ }
17
+
18
+ const PATHS: Record<IconName, React.ReactElement> = {
19
+ 'play': (
20
+ <Svg viewBox="0 0 24 24">
21
+ <Polygon points="5,3 19,12 5,21" fill="currentColor" />
22
+ </Svg>
23
+ ),
24
+ 'pause': (
25
+ <Svg viewBox="0 0 24 24">
26
+ <Rect x="6" y="4" width="4" height="16" fill="currentColor" />
27
+ <Rect x="14" y="4" width="4" height="16" fill="currentColor" />
28
+ </Svg>
29
+ ),
30
+ 'volume': (
31
+ <Svg viewBox="0 0 24 24">
32
+ <Path
33
+ d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
34
+ fill="currentColor"
35
+ />
36
+ </Svg>
37
+ ),
38
+ 'mute': (
39
+ <Svg viewBox="0 0 24 24">
40
+ <Path
41
+ d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
42
+ fill="currentColor"
43
+ />
44
+ </Svg>
45
+ ),
46
+ 'fullscreen': (
47
+ <Svg viewBox="0 0 24 24">
48
+ <Path
49
+ d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
50
+ fill="currentColor"
51
+ />
52
+ </Svg>
53
+ ),
54
+ 'fullscreen-exit': (
55
+ <Svg viewBox="0 0 24 24">
56
+ <Path
57
+ d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
58
+ fill="currentColor"
59
+ />
60
+ </Svg>
61
+ ),
62
+ };
63
+
64
+ export function Icon({ name, size = 24, color = '#fff' }: Props) {
65
+ const element = PATHS[name];
66
+ return React.cloneElement(element, {
67
+ width: size,
68
+ height: size,
69
+ color,
70
+ } as any);
71
+ }
@@ -0,0 +1,25 @@
1
+ import { StyleSheet, TouchableOpacity } from 'react-native';
2
+ import { Icon } from './Icon';
3
+ import { useVideoStore } from '../core/VideoPlayerContext';
4
+
5
+ export function PlayPauseButton() {
6
+ const status = useVideoStore((s) => s.status);
7
+ const setStatus = useVideoStore((s) => s.setStatus);
8
+
9
+ const isPlaying = status === 'playing';
10
+
11
+ const toggle = () => {
12
+ setStatus(isPlaying ? 'paused' : 'playing');
13
+ };
14
+
15
+ return (
16
+ <TouchableOpacity onPress={toggle} style={styles.btn} hitSlop={12}>
17
+ <Icon name={isPlaying ? 'pause' : 'play'} size={22} />
18
+ </TouchableOpacity>
19
+ );
20
+ }
21
+
22
+ const styles = StyleSheet.create({
23
+ btn: { padding: 4 },
24
+ icon: { fontSize: 20, color: '#fff' },
25
+ });
@@ -0,0 +1,157 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4
+ import Animated, {
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withTiming,
8
+ } from 'react-native-reanimated';
9
+ import {
10
+ useVideoPlayerContext,
11
+ useVideoStore,
12
+ // useVideoTheme,
13
+ } from '../core/VideoPlayerContext';
14
+ import { clamp } from '../../utils/clamp';
15
+ import { scheduleOnRN } from 'react-native-worklets';
16
+
17
+ interface Props {
18
+ onScrubStart?: () => void;
19
+ onScrubEnd?: () => void;
20
+ }
21
+
22
+ export function Scrubber({ onScrubStart, onScrubEnd }: Props) {
23
+ const { videoRef } = useVideoPlayerContext();
24
+ const currentTime = useVideoStore((s) => s.currentTime);
25
+ const duration = useVideoStore((s) => s.duration);
26
+ const buffered = useVideoStore((s) => s.buffered);
27
+ const { duration: d, status, setStatus } = useVideoStore((s) => s);
28
+ // const { accentColor = '#fff', trackHeight = 3 } = useVideoTheme();
29
+
30
+ // Shared values for Reanimated
31
+ const trackWidth = useSharedValue(1);
32
+ const thumbX = useSharedValue(0);
33
+ const isDragging = useSharedValue(false);
34
+ const isDraggingRef = useRef(false);
35
+ const dragStartX = useSharedValue(0);
36
+ const dragStartThumb = useSharedValue(0);
37
+
38
+ // Sync playback progress → thumb position (when not dragging)
39
+ useEffect(() => {
40
+ if (!isDraggingRef.current && duration > 0) {
41
+ thumbX.value = withTiming((currentTime / duration) * trackWidth.value, {
42
+ duration: 200,
43
+ });
44
+ }
45
+ }, [currentTime, duration, thumbX, trackWidth]);
46
+
47
+ const handleScrubStart = useCallback(() => {
48
+ isDraggingRef.current = true;
49
+ onScrubStart?.();
50
+ }, [onScrubStart]);
51
+
52
+ const handleScrubEnd = useCallback(
53
+ (x: number) => {
54
+ if (d > 0) {
55
+ const seekTime = clamp((x / trackWidth.value) * d, 0, d);
56
+ videoRef?.current?.seek(seekTime);
57
+ if (status === 'playing') {
58
+ setTimeout(() => setStatus('playing'), 150);
59
+ }
60
+ }
61
+ isDraggingRef.current = false;
62
+ onScrubEnd?.();
63
+ },
64
+ [videoRef, trackWidth, onScrubEnd, setStatus, d, status]
65
+ );
66
+
67
+ const panGesture = Gesture.Pan()
68
+ .minDistance(0)
69
+ .onStart((e) => {
70
+ 'worklet';
71
+ isDragging.value = true;
72
+
73
+ dragStartX.value = e.x;
74
+ dragStartThumb.value = thumbX.value;
75
+ scheduleOnRN(handleScrubStart);
76
+ })
77
+ .onUpdate((e) => {
78
+ 'worklet';
79
+
80
+ const delta = e.x - dragStartX.value;
81
+ thumbX.value = clamp(dragStartThumb.value + delta, 0, trackWidth.value);
82
+ })
83
+ .onEnd(() => {
84
+ 'worklet';
85
+ isDragging.value = false;
86
+ scheduleOnRN(handleScrubEnd, thumbX.value);
87
+ });
88
+
89
+ const fillStyle = useAnimatedStyle(() => ({
90
+ width: thumbX.value,
91
+ }));
92
+
93
+ const thumbStyle = useAnimatedStyle(() => ({
94
+ transform: [{ translateX: thumbX.value }],
95
+ }));
96
+
97
+ const bufferedWidth =
98
+ duration > 0 ? clamp((buffered / duration) * 100, 0, 100) : 0;
99
+
100
+ return (
101
+ <GestureDetector gesture={panGesture}>
102
+ <View
103
+ style={styles.trackHitArea}
104
+ onLayout={(e) => {
105
+ trackWidth.value = e.nativeEvent.layout.width;
106
+ }}
107
+ >
108
+ <View style={styles.track}>
109
+ {/* Buffered */}
110
+ <View style={[styles.bufferedFill, { width: `${bufferedWidth}%` }]} />
111
+ {/* Played */}
112
+ <Animated.View style={[styles.playedFill, fillStyle]} />
113
+ {/* Thumb */}
114
+ <Animated.View style={[styles.thumb, thumbStyle]} />
115
+ </View>
116
+ </View>
117
+ </GestureDetector>
118
+ );
119
+ }
120
+
121
+ const TRACK_HEIGHT = 3;
122
+ const THUMB_SIZE = 14;
123
+
124
+ const styles = StyleSheet.create({
125
+ trackHitArea: {
126
+ height: 28,
127
+ justifyContent: 'center',
128
+ paddingHorizontal: THUMB_SIZE / 2,
129
+ },
130
+ track: {
131
+ height: TRACK_HEIGHT,
132
+ backgroundColor: 'rgba(255,255,255,0.3)',
133
+ borderRadius: TRACK_HEIGHT / 2,
134
+ overflow: 'visible',
135
+ },
136
+ bufferedFill: {
137
+ position: 'absolute',
138
+ height: TRACK_HEIGHT,
139
+ backgroundColor: 'rgba(255,255,255,0.4)',
140
+ borderRadius: TRACK_HEIGHT / 2,
141
+ },
142
+ playedFill: {
143
+ position: 'absolute',
144
+ height: TRACK_HEIGHT,
145
+ backgroundColor: '#fff',
146
+ borderRadius: TRACK_HEIGHT / 2,
147
+ },
148
+ thumb: {
149
+ position: 'absolute',
150
+ top: -(THUMB_SIZE / 2 - TRACK_HEIGHT / 2),
151
+ left: -THUMB_SIZE / 2,
152
+ width: THUMB_SIZE,
153
+ height: THUMB_SIZE,
154
+ borderRadius: THUMB_SIZE / 2,
155
+ backgroundColor: '#fff',
156
+ },
157
+ });