@umituz/react-native-video-editor 1.1.44 → 1.1.46
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/package.json +1 -1
- package/src/index.ts +14 -0
- package/src/player/index.ts +14 -0
- package/src/player/infrastructure/services/player-control.service.ts +56 -17
- package/src/player/presentation/components/FullScreenVideoPlayer.tsx +170 -0
- package/src/player/presentation/components/VideoPlayer.tsx +68 -34
- package/src/player/presentation/components/VideoPlayerOverlay.tsx +159 -0
- package/src/player/presentation/components/VideoProgressBar.tsx +76 -0
- package/src/player/presentation/hooks/useControlsAutoHide.ts +66 -0
- package/src/player/presentation/hooks/useVideoPlaybackProgress.ts +61 -0
- package/src/player/presentation/hooks/useVideoPlayerControl.ts +51 -16
- package/src/player/types/index.ts +87 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-video-editor",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.46",
|
|
4
4
|
"description": "Professional video editor with layer-based timeline, text/image/shape/audio/animation layers, and export functionality",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
package/src/index.ts
CHANGED
|
@@ -125,6 +125,12 @@ export type {
|
|
|
125
125
|
VideoVisibilityConfig,
|
|
126
126
|
VideoPlayerProps,
|
|
127
127
|
VideoPlayerType,
|
|
128
|
+
PlaybackProgressState,
|
|
129
|
+
ControlsAutoHideConfig,
|
|
130
|
+
ControlsAutoHideResult,
|
|
131
|
+
VideoProgressBarProps,
|
|
132
|
+
VideoPlayerOverlayProps,
|
|
133
|
+
FullScreenVideoPlayerProps,
|
|
128
134
|
} from "./player";
|
|
129
135
|
|
|
130
136
|
export {
|
|
@@ -133,7 +139,15 @@ export {
|
|
|
133
139
|
safeToggle,
|
|
134
140
|
isPlayerReady,
|
|
135
141
|
configurePlayer,
|
|
142
|
+
safeSeekTo,
|
|
143
|
+
safeMute,
|
|
144
|
+
safeReplay,
|
|
136
145
|
useVideoPlayerControl,
|
|
137
146
|
useVideoVisibility,
|
|
147
|
+
useVideoPlaybackProgress,
|
|
148
|
+
useControlsAutoHide,
|
|
138
149
|
VideoPlayer,
|
|
150
|
+
VideoProgressBar,
|
|
151
|
+
VideoPlayerOverlay,
|
|
152
|
+
FullScreenVideoPlayer,
|
|
139
153
|
} from "./player";
|
package/src/player/index.ts
CHANGED
|
@@ -15,6 +15,12 @@ export type {
|
|
|
15
15
|
VideoDownloadProgressCallback,
|
|
16
16
|
VideoCacheResult,
|
|
17
17
|
VideoCachingState,
|
|
18
|
+
PlaybackProgressState,
|
|
19
|
+
ControlsAutoHideConfig,
|
|
20
|
+
ControlsAutoHideResult,
|
|
21
|
+
VideoProgressBarProps,
|
|
22
|
+
VideoPlayerOverlayProps,
|
|
23
|
+
FullScreenVideoPlayerProps,
|
|
18
24
|
} from "./types";
|
|
19
25
|
|
|
20
26
|
// Services
|
|
@@ -24,6 +30,9 @@ export {
|
|
|
24
30
|
safeToggle,
|
|
25
31
|
isPlayerReady,
|
|
26
32
|
configurePlayer,
|
|
33
|
+
safeSeekTo,
|
|
34
|
+
safeMute,
|
|
35
|
+
safeReplay,
|
|
27
36
|
} from "./infrastructure/services/player-control.service";
|
|
28
37
|
|
|
29
38
|
export {
|
|
@@ -39,6 +48,11 @@ export {
|
|
|
39
48
|
export { useVideoPlayerControl } from "./presentation/hooks/useVideoPlayerControl";
|
|
40
49
|
export { useVideoVisibility } from "./presentation/hooks/useVideoVisibility";
|
|
41
50
|
export { useVideoCaching } from "./presentation/hooks/useVideoCaching";
|
|
51
|
+
export { useVideoPlaybackProgress } from "./presentation/hooks/useVideoPlaybackProgress";
|
|
52
|
+
export { useControlsAutoHide } from "./presentation/hooks/useControlsAutoHide";
|
|
42
53
|
|
|
43
54
|
// Components
|
|
44
55
|
export { VideoPlayer } from "./presentation/components/VideoPlayer";
|
|
56
|
+
export { VideoProgressBar } from "./presentation/components/VideoProgressBar";
|
|
57
|
+
export { VideoPlayerOverlay } from "./presentation/components/VideoPlayerOverlay";
|
|
58
|
+
export { FullScreenVideoPlayer } from "./presentation/components/FullScreenVideoPlayer";
|
|
@@ -5,29 +5,18 @@
|
|
|
5
5
|
|
|
6
6
|
import type { VideoPlayer } from "expo-video";
|
|
7
7
|
|
|
8
|
-
declare const __DEV__: boolean;
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* Safely play video with error handling
|
|
12
10
|
*/
|
|
13
11
|
export const safePlay = (player: VideoPlayer | null): boolean => {
|
|
14
|
-
if (__DEV__) {
|
|
15
|
-
console.log("[safePlay] called, player:", !!player);
|
|
16
|
-
}
|
|
17
12
|
if (!player) return false;
|
|
18
13
|
|
|
19
14
|
try {
|
|
20
|
-
if (__DEV__) {
|
|
21
|
-
console.log("[safePlay] calling player.play()");
|
|
22
|
-
}
|
|
23
15
|
player.play();
|
|
24
|
-
if (__DEV__) {
|
|
25
|
-
console.log("[safePlay] player.play() called successfully");
|
|
26
|
-
}
|
|
27
16
|
return true;
|
|
28
17
|
} catch (error) {
|
|
29
|
-
if (__DEV__) {
|
|
30
|
-
console.log("[
|
|
18
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
19
|
+
console.log("[VideoPlayer] Play error:", error);
|
|
31
20
|
}
|
|
32
21
|
return false;
|
|
33
22
|
}
|
|
@@ -44,8 +33,7 @@ export const safePause = (player: VideoPlayer | null): boolean => {
|
|
|
44
33
|
return true;
|
|
45
34
|
} catch (error) {
|
|
46
35
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
47
|
-
|
|
48
|
-
console.log("[VideoPlayer] Pause error ignored:", error);
|
|
36
|
+
console.log("[VideoPlayer] Pause error:", error);
|
|
49
37
|
}
|
|
50
38
|
return false;
|
|
51
39
|
}
|
|
@@ -96,8 +84,59 @@ export const configurePlayer = (
|
|
|
96
84
|
}
|
|
97
85
|
} catch (error) {
|
|
98
86
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
99
|
-
|
|
100
|
-
console.log("[VideoPlayer] Configure error ignored:", error);
|
|
87
|
+
console.log("[VideoPlayer] Configure error:", error);
|
|
101
88
|
}
|
|
102
89
|
}
|
|
103
90
|
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Safely seek to a specific time position (in seconds)
|
|
94
|
+
*/
|
|
95
|
+
export const safeSeekTo = (player: VideoPlayer | null, seconds: number): boolean => {
|
|
96
|
+
if (!player) return false;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
player.currentTime = Math.max(0, seconds);
|
|
100
|
+
return true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
103
|
+
console.log("[VideoPlayer] SeekTo error:", error);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Safely set muted state
|
|
111
|
+
*/
|
|
112
|
+
export const safeMute = (player: VideoPlayer | null, muted: boolean): boolean => {
|
|
113
|
+
if (!player) return false;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
player.muted = muted;
|
|
117
|
+
return true;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
120
|
+
console.log("[VideoPlayer] Mute error:", error);
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Safely replay video from beginning
|
|
128
|
+
*/
|
|
129
|
+
export const safeReplay = (player: VideoPlayer | null): boolean => {
|
|
130
|
+
if (!player) return false;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
player.currentTime = 0;
|
|
134
|
+
player.play();
|
|
135
|
+
return true;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
138
|
+
console.log("[VideoPlayer] Replay error:", error);
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FullScreenVideoPlayer Component
|
|
3
|
+
* Modal-based fullscreen video player with custom overlay controls
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo, useCallback } from "react";
|
|
7
|
+
import { View, Modal, StyleSheet, StatusBar } from "react-native";
|
|
8
|
+
import { Image } from "expo-image";
|
|
9
|
+
// expo-video is optional — lazy require
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
let VideoView: React.ComponentType<any> = () => null;
|
|
12
|
+
try {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
VideoView = require("expo-video").VideoView;
|
|
15
|
+
} catch {
|
|
16
|
+
// expo-video not installed
|
|
17
|
+
}
|
|
18
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
19
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
20
|
+
|
|
21
|
+
import type { FullScreenVideoPlayerProps } from "../../types";
|
|
22
|
+
import { useVideoPlayerControl } from "../hooks/useVideoPlayerControl";
|
|
23
|
+
import { useVideoCaching } from "../hooks/useVideoCaching";
|
|
24
|
+
import { useControlsAutoHide } from "../hooks/useControlsAutoHide";
|
|
25
|
+
import { VideoPlayerOverlay } from "./VideoPlayerOverlay";
|
|
26
|
+
|
|
27
|
+
export const FullScreenVideoPlayer: React.FC<FullScreenVideoPlayerProps> = ({
|
|
28
|
+
visible,
|
|
29
|
+
source,
|
|
30
|
+
title,
|
|
31
|
+
subtitle,
|
|
32
|
+
thumbnailUrl,
|
|
33
|
+
onClose,
|
|
34
|
+
loop = true,
|
|
35
|
+
autoPlay = true,
|
|
36
|
+
}) => {
|
|
37
|
+
const tokens = useAppDesignTokens();
|
|
38
|
+
|
|
39
|
+
// Only cache/play when modal is visible
|
|
40
|
+
const { localUri, isDownloading, downloadProgress, error } = useVideoCaching(
|
|
41
|
+
visible ? source : null,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const { player, state, controls } = useVideoPlayerControl({
|
|
45
|
+
source: visible ? localUri : null,
|
|
46
|
+
loop,
|
|
47
|
+
muted: false,
|
|
48
|
+
autoPlay,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const { visible: controlsVisible, toggle: toggleControls } = useControlsAutoHide({
|
|
52
|
+
isPlaying: state.isPlaying,
|
|
53
|
+
autoHideDelay: 3000,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const handleClose = useCallback(() => {
|
|
57
|
+
controls.pause();
|
|
58
|
+
onClose();
|
|
59
|
+
}, [controls, onClose]);
|
|
60
|
+
|
|
61
|
+
const styles = useMemo(
|
|
62
|
+
() =>
|
|
63
|
+
StyleSheet.create({
|
|
64
|
+
container: {
|
|
65
|
+
flex: 1,
|
|
66
|
+
backgroundColor: "#000000",
|
|
67
|
+
justifyContent: "center",
|
|
68
|
+
alignItems: "center",
|
|
69
|
+
},
|
|
70
|
+
video: {
|
|
71
|
+
width: "100%",
|
|
72
|
+
height: "100%",
|
|
73
|
+
},
|
|
74
|
+
centerContent: {
|
|
75
|
+
...StyleSheet.absoluteFillObject,
|
|
76
|
+
justifyContent: "center",
|
|
77
|
+
alignItems: "center",
|
|
78
|
+
backgroundColor: "rgba(0,0,0,0.5)",
|
|
79
|
+
},
|
|
80
|
+
progressText: {
|
|
81
|
+
color: "#FFFFFF",
|
|
82
|
+
fontSize: 18,
|
|
83
|
+
fontWeight: "600",
|
|
84
|
+
},
|
|
85
|
+
errorText: {
|
|
86
|
+
color: tokens.colors.error,
|
|
87
|
+
fontSize: 16,
|
|
88
|
+
textAlign: "center",
|
|
89
|
+
padding: 24,
|
|
90
|
+
},
|
|
91
|
+
thumbnail: {
|
|
92
|
+
...StyleSheet.absoluteFillObject,
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
[tokens.colors.error],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const renderContent = () => {
|
|
99
|
+
if (error) {
|
|
100
|
+
return (
|
|
101
|
+
<View style={styles.container}>
|
|
102
|
+
<AtomicText style={styles.errorText}>{error}</AtomicText>
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (isDownloading) {
|
|
108
|
+
return (
|
|
109
|
+
<View style={styles.container}>
|
|
110
|
+
{thumbnailUrl && (
|
|
111
|
+
<Image source={{ uri: thumbnailUrl }} style={styles.thumbnail} contentFit="cover" />
|
|
112
|
+
)}
|
|
113
|
+
<View style={styles.centerContent}>
|
|
114
|
+
<AtomicText style={styles.progressText}>
|
|
115
|
+
{Math.round(downloadProgress * 100)}%
|
|
116
|
+
</AtomicText>
|
|
117
|
+
</View>
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (state.isPlayerValid && player) {
|
|
123
|
+
return (
|
|
124
|
+
<View style={styles.container}>
|
|
125
|
+
<VideoView
|
|
126
|
+
player={player}
|
|
127
|
+
style={styles.video}
|
|
128
|
+
contentFit="contain"
|
|
129
|
+
nativeControls={false}
|
|
130
|
+
/>
|
|
131
|
+
<VideoPlayerOverlay
|
|
132
|
+
visible={controlsVisible}
|
|
133
|
+
isPlaying={state.isPlaying}
|
|
134
|
+
isMuted={state.isMuted}
|
|
135
|
+
currentTime={state.currentTime}
|
|
136
|
+
duration={state.duration}
|
|
137
|
+
title={title}
|
|
138
|
+
subtitle={subtitle}
|
|
139
|
+
onTogglePlay={controls.toggle}
|
|
140
|
+
onToggleMute={controls.toggleMute}
|
|
141
|
+
onSeek={controls.seekTo}
|
|
142
|
+
onBack={handleClose}
|
|
143
|
+
onTap={toggleControls}
|
|
144
|
+
/>
|
|
145
|
+
</View>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<View style={styles.container}>
|
|
151
|
+
{thumbnailUrl && (
|
|
152
|
+
<Image source={{ uri: thumbnailUrl }} style={styles.thumbnail} contentFit="cover" />
|
|
153
|
+
)}
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<Modal
|
|
160
|
+
visible={visible}
|
|
161
|
+
animationType="fade"
|
|
162
|
+
presentationStyle="fullScreen"
|
|
163
|
+
supportedOrientations={["portrait", "landscape"]}
|
|
164
|
+
onRequestClose={handleClose}
|
|
165
|
+
>
|
|
166
|
+
<StatusBar hidden={visible} />
|
|
167
|
+
{renderContent()}
|
|
168
|
+
</Modal>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* VideoPlayer Component
|
|
3
|
-
* Reusable video player with caching, thumbnail and
|
|
3
|
+
* Reusable video player with caching, thumbnail, controls, and optional custom overlay
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import React, { useState, useCallback, useMemo } from "react";
|
|
6
|
+
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
|
7
7
|
import { View, TouchableOpacity, StyleSheet, type ViewStyle } from "react-native";
|
|
8
8
|
import { Image } from "expo-image";
|
|
9
9
|
// expo-video is optional — lazy require so it is not auto-installed
|
|
@@ -22,17 +22,20 @@ import { useResponsive } from "@umituz/react-native-design-system/responsive";
|
|
|
22
22
|
import type { VideoPlayerProps } from "../../types";
|
|
23
23
|
import { useVideoPlayerControl } from "../hooks/useVideoPlayerControl";
|
|
24
24
|
import { useVideoCaching } from "../hooks/useVideoCaching";
|
|
25
|
+
import { useControlsAutoHide } from "../hooks/useControlsAutoHide";
|
|
26
|
+
import { VideoPlayerOverlay } from "./VideoPlayerOverlay";
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
const DEFAULT_ASPECT_RATIO = 16 / 9;
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
/** Check if style provides its own sizing (width/height/aspectRatio/flex) */
|
|
31
|
+
const hasCustomSizing = (style: ViewStyle | undefined): boolean => {
|
|
32
|
+
if (!style) return false;
|
|
33
|
+
return (
|
|
34
|
+
style.width !== undefined ||
|
|
35
|
+
style.height !== undefined ||
|
|
36
|
+
style.aspectRatio !== undefined ||
|
|
37
|
+
style.flex !== undefined
|
|
38
|
+
);
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
@@ -41,22 +44,27 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
41
44
|
loop = true,
|
|
42
45
|
muted = false,
|
|
43
46
|
autoPlay = false,
|
|
47
|
+
showControls = false,
|
|
44
48
|
nativeControls = true,
|
|
45
49
|
contentFit = "cover",
|
|
46
50
|
style,
|
|
47
51
|
playbackRate = 1,
|
|
48
52
|
filterOverlay,
|
|
53
|
+
title,
|
|
54
|
+
subtitle,
|
|
55
|
+
onBack,
|
|
56
|
+
onProgress,
|
|
49
57
|
}) => {
|
|
50
|
-
// IMPORTANT: Call useResponsive BEFORE useAppDesignTokens to maintain hook order
|
|
51
58
|
const { width: screenWidth, horizontalPadding } = useResponsive();
|
|
52
59
|
const tokens = useAppDesignTokens();
|
|
53
60
|
const [userTriggeredPlay, setUserTriggeredPlay] = useState(false);
|
|
54
61
|
const showVideo = autoPlay || userTriggeredPlay;
|
|
55
62
|
|
|
56
|
-
//
|
|
63
|
+
// When showControls is true, disable native controls and show custom overlay
|
|
64
|
+
const useNativeControls = showControls ? false : nativeControls;
|
|
65
|
+
|
|
57
66
|
const { localUri, isDownloading, downloadProgress, error } = useVideoCaching(source);
|
|
58
67
|
|
|
59
|
-
// Use cached local URI for player
|
|
60
68
|
const { player, state, controls } = useVideoPlayerControl({
|
|
61
69
|
source: localUri,
|
|
62
70
|
loop,
|
|
@@ -65,31 +73,48 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
65
73
|
playbackRate,
|
|
66
74
|
});
|
|
67
75
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
const { visible: controlsVisible, toggle: toggleControls } = useControlsAutoHide({
|
|
77
|
+
isPlaying: state.isPlaying,
|
|
78
|
+
autoHideDelay: 3000,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Notify parent of progress changes
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (onProgress && state.duration > 0) {
|
|
84
|
+
onProgress(state.currentTime, state.duration);
|
|
71
85
|
}
|
|
86
|
+
}, [onProgress, state.currentTime, state.duration]);
|
|
87
|
+
|
|
88
|
+
const handlePlay = useCallback(() => {
|
|
72
89
|
setUserTriggeredPlay(true);
|
|
73
90
|
controls.play();
|
|
74
|
-
}, [
|
|
91
|
+
}, [controls]);
|
|
75
92
|
|
|
76
|
-
// Calculate dimensions
|
|
77
|
-
const
|
|
78
|
-
const
|
|
93
|
+
// Calculate fallback dimensions only when style doesn't provide sizing
|
|
94
|
+
const customSizing = hasCustomSizing(style as ViewStyle);
|
|
95
|
+
const videoWidth = customSizing ? undefined : (screenWidth - horizontalPadding * 2);
|
|
96
|
+
const videoHeight = videoWidth ? videoWidth / DEFAULT_ASPECT_RATIO : undefined;
|
|
79
97
|
|
|
80
98
|
const containerStyle = useMemo(() => ({
|
|
81
|
-
width: videoWidth,
|
|
82
|
-
height: videoHeight,
|
|
99
|
+
...(videoWidth !== undefined && { width: videoWidth }),
|
|
100
|
+
...(videoHeight !== undefined && { height: videoHeight }),
|
|
83
101
|
backgroundColor: tokens.colors.surface,
|
|
84
102
|
borderRadius: 16,
|
|
85
103
|
overflow: "hidden" as const,
|
|
86
104
|
}), [tokens.colors.surface, videoWidth, videoHeight]);
|
|
87
105
|
|
|
88
106
|
const styles = useMemo(() => StyleSheet.create({
|
|
89
|
-
video:
|
|
107
|
+
video: videoWidth !== undefined
|
|
108
|
+
? { width: videoWidth, height: videoHeight! }
|
|
109
|
+
: { width: "100%", height: "100%" },
|
|
90
110
|
thumbnailContainer: { flex: 1, justifyContent: "center", alignItems: "center" },
|
|
91
|
-
thumbnail:
|
|
92
|
-
|
|
111
|
+
thumbnail: videoWidth !== undefined
|
|
112
|
+
? { width: videoWidth, height: videoHeight! }
|
|
113
|
+
: { width: "100%", height: "100%" },
|
|
114
|
+
placeholder: {
|
|
115
|
+
...(videoWidth !== undefined ? { width: videoWidth, height: videoHeight! } : { flex: 1, width: "100%" }),
|
|
116
|
+
backgroundColor: tokens.colors.surfaceSecondary,
|
|
117
|
+
},
|
|
93
118
|
playButtonContainer: { ...StyleSheet.absoluteFillObject, justifyContent: "center", alignItems: "center" },
|
|
94
119
|
playButton: {
|
|
95
120
|
width: 64, height: 64, borderRadius: 32,
|
|
@@ -104,7 +129,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
104
129
|
errorText: { color: tokens.colors.error, fontSize: 14, textAlign: "center", padding: 16 },
|
|
105
130
|
}), [tokens, videoWidth, videoHeight]);
|
|
106
131
|
|
|
107
|
-
// Show error state
|
|
108
132
|
if (error) {
|
|
109
133
|
return (
|
|
110
134
|
<View style={[containerStyle, style]}>
|
|
@@ -118,7 +142,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
118
142
|
);
|
|
119
143
|
}
|
|
120
144
|
|
|
121
|
-
// Show download progress
|
|
122
145
|
if (isDownloading) {
|
|
123
146
|
const progressPercent = Math.round(downloadProgress * 100);
|
|
124
147
|
return (
|
|
@@ -137,18 +160,14 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
137
160
|
);
|
|
138
161
|
}
|
|
139
162
|
|
|
140
|
-
// Show video player
|
|
141
163
|
if (showVideo && state.isPlayerValid && player) {
|
|
142
|
-
if (__DEV__) {
|
|
143
|
-
console.log("[VideoPlayer] Rendering VideoView:", { videoWidth, videoHeight });
|
|
144
|
-
}
|
|
145
164
|
return (
|
|
146
165
|
<View style={[containerStyle, style]}>
|
|
147
166
|
<VideoView
|
|
148
167
|
player={player}
|
|
149
168
|
style={styles.video}
|
|
150
169
|
contentFit={contentFit}
|
|
151
|
-
nativeControls={
|
|
170
|
+
nativeControls={useNativeControls}
|
|
152
171
|
/>
|
|
153
172
|
{filterOverlay && filterOverlay.opacity > 0 && (
|
|
154
173
|
<View
|
|
@@ -159,11 +178,26 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
159
178
|
pointerEvents="none"
|
|
160
179
|
/>
|
|
161
180
|
)}
|
|
181
|
+
{showControls && (
|
|
182
|
+
<VideoPlayerOverlay
|
|
183
|
+
visible={controlsVisible}
|
|
184
|
+
isPlaying={state.isPlaying}
|
|
185
|
+
isMuted={state.isMuted}
|
|
186
|
+
currentTime={state.currentTime}
|
|
187
|
+
duration={state.duration}
|
|
188
|
+
title={title}
|
|
189
|
+
subtitle={subtitle}
|
|
190
|
+
onTogglePlay={controls.toggle}
|
|
191
|
+
onToggleMute={controls.toggleMute}
|
|
192
|
+
onSeek={controls.seekTo}
|
|
193
|
+
onBack={onBack}
|
|
194
|
+
onTap={toggleControls}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
162
197
|
</View>
|
|
163
198
|
);
|
|
164
199
|
}
|
|
165
200
|
|
|
166
|
-
// Show thumbnail with play button
|
|
167
201
|
return (
|
|
168
202
|
<TouchableOpacity style={[containerStyle, style]} onPress={handlePlay} activeOpacity={0.8}>
|
|
169
203
|
<View style={styles.thumbnailContainer}>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoPlayerOverlay Component
|
|
3
|
+
* Custom overlay controls: top bar (title, back), center play/pause, bottom bar (progress, mute)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, TouchableOpacity, StyleSheet, TouchableWithoutFeedback } from "react-native";
|
|
8
|
+
import { AtomicIcon, AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
|
|
10
|
+
import type { VideoPlayerOverlayProps } from "../../types";
|
|
11
|
+
import { VideoProgressBar } from "./VideoProgressBar";
|
|
12
|
+
|
|
13
|
+
const styles = StyleSheet.create({
|
|
14
|
+
overlay: {
|
|
15
|
+
...StyleSheet.absoluteFillObject,
|
|
16
|
+
justifyContent: "space-between",
|
|
17
|
+
},
|
|
18
|
+
topBar: {
|
|
19
|
+
flexDirection: "row",
|
|
20
|
+
alignItems: "center",
|
|
21
|
+
paddingHorizontal: 16,
|
|
22
|
+
paddingTop: 12,
|
|
23
|
+
paddingBottom: 8,
|
|
24
|
+
backgroundColor: "rgba(0,0,0,0.5)",
|
|
25
|
+
},
|
|
26
|
+
backButton: {
|
|
27
|
+
width: 40,
|
|
28
|
+
height: 40,
|
|
29
|
+
borderRadius: 20,
|
|
30
|
+
backgroundColor: "rgba(255,255,255,0.2)",
|
|
31
|
+
justifyContent: "center",
|
|
32
|
+
alignItems: "center",
|
|
33
|
+
},
|
|
34
|
+
titleContainer: {
|
|
35
|
+
flex: 1,
|
|
36
|
+
marginLeft: 12,
|
|
37
|
+
},
|
|
38
|
+
titleText: {
|
|
39
|
+
color: "#FFFFFF",
|
|
40
|
+
fontSize: 16,
|
|
41
|
+
fontWeight: "600",
|
|
42
|
+
},
|
|
43
|
+
subtitleText: {
|
|
44
|
+
color: "rgba(255,255,255,0.7)",
|
|
45
|
+
fontSize: 13,
|
|
46
|
+
marginTop: 2,
|
|
47
|
+
},
|
|
48
|
+
centerArea: {
|
|
49
|
+
flex: 1,
|
|
50
|
+
justifyContent: "center",
|
|
51
|
+
alignItems: "center",
|
|
52
|
+
},
|
|
53
|
+
centerPlayButton: {
|
|
54
|
+
width: 72,
|
|
55
|
+
height: 72,
|
|
56
|
+
borderRadius: 36,
|
|
57
|
+
backgroundColor: "rgba(0,0,0,0.6)",
|
|
58
|
+
justifyContent: "center",
|
|
59
|
+
alignItems: "center",
|
|
60
|
+
},
|
|
61
|
+
bottomBar: {
|
|
62
|
+
paddingHorizontal: 16,
|
|
63
|
+
paddingBottom: 16,
|
|
64
|
+
paddingTop: 8,
|
|
65
|
+
backgroundColor: "rgba(0,0,0,0.5)",
|
|
66
|
+
},
|
|
67
|
+
bottomControls: {
|
|
68
|
+
flexDirection: "row",
|
|
69
|
+
alignItems: "center",
|
|
70
|
+
justifyContent: "flex-end",
|
|
71
|
+
marginTop: 10,
|
|
72
|
+
},
|
|
73
|
+
controlButton: {
|
|
74
|
+
width: 36,
|
|
75
|
+
height: 36,
|
|
76
|
+
borderRadius: 18,
|
|
77
|
+
backgroundColor: "rgba(255,255,255,0.2)",
|
|
78
|
+
justifyContent: "center",
|
|
79
|
+
alignItems: "center",
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const VideoPlayerOverlay: React.FC<VideoPlayerOverlayProps> = ({
|
|
84
|
+
visible,
|
|
85
|
+
isPlaying,
|
|
86
|
+
isMuted,
|
|
87
|
+
currentTime,
|
|
88
|
+
duration,
|
|
89
|
+
title,
|
|
90
|
+
subtitle,
|
|
91
|
+
onTogglePlay,
|
|
92
|
+
onToggleMute,
|
|
93
|
+
onSeek,
|
|
94
|
+
onBack,
|
|
95
|
+
onTap,
|
|
96
|
+
}) => {
|
|
97
|
+
const hasTopBar = Boolean(onBack || title || subtitle);
|
|
98
|
+
|
|
99
|
+
if (!visible) {
|
|
100
|
+
return (
|
|
101
|
+
<TouchableWithoutFeedback onPress={onTap}>
|
|
102
|
+
<View style={styles.overlay} />
|
|
103
|
+
</TouchableWithoutFeedback>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<TouchableWithoutFeedback onPress={onTap}>
|
|
109
|
+
<View style={styles.overlay}>
|
|
110
|
+
{/* Top Bar — only render when there's content */}
|
|
111
|
+
{hasTopBar && (
|
|
112
|
+
<View style={styles.topBar}>
|
|
113
|
+
{onBack && (
|
|
114
|
+
<TouchableOpacity style={styles.backButton} onPress={onBack} activeOpacity={0.7}>
|
|
115
|
+
<AtomicIcon name="chevron-back" customSize={22} color="onPrimary" />
|
|
116
|
+
</TouchableOpacity>
|
|
117
|
+
)}
|
|
118
|
+
{(title || subtitle) && (
|
|
119
|
+
<View style={styles.titleContainer}>
|
|
120
|
+
{title && <AtomicText style={styles.titleText} numberOfLines={1}>{title}</AtomicText>}
|
|
121
|
+
{subtitle && <AtomicText style={styles.subtitleText} numberOfLines={1}>{subtitle}</AtomicText>}
|
|
122
|
+
</View>
|
|
123
|
+
)}
|
|
124
|
+
</View>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{/* Center Play/Pause */}
|
|
128
|
+
<View style={styles.centerArea}>
|
|
129
|
+
<TouchableOpacity style={styles.centerPlayButton} onPress={onTogglePlay} activeOpacity={0.7}>
|
|
130
|
+
<AtomicIcon
|
|
131
|
+
name={isPlaying ? "pause" : "play"}
|
|
132
|
+
customSize={36}
|
|
133
|
+
color="onPrimary"
|
|
134
|
+
/>
|
|
135
|
+
</TouchableOpacity>
|
|
136
|
+
</View>
|
|
137
|
+
|
|
138
|
+
{/* Bottom Bar — progress + mute */}
|
|
139
|
+
<View style={styles.bottomBar}>
|
|
140
|
+
<VideoProgressBar
|
|
141
|
+
currentTime={currentTime}
|
|
142
|
+
duration={duration}
|
|
143
|
+
onSeek={onSeek}
|
|
144
|
+
showTimeLabels
|
|
145
|
+
/>
|
|
146
|
+
<View style={styles.bottomControls}>
|
|
147
|
+
<TouchableOpacity style={styles.controlButton} onPress={onToggleMute} activeOpacity={0.7}>
|
|
148
|
+
<AtomicIcon
|
|
149
|
+
name={isMuted ? "volume-x" : "volume-2"}
|
|
150
|
+
customSize={18}
|
|
151
|
+
color="onPrimary"
|
|
152
|
+
/>
|
|
153
|
+
</TouchableOpacity>
|
|
154
|
+
</View>
|
|
155
|
+
</View>
|
|
156
|
+
</View>
|
|
157
|
+
</TouchableWithoutFeedback>
|
|
158
|
+
);
|
|
159
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoProgressBar Component
|
|
3
|
+
* Seekable progress bar with time labels for video playback
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useCallback, useRef } from "react";
|
|
7
|
+
import { View, StyleSheet, type LayoutChangeEvent } from "react-native";
|
|
8
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
+
|
|
11
|
+
import type { VideoProgressBarProps } from "../../types";
|
|
12
|
+
import { formatTimeDisplay } from "../../../infrastructure/utils/srt.utils";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_HEIGHT = 4;
|
|
15
|
+
|
|
16
|
+
export const VideoProgressBar: React.FC<VideoProgressBarProps> = ({
|
|
17
|
+
currentTime,
|
|
18
|
+
duration,
|
|
19
|
+
onSeek,
|
|
20
|
+
showTimeLabels = true,
|
|
21
|
+
height = DEFAULT_HEIGHT,
|
|
22
|
+
trackColor,
|
|
23
|
+
fillColor,
|
|
24
|
+
style,
|
|
25
|
+
}) => {
|
|
26
|
+
const tokens = useAppDesignTokens();
|
|
27
|
+
const progressPercent = duration > 0 ? Math.min(currentTime / duration, 1) * 100 : 0;
|
|
28
|
+
const barWidthRef = useRef(0);
|
|
29
|
+
|
|
30
|
+
const resolvedTrackColor = trackColor ?? "rgba(255,255,255,0.3)";
|
|
31
|
+
const resolvedFillColor = fillColor ?? tokens.colors.primary;
|
|
32
|
+
|
|
33
|
+
const handleLayout = useCallback((e: LayoutChangeEvent) => {
|
|
34
|
+
barWidthRef.current = e.nativeEvent.layout.width;
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const handleSeek = useCallback(
|
|
38
|
+
(e: { nativeEvent: { locationX: number } }) => {
|
|
39
|
+
if (!onSeek || duration <= 0 || barWidthRef.current <= 0) return;
|
|
40
|
+
const ratio = Math.max(0, Math.min(1, e.nativeEvent.locationX / barWidthRef.current));
|
|
41
|
+
onSeek(ratio * duration);
|
|
42
|
+
},
|
|
43
|
+
[onSeek, duration],
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={[barStyles.container, style]}>
|
|
48
|
+
<View style={barStyles.row}>
|
|
49
|
+
{showTimeLabels && (
|
|
50
|
+
<AtomicText style={barStyles.timeText}>{formatTimeDisplay(currentTime)}</AtomicText>
|
|
51
|
+
)}
|
|
52
|
+
<View
|
|
53
|
+
style={[barStyles.barContainer, { height, borderRadius: height / 2, backgroundColor: resolvedTrackColor }]}
|
|
54
|
+
onLayout={handleLayout}
|
|
55
|
+
onStartShouldSetResponder={() => Boolean(onSeek)}
|
|
56
|
+
onResponderRelease={handleSeek}
|
|
57
|
+
>
|
|
58
|
+
<View style={[barStyles.barFill, { borderRadius: height / 2, backgroundColor: resolvedFillColor, width: `${progressPercent}%` }]} />
|
|
59
|
+
</View>
|
|
60
|
+
{showTimeLabels && (
|
|
61
|
+
<AtomicText style={[barStyles.timeText, { textAlign: "right" }]}>
|
|
62
|
+
{formatTimeDisplay(duration)}
|
|
63
|
+
</AtomicText>
|
|
64
|
+
)}
|
|
65
|
+
</View>
|
|
66
|
+
</View>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const barStyles = StyleSheet.create({
|
|
71
|
+
container: { width: "100%" },
|
|
72
|
+
row: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
73
|
+
timeText: { color: "#FFFFFF", fontSize: 12, fontWeight: "500", minWidth: 40 },
|
|
74
|
+
barContainer: { flex: 1, overflow: "hidden" },
|
|
75
|
+
barFill: { height: "100%" },
|
|
76
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useControlsAutoHide Hook
|
|
3
|
+
* Manages visibility of video player overlay controls with auto-hide timer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
7
|
+
|
|
8
|
+
import type { ControlsAutoHideConfig, ControlsAutoHideResult } from "../../types";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_AUTO_HIDE_DELAY = 3000;
|
|
11
|
+
|
|
12
|
+
export const useControlsAutoHide = (
|
|
13
|
+
config: ControlsAutoHideConfig,
|
|
14
|
+
): ControlsAutoHideResult => {
|
|
15
|
+
const { autoHideDelay = DEFAULT_AUTO_HIDE_DELAY, isPlaying } = config;
|
|
16
|
+
const [visible, setVisible] = useState(true);
|
|
17
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
18
|
+
|
|
19
|
+
const clearTimer = useCallback(() => {
|
|
20
|
+
if (timerRef.current) {
|
|
21
|
+
clearTimeout(timerRef.current);
|
|
22
|
+
timerRef.current = null;
|
|
23
|
+
}
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const startTimer = useCallback(() => {
|
|
27
|
+
clearTimer();
|
|
28
|
+
if (isPlaying) {
|
|
29
|
+
timerRef.current = setTimeout(() => {
|
|
30
|
+
setVisible(false);
|
|
31
|
+
}, autoHideDelay);
|
|
32
|
+
}
|
|
33
|
+
}, [clearTimer, isPlaying, autoHideDelay]);
|
|
34
|
+
|
|
35
|
+
const show = useCallback(() => {
|
|
36
|
+
setVisible(true);
|
|
37
|
+
startTimer();
|
|
38
|
+
}, [startTimer]);
|
|
39
|
+
|
|
40
|
+
const hide = useCallback(() => {
|
|
41
|
+
clearTimer();
|
|
42
|
+
setVisible(false);
|
|
43
|
+
}, [clearTimer]);
|
|
44
|
+
|
|
45
|
+
const toggle = useCallback(() => {
|
|
46
|
+
if (visible) {
|
|
47
|
+
hide();
|
|
48
|
+
} else {
|
|
49
|
+
show();
|
|
50
|
+
}
|
|
51
|
+
}, [visible, show, hide]);
|
|
52
|
+
|
|
53
|
+
// Auto-hide when playing starts, show when paused
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (isPlaying) {
|
|
56
|
+
startTimer();
|
|
57
|
+
} else {
|
|
58
|
+
clearTimer();
|
|
59
|
+
setVisible(true);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return clearTimer;
|
|
63
|
+
}, [isPlaying, startTimer, clearTimer]);
|
|
64
|
+
|
|
65
|
+
return { visible, show, hide, toggle };
|
|
66
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useVideoPlaybackProgress Hook
|
|
3
|
+
* Tracks currentTime, duration, progress, and actual playing state by polling the expo-video player
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
7
|
+
|
|
8
|
+
import type { PlaybackProgressState } from "../../types";
|
|
9
|
+
|
|
10
|
+
const POLL_INTERVAL_MS = 250;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Polls the player for currentTime/duration and returns progress (0-1)
|
|
14
|
+
* Also returns the player's actual playing state to detect out-of-sync conditions
|
|
15
|
+
*/
|
|
16
|
+
export const useVideoPlaybackProgress = (
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
player: any,
|
|
19
|
+
isPlayerValid: boolean,
|
|
20
|
+
isPlaying: boolean,
|
|
21
|
+
onPlayingStateChanged?: (actuallyPlaying: boolean) => void,
|
|
22
|
+
): PlaybackProgressState => {
|
|
23
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
24
|
+
const [duration, setDuration] = useState(0);
|
|
25
|
+
const onPlayingStateChangedRef = useRef(onPlayingStateChanged);
|
|
26
|
+
onPlayingStateChangedRef.current = onPlayingStateChanged;
|
|
27
|
+
|
|
28
|
+
const pollPlayer = useCallback(() => {
|
|
29
|
+
if (!player || !isPlayerValid) return;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const ct = typeof player.currentTime === "number" ? Math.max(0, player.currentTime) : 0;
|
|
33
|
+
const dur = typeof player.duration === "number" && isFinite(player.duration) ? player.duration : 0;
|
|
34
|
+
|
|
35
|
+
setCurrentTime(ct);
|
|
36
|
+
if (dur > 0) setDuration(dur);
|
|
37
|
+
|
|
38
|
+
// Sync actual playing state — detects when video ends or system pauses
|
|
39
|
+
const actuallyPlaying = Boolean(player.playing);
|
|
40
|
+
if (actuallyPlaying !== isPlaying) {
|
|
41
|
+
onPlayingStateChangedRef.current?.(actuallyPlaying);
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Player may be in an invalid state during transitions
|
|
45
|
+
}
|
|
46
|
+
}, [player, isPlayerValid, isPlaying]);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
// Poll once immediately to get initial state
|
|
50
|
+
pollPlayer();
|
|
51
|
+
|
|
52
|
+
if (isPlaying && isPlayerValid) {
|
|
53
|
+
const id = setInterval(pollPlayer, POLL_INTERVAL_MS);
|
|
54
|
+
return () => clearInterval(id);
|
|
55
|
+
}
|
|
56
|
+
}, [isPlaying, isPlayerValid, pollPlayer]);
|
|
57
|
+
|
|
58
|
+
const progress = duration > 0 ? Math.min(currentTime / duration, 1) : 0;
|
|
59
|
+
|
|
60
|
+
return { currentTime, duration, progress };
|
|
61
|
+
};
|
|
@@ -26,9 +26,11 @@ import {
|
|
|
26
26
|
safeToggle,
|
|
27
27
|
isPlayerReady,
|
|
28
28
|
configurePlayer,
|
|
29
|
+
safeSeekTo,
|
|
30
|
+
safeMute,
|
|
31
|
+
safeReplay,
|
|
29
32
|
} from "../../infrastructure/services/player-control.service";
|
|
30
|
-
|
|
31
|
-
declare const __DEV__: boolean;
|
|
33
|
+
import { useVideoPlaybackProgress } from "./useVideoPlaybackProgress";
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* Hook for managing video player with safe operations
|
|
@@ -41,25 +43,16 @@ export const useVideoPlayerControl = (
|
|
|
41
43
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
42
44
|
const [isLoading, setIsLoading] = useState(true);
|
|
43
45
|
const [playbackRate, setPlaybackRateState] = useState(initialRate);
|
|
46
|
+
const [isMuted, setIsMuted] = useState(muted);
|
|
44
47
|
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
49
|
const player = useExpoVideoPlayer(source || "", (p: any) => {
|
|
46
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
47
|
-
console.log("[useVideoPlayerControl] Player callback, source:", source, "player:", !!p);
|
|
48
|
-
}
|
|
49
50
|
if (source && p) {
|
|
50
51
|
configurePlayer(p, { loop, muted, autoPlay });
|
|
51
52
|
setIsLoading(false);
|
|
52
53
|
if (autoPlay) {
|
|
53
54
|
setIsPlaying(true);
|
|
54
55
|
}
|
|
55
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
56
|
-
console.log("[useVideoPlayerControl] Player configured:", {
|
|
57
|
-
status: p.status,
|
|
58
|
-
playing: p.playing,
|
|
59
|
-
muted: p.muted,
|
|
60
|
-
loop: p.loop,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
56
|
}
|
|
64
57
|
});
|
|
65
58
|
|
|
@@ -68,6 +61,19 @@ export const useVideoPlayerControl = (
|
|
|
68
61
|
[player, source],
|
|
69
62
|
);
|
|
70
63
|
|
|
64
|
+
// Sync isPlaying with actual player state (handles video end, system pause, etc.)
|
|
65
|
+
const handlePlayingStateChanged = useCallback((actuallyPlaying: boolean) => {
|
|
66
|
+
setIsPlaying(actuallyPlaying);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
// Track playback progress + sync playing state
|
|
70
|
+
const { currentTime, duration, progress } = useVideoPlaybackProgress(
|
|
71
|
+
player,
|
|
72
|
+
isPlayerValid,
|
|
73
|
+
isPlaying,
|
|
74
|
+
handlePlayingStateChanged,
|
|
75
|
+
);
|
|
76
|
+
|
|
71
77
|
const play = useCallback(() => {
|
|
72
78
|
if (!isPlayerValid) return;
|
|
73
79
|
const success = safePlay(player);
|
|
@@ -88,23 +94,52 @@ export const useVideoPlayerControl = (
|
|
|
88
94
|
|
|
89
95
|
const setPlaybackRate = useCallback((rate: number) => {
|
|
90
96
|
if (!isPlayerValid || !player) return;
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
98
|
(player as any).playbackRate = rate;
|
|
92
99
|
setPlaybackRateState(rate);
|
|
93
100
|
}, [player, isPlayerValid]);
|
|
94
101
|
|
|
102
|
+
const toggleMute = useCallback(() => {
|
|
103
|
+
if (!isPlayerValid) return;
|
|
104
|
+
const newMuted = !isMuted;
|
|
105
|
+
const success = safeMute(player, newMuted);
|
|
106
|
+
if (success) setIsMuted(newMuted);
|
|
107
|
+
}, [player, isPlayerValid, isMuted]);
|
|
108
|
+
|
|
109
|
+
const setMutedFn = useCallback((value: boolean) => {
|
|
110
|
+
if (!isPlayerValid) return;
|
|
111
|
+
const success = safeMute(player, value);
|
|
112
|
+
if (success) setIsMuted(value);
|
|
113
|
+
}, [player, isPlayerValid]);
|
|
114
|
+
|
|
115
|
+
const seekTo = useCallback((seconds: number) => {
|
|
116
|
+
if (!isPlayerValid) return;
|
|
117
|
+
safeSeekTo(player, seconds);
|
|
118
|
+
}, [player, isPlayerValid]);
|
|
119
|
+
|
|
120
|
+
const replay = useCallback(() => {
|
|
121
|
+
if (!isPlayerValid) return;
|
|
122
|
+
const success = safeReplay(player);
|
|
123
|
+
if (success) setIsPlaying(true);
|
|
124
|
+
}, [player, isPlayerValid]);
|
|
125
|
+
|
|
95
126
|
const state: VideoPlayerState = useMemo(
|
|
96
127
|
() => ({
|
|
97
128
|
isPlaying,
|
|
98
129
|
isPlayerValid,
|
|
99
130
|
isLoading: isLoading && Boolean(source),
|
|
100
131
|
playbackRate,
|
|
132
|
+
isMuted,
|
|
133
|
+
currentTime,
|
|
134
|
+
duration,
|
|
135
|
+
progress,
|
|
101
136
|
}),
|
|
102
|
-
[isPlaying, isPlayerValid, isLoading, source, playbackRate],
|
|
137
|
+
[isPlaying, isPlayerValid, isLoading, source, playbackRate, isMuted, currentTime, duration, progress],
|
|
103
138
|
);
|
|
104
139
|
|
|
105
140
|
const controls: VideoPlayerControls = useMemo(
|
|
106
|
-
() => ({ play, pause, toggle, setPlaybackRate }),
|
|
107
|
-
[play, pause, toggle, setPlaybackRate],
|
|
141
|
+
() => ({ play, pause, toggle, setPlaybackRate, toggleMute, setMuted: setMutedFn, seekTo, replay }),
|
|
142
|
+
[play, pause, toggle, setPlaybackRate, toggleMute, setMutedFn, seekTo, replay],
|
|
108
143
|
);
|
|
109
144
|
|
|
110
145
|
return { player, state, controls };
|
|
@@ -25,6 +25,10 @@ export interface VideoPlayerState {
|
|
|
25
25
|
readonly isPlayerValid: boolean;
|
|
26
26
|
readonly isLoading: boolean;
|
|
27
27
|
readonly playbackRate: number;
|
|
28
|
+
readonly isMuted: boolean;
|
|
29
|
+
readonly currentTime: number;
|
|
30
|
+
readonly duration: number;
|
|
31
|
+
readonly progress: number;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/**
|
|
@@ -35,6 +39,10 @@ export interface VideoPlayerControls {
|
|
|
35
39
|
readonly pause: () => void;
|
|
36
40
|
readonly toggle: () => void;
|
|
37
41
|
readonly setPlaybackRate: (rate: number) => void;
|
|
42
|
+
readonly toggleMute: () => void;
|
|
43
|
+
readonly setMuted: (muted: boolean) => void;
|
|
44
|
+
readonly seekTo: (seconds: number) => void;
|
|
45
|
+
readonly replay: () => void;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/**
|
|
@@ -61,19 +69,95 @@ export interface VideoVisibilityConfig {
|
|
|
61
69
|
*/
|
|
62
70
|
export interface VideoPlayerProps {
|
|
63
71
|
readonly source: string | null;
|
|
64
|
-
readonly isVisible?: boolean;
|
|
65
72
|
readonly loop?: boolean;
|
|
66
73
|
readonly muted?: boolean;
|
|
67
74
|
readonly autoPlay?: boolean;
|
|
75
|
+
/** When true, shows custom overlay controls and disables nativeControls */
|
|
68
76
|
readonly showControls?: boolean;
|
|
77
|
+
/** Native video controls (ignored when showControls is true) */
|
|
69
78
|
readonly nativeControls?: boolean;
|
|
70
|
-
readonly onPlayingChange?: (isPlaying: boolean) => void;
|
|
71
|
-
readonly onError?: (error: Error) => void;
|
|
72
79
|
readonly style?: ViewStyle;
|
|
73
80
|
readonly contentFit?: "contain" | "cover" | "fill";
|
|
74
81
|
readonly thumbnailUrl?: string;
|
|
75
82
|
readonly playbackRate?: number;
|
|
76
83
|
readonly filterOverlay?: { overlay: string; opacity: number };
|
|
84
|
+
readonly title?: string;
|
|
85
|
+
readonly subtitle?: string;
|
|
86
|
+
readonly onBack?: () => void;
|
|
87
|
+
readonly onProgress?: (currentTime: number, duration: number) => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Playback progress state
|
|
92
|
+
*/
|
|
93
|
+
export interface PlaybackProgressState {
|
|
94
|
+
readonly currentTime: number;
|
|
95
|
+
readonly duration: number;
|
|
96
|
+
readonly progress: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Controls auto-hide configuration
|
|
101
|
+
*/
|
|
102
|
+
export interface ControlsAutoHideConfig {
|
|
103
|
+
readonly autoHideDelay?: number;
|
|
104
|
+
readonly isPlaying: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Controls auto-hide result
|
|
109
|
+
*/
|
|
110
|
+
export interface ControlsAutoHideResult {
|
|
111
|
+
readonly visible: boolean;
|
|
112
|
+
readonly show: () => void;
|
|
113
|
+
readonly hide: () => void;
|
|
114
|
+
readonly toggle: () => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Video progress bar props
|
|
119
|
+
*/
|
|
120
|
+
export interface VideoProgressBarProps {
|
|
121
|
+
readonly currentTime: number;
|
|
122
|
+
readonly duration: number;
|
|
123
|
+
readonly onSeek?: (seconds: number) => void;
|
|
124
|
+
readonly showTimeLabels?: boolean;
|
|
125
|
+
readonly height?: number;
|
|
126
|
+
readonly trackColor?: string;
|
|
127
|
+
readonly fillColor?: string;
|
|
128
|
+
readonly style?: ViewStyle;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Video player overlay props
|
|
133
|
+
*/
|
|
134
|
+
export interface VideoPlayerOverlayProps {
|
|
135
|
+
readonly visible: boolean;
|
|
136
|
+
readonly isPlaying: boolean;
|
|
137
|
+
readonly isMuted: boolean;
|
|
138
|
+
readonly currentTime: number;
|
|
139
|
+
readonly duration: number;
|
|
140
|
+
readonly title?: string;
|
|
141
|
+
readonly subtitle?: string;
|
|
142
|
+
readonly onTogglePlay: () => void;
|
|
143
|
+
readonly onToggleMute: () => void;
|
|
144
|
+
readonly onSeek: (seconds: number) => void;
|
|
145
|
+
readonly onBack?: () => void;
|
|
146
|
+
readonly onTap: () => void;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Full screen video player props
|
|
151
|
+
*/
|
|
152
|
+
export interface FullScreenVideoPlayerProps {
|
|
153
|
+
readonly visible: boolean;
|
|
154
|
+
readonly source: string | null;
|
|
155
|
+
readonly title?: string;
|
|
156
|
+
readonly subtitle?: string;
|
|
157
|
+
readonly thumbnailUrl?: string;
|
|
158
|
+
readonly onClose: () => void;
|
|
159
|
+
readonly loop?: boolean;
|
|
160
|
+
readonly autoPlay?: boolean;
|
|
77
161
|
}
|
|
78
162
|
|
|
79
163
|
export type { VideoPlayer } from "expo-video";
|