@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.
- package/LICENSE +20 -0
- package/README.md +248 -0
- package/lib/module/components/VideoKit.js +347 -0
- package/lib/module/components/VideoKit.js.map +1 -0
- package/lib/module/components/controls/FullscreenButton.js +38 -0
- package/lib/module/components/controls/FullscreenButton.js.map +1 -0
- package/lib/module/components/controls/Icon.js +71 -0
- package/lib/module/components/controls/Icon.js.map +1 -0
- package/lib/module/components/controls/PlayPauseButton.js +33 -0
- package/lib/module/components/controls/PlayPauseButton.js.map +1 -0
- package/lib/module/components/controls/Scrubber.js +146 -0
- package/lib/module/components/controls/Scrubber.js.map +1 -0
- package/lib/module/components/controls/SpeedButton.js +96 -0
- package/lib/module/components/controls/SpeedButton.js.map +1 -0
- package/lib/module/components/controls/TimeDisplay.js +28 -0
- package/lib/module/components/controls/TimeDisplay.js.map +1 -0
- package/lib/module/components/controls/VolumeButton.js +31 -0
- package/lib/module/components/controls/VolumeButton.js.map +1 -0
- package/lib/module/components/core/VideoPlayer.js +114 -0
- package/lib/module/components/core/VideoPlayer.js.map +1 -0
- package/lib/module/components/core/VideoPlayerContext.js +119 -0
- package/lib/module/components/core/VideoPlayerContext.js.map +1 -0
- package/lib/module/components/core/index.js +5 -0
- package/lib/module/components/core/index.js.map +1 -0
- package/lib/module/components/index.js +14 -0
- package/lib/module/components/index.js.map +1 -0
- package/lib/module/components/overlays/BufferingOverlay.js +24 -0
- package/lib/module/components/overlays/BufferingOverlay.js.map +1 -0
- package/lib/module/components/overlays/DoubleTapSeek.js +95 -0
- package/lib/module/components/overlays/DoubleTapSeek.js.map +1 -0
- package/lib/module/components/overlays/ErrorOverlay.js +60 -0
- package/lib/module/components/overlays/ErrorOverlay.js.map +1 -0
- package/lib/module/components/overlays/GestureIndicator.js +118 -0
- package/lib/module/components/overlays/GestureIndicator.js.map +1 -0
- package/lib/module/components/overlays/LoadingPoster.js +22 -0
- package/lib/module/components/overlays/LoadingPoster.js.map +1 -0
- package/lib/module/hooks/index.js +6 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/useVideoBrightness.js +33 -0
- package/lib/module/hooks/useVideoBrightness.js.map +1 -0
- package/lib/module/hooks/useVideoControls.js +64 -0
- package/lib/module/hooks/useVideoControls.js.map +1 -0
- package/lib/module/hooks/useVideoOrientation.js +24 -0
- package/lib/module/hooks/useVideoOrientation.js.map +1 -0
- package/lib/module/hooks/useVideoPlayer.js +59 -0
- package/lib/module/hooks/useVideoPlayer.js.map +1 -0
- package/lib/module/hooks/useVideoVolume.js +42 -0
- package/lib/module/hooks/useVideoVolume.js.map +1 -0
- package/lib/module/index.js +7 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types/index.js +4 -0
- package/lib/module/types/index.js.map +1 -0
- package/lib/module/utils/clamp.js +8 -0
- package/lib/module/utils/clamp.js.map +1 -0
- package/lib/module/utils/formatTime.js +12 -0
- package/lib/module/utils/formatTime.js.map +1 -0
- package/lib/module/utils/index.js +5 -0
- package/lib/module/utils/index.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/components/VideoKit.d.ts +3 -0
- package/lib/typescript/src/components/VideoKit.d.ts.map +1 -0
- package/lib/typescript/src/components/controls/FullscreenButton.d.ts +6 -0
- package/lib/typescript/src/components/controls/FullscreenButton.d.ts.map +1 -0
- package/lib/typescript/src/components/controls/Icon.d.ts +10 -0
- package/lib/typescript/src/components/controls/Icon.d.ts.map +1 -0
- package/lib/typescript/src/components/controls/PlayPauseButton.d.ts +2 -0
- package/lib/typescript/src/components/controls/PlayPauseButton.d.ts.map +1 -0
- package/lib/typescript/src/components/controls/Scrubber.d.ts +7 -0
- package/lib/typescript/src/components/controls/Scrubber.d.ts.map +1 -0
- package/lib/typescript/src/components/controls/SpeedButton.d.ts +2 -0
- package/lib/typescript/src/components/controls/SpeedButton.d.ts.map +1 -0
- package/lib/typescript/src/components/controls/TimeDisplay.d.ts +2 -0
- package/lib/typescript/src/components/controls/TimeDisplay.d.ts.map +1 -0
- package/lib/typescript/src/components/controls/VolumeButton.d.ts +2 -0
- package/lib/typescript/src/components/controls/VolumeButton.d.ts.map +1 -0
- package/lib/typescript/src/components/core/VideoPlayer.d.ts +14 -0
- package/lib/typescript/src/components/core/VideoPlayer.d.ts.map +1 -0
- package/lib/typescript/src/components/core/VideoPlayerContext.d.ts +48 -0
- package/lib/typescript/src/components/core/VideoPlayerContext.d.ts.map +1 -0
- package/lib/typescript/src/components/core/index.d.ts +3 -0
- package/lib/typescript/src/components/core/index.d.ts.map +1 -0
- package/lib/typescript/src/components/index.d.ts +6 -0
- package/lib/typescript/src/components/index.d.ts.map +1 -0
- package/lib/typescript/src/components/overlays/BufferingOverlay.d.ts +2 -0
- package/lib/typescript/src/components/overlays/BufferingOverlay.d.ts.map +1 -0
- package/lib/typescript/src/components/overlays/DoubleTapSeek.d.ts +5 -0
- package/lib/typescript/src/components/overlays/DoubleTapSeek.d.ts.map +1 -0
- package/lib/typescript/src/components/overlays/ErrorOverlay.d.ts +6 -0
- package/lib/typescript/src/components/overlays/ErrorOverlay.d.ts.map +1 -0
- package/lib/typescript/src/components/overlays/GestureIndicator.d.ts +9 -0
- package/lib/typescript/src/components/overlays/GestureIndicator.d.ts.map +1 -0
- package/lib/typescript/src/components/overlays/LoadingPoster.d.ts +6 -0
- package/lib/typescript/src/components/overlays/LoadingPoster.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +4 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useVideoBrightness.d.ts +5 -0
- package/lib/typescript/src/hooks/useVideoBrightness.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useVideoControls.d.ts +11 -0
- package/lib/typescript/src/hooks/useVideoControls.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useVideoOrientation.d.ts +6 -0
- package/lib/typescript/src/hooks/useVideoOrientation.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useVideoPlayer.d.ts +7 -0
- package/lib/typescript/src/hooks/useVideoPlayer.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useVideoVolume.d.ts +6 -0
- package/lib/typescript/src/hooks/useVideoVolume.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +6 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types/index.d.ts +96 -0
- package/lib/typescript/src/types/index.d.ts.map +1 -0
- package/lib/typescript/src/utils/clamp.d.ts +2 -0
- package/lib/typescript/src/utils/clamp.d.ts.map +1 -0
- package/lib/typescript/src/utils/formatTime.d.ts +2 -0
- package/lib/typescript/src/utils/formatTime.d.ts.map +1 -0
- package/lib/typescript/src/utils/index.d.ts +3 -0
- package/lib/typescript/src/utils/index.d.ts.map +1 -0
- package/package.json +191 -0
- package/src/components/VideoKit.tsx +415 -0
- package/src/components/controls/FullscreenButton.tsx +29 -0
- package/src/components/controls/Icon.tsx +71 -0
- package/src/components/controls/PlayPauseButton.tsx +25 -0
- package/src/components/controls/Scrubber.tsx +157 -0
- package/src/components/controls/SpeedButton.tsx +86 -0
- package/src/components/controls/TimeDisplay.tsx +21 -0
- package/src/components/controls/VolumeButton.tsx +23 -0
- package/src/components/core/VideoPlayer.tsx +148 -0
- package/src/components/core/VideoPlayerContext.tsx +133 -0
- package/src/components/core/index.ts +5 -0
- package/src/components/index.ts +25 -0
- package/src/components/overlays/BufferingOverlay.tsx +21 -0
- package/src/components/overlays/DoubleTapSeek.tsx +91 -0
- package/src/components/overlays/ErrorOverlay.tsx +49 -0
- package/src/components/overlays/GestureIndicator.tsx +114 -0
- package/src/components/overlays/LoadingPoster.tsx +21 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useVideoBrightness.ts +34 -0
- package/src/hooks/useVideoControls.ts +65 -0
- package/src/hooks/useVideoOrientation.ts +22 -0
- package/src/hooks/useVideoPlayer.ts +69 -0
- package/src/hooks/useVideoVolume.ts +36 -0
- package/src/index.ts +15 -0
- package/src/types/index.ts +137 -0
- package/src/utils/clamp.ts +4 -0
- package/src/utils/formatTime.ts +9 -0
- package/src/utils/index.ts +2 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
3
|
+
import { useVideoStore } from '../core/VideoPlayerContext';
|
|
4
|
+
|
|
5
|
+
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
6
|
+
|
|
7
|
+
export function SpeedButton() {
|
|
8
|
+
const speed = useVideoStore((s) => s.speed);
|
|
9
|
+
const { setSpeed } = useVideoStore((s) => s);
|
|
10
|
+
const [open, setOpen] = useState(false);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<TouchableOpacity
|
|
15
|
+
onPress={() => setOpen(true)}
|
|
16
|
+
style={styles.btn}
|
|
17
|
+
hitSlop={12}
|
|
18
|
+
>
|
|
19
|
+
<Text style={styles.label}>{speed === 1 ? '1×' : `${speed}×`}</Text>
|
|
20
|
+
</TouchableOpacity>
|
|
21
|
+
|
|
22
|
+
<Modal transparent visible={open} onRequestClose={() => setOpen(false)}>
|
|
23
|
+
<TouchableOpacity
|
|
24
|
+
style={styles.backdrop}
|
|
25
|
+
activeOpacity={1}
|
|
26
|
+
onPress={() => setOpen(false)}
|
|
27
|
+
>
|
|
28
|
+
<View style={styles.sheet}>
|
|
29
|
+
<Text style={styles.title}>Playback Speed</Text>
|
|
30
|
+
{SPEEDS.map((s) => (
|
|
31
|
+
<TouchableOpacity
|
|
32
|
+
key={s}
|
|
33
|
+
style={[styles.option, speed === s && styles.optionActive]}
|
|
34
|
+
onPress={() => {
|
|
35
|
+
setSpeed(s);
|
|
36
|
+
setOpen(false);
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<Text
|
|
40
|
+
style={[
|
|
41
|
+
styles.optionText,
|
|
42
|
+
speed === s && styles.optionTextActive,
|
|
43
|
+
]}
|
|
44
|
+
>
|
|
45
|
+
{s === 1 ? 'Normal' : `${s}×`}
|
|
46
|
+
</Text>
|
|
47
|
+
</TouchableOpacity>
|
|
48
|
+
))}
|
|
49
|
+
</View>
|
|
50
|
+
</TouchableOpacity>
|
|
51
|
+
</Modal>
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const styles = StyleSheet.create({
|
|
57
|
+
btn: { padding: 4 },
|
|
58
|
+
label: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
|
59
|
+
backdrop: {
|
|
60
|
+
flex: 1,
|
|
61
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
62
|
+
justifyContent: 'flex-end',
|
|
63
|
+
},
|
|
64
|
+
sheet: {
|
|
65
|
+
backgroundColor: '#1a1a1a',
|
|
66
|
+
borderTopLeftRadius: 16,
|
|
67
|
+
borderTopRightRadius: 16,
|
|
68
|
+
padding: 20,
|
|
69
|
+
paddingBottom: 36,
|
|
70
|
+
gap: 4,
|
|
71
|
+
},
|
|
72
|
+
title: {
|
|
73
|
+
color: '#aaa',
|
|
74
|
+
fontSize: 13,
|
|
75
|
+
fontWeight: '600',
|
|
76
|
+
marginBottom: 8,
|
|
77
|
+
},
|
|
78
|
+
option: {
|
|
79
|
+
paddingVertical: 12,
|
|
80
|
+
paddingHorizontal: 16,
|
|
81
|
+
borderRadius: 8,
|
|
82
|
+
},
|
|
83
|
+
optionActive: { backgroundColor: 'rgba(255,255,255,0.1)' },
|
|
84
|
+
optionText: { color: '#fff', fontSize: 15 },
|
|
85
|
+
optionTextActive: { fontWeight: '700' },
|
|
86
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { StyleSheet, Text } from 'react-native';
|
|
2
|
+
import { useVideoStore } from '../core/VideoPlayerContext';
|
|
3
|
+
import { formatTime } from '../../utils/formatTime';
|
|
4
|
+
|
|
5
|
+
export function TimeDisplay() {
|
|
6
|
+
const currentTime = useVideoStore((s) => s.currentTime);
|
|
7
|
+
const duration = useVideoStore((s) => s.duration);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Text style={styles.text}>
|
|
11
|
+
{formatTime(currentTime)}
|
|
12
|
+
<Text style={styles.separator}> / </Text>
|
|
13
|
+
{formatTime(duration)}
|
|
14
|
+
</Text>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const styles = StyleSheet.create({
|
|
19
|
+
text: { color: '#fff', fontSize: 12, fontWeight: '500' },
|
|
20
|
+
separator: { color: 'rgba(255,255,255,0.5)' },
|
|
21
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { StyleSheet, TouchableOpacity } from 'react-native';
|
|
2
|
+
import { useVideoStore } from '../core/VideoPlayerContext';
|
|
3
|
+
import { Icon } from './Icon';
|
|
4
|
+
|
|
5
|
+
export function VolumeButton() {
|
|
6
|
+
const muted = useVideoStore((s) => s.muted);
|
|
7
|
+
const { setMuted } = useVideoStore((s) => s);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<TouchableOpacity
|
|
11
|
+
onPress={() => setMuted(!muted)}
|
|
12
|
+
style={styles.btn}
|
|
13
|
+
hitSlop={12}
|
|
14
|
+
>
|
|
15
|
+
<Icon size={22} name={muted ? 'mute' : 'volume'} />
|
|
16
|
+
</TouchableOpacity>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const styles = StyleSheet.create({
|
|
21
|
+
btn: { padding: 4 },
|
|
22
|
+
icon: { fontSize: 18, color: '#fff' },
|
|
23
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React, { useCallback, useRef } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import Video, {
|
|
4
|
+
type VideoRef,
|
|
5
|
+
type OnLoadData,
|
|
6
|
+
type OnProgressData,
|
|
7
|
+
type OnBufferData,
|
|
8
|
+
type OnVideoErrorData,
|
|
9
|
+
} from 'react-native-video';
|
|
10
|
+
import { useVideoPlayerContext, useVideoStore } from './VideoPlayerContext';
|
|
11
|
+
import type { VideoSource, VideoProgress } from '../../types';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
source: VideoSource;
|
|
15
|
+
autoPlay: boolean;
|
|
16
|
+
loop: boolean;
|
|
17
|
+
poster?: string;
|
|
18
|
+
onProgress?: (p: VideoProgress) => void;
|
|
19
|
+
onEnd?: () => void;
|
|
20
|
+
onError?: (e: Error) => void;
|
|
21
|
+
onBuffer?: (buffering: boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function VideoPlayer({
|
|
25
|
+
source,
|
|
26
|
+
autoPlay,
|
|
27
|
+
loop,
|
|
28
|
+
poster,
|
|
29
|
+
onProgress,
|
|
30
|
+
onEnd,
|
|
31
|
+
onError,
|
|
32
|
+
onBuffer,
|
|
33
|
+
}: Props) {
|
|
34
|
+
const { videoRef } = useVideoPlayerContext();
|
|
35
|
+
const {
|
|
36
|
+
setStatus,
|
|
37
|
+
setCurrentTime,
|
|
38
|
+
setDuration,
|
|
39
|
+
setBuffered,
|
|
40
|
+
setError,
|
|
41
|
+
status,
|
|
42
|
+
} = useVideoStore((s) => s); // state as reactive subscriptions
|
|
43
|
+
const muted = useVideoStore((s) => s.muted);
|
|
44
|
+
const speed = useVideoStore((s) => s.speed);
|
|
45
|
+
const isPaused = useVideoStore(
|
|
46
|
+
(s) => s.status === 'paused' || s.status === 'idle' || s.status === 'error'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const handleLoad = useCallback(
|
|
50
|
+
(data: OnLoadData) => {
|
|
51
|
+
setDuration(data.duration);
|
|
52
|
+
setStatus(autoPlay ? 'playing' : 'paused');
|
|
53
|
+
},
|
|
54
|
+
[autoPlay, setDuration, setStatus]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const handleProgress = useCallback(
|
|
58
|
+
(data: OnProgressData) => {
|
|
59
|
+
setCurrentTime(data.currentTime);
|
|
60
|
+
setBuffered(data.playableDuration);
|
|
61
|
+
onProgress?.({
|
|
62
|
+
currentTime: data.currentTime,
|
|
63
|
+
playableDuration: data.playableDuration,
|
|
64
|
+
seekableDuration: data.seekableDuration,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
[onProgress, setCurrentTime, setBuffered]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const bufferTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
71
|
+
|
|
72
|
+
const handleBuffer = useCallback(
|
|
73
|
+
({ isBuffering }: OnBufferData) => {
|
|
74
|
+
clearTimeout(bufferTimerRef.current);
|
|
75
|
+
if (isBuffering) {
|
|
76
|
+
setStatus('buffering');
|
|
77
|
+
onBuffer?.(true);
|
|
78
|
+
} else {
|
|
79
|
+
// debounce the recovery — ignore spurious false events
|
|
80
|
+
bufferTimerRef.current = setTimeout(() => {
|
|
81
|
+
setStatus('playing');
|
|
82
|
+
onBuffer?.(false);
|
|
83
|
+
}, 300);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[onBuffer, setStatus]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const handleError = useCallback(
|
|
90
|
+
(e: OnVideoErrorData) => {
|
|
91
|
+
const msg = e.error?.errorString ?? 'Playback error';
|
|
92
|
+
setError(msg);
|
|
93
|
+
setStatus('error');
|
|
94
|
+
onError?.(new Error(msg));
|
|
95
|
+
},
|
|
96
|
+
[onError, setError, setStatus]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const handleEnd = useCallback(() => {
|
|
100
|
+
setStatus('paused');
|
|
101
|
+
onEnd?.();
|
|
102
|
+
}, [onEnd, setStatus]);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Video
|
|
106
|
+
ref={videoRef as React.RefObject<VideoRef>}
|
|
107
|
+
source={{
|
|
108
|
+
bufferConfig: {
|
|
109
|
+
minBufferMs: 2500,
|
|
110
|
+
maxBufferMs: 10000,
|
|
111
|
+
bufferForPlaybackMs: 1000,
|
|
112
|
+
bufferForPlaybackAfterRebufferMs: 2000,
|
|
113
|
+
},
|
|
114
|
+
...(source as any),
|
|
115
|
+
}}
|
|
116
|
+
poster={poster}
|
|
117
|
+
paused={isPaused}
|
|
118
|
+
muted={muted}
|
|
119
|
+
rate={speed}
|
|
120
|
+
repeat={loop}
|
|
121
|
+
style={StyleSheet.absoluteFill}
|
|
122
|
+
resizeMode="contain"
|
|
123
|
+
controls={false}
|
|
124
|
+
progressUpdateInterval={250}
|
|
125
|
+
onLoad={handleLoad}
|
|
126
|
+
onProgress={handleProgress}
|
|
127
|
+
onBuffer={handleBuffer}
|
|
128
|
+
onError={handleError}
|
|
129
|
+
onEnd={handleEnd}
|
|
130
|
+
pointerEvents="none"
|
|
131
|
+
onLoadStart={() => {
|
|
132
|
+
setStatus('loading');
|
|
133
|
+
}}
|
|
134
|
+
onReadyForDisplay={() => {
|
|
135
|
+
// only auto-play if we haven't been manually paused
|
|
136
|
+
if (status === 'loading' || status === 'buffering') {
|
|
137
|
+
setStatus(autoPlay ? 'playing' : 'paused');
|
|
138
|
+
}
|
|
139
|
+
}}
|
|
140
|
+
bufferConfig={{
|
|
141
|
+
minBufferMs: 2500,
|
|
142
|
+
maxBufferMs: 10000,
|
|
143
|
+
bufferForPlaybackMs: 1000,
|
|
144
|
+
bufferForPlaybackAfterRebufferMs: 2000,
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React, { createContext, useContext, useRef } from 'react';
|
|
2
|
+
import { useRef as useStoreRef } from 'react';
|
|
3
|
+
import type { VideoRef } from 'react-native-video';
|
|
4
|
+
import { createStore, useStore } from 'zustand';
|
|
5
|
+
import type { PlaybackStatus, VideoKitTheme } from '../../types';
|
|
6
|
+
|
|
7
|
+
//Theme
|
|
8
|
+
|
|
9
|
+
const ThemeContext = createContext<VideoKitTheme>({});
|
|
10
|
+
export const useVideoTheme = () => useContext(ThemeContext);
|
|
11
|
+
|
|
12
|
+
// ─── Store shape ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface VideoState {
|
|
15
|
+
status: PlaybackStatus;
|
|
16
|
+
currentTime: number;
|
|
17
|
+
duration: number;
|
|
18
|
+
buffered: number;
|
|
19
|
+
muted: boolean;
|
|
20
|
+
speed: number;
|
|
21
|
+
isFullscreen: boolean;
|
|
22
|
+
controlsVisible: boolean;
|
|
23
|
+
error: string | null;
|
|
24
|
+
setStatus: (status: PlaybackStatus) => void;
|
|
25
|
+
setCurrentTime: (t: number) => void;
|
|
26
|
+
setDuration: (d: number) => void;
|
|
27
|
+
setBuffered: (b: number) => void;
|
|
28
|
+
setMuted: (m: boolean) => void;
|
|
29
|
+
setSpeed: (s: number) => void;
|
|
30
|
+
setFullscreen: (f: boolean) => void;
|
|
31
|
+
setControlsVisible: (v: boolean) => void;
|
|
32
|
+
setError: (e: string | null) => void;
|
|
33
|
+
reset: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const initialState = {
|
|
37
|
+
status: 'idle' as PlaybackStatus,
|
|
38
|
+
currentTime: 0,
|
|
39
|
+
duration: 0,
|
|
40
|
+
buffered: 0,
|
|
41
|
+
muted: false,
|
|
42
|
+
speed: 1,
|
|
43
|
+
isFullscreen: false,
|
|
44
|
+
controlsVisible: true,
|
|
45
|
+
error: null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type VideoStore = ReturnType<typeof createVideoStore>;
|
|
49
|
+
|
|
50
|
+
export const createVideoStore = () =>
|
|
51
|
+
createStore<VideoState>((set) => ({
|
|
52
|
+
...initialState,
|
|
53
|
+
setStatus: (status) => set({ status }),
|
|
54
|
+
setCurrentTime: (currentTime) => set({ currentTime }),
|
|
55
|
+
setDuration: (duration) => set({ duration }),
|
|
56
|
+
setBuffered: (buffered) => set({ buffered }),
|
|
57
|
+
setMuted: (muted) => set({ muted }),
|
|
58
|
+
setSpeed: (speed) => set({ speed }),
|
|
59
|
+
setFullscreen: (isFullscreen) => set({ isFullscreen }),
|
|
60
|
+
setControlsVisible: (controlsVisible) => set({ controlsVisible }),
|
|
61
|
+
setError: (error) => set({ error }),
|
|
62
|
+
reset: () => set(initialState),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// ─── Context ─────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
interface VideoPlayerContextValue {
|
|
68
|
+
videoRef: React.RefObject<VideoRef | null>;
|
|
69
|
+
store: VideoStore;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const VideoPlayerContext = createContext<VideoPlayerContextValue | null>(null);
|
|
73
|
+
|
|
74
|
+
// ─── Provider ────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export const VideoPlayerProvider = ({
|
|
77
|
+
children,
|
|
78
|
+
theme,
|
|
79
|
+
}: {
|
|
80
|
+
children: React.ReactNode;
|
|
81
|
+
theme?: VideoKitTheme;
|
|
82
|
+
}) => {
|
|
83
|
+
const videoRef = useRef<VideoRef | null>(null);
|
|
84
|
+
const storeRef = useStoreRef<VideoStore | null>(null);
|
|
85
|
+
|
|
86
|
+
// create the store once per provider instance
|
|
87
|
+
if (storeRef.current === null) {
|
|
88
|
+
storeRef.current = createVideoStore();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<VideoPlayerContext.Provider value={{ videoRef, store: storeRef.current }}>
|
|
93
|
+
<ThemeContext.Provider value={theme ?? {}}>
|
|
94
|
+
{children}
|
|
95
|
+
</ThemeContext.Provider>
|
|
96
|
+
</VideoPlayerContext.Provider>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ─── Hooks ───────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export const useVideoPlayerContext = () => {
|
|
103
|
+
const ctx = useContext(VideoPlayerContext);
|
|
104
|
+
if (!ctx)
|
|
105
|
+
throw new Error(
|
|
106
|
+
'useVideoPlayerContext must be used inside VideoPlayerProvider'
|
|
107
|
+
);
|
|
108
|
+
return ctx;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Scoped reactive store hook — replaces the old global useVideoStore.
|
|
113
|
+
* Use this everywhere instead of the old import.
|
|
114
|
+
*/
|
|
115
|
+
export const useVideoStore: <T>(selector: (state: VideoState) => T) => T = (
|
|
116
|
+
selector
|
|
117
|
+
) => {
|
|
118
|
+
const { store } = useVideoPlayerContext();
|
|
119
|
+
return useStore(store, selector);
|
|
120
|
+
};
|
|
121
|
+
// export function useVideoStore<T>(selector: (state: VideoState) => T): T {
|
|
122
|
+
// const { store } = useVideoPlayerContext();
|
|
123
|
+
// return useStore(store, selector);
|
|
124
|
+
// }
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* For imperative access (getState / setState) without subscribing.
|
|
128
|
+
* Use in callbacks and gesture handlers.
|
|
129
|
+
*/
|
|
130
|
+
export const useVideoStoreApi = () => {
|
|
131
|
+
const { store } = useVideoPlayerContext();
|
|
132
|
+
return store;
|
|
133
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ─── Main component ───────────────────────────────────────────────────────────
|
|
2
|
+
export { VideoPlayer } from './core/VideoPlayer';
|
|
3
|
+
export { VideoKit } from './VideoKit';
|
|
4
|
+
|
|
5
|
+
// ─── Headless hook ────────────────────────────────────────────────────────────
|
|
6
|
+
export { useVideoPlayer } from '../hooks/useVideoPlayer';
|
|
7
|
+
|
|
8
|
+
// ─── Context & store (for advanced usage) ────────────────────────────────────
|
|
9
|
+
export {
|
|
10
|
+
VideoPlayerProvider,
|
|
11
|
+
useVideoPlayerContext,
|
|
12
|
+
useVideoStore,
|
|
13
|
+
} from './core/VideoPlayerContext';
|
|
14
|
+
|
|
15
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
16
|
+
export type {
|
|
17
|
+
VideoKitProps,
|
|
18
|
+
VideoKitTheme,
|
|
19
|
+
VideoKitControlsConfig,
|
|
20
|
+
VideoSource,
|
|
21
|
+
VideoPlayerAPI,
|
|
22
|
+
VideoPlayerState,
|
|
23
|
+
VideoProgress,
|
|
24
|
+
PlaybackStatus,
|
|
25
|
+
} from '../types';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
|
2
|
+
import { useVideoStore } from '../core/VideoPlayerContext';
|
|
3
|
+
|
|
4
|
+
export function BufferingOverlay() {
|
|
5
|
+
const status = useVideoStore((s) => s.status);
|
|
6
|
+
if (status !== 'buffering' && status !== 'loading') return null;
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<View style={styles.container}>
|
|
10
|
+
<ActivityIndicator size="large" color="#fff" />
|
|
11
|
+
</View>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const styles = StyleSheet.create({
|
|
16
|
+
container: {
|
|
17
|
+
...StyleSheet.absoluteFill,
|
|
18
|
+
alignItems: 'center',
|
|
19
|
+
justifyContent: 'center',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
|
2
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withSequence,
|
|
7
|
+
withTiming,
|
|
8
|
+
} from 'react-native-reanimated';
|
|
9
|
+
import { scheduleOnRN } from 'react-native-worklets';
|
|
10
|
+
|
|
11
|
+
export interface DoubleTapSeekHandle {
|
|
12
|
+
show: (side: 'left' | 'right', seconds: number) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DoubleTapSeek = forwardRef<DoubleTapSeekHandle>((_, ref) => {
|
|
16
|
+
const [state, setState] = useState<{
|
|
17
|
+
side: 'left' | 'right';
|
|
18
|
+
seconds: number;
|
|
19
|
+
} | null>(null);
|
|
20
|
+
|
|
21
|
+
const opacity = useSharedValue(0);
|
|
22
|
+
const scale = useSharedValue(0.8);
|
|
23
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
24
|
+
|
|
25
|
+
const hide = () => setState(null);
|
|
26
|
+
|
|
27
|
+
useImperativeHandle(ref, () => ({
|
|
28
|
+
show: (side, seconds) => {
|
|
29
|
+
clearTimeout(timerRef.current);
|
|
30
|
+
setState({ side, seconds });
|
|
31
|
+
opacity.value = withSequence(
|
|
32
|
+
withTiming(1, { duration: 100 }),
|
|
33
|
+
withTiming(1, { duration: 400 }),
|
|
34
|
+
withTiming(0, { duration: 200 }, (done) => {
|
|
35
|
+
if (done) scheduleOnRN(hide);
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
scale.value = withSequence(
|
|
39
|
+
withTiming(1, { duration: 150 }),
|
|
40
|
+
withTiming(0.9, { duration: 350 })
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
const animStyle = useAnimatedStyle(() => ({
|
|
46
|
+
opacity: opacity.value,
|
|
47
|
+
transform: [{ scale: scale.value }],
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
if (!state) return null;
|
|
51
|
+
|
|
52
|
+
const isLeft = state.side === 'left';
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View
|
|
56
|
+
style={[styles.container, isLeft ? styles.left : styles.right]}
|
|
57
|
+
pointerEvents="none"
|
|
58
|
+
>
|
|
59
|
+
<Animated.View style={[styles.ripple, animStyle]}>
|
|
60
|
+
<Text style={styles.arrow}>{isLeft ? '«' : '»'}</Text>
|
|
61
|
+
<Text style={styles.label}>
|
|
62
|
+
{isLeft ? `-${state.seconds}s` : `+${state.seconds}s`}
|
|
63
|
+
</Text>
|
|
64
|
+
</Animated.View>
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const styles = StyleSheet.create({
|
|
70
|
+
container: {
|
|
71
|
+
position: 'absolute',
|
|
72
|
+
top: 0,
|
|
73
|
+
bottom: 0,
|
|
74
|
+
width: '40%',
|
|
75
|
+
alignItems: 'center',
|
|
76
|
+
justifyContent: 'center',
|
|
77
|
+
},
|
|
78
|
+
left: { left: 0 },
|
|
79
|
+
right: { right: 0 },
|
|
80
|
+
ripple: {
|
|
81
|
+
width: 80,
|
|
82
|
+
height: 80,
|
|
83
|
+
borderRadius: 40,
|
|
84
|
+
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
justifyContent: 'center',
|
|
87
|
+
gap: 2,
|
|
88
|
+
},
|
|
89
|
+
arrow: { color: '#fff', fontSize: 20, fontWeight: '700' },
|
|
90
|
+
label: { color: '#fff', fontSize: 11, fontWeight: '600' },
|
|
91
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
2
|
+
import { useVideoStore } from '../core/VideoPlayerContext';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
onRetry?: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ErrorOverlay({ onRetry }: Props) {
|
|
9
|
+
const status = useVideoStore((s) => s.status);
|
|
10
|
+
const error = useVideoStore((s) => s.error);
|
|
11
|
+
if (status !== 'error') return null;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<View style={styles.container}>
|
|
15
|
+
<Text style={styles.icon}>⚠️</Text>
|
|
16
|
+
<Text style={styles.message}>{error ?? 'Something went wrong'}</Text>
|
|
17
|
+
{onRetry && (
|
|
18
|
+
<TouchableOpacity style={styles.retryBtn} onPress={onRetry}>
|
|
19
|
+
<Text style={styles.retryText}>Retry</Text>
|
|
20
|
+
</TouchableOpacity>
|
|
21
|
+
)}
|
|
22
|
+
</View>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const styles = StyleSheet.create({
|
|
27
|
+
container: {
|
|
28
|
+
...StyleSheet.absoluteFill,
|
|
29
|
+
alignItems: 'center',
|
|
30
|
+
justifyContent: 'center',
|
|
31
|
+
backgroundColor: 'rgba(0,0,0,0.75)',
|
|
32
|
+
gap: 12,
|
|
33
|
+
},
|
|
34
|
+
icon: { fontSize: 36 },
|
|
35
|
+
message: {
|
|
36
|
+
color: '#fff',
|
|
37
|
+
fontSize: 14,
|
|
38
|
+
textAlign: 'center',
|
|
39
|
+
paddingHorizontal: 24,
|
|
40
|
+
},
|
|
41
|
+
retryBtn: {
|
|
42
|
+
marginTop: 8,
|
|
43
|
+
paddingHorizontal: 24,
|
|
44
|
+
paddingVertical: 10,
|
|
45
|
+
backgroundColor: '#fff',
|
|
46
|
+
borderRadius: 20,
|
|
47
|
+
},
|
|
48
|
+
retryText: { color: '#000', fontWeight: '700', fontSize: 14 },
|
|
49
|
+
});
|