@umituz/react-native-video-editor 1.1.54 → 1.1.56

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.
@@ -3,7 +3,7 @@
3
3
  * Single Responsibility: Display editor tool buttons
4
4
  */
5
5
 
6
- import React from "react";
6
+ import React, { useMemo, useCallback } from "react";
7
7
  import {
8
8
  View,
9
9
  ScrollView,
@@ -24,7 +24,70 @@ interface EditorToolPanelProps {
24
24
  onSpeed?: () => void;
25
25
  }
26
26
 
27
- export const EditorToolPanel: React.FC<EditorToolPanelProps> = ({
27
+ interface ToolButtonProps {
28
+ icon: string;
29
+ label: string;
30
+ onPress: () => void;
31
+ backgroundColor: string;
32
+ textColor: string;
33
+ showBadge?: boolean;
34
+ badgeColor?: string;
35
+ badgeBorderColor?: string;
36
+ }
37
+
38
+ /**
39
+ * Memoized ToolButton component to prevent unnecessary re-renders
40
+ */
41
+ const ToolButton = React.memo<ToolButtonProps>(({
42
+ icon,
43
+ label,
44
+ onPress,
45
+ backgroundColor,
46
+ textColor,
47
+ showBadge,
48
+ badgeColor,
49
+ badgeBorderColor,
50
+ }) => {
51
+ const buttonStyle = useMemo(() => [
52
+ styles.toolButton,
53
+ { backgroundColor }
54
+ ], [backgroundColor]);
55
+
56
+ const textStyle = useMemo(() => ({
57
+ color: textColor,
58
+ marginTop: 4
59
+ }), [textColor]);
60
+
61
+ const badgeStyle = useMemo(() => [
62
+ styles.audioBadge,
63
+ {
64
+ backgroundColor: badgeColor,
65
+ borderColor: badgeBorderColor,
66
+ }
67
+ ], [badgeColor, badgeBorderColor]);
68
+
69
+ return (
70
+ <TouchableOpacity
71
+ style={buttonStyle}
72
+ onPress={onPress}
73
+ >
74
+ <AtomicIcon name={icon as any} size="md" color="primary" />
75
+ <AtomicText
76
+ type="labelSmall"
77
+ style={textStyle}
78
+ >
79
+ {label}
80
+ </AtomicText>
81
+ {showBadge && badgeColor && (
82
+ <View style={badgeStyle} />
83
+ )}
84
+ </TouchableOpacity>
85
+ );
86
+ });
87
+
88
+ ToolButton.displayName = 'ToolButton';
89
+
90
+ export const EditorToolPanel: React.FC<EditorToolPanelProps> = React.memo(({
28
91
  onAddText,
29
92
  onAddImage,
30
93
  onAddShape,
@@ -36,136 +99,99 @@ export const EditorToolPanel: React.FC<EditorToolPanelProps> = ({
36
99
  const { t } = useLocalization();
37
100
  const tokens = useAppDesignTokens();
38
101
 
102
+ // Memoize styles
103
+ const containerStyle = useMemo(() => [
104
+ styles.toolPanel,
105
+ { backgroundColor: tokens.colors.surface }
106
+ ], [tokens.colors.surface]);
107
+
108
+ const titleStyle = useMemo(() => ({
109
+ color: tokens.colors.textPrimary,
110
+ fontWeight: "600" as const,
111
+ marginBottom: 12,
112
+ }), [tokens.colors.textPrimary]);
113
+
114
+ const backgroundColor = tokens.colors.backgroundPrimary;
115
+ const textColor = tokens.colors.textPrimary;
116
+
117
+ // Stable callbacks
118
+ const handleAddText = useCallback(() => onAddText(), [onAddText]);
119
+ const handleAddImage = useCallback(() => onAddImage(), [onAddImage]);
120
+ const handleAddShape = useCallback(() => onAddShape(), [onAddShape]);
121
+ const handleAudio = useCallback(() => onAudio(), [onAudio]);
122
+ const handleFilters = useCallback(() => onFilters?.(), [onFilters]);
123
+ const handleSpeed = useCallback(() => onSpeed?.(), [onSpeed]);
124
+
39
125
  return (
40
- <View
41
- style={[styles.toolPanel, { backgroundColor: tokens.colors.surface }]}
42
- >
126
+ <View style={containerStyle}>
43
127
  <AtomicText
44
128
  type="bodyMedium"
45
- style={{
46
- color: tokens.colors.textPrimary,
47
- fontWeight: "600",
48
- marginBottom: 12,
49
- }}
129
+ style={titleStyle}
50
130
  >
51
131
  {t("editor.tools.title")}
52
132
  </AtomicText>
53
133
 
54
134
  <ScrollView horizontal showsHorizontalScrollIndicator={false}>
55
- <TouchableOpacity
56
- style={[
57
- styles.toolButton,
58
- { backgroundColor: tokens.colors.backgroundPrimary },
59
- ]}
60
- onPress={onAddText}
61
- >
62
- <AtomicIcon name="text-outline" size="md" color="primary" />
63
- <AtomicText
64
- type="labelSmall"
65
- style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
66
- >
67
- {t("editor.tools.text")}
68
- </AtomicText>
69
- </TouchableOpacity>
70
-
71
- <TouchableOpacity
72
- style={[
73
- styles.toolButton,
74
- { backgroundColor: tokens.colors.backgroundPrimary },
75
- ]}
76
- onPress={onAddImage}
77
- >
78
- <AtomicIcon name="image-outline" size="md" color="primary" />
79
- <AtomicText
80
- type="labelSmall"
81
- style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
82
- >
83
- {t("editor.tools.image")}
84
- </AtomicText>
85
- </TouchableOpacity>
86
-
87
- <TouchableOpacity
88
- style={[
89
- styles.toolButton,
90
- { backgroundColor: tokens.colors.backgroundPrimary },
91
- ]}
92
- onPress={onAddShape}
93
- >
94
- <AtomicIcon name="square-outline" size="md" color="primary" />
95
- <AtomicText
96
- type="labelSmall"
97
- style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
98
- >
99
- {t("editor.tools.shape")}
100
- </AtomicText>
101
- </TouchableOpacity>
102
-
103
- <TouchableOpacity
104
- style={[
105
- styles.toolButton,
106
- { backgroundColor: tokens.colors.backgroundPrimary },
107
- ]}
108
- onPress={onAudio}
109
- >
110
- <AtomicIcon name="musical-notes-outline" size="md" color="primary" />
111
- <AtomicText
112
- type="labelSmall"
113
- style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
114
- >
115
- {t("editor.tools.audio")}
116
- </AtomicText>
117
- {hasAudio && (
118
- <View
119
- style={[
120
- styles.audioBadge,
121
- {
122
- backgroundColor: tokens.colors.success,
123
- borderColor: tokens.colors.surface,
124
- },
125
- ]}
126
- />
127
- )}
128
- </TouchableOpacity>
135
+ <ToolButton
136
+ icon="text-outline"
137
+ label={t("editor.tools.text")}
138
+ onPress={handleAddText}
139
+ backgroundColor={backgroundColor}
140
+ textColor={textColor}
141
+ />
142
+
143
+ <ToolButton
144
+ icon="image-outline"
145
+ label={t("editor.tools.image")}
146
+ onPress={handleAddImage}
147
+ backgroundColor={backgroundColor}
148
+ textColor={textColor}
149
+ />
150
+
151
+ <ToolButton
152
+ icon="square-outline"
153
+ label={t("editor.tools.shape")}
154
+ onPress={handleAddShape}
155
+ backgroundColor={backgroundColor}
156
+ textColor={textColor}
157
+ />
158
+
159
+ <ToolButton
160
+ icon="musical-notes-outline"
161
+ label={t("editor.tools.audio")}
162
+ onPress={handleAudio}
163
+ backgroundColor={backgroundColor}
164
+ textColor={textColor}
165
+ showBadge={hasAudio}
166
+ badgeColor={tokens.colors.success}
167
+ badgeBorderColor={tokens.colors.surface}
168
+ />
129
169
 
130
170
  {onFilters && (
131
- <TouchableOpacity
132
- style={[
133
- styles.toolButton,
134
- { backgroundColor: tokens.colors.backgroundPrimary },
135
- ]}
136
- onPress={onFilters}
137
- >
138
- <AtomicIcon name="sparkles" size="md" color="primary" />
139
- <AtomicText
140
- type="labelSmall"
141
- style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
142
- >
143
- {t("editor.tools.filters") || "Filters"}
144
- </AtomicText>
145
- </TouchableOpacity>
171
+ <ToolButton
172
+ icon="sparkles"
173
+ label={t("editor.tools.filters") || "Filters"}
174
+ onPress={handleFilters}
175
+ backgroundColor={backgroundColor}
176
+ textColor={textColor}
177
+ />
146
178
  )}
147
179
 
148
180
  {onSpeed && (
149
- <TouchableOpacity
150
- style={[
151
- styles.toolButton,
152
- { backgroundColor: tokens.colors.backgroundPrimary },
153
- ]}
154
- onPress={onSpeed}
155
- >
156
- <AtomicIcon name="flash" size="md" color="primary" />
157
- <AtomicText
158
- type="labelSmall"
159
- style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
160
- >
161
- {t("editor.tools.speed") || "Speed"}
162
- </AtomicText>
163
- </TouchableOpacity>
181
+ <ToolButton
182
+ icon="flash"
183
+ label={t("editor.tools.speed") || "Speed"}
184
+ onPress={handleSpeed}
185
+ backgroundColor={backgroundColor}
186
+ textColor={textColor}
187
+ />
164
188
  )}
165
189
  </ScrollView>
166
190
  </View>
167
191
  );
168
- };
192
+ });
193
+
194
+ EditorToolPanel.displayName = 'EditorToolPanel';
169
195
 
170
196
  const styles = StyleSheet.create({
171
197
  toolPanel: {
@@ -3,8 +3,9 @@
3
3
  * Single Responsibility: History operations for editor
4
4
  */
5
5
 
6
- import { useCallback, useState } from "react";
6
+ import { useCallback, useState, useRef } from "react";
7
7
  import type { VideoProject } from "../../domain/entities/video-project.types";
8
+ import { cloneVideoProject } from "../../infrastructure/utils/data-clone.utils";
8
9
 
9
10
  interface UseEditorHistoryParams {
10
11
  project: VideoProject | undefined;
@@ -27,20 +28,30 @@ export function useEditorHistory({
27
28
  }: UseEditorHistoryParams): UseEditorHistoryReturn {
28
29
  const [history, setHistory] = useState<VideoProject[]>([]);
29
30
  const [future, setFuture] = useState<VideoProject[]>([]);
31
+ const lastProjectRef = useRef<string | null>(null);
30
32
 
31
33
  const updateWithHistory = useCallback(
32
34
  (updates: Partial<VideoProject>, _action: string) => {
33
- if (project) {
34
- // Deep clone the project to avoid reference issues
35
- const clonedProject = JSON.parse(JSON.stringify(project)) as VideoProject;
36
- setHistory((prev) => {
37
- const next = [...prev, clonedProject];
38
- return next.length > MAX_HISTORY_SIZE ? next.slice(-MAX_HISTORY_SIZE) : next;
39
- });
40
- setFuture([]);
35
+ if (!project) return;
41
36
 
37
+ // Prevent duplicate history entries for same project state
38
+ const projectHash = JSON.stringify({ id: project.id, updatedAt: project.updatedAt });
39
+ if (lastProjectRef.current === projectHash) {
42
40
  onUpdateProject(updates);
41
+ return;
43
42
  }
43
+ lastProjectRef.current = projectHash;
44
+
45
+ // Optimized deep clone
46
+ const clonedProject = cloneVideoProject(project);
47
+
48
+ setHistory((prev) => {
49
+ const next = [...prev, clonedProject];
50
+ return next.length > MAX_HISTORY_SIZE ? next.slice(-MAX_HISTORY_SIZE) : next;
51
+ });
52
+ setFuture([]);
53
+
54
+ onUpdateProject(updates);
44
55
  },
45
56
  [project, onUpdateProject],
46
57
  );
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * useEditorPlayback Hook
3
3
  * Single Responsibility: Playback control for editor
4
+ * Optimized for performance with stable animation loop
4
5
  */
5
6
 
6
- import { useState, useEffect, useCallback } from "react";
7
+ import { useState, useEffect, useCallback, useRef } from "react";
7
8
  import type { Scene } from "../../domain/entities/video-project.types";
9
+ import {
10
+ calculateDelta,
11
+ addDeltaTime,
12
+ isTimeAtEnd,
13
+ } from "../../infrastructure/utils/time-calculations.utils";
8
14
 
9
15
  interface UseEditorPlaybackParams {
10
16
  currentScene: Scene | undefined;
@@ -24,56 +30,87 @@ export function useEditorPlayback({
24
30
  const [isPlaying, setIsPlaying] = useState(false);
25
31
  const [currentTime, setCurrentTime] = useState(0);
26
32
 
27
- // Video playback animation loop
33
+ // Use refs to avoid re-creating animation loop on every render
34
+ const animationFrameRef = useRef<number | null>(null);
35
+ const lastTimestampRef = useRef<number>(0);
36
+ const isPlayingRef = useRef(isPlaying);
37
+ const currentSceneRef = useRef(currentScene);
38
+ const durationRef = useRef(0);
39
+
40
+ // Keep refs in sync
41
+ useEffect(() => {
42
+ isPlayingRef.current = isPlaying;
43
+ }, [isPlaying]);
44
+
45
+ useEffect(() => {
46
+ currentSceneRef.current = currentScene;
47
+ if (currentScene) {
48
+ durationRef.current = currentScene.duration;
49
+ }
50
+ }, [currentScene]);
51
+
52
+ // Video playback animation loop - optimized with refs
28
53
  useEffect(() => {
29
54
  if (!currentScene) return;
30
- let animationFrameId: number;
31
- let lastTimestamp = 0;
32
55
 
33
56
  const animate = (timestamp: number) => {
34
- if (!isPlaying || !currentScene) return;
57
+ // Use refs for checks to avoid closure staleness
58
+ if (!isPlayingRef.current || !currentSceneRef.current) {
59
+ animationFrameRef.current = null;
60
+ return;
61
+ }
35
62
 
36
- if (lastTimestamp === 0) {
37
- lastTimestamp = timestamp;
63
+ if (lastTimestampRef.current === 0) {
64
+ lastTimestampRef.current = timestamp;
38
65
  }
39
- const deltaTime = timestamp - lastTimestamp;
40
- lastTimestamp = timestamp;
41
66
 
42
- setCurrentTime((prevTime) => {
43
- const newTime = prevTime + deltaTime;
44
- const sceneDuration = currentScene.duration;
67
+ const deltaTime = calculateDelta(timestamp, lastTimestampRef.current);
68
+ lastTimestampRef.current = timestamp;
45
69
 
46
- if (newTime >= sceneDuration) {
47
- setIsPlaying(false);
70
+ setCurrentTime((prevTime) => {
71
+ const newTime = addDeltaTime(prevTime, deltaTime);
72
+ const sceneDuration = durationRef.current;
73
+
74
+ if (isTimeAtEnd(newTime, sceneDuration)) {
75
+ // Defer state update to avoid in-render setState
76
+ Promise.resolve().then(() => {
77
+ setIsPlaying(false);
78
+ });
48
79
  return sceneDuration;
49
80
  }
50
81
 
51
82
  return newTime;
52
83
  });
53
84
 
54
- animationFrameId = requestAnimationFrame(animate);
85
+ animationFrameRef.current = requestAnimationFrame(animate);
55
86
  };
56
87
 
57
88
  if (isPlaying) {
58
- lastTimestamp = 0;
59
- animationFrameId = requestAnimationFrame(animate);
89
+ lastTimestampRef.current = 0;
90
+ animationFrameRef.current = requestAnimationFrame(animate);
60
91
  }
61
92
 
62
93
  return () => {
63
- if (animationFrameId) {
64
- cancelAnimationFrame(animationFrameId);
94
+ if (animationFrameRef.current !== null) {
95
+ cancelAnimationFrame(animationFrameRef.current);
96
+ animationFrameRef.current = null;
65
97
  }
66
98
  };
67
- }, [isPlaying, currentScene?.duration]);
99
+ }, [isPlaying, currentScene]); // Only re-create loop when these change
68
100
 
69
101
  const playPause = useCallback(() => {
70
102
  if (!currentScene) return;
71
103
 
72
- if (currentTime >= currentScene.duration) {
73
- setCurrentTime(0);
74
- }
75
- setIsPlaying(!isPlaying);
76
- }, [currentScene, currentTime, isPlaying]);
104
+ setCurrentTime((prevTime) => {
105
+ // Reset to 0 if at end
106
+ if (prevTime >= currentScene.duration) {
107
+ return 0;
108
+ }
109
+ return prevTime;
110
+ });
111
+
112
+ setIsPlaying((prev) => !prev);
113
+ }, [currentScene]);
77
114
 
78
115
  const reset = useCallback(() => {
79
116
  setCurrentTime(0);
@@ -11,10 +11,9 @@ import type {
11
11
  Format,
12
12
  } from "../../infrastructure/constants/export.constants";
13
13
  import {
14
- BASE_SIZE_PER_SECOND,
15
- RESOLUTION_MULTIPLIERS,
16
- QUALITY_MULTIPLIERS,
17
- } from "../../infrastructure/constants/export.constants";
14
+ calculateEstimatedFileSize,
15
+ calculateVideoProjectDuration,
16
+ } from "../../infrastructure/utils/video-calculations.utils";
18
17
 
19
18
  interface ExportFormState {
20
19
  resolution: Resolution;
@@ -46,16 +45,15 @@ export function useExportForm(project: VideoProject): UseExportFormReturn {
46
45
  });
47
46
 
48
47
  const projectDuration = useMemo(() => {
49
- return (
50
- project.scenes.reduce((acc, scene) => acc + scene.duration, 0) / 1000
51
- );
52
- }, [project.scenes]);
48
+ return calculateVideoProjectDuration(project);
49
+ }, [project]);
53
50
 
54
51
  const estimatedSize = useMemo(() => {
55
- const baseSize = projectDuration * BASE_SIZE_PER_SECOND;
56
- const resolutionMultiplier = RESOLUTION_MULTIPLIERS[formState.resolution];
57
- const qualityMultiplier = QUALITY_MULTIPLIERS[formState.quality];
58
- return (baseSize * resolutionMultiplier * qualityMultiplier).toFixed(1);
52
+ return calculateEstimatedFileSize(
53
+ projectDuration,
54
+ formState.resolution,
55
+ formState.quality,
56
+ ).toFixed(1);
59
57
  }, [projectDuration, formState.resolution, formState.quality]);
60
58
 
61
59
  const setResolution = useCallback((resolution: Resolution) => {