react-native-expo-video-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.
@@ -0,0 +1,429 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { Dimensions, StyleSheet, Text, TouchableOpacity, View, Animated, Platform, } from 'react-native';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { useEvent } from 'expo';
6
+ import Slider from '@react-native-community/slider';
7
+ import { PlayerIcon } from './PlayerIcon';
8
+ const defaultThumbImage = Platform.OS === 'ios' ? require('./assets/slider-thumb.png') : undefined;
9
+ /** 格式化秒数为 m:ss */
10
+ const formatTime = (seconds) => {
11
+ if (!seconds || seconds < 0)
12
+ return '0:00';
13
+ const m = Math.floor(seconds / 60);
14
+ const s = Math.floor(seconds % 60);
15
+ return `${m}:${s.toString().padStart(2, '0')}`;
16
+ };
17
+ export const VideoControls = ({ player, theme, isFullscreen, onToggleFullscreen, onBack, speedOptions, speedLabel, autoHideTimeout, enableGestures, showSpeedPicker: showSpeedPickerButton, showFullscreenButton, showQualityPicker: showQualityPickerButton, qualityLabel, qualityOptions, onQualityChange, thumbImage, }) => {
18
+ const insets = useSafeAreaInsets();
19
+ const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
20
+ const { status } = useEvent(player, 'statusChange', { status: player.status });
21
+ const [currentTime, setCurrentTime] = useState(0);
22
+ const [duration, setDuration] = useState(0);
23
+ const [visible, setVisible] = useState(true);
24
+ const [isSeeking, setIsSeeking] = useState(false);
25
+ const [seekValue, setSeekValue] = useState(0);
26
+ const [playbackRate, setPlaybackRate] = useState(1);
27
+ const [showSpeedPicker, setShowSpeedPicker] = useState(false);
28
+ const [showQualityPicker, setShowQualityPicker] = useState(false);
29
+ const [selectedQualityLabel, setSelectedQualityLabel] = useState('自动');
30
+ const [brightness, setBrightness] = useState(0.5);
31
+ const [gestureHint, setGestureHint] = useState(null);
32
+ const lastTapRef = useRef(0);
33
+ const gestureStartValue = useRef(0);
34
+ const gestureType = useRef(null);
35
+ const touchStartY = useRef(0);
36
+ const touchStartX = useRef(0);
37
+ const isGesturing = useRef(false);
38
+ const seekTarget = useRef(0);
39
+ const fadeAnim = useRef(new Animated.Value(1)).current;
40
+ const hideTimer = useRef(null);
41
+ const resolvedThumbImage = thumbImage ?? defaultThumbImage;
42
+ // 时间更新
43
+ useEffect(() => {
44
+ player.timeUpdateEventInterval = 0.5;
45
+ const sub = player.addListener('timeUpdate', (payload) => {
46
+ if (!isSeeking)
47
+ setCurrentTime(payload.currentTime);
48
+ });
49
+ return () => sub.remove();
50
+ }, [player, isSeeking]);
51
+ // 从资源加载获取时长
52
+ useEffect(() => {
53
+ const sub = player.addListener('sourceLoad', (payload) => {
54
+ setDuration(payload.duration);
55
+ });
56
+ if (player.duration > 0)
57
+ setDuration(player.duration);
58
+ return () => sub.remove();
59
+ }, [player]);
60
+ // 全屏时初始化亮度
61
+ useEffect(() => {
62
+ if (isFullscreen && enableGestures) {
63
+ let Brightness;
64
+ try {
65
+ Brightness = require('expo-brightness');
66
+ Brightness.getBrightnessAsync().then((b) => setBrightness(b)).catch(() => { });
67
+ }
68
+ catch { }
69
+ }
70
+ }, [isFullscreen, enableGestures]);
71
+ // 全屏手势处理
72
+ const onGestureTouchStart = useCallback((e) => {
73
+ if (!isFullscreen || !enableGestures)
74
+ return;
75
+ touchStartX.current = e.nativeEvent.pageX;
76
+ touchStartY.current = e.nativeEvent.pageY;
77
+ isGesturing.current = false;
78
+ gestureType.current = null;
79
+ }, [isFullscreen, enableGestures]);
80
+ const onGestureTouchMove = useCallback((e) => {
81
+ if (!isFullscreen || !enableGestures)
82
+ return;
83
+ const dx = e.nativeEvent.pageX - touchStartX.current;
84
+ const dy = e.nativeEvent.pageY - touchStartY.current;
85
+ if (!isGesturing.current && !gestureType.current) {
86
+ if (Math.abs(dy) > 15 && Math.abs(dy) > Math.abs(dx) * 1.5) {
87
+ const windowWidth = Dimensions.get('window').width;
88
+ const isLeft = touchStartX.current < windowWidth / 2;
89
+ gestureType.current = isLeft ? 'volume' : 'brightness';
90
+ gestureStartValue.current = isLeft ? player.volume : brightness;
91
+ isGesturing.current = true;
92
+ }
93
+ else if (Math.abs(dx) > 15 && Math.abs(dx) > Math.abs(dy) * 1.5) {
94
+ gestureType.current = 'seek';
95
+ gestureStartValue.current = player.currentTime;
96
+ seekTarget.current = player.currentTime;
97
+ isGesturing.current = true;
98
+ }
99
+ return;
100
+ }
101
+ if (!isGesturing.current)
102
+ return;
103
+ const delta = -dy / 200;
104
+ if (gestureType.current === 'volume') {
105
+ const newVal = Math.max(0, Math.min(1, gestureStartValue.current + delta));
106
+ player.volume = newVal;
107
+ setGestureHint({ type: 'volume', value: newVal });
108
+ }
109
+ else if (gestureType.current === 'brightness') {
110
+ const newVal = Math.max(0, Math.min(1, gestureStartValue.current + delta));
111
+ try {
112
+ const Brightness = require('expo-brightness');
113
+ Brightness.setBrightnessAsync(newVal).catch(() => { });
114
+ }
115
+ catch { }
116
+ setBrightness(newVal);
117
+ setGestureHint({ type: 'brightness', value: newVal });
118
+ }
119
+ else if (gestureType.current === 'seek') {
120
+ const seekDelta = Math.max(-120, Math.min(120, dx * 0.5));
121
+ const newTime = Math.max(0, Math.min(duration, gestureStartValue.current + seekDelta));
122
+ seekTarget.current = newTime;
123
+ setGestureHint({ type: 'seek', value: seekDelta });
124
+ }
125
+ }, [isFullscreen, enableGestures, player, brightness, duration]);
126
+ const onGestureTouchEnd = useCallback(() => {
127
+ if (isGesturing.current) {
128
+ if (gestureType.current === 'seek') {
129
+ player.currentTime = seekTarget.current;
130
+ }
131
+ isGesturing.current = false;
132
+ gestureType.current = null;
133
+ setGestureHint(null);
134
+ }
135
+ }, [player]);
136
+ // 控制栏自动隐藏
137
+ const resetHideTimer = useCallback(() => {
138
+ if (hideTimer.current)
139
+ clearTimeout(hideTimer.current);
140
+ if (isPlaying) {
141
+ hideTimer.current = setTimeout(() => {
142
+ Animated.timing(fadeAnim, {
143
+ toValue: 0,
144
+ duration: 300,
145
+ useNativeDriver: true,
146
+ }).start(() => setVisible(false));
147
+ }, autoHideTimeout);
148
+ }
149
+ }, [isPlaying, fadeAnim, autoHideTimeout]);
150
+ useEffect(() => {
151
+ if (isPlaying) {
152
+ resetHideTimer();
153
+ }
154
+ else {
155
+ if (hideTimer.current)
156
+ clearTimeout(hideTimer.current);
157
+ setVisible(true);
158
+ fadeAnim.setValue(1);
159
+ }
160
+ return () => {
161
+ if (hideTimer.current)
162
+ clearTimeout(hideTimer.current);
163
+ };
164
+ }, [isPlaying, resetHideTimer, fadeAnim]);
165
+ const toggleVisibility = () => {
166
+ if (visible) {
167
+ Animated.timing(fadeAnim, {
168
+ toValue: 0,
169
+ duration: 200,
170
+ useNativeDriver: true,
171
+ }).start(() => setVisible(false));
172
+ }
173
+ else {
174
+ setVisible(true);
175
+ fadeAnim.setValue(1);
176
+ resetHideTimer();
177
+ }
178
+ };
179
+ const togglePlay = () => {
180
+ if (isPlaying)
181
+ player.pause();
182
+ else
183
+ player.play();
184
+ resetHideTimer();
185
+ };
186
+ const handleSeekStart = () => setIsSeeking(true);
187
+ const handleSeekChange = (value) => {
188
+ setSeekValue(value);
189
+ setCurrentTime(value);
190
+ };
191
+ const handleSeekComplete = (value) => {
192
+ player.currentTime = value;
193
+ setIsSeeking(false);
194
+ resetHideTimer();
195
+ };
196
+ const handleTap = () => {
197
+ const now = Date.now();
198
+ if (now - lastTapRef.current < 300) {
199
+ togglePlay();
200
+ lastTapRef.current = 0;
201
+ }
202
+ else {
203
+ lastTapRef.current = now;
204
+ setTimeout(() => {
205
+ if (lastTapRef.current !== 0 && Date.now() - lastTapRef.current >= 280) {
206
+ toggleVisibility();
207
+ lastTapRef.current = 0;
208
+ }
209
+ }, 300);
210
+ }
211
+ };
212
+ const handleSpeedChange = (speed) => {
213
+ player.playbackRate = speed;
214
+ setPlaybackRate(speed);
215
+ setShowSpeedPicker(false);
216
+ setShowQualityPicker(false);
217
+ resetHideTimer();
218
+ };
219
+ /** 切换画质:通过 replaceAsync 切换视频 URL */
220
+ const handleQualityChange = async (option) => {
221
+ const prevTime = player.currentTime;
222
+ const wasPlaying = player.playing;
223
+ if (option) {
224
+ await player.replaceAsync(option.url);
225
+ }
226
+ else if (qualityOptions.length > 0) {
227
+ // "自动"选项:使用第一个 URL
228
+ await player.replaceAsync(qualityOptions[0].url);
229
+ }
230
+ // 恢复播放进度和状态
231
+ player.currentTime = prevTime;
232
+ if (wasPlaying)
233
+ player.play();
234
+ setSelectedQualityLabel(option?.label ?? '自动');
235
+ setShowQualityPicker(false);
236
+ onQualityChange(option);
237
+ resetHideTimer();
238
+ };
239
+ const handleFullscreen = () => {
240
+ onToggleFullscreen();
241
+ resetHideTimer();
242
+ };
243
+ const displayTime = isSeeking ? seekValue : currentTime;
244
+ return (_jsxs(View, { style: StyleSheet.absoluteFill, onTouchStart: onGestureTouchStart, onTouchMove: onGestureTouchMove, onTouchEnd: onGestureTouchEnd, children: [_jsx(TouchableOpacity, { style: StyleSheet.absoluteFill, activeOpacity: 1, onPress: handleTap, children: (visible || !isPlaying) && (_jsxs(Animated.View, { style: [styles.overlay, { opacity: fadeAnim }], children: [_jsx(TouchableOpacity, { onPress: onBack, style: [styles.backBtn, { top: 6, left: isFullscreen ? insets.left + 8 : 8 }], activeOpacity: 0.7, children: _jsx(PlayerIcon, { name: "arrow-left", size: 24, color: "#fff" }) }), _jsxs(View, { style: [
245
+ styles.bottomBar,
246
+ {
247
+ paddingBottom: isFullscreen
248
+ ? Math.max(insets.bottom, Platform.OS === 'ios' ? 8 : 4)
249
+ : 2,
250
+ paddingLeft: isFullscreen ? insets.left + 12 : 12,
251
+ paddingRight: isFullscreen ? insets.right + 12 : 12,
252
+ },
253
+ ], children: [_jsx(TouchableOpacity, { onPress: togglePlay, style: styles.playBtn, activeOpacity: 0.7, children: _jsx(PlayerIcon, { name: isPlaying ? 'pause' : 'play', size: 22, color: "#fff" }) }), _jsx(Text, { style: styles.timeText, children: formatTime(displayTime) }), _jsx(View, { style: styles.sliderContainer, children: _jsx(Slider, { style: styles.slider, minimumValue: 0, maximumValue: duration > 0 ? duration : 1, value: displayTime, onSlidingStart: handleSeekStart, onValueChange: handleSeekChange, onSlidingComplete: handleSeekComplete, minimumTrackTintColor: theme.primary, maximumTrackTintColor: "rgba(255,255,255,0.3)", thumbTintColor: theme.primary, thumbImage: resolvedThumbImage, ...(Platform.OS === 'ios' ? { tapToSeek: true } : {}) }) }), _jsx(Text, { style: styles.timeText, children: formatTime(duration) }), showSpeedPickerButton && (_jsx(TouchableOpacity, { onPress: () => {
254
+ setShowSpeedPicker(!showSpeedPicker);
255
+ setShowQualityPicker(false);
256
+ resetHideTimer();
257
+ }, style: styles.bottomBtn, activeOpacity: 0.7, children: _jsx(Text, { style: styles.speedText, children: playbackRate === 1 ? speedLabel : `${playbackRate}x` }) })), showQualityPickerButton && (_jsx(TouchableOpacity, { onPress: () => {
258
+ setShowQualityPicker(!showQualityPicker);
259
+ setShowSpeedPicker(false);
260
+ resetHideTimer();
261
+ }, style: styles.bottomBtn, activeOpacity: 0.7, children: _jsx(Text, { style: styles.speedText, children: selectedQualityLabel === '自动' ? qualityLabel : selectedQualityLabel }) })), showFullscreenButton && (_jsx(TouchableOpacity, { onPress: handleFullscreen, style: styles.bottomBtn, activeOpacity: 0.7, children: _jsx(PlayerIcon, { name: isFullscreen ? 'fullscreen-exit' : 'fullscreen', size: 22, color: "#fff" }) }))] }), showSpeedPicker && (_jsx(View, { style: [
262
+ styles.speedPicker,
263
+ {
264
+ right: isFullscreen ? insets.right + 12 : 12,
265
+ bottom: isFullscreen
266
+ ? 44 + Math.max(insets.bottom, Platform.OS === 'ios' ? 8 : 4)
267
+ : 44,
268
+ },
269
+ ], children: speedOptions.map((speed) => (_jsx(TouchableOpacity, { style: [
270
+ styles.speedOption,
271
+ playbackRate === speed && { backgroundColor: theme.primary },
272
+ ], onPress: () => handleSpeedChange(speed), activeOpacity: 0.7, children: _jsx(Text, { style: [
273
+ styles.speedOptionText,
274
+ playbackRate === speed && { color: '#fff' },
275
+ ], children: speed === 1 ? '1.0x' : `${speed}x` }) }, speed))) })), showQualityPicker && qualityOptions.length > 0 && (_jsxs(View, { style: [
276
+ styles.speedPicker,
277
+ {
278
+ right: isFullscreen ? insets.right + 12 : 12,
279
+ bottom: isFullscreen
280
+ ? 44 + Math.max(insets.bottom, Platform.OS === 'ios' ? 8 : 4)
281
+ : 44,
282
+ },
283
+ ], children: [_jsx(TouchableOpacity, { style: [
284
+ styles.speedOption,
285
+ selectedQualityLabel === '自动' && { backgroundColor: theme.primary },
286
+ ], onPress: () => handleQualityChange(null), activeOpacity: 0.7, children: _jsx(Text, { style: [
287
+ styles.speedOptionText,
288
+ selectedQualityLabel === '自动' && { color: '#fff' },
289
+ ], children: "\u81EA\u52A8" }) }), qualityOptions.map((option) => (_jsx(TouchableOpacity, { style: [
290
+ styles.speedOption,
291
+ selectedQualityLabel === option.label && { backgroundColor: theme.primary },
292
+ ], onPress: () => handleQualityChange(option), activeOpacity: 0.7, children: _jsx(Text, { style: [
293
+ styles.speedOptionText,
294
+ selectedQualityLabel === option.label && { color: '#fff' },
295
+ ], children: option.label }) }, option.label)))] }))] })) }), gestureHint && (_jsx(View, { style: styles.gestureIndicator, pointerEvents: "none", children: gestureHint.type !== 'seek' ? (_jsxs(_Fragment, { children: [_jsx(PlayerIcon, { name: gestureHint.type === 'volume'
296
+ ? gestureHint.value === 0
297
+ ? 'volume-off'
298
+ : gestureHint.value < 0.5
299
+ ? 'volume-medium'
300
+ : 'volume-high'
301
+ : 'brightness-6', size: 22, color: "#fff" }), _jsx(View, { style: styles.gestureBarBg, children: _jsx(View, { style: [
302
+ styles.gestureBarFill,
303
+ { height: `${Math.round(gestureHint.value * 100)}%` },
304
+ ] }) }), _jsxs(Text, { style: styles.gestureValueText, children: [Math.round(gestureHint.value * 100), "%"] })] })) : (_jsxs(_Fragment, { children: [_jsx(PlayerIcon, { name: gestureHint.value >= 0 ? 'fast-forward' : 'rewind', size: 22, color: "#fff" }), _jsxs(Text, { style: styles.gestureSeekText, children: [gestureHint.value >= 0 ? '+' : '', Math.round(gestureHint.value), "s"] }), _jsx(Text, { style: styles.gestureSeekTime, children: formatTime(seekTarget.current) })] })) }))] }));
305
+ };
306
+ const styles = StyleSheet.create({
307
+ overlay: {
308
+ ...StyleSheet.absoluteFillObject,
309
+ backgroundColor: 'rgba(0,0,0,0.15)',
310
+ justifyContent: 'flex-end',
311
+ },
312
+ backBtn: {
313
+ position: 'absolute',
314
+ width: 36,
315
+ height: 36,
316
+ borderRadius: 18,
317
+ backgroundColor: 'rgba(0,0,0,0.4)',
318
+ justifyContent: 'center',
319
+ alignItems: 'center',
320
+ },
321
+ bottomBar: {
322
+ position: 'absolute',
323
+ bottom: 0,
324
+ left: 0,
325
+ right: 0,
326
+ flexDirection: 'row',
327
+ alignItems: 'center',
328
+ paddingHorizontal: 12,
329
+ paddingTop: 4,
330
+ },
331
+ timeText: {
332
+ color: '#fff',
333
+ fontSize: 12,
334
+ fontVariant: ['tabular-nums'],
335
+ minWidth: 36,
336
+ textAlign: 'center',
337
+ },
338
+ sliderContainer: {
339
+ flex: 1,
340
+ marginHorizontal: 4,
341
+ },
342
+ slider: {
343
+ width: '100%',
344
+ height: Platform.OS === 'ios' ? 20 : 28,
345
+ },
346
+ bottomBtn: {
347
+ marginLeft: 6,
348
+ width: 32,
349
+ height: 32,
350
+ justifyContent: 'center',
351
+ alignItems: 'center',
352
+ },
353
+ playBtn: {
354
+ width: 32,
355
+ height: 32,
356
+ justifyContent: 'center',
357
+ alignItems: 'center',
358
+ },
359
+ speedText: {
360
+ color: '#fff',
361
+ fontSize: 11,
362
+ fontWeight: '600',
363
+ },
364
+ speedPicker: {
365
+ position: 'absolute',
366
+ backgroundColor: 'rgba(0,0,0,0.85)',
367
+ borderRadius: 8,
368
+ paddingVertical: 4,
369
+ paddingHorizontal: 4,
370
+ flexDirection: 'row',
371
+ gap: 4,
372
+ },
373
+ speedOption: {
374
+ paddingHorizontal: 10,
375
+ paddingVertical: 6,
376
+ borderRadius: 6,
377
+ },
378
+ speedOptionText: {
379
+ color: 'rgba(255,255,255,0.8)',
380
+ fontSize: 12,
381
+ fontWeight: '500',
382
+ },
383
+ gestureIndicator: {
384
+ position: 'absolute',
385
+ top: '50%',
386
+ left: '50%',
387
+ marginTop: -60,
388
+ marginLeft: -36,
389
+ width: 72,
390
+ alignItems: 'center',
391
+ backgroundColor: 'rgba(0,0,0,0.7)',
392
+ borderRadius: 8,
393
+ paddingVertical: 8,
394
+ paddingHorizontal: 6,
395
+ },
396
+ gestureBarBg: {
397
+ width: 4,
398
+ height: 60,
399
+ backgroundColor: 'rgba(255,255,255,0.3)',
400
+ borderRadius: 2,
401
+ marginTop: 6,
402
+ overflow: 'hidden',
403
+ justifyContent: 'flex-end',
404
+ },
405
+ gestureBarFill: {
406
+ width: '100%',
407
+ backgroundColor: '#fff',
408
+ borderRadius: 2,
409
+ },
410
+ gestureValueText: {
411
+ color: '#fff',
412
+ fontSize: 10,
413
+ marginTop: 4,
414
+ fontVariant: ['tabular-nums'],
415
+ },
416
+ gestureSeekText: {
417
+ color: '#fff',
418
+ fontSize: 14,
419
+ fontWeight: '600',
420
+ marginTop: 4,
421
+ fontVariant: ['tabular-nums'],
422
+ },
423
+ gestureSeekTime: {
424
+ color: 'rgba(255,255,255,0.7)',
425
+ fontSize: 10,
426
+ marginTop: 2,
427
+ fontVariant: ['tabular-nums'],
428
+ },
429
+ });
package/lib/index.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { ExpoVideoPlayer } from './ExpoVideoPlayer';
2
+ export { VideoControls } from './VideoControls';
3
+ export { useFullscreen } from './useFullscreen';
4
+ export { PlayerIcon } from './PlayerIcon';
5
+ export type { ExpoVideoPlayerProps, VideoControlsProps, VideoPlayerTheme } from './types';
6
+ export { useVideoPlayer, VideoView, createVideoPlayer, isPictureInPictureSupported, } from 'expo-video';
7
+ export type { VideoSource, VideoPlayer, VideoPlayerStatus, VideoPlayerEvents, VideoContentFit, DRMOptions, BufferOptions, VideoMetadata, } from 'expo-video';
package/lib/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { ExpoVideoPlayer } from './ExpoVideoPlayer';
2
+ export { VideoControls } from './VideoControls';
3
+ export { useFullscreen } from './useFullscreen';
4
+ export { PlayerIcon } from './PlayerIcon';
5
+ // 从 expo-video 重新导出常用符号,方便使用者直接引入
6
+ export { useVideoPlayer, VideoView, createVideoPlayer, isPictureInPictureSupported, } from 'expo-video';
package/lib/types.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { VideoView } from 'expo-video';
2
+ /**
3
+ * 从 VideoView 组件中提取 props 类型。
4
+ * 用于透传所有原生 VideoView 属性。
5
+ */
6
+ type VideoViewProps = React.ComponentProps<typeof VideoView>;
7
+ export interface VideoPlayerTheme {
8
+ /** 主题色,用于滑块、选中状态等 */
9
+ primary: string;
10
+ /** 文字颜色 */
11
+ text?: string;
12
+ /** 次要文字颜色 */
13
+ textSecondary?: string;
14
+ }
15
+ /**
16
+ * ExpoVideoPlayer 组件的 Props。
17
+ *
18
+ * 继承了 expo-video 的所有原生 `VideoView` 属性(如 `contentFit`、
19
+ * `allowsPictureInPicture`、`surfaceType`、`onFirstFrameRender` 等)。
20
+ *
21
+ * 内部管理的属性(`player`、`nativeControls`、`style`)已排除,
22
+ * 请使用本接口中的专用属性代替。
23
+ */
24
+ export interface ExpoVideoPlayerProps extends Omit<VideoViewProps, 'player' | 'nativeControls' | 'style'> {
25
+ /** expo-video 的 VideoPlayer 实例,通过 useVideoPlayer() 创建 */
26
+ player: import('expo-video').VideoPlayer;
27
+ /** 播放器 UI 主题色 */
28
+ theme?: VideoPlayerTheme;
29
+ /** 是否当前处于全屏模式 */
30
+ isFullscreen?: boolean;
31
+ /** 全屏切换回调 */
32
+ onToggleFullscreen?: () => void;
33
+ /** 返回按钮回调 */
34
+ onBack?: () => void;
35
+ /** 倍速选项,默认 [0.5, 0.75, 1, 1.25, 1.5, 2] */
36
+ speedOptions?: number[];
37
+ /** 1x 速度时显示的文字,默认 "倍速" */
38
+ speedLabel?: string;
39
+ /** 控制栏自动隐藏超时(毫秒),默认 3000 */
40
+ autoHideTimeout?: number;
41
+ /** 是否启用全屏手势(音量/亮度/快进),默认 true */
42
+ enableGestures?: boolean;
43
+ /** 是否显示倍速按钮,默认 true */
44
+ showSpeedPicker?: boolean;
45
+ /** 是否显示全屏按钮,默认 true */
46
+ showFullscreenButton?: boolean;
47
+ /** 是否显示画质选择按钮,默认 false */
48
+ showQualityPicker?: boolean;
49
+ /** 画质选择标签(按钮上"自动"时显示的文字),默认 "画质" */
50
+ qualityLabel?: string;
51
+ /**
52
+ * 画质选项列表,格式为 { label: string; url: string }[]。
53
+ * 示例: [{ label: '1080p', url: 'https://...' }, { label: '720p', url: 'https://...' }]
54
+ * 会自动在最前面添加"自动"选项(使用第一个 URL)。
55
+ * 不传或为空数组时画质按钮不会显示。
56
+ */
57
+ qualityOptions?: Array<{
58
+ label: string;
59
+ url: string;
60
+ }>;
61
+ /** 画质切换回调,返回选中的画质标签和 URL,null 表示选择了"自动" */
62
+ onQualityChange?: (quality: {
63
+ label: string;
64
+ url: string;
65
+ } | null) => void;
66
+ /** 自定义滑块拇指图片(require() 结果),iOS 专用 */
67
+ thumbImage?: any;
68
+ /** 视频容器宽度(非全屏),默认窗口宽度 */
69
+ width?: number;
70
+ /** 视频容器高度(非全屏),默认 width * 9/16 */
71
+ height?: number;
72
+ }
73
+ export interface VideoControlsProps {
74
+ player: import('expo-video').VideoPlayer;
75
+ theme: VideoPlayerTheme;
76
+ isFullscreen: boolean;
77
+ onToggleFullscreen: () => void;
78
+ onBack: () => void;
79
+ speedOptions: number[];
80
+ speedLabel: string;
81
+ autoHideTimeout: number;
82
+ enableGestures: boolean;
83
+ showSpeedPicker: boolean;
84
+ showFullscreenButton: boolean;
85
+ showQualityPicker: boolean;
86
+ qualityLabel: string;
87
+ qualityOptions: Array<{
88
+ label: string;
89
+ url: string;
90
+ }>;
91
+ onQualityChange: (quality: {
92
+ label: string;
93
+ url: string;
94
+ } | null) => void;
95
+ thumbImage?: any;
96
+ }
97
+ export {};
package/lib/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 全屏状态管理 Hook:屏幕方向锁定、状态栏显隐、Android 返回键处理。
3
+ *
4
+ * 需要 `expo-screen-orientation` 作为 peer dependency。
5
+ */
6
+ export declare function useFullscreen(initialFullscreen?: boolean): {
7
+ isFullscreen: boolean;
8
+ enterFullscreen: () => Promise<void>;
9
+ exitFullscreen: () => Promise<void>;
10
+ toggleFullscreen: () => void;
11
+ };
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { Platform, StatusBar, BackHandler } from 'react-native';
3
+ /**
4
+ * 全屏状态管理 Hook:屏幕方向锁定、状态栏显隐、Android 返回键处理。
5
+ *
6
+ * 需要 `expo-screen-orientation` 作为 peer dependency。
7
+ */
8
+ export function useFullscreen(initialFullscreen = false) {
9
+ const [isFullscreen, setIsFullscreen] = useState(initialFullscreen);
10
+ const prevOrientation = useRef(null);
11
+ const enterFullscreen = useCallback(async () => {
12
+ try {
13
+ const ScreenOrientation = require('expo-screen-orientation');
14
+ prevOrientation.current = await ScreenOrientation.getOrientationLockAsync?.() ?? null;
15
+ await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
16
+ }
17
+ catch { }
18
+ StatusBar.setHidden(true, 'fade');
19
+ setIsFullscreen(true);
20
+ }, []);
21
+ const exitFullscreen = useCallback(async () => {
22
+ try {
23
+ const ScreenOrientation = require('expo-screen-orientation');
24
+ await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
25
+ }
26
+ catch { }
27
+ StatusBar.setHidden(false, 'fade');
28
+ setIsFullscreen(false);
29
+ }, []);
30
+ const toggleFullscreen = useCallback(() => {
31
+ if (isFullscreen)
32
+ exitFullscreen();
33
+ else
34
+ enterFullscreen();
35
+ }, [isFullscreen, enterFullscreen, exitFullscreen]);
36
+ // Android 返回键退出全屏
37
+ useEffect(() => {
38
+ if (Platform.OS !== 'android' || !isFullscreen)
39
+ return;
40
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
41
+ exitFullscreen();
42
+ return true;
43
+ });
44
+ return () => sub.remove();
45
+ }, [isFullscreen, exitFullscreen]);
46
+ // 组件卸载时恢复屏幕方向
47
+ useEffect(() => {
48
+ return () => {
49
+ try {
50
+ const ScreenOrientation = require('expo-screen-orientation');
51
+ ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { });
52
+ }
53
+ catch { }
54
+ StatusBar.setHidden(false, 'fade');
55
+ };
56
+ }, []);
57
+ return {
58
+ isFullscreen,
59
+ enterFullscreen,
60
+ exitFullscreen,
61
+ toggleFullscreen,
62
+ };
63
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "react-native-expo-video-player",
3
+ "version": "0.1.0",
4
+ "description": "基于 expo-video 的功能丰富的 React Native 视频播放器组件,内置自定义控制栏、全屏手势、倍速切换等功能。",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "prepublishOnly": "tsc"
10
+ },
11
+ "keywords": [
12
+ "react-native",
13
+ "expo",
14
+ "video",
15
+ "player",
16
+ "expo-video",
17
+ "fullscreen",
18
+ "controls",
19
+ "gesture"
20
+ ],
21
+ "author": "xiaocheng",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/xiaocheng121380/react-native-expo-video-player.git"
26
+ },
27
+ "peerDependencies": {
28
+ "expo": ">=51.0.0",
29
+ "expo-brightness": ">=12.0.0",
30
+ "expo-screen-orientation": ">=7.0.0",
31
+ "expo-video": ">=2.0.0",
32
+ "react": ">=18.0.0",
33
+ "react-native": ">=0.75.0",
34
+ "react-native-safe-area-context": ">=4.0.0",
35
+ "@react-native-community/slider": ">=4.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/react": ">=18.0.0",
39
+ "typescript": "^5.0.0"
40
+ },
41
+ "files": [
42
+ "src",
43
+ "lib",
44
+ "README.md",
45
+ "LICENSE"
46
+ ]
47
+ }