@umituz/react-native-video-editor 1.1.40 → 1.1.42
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/domain/entities/video-project.types.ts +15 -0
- package/src/index.ts +6 -0
- package/src/infrastructure/constants/index.ts +1 -0
- package/src/infrastructure/constants/subtitle.constants.ts +34 -0
- package/src/infrastructure/utils/srt.utils.ts +33 -0
- package/src/presentation/components/SubtitleListPanel.tsx +305 -0
- package/src/presentation/components/SubtitleOverlay.tsx +58 -0
- package/src/presentation/components/SubtitleStylePicker.tsx +189 -0
- package/src/presentation/components/SubtitleTimeInput.tsx +89 -0
- package/src/presentation/components/index.ts +4 -0
- package/src/presentation/hooks/useSubtitleEditor.ts +71 -0
- package/README.md +0 -92
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.42",
|
|
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",
|
|
@@ -14,6 +14,21 @@ export interface FilterPreset {
|
|
|
14
14
|
|
|
15
15
|
export type LayerType = "text" | "image" | "video" | "shape";
|
|
16
16
|
|
|
17
|
+
export interface SubtitleStyle {
|
|
18
|
+
fontSize: "small" | "medium" | "large" | "extraLarge";
|
|
19
|
+
fontColor: string;
|
|
20
|
+
backgroundColor: string;
|
|
21
|
+
position: "top" | "center" | "bottom";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Subtitle {
|
|
25
|
+
id: string;
|
|
26
|
+
text: string;
|
|
27
|
+
startTime: number;
|
|
28
|
+
endTime: number;
|
|
29
|
+
style: SubtitleStyle;
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
export type TransitionType = "fade" | "slide" | "zoom" | "wipe" | "none";
|
|
18
33
|
|
|
19
34
|
export type AnimationType =
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,8 @@ export type {
|
|
|
34
34
|
AddImageLayerData,
|
|
35
35
|
AddShapeLayerData,
|
|
36
36
|
FilterPreset,
|
|
37
|
+
SubtitleStyle,
|
|
38
|
+
Subtitle,
|
|
37
39
|
} from "./domain/entities";
|
|
38
40
|
|
|
39
41
|
// =============================================================================
|
|
@@ -106,6 +108,10 @@ export { useMenuActions } from "./presentation/hooks/useMenuActions";
|
|
|
106
108
|
export { useExportActions } from "./presentation/hooks/useExportActions";
|
|
107
109
|
export { useCollageEditor } from "./presentation/hooks/useCollageEditor";
|
|
108
110
|
export type { UseCollageEditorReturn } from "./presentation/hooks/useCollageEditor";
|
|
111
|
+
export { useSubtitleEditor } from "./presentation/hooks/useSubtitleEditor";
|
|
112
|
+
export type { UseSubtitleEditorReturn } from "./presentation/hooks/useSubtitleEditor";
|
|
113
|
+
|
|
114
|
+
export { generateSRT, formatTimeDisplay, formatTimeDetailed } from "./infrastructure/utils/srt.utils";
|
|
109
115
|
|
|
110
116
|
// =============================================================================
|
|
111
117
|
// VIDEO PLAYER MODULE
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtitle Constants
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SubtitleStyle } from "../../domain/entities/video-project.types";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyle = {
|
|
8
|
+
fontSize: "medium",
|
|
9
|
+
fontColor: "#FFFFFF",
|
|
10
|
+
backgroundColor: "rgba(0,0,0,0.6)",
|
|
11
|
+
position: "bottom",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const FONT_SIZE_MAP: Record<SubtitleStyle["fontSize"], number> = {
|
|
15
|
+
small: 14,
|
|
16
|
+
medium: 18,
|
|
17
|
+
large: 24,
|
|
18
|
+
extraLarge: 32,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const SUBTITLE_FONT_COLORS = [
|
|
22
|
+
"#FFFFFF",
|
|
23
|
+
"#FFEB3B",
|
|
24
|
+
"#00BCD4",
|
|
25
|
+
"#4CAF50",
|
|
26
|
+
"#F44336",
|
|
27
|
+
"#FF9800",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export const SUBTITLE_BG_COLORS = [
|
|
31
|
+
{ label: "None", value: "transparent" },
|
|
32
|
+
{ label: "Dark", value: "rgba(0,0,0,0.6)" },
|
|
33
|
+
{ label: "Gray", value: "rgba(50,50,50,0.8)" },
|
|
34
|
+
] as const;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SRT Subtitle Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Subtitle } from "../../domain/entities/video-project.types";
|
|
6
|
+
|
|
7
|
+
function toSrtTime(seconds: number): string {
|
|
8
|
+
const h = Math.floor(seconds / 3600);
|
|
9
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
10
|
+
const s = Math.floor(seconds % 60);
|
|
11
|
+
const ms = Math.floor((seconds % 1) * 1000);
|
|
12
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function generateSRT(subtitles: Subtitle[]): string {
|
|
16
|
+
const sorted = [...subtitles].sort((a, b) => a.startTime - b.startTime);
|
|
17
|
+
return sorted
|
|
18
|
+
.map((sub, index) => `${index + 1}\n${toSrtTime(sub.startTime)} --> ${toSrtTime(sub.endTime)}\n${sub.text}\n`)
|
|
19
|
+
.join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatTimeDisplay(seconds: number): string {
|
|
23
|
+
const m = Math.floor(seconds / 60);
|
|
24
|
+
const s = Math.floor(seconds % 60);
|
|
25
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatTimeDetailed(seconds: number): string {
|
|
29
|
+
const m = Math.floor(seconds / 60);
|
|
30
|
+
const s = Math.floor(seconds % 60);
|
|
31
|
+
const t = Math.floor((seconds % 1) * 10);
|
|
32
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${t}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubtitleListPanel Component
|
|
3
|
+
* Full subtitle editor panel: list + add/edit modal
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useCallback, useMemo } from "react";
|
|
7
|
+
import { View, ScrollView, TouchableOpacity, Modal, TextInput, StyleSheet } from "react-native";
|
|
8
|
+
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
+
import { SubtitleTimeInput } from "./SubtitleTimeInput";
|
|
11
|
+
import { SubtitleStylePicker } from "./SubtitleStylePicker";
|
|
12
|
+
import { DEFAULT_SUBTITLE_STYLE } from "../../infrastructure/constants/subtitle.constants";
|
|
13
|
+
import { formatTimeDetailed } from "../../infrastructure/utils/srt.utils";
|
|
14
|
+
import type { Subtitle, SubtitleStyle } from "../../domain/entities/video-project.types";
|
|
15
|
+
|
|
16
|
+
interface SubtitleListPanelProps {
|
|
17
|
+
subtitles: Subtitle[];
|
|
18
|
+
currentTime: number;
|
|
19
|
+
onAdd: (text: string, startTime: number, endTime: number, style: SubtitleStyle) => void;
|
|
20
|
+
onUpdate: (id: string, patch: Partial<Omit<Subtitle, "id">>) => void;
|
|
21
|
+
onDelete: (id: string) => void;
|
|
22
|
+
onSeek: (time: number) => void;
|
|
23
|
+
t: (key: string) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const SubtitleListPanel: React.FC<SubtitleListPanelProps> = ({
|
|
27
|
+
subtitles,
|
|
28
|
+
currentTime,
|
|
29
|
+
onAdd,
|
|
30
|
+
onUpdate,
|
|
31
|
+
onDelete,
|
|
32
|
+
onSeek,
|
|
33
|
+
t,
|
|
34
|
+
}) => {
|
|
35
|
+
const tokens = useAppDesignTokens();
|
|
36
|
+
|
|
37
|
+
const [showModal, setShowModal] = useState(false);
|
|
38
|
+
const [editing, setEditing] = useState<Subtitle | null>(null);
|
|
39
|
+
const [text, setText] = useState("");
|
|
40
|
+
const [startTime, setStartTime] = useState(0);
|
|
41
|
+
const [endTime, setEndTime] = useState(3);
|
|
42
|
+
const [style, setStyle] = useState<SubtitleStyle>({ ...DEFAULT_SUBTITLE_STYLE });
|
|
43
|
+
|
|
44
|
+
const styles = useMemo(() => StyleSheet.create({
|
|
45
|
+
header: {
|
|
46
|
+
flexDirection: "row",
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
justifyContent: "space-between",
|
|
49
|
+
paddingHorizontal: tokens.spacing.md,
|
|
50
|
+
paddingVertical: tokens.spacing.sm,
|
|
51
|
+
borderBottomWidth: 1,
|
|
52
|
+
borderBottomColor: tokens.colors.border,
|
|
53
|
+
},
|
|
54
|
+
addBtn: {
|
|
55
|
+
width: 34,
|
|
56
|
+
height: 34,
|
|
57
|
+
borderRadius: 17,
|
|
58
|
+
backgroundColor: tokens.colors.primary,
|
|
59
|
+
alignItems: "center",
|
|
60
|
+
justifyContent: "center",
|
|
61
|
+
},
|
|
62
|
+
emptyBox: {
|
|
63
|
+
alignItems: "center",
|
|
64
|
+
paddingTop: tokens.spacing.xl,
|
|
65
|
+
gap: tokens.spacing.sm,
|
|
66
|
+
},
|
|
67
|
+
item: {
|
|
68
|
+
flexDirection: "row",
|
|
69
|
+
alignItems: "center",
|
|
70
|
+
backgroundColor: tokens.colors.surface,
|
|
71
|
+
borderRadius: tokens.borders.radius.md,
|
|
72
|
+
marginHorizontal: tokens.spacing.md,
|
|
73
|
+
marginBottom: tokens.spacing.sm,
|
|
74
|
+
borderWidth: 1,
|
|
75
|
+
borderColor: tokens.colors.border,
|
|
76
|
+
overflow: "hidden",
|
|
77
|
+
},
|
|
78
|
+
itemActive: {
|
|
79
|
+
borderColor: tokens.colors.primary,
|
|
80
|
+
backgroundColor: tokens.colors.primaryContainer,
|
|
81
|
+
},
|
|
82
|
+
itemBar: { width: 4, alignSelf: "stretch", backgroundColor: tokens.colors.surfaceVariant },
|
|
83
|
+
itemBarActive: { backgroundColor: tokens.colors.primary },
|
|
84
|
+
itemContent: { flex: 1, padding: tokens.spacing.sm },
|
|
85
|
+
itemTimeRow: { flexDirection: "row", alignItems: "center", gap: tokens.spacing.xs, marginBottom: 2 },
|
|
86
|
+
itemEditBtn: {
|
|
87
|
+
width: 40,
|
|
88
|
+
height: 40,
|
|
89
|
+
alignItems: "center",
|
|
90
|
+
justifyContent: "center",
|
|
91
|
+
},
|
|
92
|
+
// Modal
|
|
93
|
+
overlay: {
|
|
94
|
+
flex: 1,
|
|
95
|
+
backgroundColor: "rgba(0,0,0,0.5)",
|
|
96
|
+
justifyContent: "flex-end",
|
|
97
|
+
},
|
|
98
|
+
modal: {
|
|
99
|
+
backgroundColor: tokens.colors.surface,
|
|
100
|
+
borderTopLeftRadius: tokens.borders.radius.xl,
|
|
101
|
+
borderTopRightRadius: tokens.borders.radius.xl,
|
|
102
|
+
paddingHorizontal: tokens.spacing.md,
|
|
103
|
+
paddingTop: tokens.spacing.md,
|
|
104
|
+
paddingBottom: tokens.spacing.xl,
|
|
105
|
+
maxHeight: "90%",
|
|
106
|
+
},
|
|
107
|
+
handle: {
|
|
108
|
+
width: 36,
|
|
109
|
+
height: 4,
|
|
110
|
+
borderRadius: 2,
|
|
111
|
+
backgroundColor: tokens.colors.border,
|
|
112
|
+
alignSelf: "center",
|
|
113
|
+
marginBottom: tokens.spacing.md,
|
|
114
|
+
},
|
|
115
|
+
textInput: {
|
|
116
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
117
|
+
borderRadius: tokens.borders.radius.md,
|
|
118
|
+
padding: tokens.spacing.md,
|
|
119
|
+
color: tokens.colors.textPrimary,
|
|
120
|
+
fontSize: 16,
|
|
121
|
+
minHeight: 80,
|
|
122
|
+
textAlignVertical: "top",
|
|
123
|
+
marginBottom: tokens.spacing.md,
|
|
124
|
+
borderWidth: 1,
|
|
125
|
+
borderColor: tokens.colors.border,
|
|
126
|
+
},
|
|
127
|
+
timeRow: {
|
|
128
|
+
flexDirection: "row",
|
|
129
|
+
gap: tokens.spacing.md,
|
|
130
|
+
marginBottom: tokens.spacing.md,
|
|
131
|
+
},
|
|
132
|
+
actionRow: {
|
|
133
|
+
flexDirection: "row",
|
|
134
|
+
gap: tokens.spacing.md,
|
|
135
|
+
marginTop: tokens.spacing.md,
|
|
136
|
+
},
|
|
137
|
+
cancelBtn: {
|
|
138
|
+
flex: 1,
|
|
139
|
+
paddingVertical: tokens.spacing.md,
|
|
140
|
+
borderRadius: tokens.borders.radius.md,
|
|
141
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
142
|
+
alignItems: "center",
|
|
143
|
+
},
|
|
144
|
+
saveBtn: {
|
|
145
|
+
flex: 1,
|
|
146
|
+
paddingVertical: tokens.spacing.md,
|
|
147
|
+
borderRadius: tokens.borders.radius.md,
|
|
148
|
+
backgroundColor: tokens.colors.primary,
|
|
149
|
+
alignItems: "center",
|
|
150
|
+
},
|
|
151
|
+
saveBtnDisabled: { opacity: 0.4 },
|
|
152
|
+
}), [tokens]);
|
|
153
|
+
|
|
154
|
+
const activeId = useMemo(() => {
|
|
155
|
+
return subtitles.find((s) => currentTime >= s.startTime && currentTime <= s.endTime)?.id ?? null;
|
|
156
|
+
}, [subtitles, currentTime]);
|
|
157
|
+
|
|
158
|
+
const openAdd = useCallback(() => {
|
|
159
|
+
setEditing(null);
|
|
160
|
+
setText("");
|
|
161
|
+
setStartTime(Math.floor(currentTime));
|
|
162
|
+
setEndTime(Math.floor(currentTime) + 3);
|
|
163
|
+
setStyle({ ...DEFAULT_SUBTITLE_STYLE });
|
|
164
|
+
setShowModal(true);
|
|
165
|
+
}, [currentTime]);
|
|
166
|
+
|
|
167
|
+
const openEdit = useCallback((sub: Subtitle) => {
|
|
168
|
+
setEditing(sub);
|
|
169
|
+
setText(sub.text);
|
|
170
|
+
setStartTime(sub.startTime);
|
|
171
|
+
setEndTime(sub.endTime);
|
|
172
|
+
setStyle({ ...sub.style });
|
|
173
|
+
setShowModal(true);
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
const handleSave = useCallback(() => {
|
|
177
|
+
if (!text.trim()) return;
|
|
178
|
+
const resolvedEnd = Math.max(endTime, startTime + 0.5);
|
|
179
|
+
if (editing) {
|
|
180
|
+
onUpdate(editing.id, { text: text.trim(), startTime, endTime: resolvedEnd, style });
|
|
181
|
+
} else {
|
|
182
|
+
onAdd(text.trim(), startTime, resolvedEnd, style);
|
|
183
|
+
}
|
|
184
|
+
setShowModal(false);
|
|
185
|
+
}, [text, editing, startTime, endTime, style, onAdd, onUpdate]);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<View style={{ flex: 1 }}>
|
|
189
|
+
{/* Header */}
|
|
190
|
+
<View style={styles.header}>
|
|
191
|
+
<AtomicText fontWeight="semibold" color="textPrimary">
|
|
192
|
+
{t("subtitle.panel.title") || "Subtitles"} ({subtitles.length})
|
|
193
|
+
</AtomicText>
|
|
194
|
+
<TouchableOpacity style={styles.addBtn} onPress={openAdd} accessibilityRole="button">
|
|
195
|
+
<AtomicIcon name="add" size="sm" color="onPrimary" />
|
|
196
|
+
</TouchableOpacity>
|
|
197
|
+
</View>
|
|
198
|
+
|
|
199
|
+
{/* List */}
|
|
200
|
+
<ScrollView contentContainerStyle={{ paddingVertical: tokens.spacing.sm }}>
|
|
201
|
+
{subtitles.length === 0 ? (
|
|
202
|
+
<View style={styles.emptyBox}>
|
|
203
|
+
<AtomicIcon name="video" size="lg" color="textSecondary" />
|
|
204
|
+
<AtomicText color="textSecondary">{t("subtitle.panel.empty") || "No subtitles yet"}</AtomicText>
|
|
205
|
+
<AtomicText type="labelSmall" color="textTertiary">
|
|
206
|
+
{t("subtitle.panel.emptyHint") || "Tap + to add a subtitle"}
|
|
207
|
+
</AtomicText>
|
|
208
|
+
</View>
|
|
209
|
+
) : (
|
|
210
|
+
subtitles.map((sub) => {
|
|
211
|
+
const isActive = sub.id === activeId;
|
|
212
|
+
return (
|
|
213
|
+
<TouchableOpacity
|
|
214
|
+
key={sub.id}
|
|
215
|
+
style={[styles.item, isActive && styles.itemActive]}
|
|
216
|
+
onPress={() => onSeek(sub.startTime)}
|
|
217
|
+
accessibilityRole="button"
|
|
218
|
+
>
|
|
219
|
+
<View style={[styles.itemBar, isActive && styles.itemBarActive]} />
|
|
220
|
+
<View style={styles.itemContent}>
|
|
221
|
+
<View style={styles.itemTimeRow}>
|
|
222
|
+
<AtomicText type="labelSmall" color="textSecondary">
|
|
223
|
+
{formatTimeDetailed(sub.startTime)}
|
|
224
|
+
</AtomicText>
|
|
225
|
+
<AtomicText type="labelSmall" color="textTertiary">→</AtomicText>
|
|
226
|
+
<AtomicText type="labelSmall" color="textSecondary">
|
|
227
|
+
{formatTimeDetailed(sub.endTime)}
|
|
228
|
+
</AtomicText>
|
|
229
|
+
</View>
|
|
230
|
+
<AtomicText color="textPrimary" numberOfLines={2}>
|
|
231
|
+
{sub.text}
|
|
232
|
+
</AtomicText>
|
|
233
|
+
</View>
|
|
234
|
+
<TouchableOpacity
|
|
235
|
+
style={styles.itemEditBtn}
|
|
236
|
+
onPress={() => openEdit(sub)}
|
|
237
|
+
accessibilityRole="button"
|
|
238
|
+
>
|
|
239
|
+
<AtomicIcon name="edit" size="sm" color="textSecondary" />
|
|
240
|
+
</TouchableOpacity>
|
|
241
|
+
</TouchableOpacity>
|
|
242
|
+
);
|
|
243
|
+
})
|
|
244
|
+
)}
|
|
245
|
+
</ScrollView>
|
|
246
|
+
|
|
247
|
+
{/* Add/Edit Modal */}
|
|
248
|
+
<Modal visible={showModal} animationType="slide" transparent onRequestClose={() => setShowModal(false)}>
|
|
249
|
+
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={() => setShowModal(false)}>
|
|
250
|
+
<TouchableOpacity style={styles.modal} activeOpacity={1} onPress={() => {}}>
|
|
251
|
+
<View style={styles.handle} />
|
|
252
|
+
<ScrollView showsVerticalScrollIndicator={false}>
|
|
253
|
+
<AtomicText fontWeight="bold" color="textPrimary" style={{ marginBottom: tokens.spacing.md, fontSize: 18 }}>
|
|
254
|
+
{editing ? t("subtitle.modal.edit") || "Edit Subtitle" : t("subtitle.modal.add") || "Add Subtitle"}
|
|
255
|
+
</AtomicText>
|
|
256
|
+
|
|
257
|
+
<TextInput
|
|
258
|
+
style={styles.textInput}
|
|
259
|
+
value={text}
|
|
260
|
+
onChangeText={setText}
|
|
261
|
+
placeholder={t("subtitle.modal.placeholder") || "Enter subtitle text…"}
|
|
262
|
+
placeholderTextColor={tokens.colors.textSecondary}
|
|
263
|
+
multiline
|
|
264
|
+
numberOfLines={3}
|
|
265
|
+
/>
|
|
266
|
+
|
|
267
|
+
<View style={styles.timeRow}>
|
|
268
|
+
<SubtitleTimeInput
|
|
269
|
+
label={t("subtitle.modal.startTime") || "Start"}
|
|
270
|
+
value={startTime}
|
|
271
|
+
onChange={setStartTime}
|
|
272
|
+
/>
|
|
273
|
+
<SubtitleTimeInput
|
|
274
|
+
label={t("subtitle.modal.endTime") || "End"}
|
|
275
|
+
value={endTime}
|
|
276
|
+
onChange={setEndTime}
|
|
277
|
+
/>
|
|
278
|
+
</View>
|
|
279
|
+
|
|
280
|
+
<SubtitleStylePicker style={style} previewText={text} onChange={setStyle} t={t} />
|
|
281
|
+
|
|
282
|
+
<View style={styles.actionRow}>
|
|
283
|
+
<TouchableOpacity style={styles.cancelBtn} onPress={() => setShowModal(false)} accessibilityRole="button">
|
|
284
|
+
<AtomicText fontWeight="semibold" color="textSecondary">
|
|
285
|
+
{t("common.cancel") || "Cancel"}
|
|
286
|
+
</AtomicText>
|
|
287
|
+
</TouchableOpacity>
|
|
288
|
+
<TouchableOpacity
|
|
289
|
+
style={[styles.saveBtn, !text.trim() && styles.saveBtnDisabled]}
|
|
290
|
+
onPress={handleSave}
|
|
291
|
+
disabled={!text.trim()}
|
|
292
|
+
accessibilityRole="button"
|
|
293
|
+
>
|
|
294
|
+
<AtomicText fontWeight="semibold" color="onPrimary">
|
|
295
|
+
{t("common.save") || "Save"}
|
|
296
|
+
</AtomicText>
|
|
297
|
+
</TouchableOpacity>
|
|
298
|
+
</View>
|
|
299
|
+
</ScrollView>
|
|
300
|
+
</TouchableOpacity>
|
|
301
|
+
</TouchableOpacity>
|
|
302
|
+
</Modal>
|
|
303
|
+
</View>
|
|
304
|
+
);
|
|
305
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubtitleOverlay Component
|
|
3
|
+
* Renders the active subtitle on top of the video at the correct position
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { View, StyleSheet } from "react-native";
|
|
8
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
import { FONT_SIZE_MAP } from "../../infrastructure/constants/subtitle.constants";
|
|
10
|
+
import type { Subtitle } from "../../domain/entities/video-project.types";
|
|
11
|
+
|
|
12
|
+
interface SubtitleOverlayProps {
|
|
13
|
+
subtitle: Subtitle | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const SubtitleOverlay: React.FC<SubtitleOverlayProps> = ({ subtitle }) => {
|
|
17
|
+
if (!subtitle) return null;
|
|
18
|
+
|
|
19
|
+
const positionStyle =
|
|
20
|
+
subtitle.style.position === "top"
|
|
21
|
+
? styles.top
|
|
22
|
+
: subtitle.style.position === "center"
|
|
23
|
+
? styles.center
|
|
24
|
+
: styles.bottom;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<View style={[styles.overlay, positionStyle]} pointerEvents="none">
|
|
28
|
+
<View style={[styles.bubble, { backgroundColor: subtitle.style.backgroundColor }]}>
|
|
29
|
+
<AtomicText
|
|
30
|
+
style={{
|
|
31
|
+
color: subtitle.style.fontColor,
|
|
32
|
+
fontSize: FONT_SIZE_MAP[subtitle.style.fontSize],
|
|
33
|
+
textAlign: "center",
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
{subtitle.text}
|
|
37
|
+
</AtomicText>
|
|
38
|
+
</View>
|
|
39
|
+
</View>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const styles = StyleSheet.create({
|
|
44
|
+
overlay: {
|
|
45
|
+
position: "absolute",
|
|
46
|
+
left: 16,
|
|
47
|
+
right: 16,
|
|
48
|
+
alignItems: "center",
|
|
49
|
+
},
|
|
50
|
+
top: { top: 16 },
|
|
51
|
+
center: { top: "40%" },
|
|
52
|
+
bottom: { bottom: 16 },
|
|
53
|
+
bubble: {
|
|
54
|
+
borderRadius: 6,
|
|
55
|
+
paddingHorizontal: 12,
|
|
56
|
+
paddingVertical: 6,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubtitleStylePicker Component
|
|
3
|
+
* Font size, color, background, position pickers + live preview
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, TouchableOpacity, ScrollView, StyleSheet } from "react-native";
|
|
8
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
+
import {
|
|
11
|
+
FONT_SIZE_MAP,
|
|
12
|
+
SUBTITLE_FONT_COLORS,
|
|
13
|
+
SUBTITLE_BG_COLORS,
|
|
14
|
+
} from "../../infrastructure/constants/subtitle.constants";
|
|
15
|
+
import type { SubtitleStyle } from "../../domain/entities/video-project.types";
|
|
16
|
+
|
|
17
|
+
interface SubtitleStylePickerProps {
|
|
18
|
+
style: SubtitleStyle;
|
|
19
|
+
previewText: string;
|
|
20
|
+
onChange: (style: SubtitleStyle) => void;
|
|
21
|
+
t: (key: string) => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const FONT_SIZES: SubtitleStyle["fontSize"][] = ["small", "medium", "large", "extraLarge"];
|
|
25
|
+
const POSITIONS: SubtitleStyle["position"][] = ["top", "center", "bottom"];
|
|
26
|
+
|
|
27
|
+
export const SubtitleStylePicker: React.FC<SubtitleStylePickerProps> = ({
|
|
28
|
+
style,
|
|
29
|
+
previewText,
|
|
30
|
+
onChange,
|
|
31
|
+
t,
|
|
32
|
+
}) => {
|
|
33
|
+
const tokens = useAppDesignTokens();
|
|
34
|
+
|
|
35
|
+
const styles = useMemo(() => StyleSheet.create({
|
|
36
|
+
section: { marginTop: tokens.spacing.md },
|
|
37
|
+
sectionLabel: { marginBottom: tokens.spacing.sm },
|
|
38
|
+
row: { flexDirection: "row", gap: tokens.spacing.sm, flexWrap: "wrap" },
|
|
39
|
+
optionBtn: {
|
|
40
|
+
paddingHorizontal: tokens.spacing.md,
|
|
41
|
+
paddingVertical: tokens.spacing.sm,
|
|
42
|
+
borderRadius: tokens.borders.radius.md,
|
|
43
|
+
backgroundColor: tokens.colors.surface,
|
|
44
|
+
borderWidth: 1,
|
|
45
|
+
borderColor: tokens.colors.border,
|
|
46
|
+
},
|
|
47
|
+
optionBtnActive: {
|
|
48
|
+
backgroundColor: tokens.colors.primaryContainer,
|
|
49
|
+
borderColor: tokens.colors.primary,
|
|
50
|
+
},
|
|
51
|
+
colorRow: { flexDirection: "row", gap: tokens.spacing.sm },
|
|
52
|
+
colorBtn: {
|
|
53
|
+
width: 36,
|
|
54
|
+
height: 36,
|
|
55
|
+
borderRadius: 18,
|
|
56
|
+
borderWidth: 2,
|
|
57
|
+
borderColor: "transparent",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
justifyContent: "center",
|
|
60
|
+
},
|
|
61
|
+
colorBtnActive: {
|
|
62
|
+
borderColor: tokens.colors.primary,
|
|
63
|
+
},
|
|
64
|
+
previewBox: {
|
|
65
|
+
height: 72,
|
|
66
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
67
|
+
borderRadius: tokens.borders.radius.md,
|
|
68
|
+
marginTop: tokens.spacing.md,
|
|
69
|
+
justifyContent: "center",
|
|
70
|
+
alignItems: "center",
|
|
71
|
+
borderWidth: 1,
|
|
72
|
+
borderColor: tokens.colors.border,
|
|
73
|
+
overflow: "hidden",
|
|
74
|
+
},
|
|
75
|
+
previewBubble: {
|
|
76
|
+
paddingHorizontal: tokens.spacing.md,
|
|
77
|
+
paddingVertical: tokens.spacing.xs,
|
|
78
|
+
borderRadius: tokens.borders.radius.sm,
|
|
79
|
+
},
|
|
80
|
+
}), [tokens]);
|
|
81
|
+
|
|
82
|
+
const update = (patch: Partial<SubtitleStyle>) => onChange({ ...style, ...patch });
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<View>
|
|
86
|
+
{/* Font size */}
|
|
87
|
+
<View style={styles.section}>
|
|
88
|
+
<AtomicText type="labelSmall" color="textSecondary" style={styles.sectionLabel}>
|
|
89
|
+
{t("subtitle.style.fontSize") || "Size"}
|
|
90
|
+
</AtomicText>
|
|
91
|
+
<View style={styles.row}>
|
|
92
|
+
{FONT_SIZES.map((size) => (
|
|
93
|
+
<TouchableOpacity
|
|
94
|
+
key={size}
|
|
95
|
+
style={[styles.optionBtn, style.fontSize === size && styles.optionBtnActive]}
|
|
96
|
+
onPress={() => update({ fontSize: size })}
|
|
97
|
+
accessibilityRole="button"
|
|
98
|
+
>
|
|
99
|
+
<AtomicText
|
|
100
|
+
type="labelSmall"
|
|
101
|
+
color={style.fontSize === size ? "primary" : "textSecondary"}
|
|
102
|
+
>
|
|
103
|
+
{size}
|
|
104
|
+
</AtomicText>
|
|
105
|
+
</TouchableOpacity>
|
|
106
|
+
))}
|
|
107
|
+
</View>
|
|
108
|
+
</View>
|
|
109
|
+
|
|
110
|
+
{/* Font color */}
|
|
111
|
+
<View style={styles.section}>
|
|
112
|
+
<AtomicText type="labelSmall" color="textSecondary" style={styles.sectionLabel}>
|
|
113
|
+
{t("subtitle.style.fontColor") || "Color"}
|
|
114
|
+
</AtomicText>
|
|
115
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
116
|
+
<View style={styles.colorRow}>
|
|
117
|
+
{SUBTITLE_FONT_COLORS.map((color) => (
|
|
118
|
+
<TouchableOpacity
|
|
119
|
+
key={color}
|
|
120
|
+
style={[styles.colorBtn, { backgroundColor: color }, style.fontColor === color && styles.colorBtnActive]}
|
|
121
|
+
onPress={() => update({ fontColor: color })}
|
|
122
|
+
accessibilityRole="button"
|
|
123
|
+
/>
|
|
124
|
+
))}
|
|
125
|
+
</View>
|
|
126
|
+
</ScrollView>
|
|
127
|
+
</View>
|
|
128
|
+
|
|
129
|
+
{/* Background */}
|
|
130
|
+
<View style={styles.section}>
|
|
131
|
+
<AtomicText type="labelSmall" color="textSecondary" style={styles.sectionLabel}>
|
|
132
|
+
{t("subtitle.style.background") || "Background"}
|
|
133
|
+
</AtomicText>
|
|
134
|
+
<View style={styles.row}>
|
|
135
|
+
{SUBTITLE_BG_COLORS.map((bg) => (
|
|
136
|
+
<TouchableOpacity
|
|
137
|
+
key={bg.value}
|
|
138
|
+
style={[styles.optionBtn, style.backgroundColor === bg.value && styles.optionBtnActive]}
|
|
139
|
+
onPress={() => update({ backgroundColor: bg.value })}
|
|
140
|
+
accessibilityRole="button"
|
|
141
|
+
>
|
|
142
|
+
<AtomicText
|
|
143
|
+
type="labelSmall"
|
|
144
|
+
color={style.backgroundColor === bg.value ? "primary" : "textSecondary"}
|
|
145
|
+
>
|
|
146
|
+
{bg.label}
|
|
147
|
+
</AtomicText>
|
|
148
|
+
</TouchableOpacity>
|
|
149
|
+
))}
|
|
150
|
+
</View>
|
|
151
|
+
</View>
|
|
152
|
+
|
|
153
|
+
{/* Position */}
|
|
154
|
+
<View style={styles.section}>
|
|
155
|
+
<AtomicText type="labelSmall" color="textSecondary" style={styles.sectionLabel}>
|
|
156
|
+
{t("subtitle.style.position") || "Position"}
|
|
157
|
+
</AtomicText>
|
|
158
|
+
<View style={styles.row}>
|
|
159
|
+
{POSITIONS.map((pos) => (
|
|
160
|
+
<TouchableOpacity
|
|
161
|
+
key={pos}
|
|
162
|
+
style={[styles.optionBtn, style.position === pos && styles.optionBtnActive]}
|
|
163
|
+
onPress={() => update({ position: pos })}
|
|
164
|
+
accessibilityRole="button"
|
|
165
|
+
>
|
|
166
|
+
<AtomicText
|
|
167
|
+
type="labelSmall"
|
|
168
|
+
color={style.position === pos ? "primary" : "textSecondary"}
|
|
169
|
+
>
|
|
170
|
+
{pos}
|
|
171
|
+
</AtomicText>
|
|
172
|
+
</TouchableOpacity>
|
|
173
|
+
))}
|
|
174
|
+
</View>
|
|
175
|
+
</View>
|
|
176
|
+
|
|
177
|
+
{/* Preview */}
|
|
178
|
+
<View style={styles.previewBox}>
|
|
179
|
+
<View style={[styles.previewBubble, { backgroundColor: style.backgroundColor }]}>
|
|
180
|
+
<AtomicText
|
|
181
|
+
style={{ color: style.fontColor, fontSize: FONT_SIZE_MAP[style.fontSize] * 0.75, textAlign: "center" }}
|
|
182
|
+
>
|
|
183
|
+
{previewText || t("subtitle.preview.placeholder") || "Preview text"}
|
|
184
|
+
</AtomicText>
|
|
185
|
+
</View>
|
|
186
|
+
</View>
|
|
187
|
+
</View>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubtitleTimeInput Component
|
|
3
|
+
* Coarse (+/-1s) and fine (+/-0.1s, +/-5s) time adjustment
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
|
8
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
+
import { formatTimeDetailed } from "../../infrastructure/utils/srt.utils";
|
|
11
|
+
|
|
12
|
+
interface SubtitleTimeInputProps {
|
|
13
|
+
label: string;
|
|
14
|
+
value: number;
|
|
15
|
+
onChange: (v: number) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SubtitleTimeInput: React.FC<SubtitleTimeInputProps> = ({ label, value, onChange }) => {
|
|
19
|
+
const tokens = useAppDesignTokens();
|
|
20
|
+
|
|
21
|
+
const styles = useMemo(() => StyleSheet.create({
|
|
22
|
+
container: { flex: 1 },
|
|
23
|
+
label: { marginBottom: tokens.spacing.xs },
|
|
24
|
+
row: { flexDirection: "row", alignItems: "center", gap: tokens.spacing.xs },
|
|
25
|
+
adjustBtn: {
|
|
26
|
+
width: 32,
|
|
27
|
+
height: 32,
|
|
28
|
+
borderRadius: 16,
|
|
29
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
30
|
+
alignItems: "center",
|
|
31
|
+
justifyContent: "center",
|
|
32
|
+
},
|
|
33
|
+
display: {
|
|
34
|
+
flex: 1,
|
|
35
|
+
backgroundColor: tokens.colors.surface,
|
|
36
|
+
borderRadius: tokens.borders.radius.md,
|
|
37
|
+
paddingVertical: tokens.spacing.sm,
|
|
38
|
+
alignItems: "center",
|
|
39
|
+
borderWidth: 1,
|
|
40
|
+
borderColor: tokens.colors.border,
|
|
41
|
+
},
|
|
42
|
+
fineRow: {
|
|
43
|
+
flexDirection: "row",
|
|
44
|
+
gap: tokens.spacing.xs,
|
|
45
|
+
marginTop: tokens.spacing.xs,
|
|
46
|
+
flexWrap: "wrap",
|
|
47
|
+
},
|
|
48
|
+
fineBtn: {
|
|
49
|
+
backgroundColor: tokens.colors.surface,
|
|
50
|
+
borderRadius: tokens.borders.radius.sm,
|
|
51
|
+
paddingHorizontal: tokens.spacing.sm,
|
|
52
|
+
paddingVertical: tokens.spacing.xs,
|
|
53
|
+
borderWidth: 1,
|
|
54
|
+
borderColor: tokens.colors.border,
|
|
55
|
+
},
|
|
56
|
+
}), [tokens]);
|
|
57
|
+
|
|
58
|
+
const adjust = (delta: number) => onChange(Math.max(0, value + delta));
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View style={styles.container}>
|
|
62
|
+
<AtomicText type="labelSmall" color="textSecondary" style={styles.label}>
|
|
63
|
+
{label}
|
|
64
|
+
</AtomicText>
|
|
65
|
+
<View style={styles.row}>
|
|
66
|
+
<TouchableOpacity style={styles.adjustBtn} onPress={() => adjust(-1)} accessibilityRole="button">
|
|
67
|
+
<AtomicText fontWeight="bold" color="textPrimary">−</AtomicText>
|
|
68
|
+
</TouchableOpacity>
|
|
69
|
+
<View style={styles.display}>
|
|
70
|
+
<AtomicText fontWeight="semibold" color="textPrimary">
|
|
71
|
+
{formatTimeDetailed(value)}
|
|
72
|
+
</AtomicText>
|
|
73
|
+
</View>
|
|
74
|
+
<TouchableOpacity style={styles.adjustBtn} onPress={() => adjust(1)} accessibilityRole="button">
|
|
75
|
+
<AtomicText fontWeight="bold" color="textPrimary">+</AtomicText>
|
|
76
|
+
</TouchableOpacity>
|
|
77
|
+
</View>
|
|
78
|
+
<View style={styles.fineRow}>
|
|
79
|
+
{([-5, -0.1, 0.1, 5] as const).map((delta) => (
|
|
80
|
+
<TouchableOpacity key={delta} style={styles.fineBtn} onPress={() => adjust(delta)} accessibilityRole="button">
|
|
81
|
+
<AtomicText type="labelSmall" color="textSecondary">
|
|
82
|
+
{delta > 0 ? `+${delta}s` : `${delta}s`}
|
|
83
|
+
</AtomicText>
|
|
84
|
+
</TouchableOpacity>
|
|
85
|
+
))}
|
|
86
|
+
</View>
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
@@ -18,3 +18,7 @@ export * from "./ExportDialog";
|
|
|
18
18
|
export * from "./SpeedControlPanel";
|
|
19
19
|
export * from "./VideoFilterPicker";
|
|
20
20
|
export * from "./CollageEditorCanvas";
|
|
21
|
+
export * from "./SubtitleTimeInput";
|
|
22
|
+
export * from "./SubtitleStylePicker";
|
|
23
|
+
export * from "./SubtitleOverlay";
|
|
24
|
+
export * from "./SubtitleListPanel";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSubtitleEditor Hook
|
|
3
|
+
* CRUD state management for subtitles
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback, useMemo } from "react";
|
|
7
|
+
import type { Subtitle, SubtitleStyle } from "../../domain/entities/video-project.types";
|
|
8
|
+
import { DEFAULT_SUBTITLE_STYLE } from "../../infrastructure/constants/subtitle.constants";
|
|
9
|
+
import { generateSRT } from "../../infrastructure/utils/srt.utils";
|
|
10
|
+
|
|
11
|
+
function generateId(): string {
|
|
12
|
+
return `sub_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UseSubtitleEditorReturn {
|
|
16
|
+
subtitles: Subtitle[];
|
|
17
|
+
addSubtitle: (text: string, startTime: number, endTime: number, style?: SubtitleStyle) => void;
|
|
18
|
+
updateSubtitle: (id: string, patch: Partial<Omit<Subtitle, "id">>) => void;
|
|
19
|
+
deleteSubtitle: (id: string) => void;
|
|
20
|
+
clearSubtitles: () => void;
|
|
21
|
+
getActiveSubtitle: (currentTime: number) => Subtitle | null;
|
|
22
|
+
exportSRT: () => string;
|
|
23
|
+
setSubtitles: (subtitles: Subtitle[]) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useSubtitleEditor(initial: Subtitle[] = []): UseSubtitleEditorReturn {
|
|
27
|
+
const [subtitles, setSubtitles] = useState<Subtitle[]>(initial);
|
|
28
|
+
|
|
29
|
+
const addSubtitle = useCallback((
|
|
30
|
+
text: string,
|
|
31
|
+
startTime: number,
|
|
32
|
+
endTime: number,
|
|
33
|
+
style: SubtitleStyle = { ...DEFAULT_SUBTITLE_STYLE },
|
|
34
|
+
) => {
|
|
35
|
+
const newSub: Subtitle = { id: generateId(), text, startTime, endTime, style };
|
|
36
|
+
setSubtitles((prev) => [...prev, newSub].sort((a, b) => a.startTime - b.startTime));
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const updateSubtitle = useCallback((id: string, patch: Partial<Omit<Subtitle, "id">>) => {
|
|
40
|
+
setSubtitles((prev) =>
|
|
41
|
+
prev
|
|
42
|
+
.map((s) => (s.id === id ? { ...s, ...patch } : s))
|
|
43
|
+
.sort((a, b) => a.startTime - b.startTime),
|
|
44
|
+
);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const deleteSubtitle = useCallback((id: string) => {
|
|
48
|
+
setSubtitles((prev) => prev.filter((s) => s.id !== id));
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const clearSubtitles = useCallback(() => setSubtitles([]), []);
|
|
52
|
+
|
|
53
|
+
const getActiveSubtitle = useCallback(
|
|
54
|
+
(currentTime: number): Subtitle | null =>
|
|
55
|
+
subtitles.find((s) => currentTime >= s.startTime && currentTime <= s.endTime) ?? null,
|
|
56
|
+
[subtitles],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const exportSRT = useCallback((): string => generateSRT(subtitles), [subtitles]);
|
|
60
|
+
|
|
61
|
+
return useMemo(() => ({
|
|
62
|
+
subtitles,
|
|
63
|
+
addSubtitle,
|
|
64
|
+
updateSubtitle,
|
|
65
|
+
deleteSubtitle,
|
|
66
|
+
clearSubtitles,
|
|
67
|
+
getActiveSubtitle,
|
|
68
|
+
exportSRT,
|
|
69
|
+
setSubtitles,
|
|
70
|
+
}), [subtitles, addSubtitle, updateSubtitle, deleteSubtitle, clearSubtitles, getActiveSubtitle, exportSRT]);
|
|
71
|
+
}
|
package/README.md
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# @umituz/react-native-video-editor
|
|
2
|
-
|
|
3
|
-
Professional video editor package with layer-based timeline, multiple layer types, and export functionality.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- Layer-based editing system (text, image, shape, audio, animation)
|
|
8
|
-
- Timeline management with scenes
|
|
9
|
-
- Playback controls
|
|
10
|
-
- History/undo functionality
|
|
11
|
-
- Video export with customizable settings
|
|
12
|
-
- Bottom sheet integration for layer editing
|
|
13
|
-
|
|
14
|
-
## Installation
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
npm install @umituz/react-native-video-editor
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
## Peer Dependencies
|
|
21
|
-
|
|
22
|
-
This package requires the following peer dependencies:
|
|
23
|
-
|
|
24
|
-
```json
|
|
25
|
-
{
|
|
26
|
-
"react": ">=18.2.0",
|
|
27
|
-
"react-native": ">=0.74.0",
|
|
28
|
-
"@umituz/react-native-design-system": ">=1.0.0",
|
|
29
|
-
"zustand": ">=4.0.0"
|
|
30
|
-
}
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## Usage
|
|
34
|
-
|
|
35
|
-
### Hooks
|
|
36
|
-
|
|
37
|
-
```typescript
|
|
38
|
-
import {
|
|
39
|
-
useEditorLayers,
|
|
40
|
-
useEditorScenes,
|
|
41
|
-
useEditorPlayback,
|
|
42
|
-
useEditorHistory,
|
|
43
|
-
useEditorBottomSheet,
|
|
44
|
-
useEditorActions,
|
|
45
|
-
} from "@umituz/react-native-video-editor";
|
|
46
|
-
|
|
47
|
-
// Use hooks in your component
|
|
48
|
-
const { layers, addLayer, removeLayer } = useEditorLayers();
|
|
49
|
-
const { scenes, addScene, removeScene } = useEditorScenes();
|
|
50
|
-
const { isPlaying, play, pause } = useEditorPlayback();
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
### Components
|
|
54
|
-
|
|
55
|
-
```typescript
|
|
56
|
-
import {
|
|
57
|
-
EditorHeader,
|
|
58
|
-
EditorPreviewArea,
|
|
59
|
-
EditorTimeline,
|
|
60
|
-
EditorToolPanel,
|
|
61
|
-
TextLayerEditor,
|
|
62
|
-
ImageLayerEditor,
|
|
63
|
-
ShapeLayerEditor,
|
|
64
|
-
AudioEditor,
|
|
65
|
-
AnimationEditor,
|
|
66
|
-
ExportDialog,
|
|
67
|
-
} from "@umituz/react-native-video-editor";
|
|
68
|
-
|
|
69
|
-
// Use components in your editor screen
|
|
70
|
-
<EditorHeader />
|
|
71
|
-
<EditorPreviewArea />
|
|
72
|
-
<EditorTimeline />
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
### Services
|
|
76
|
-
|
|
77
|
-
```typescript
|
|
78
|
-
import {
|
|
79
|
-
layerOperationsService,
|
|
80
|
-
sceneOperationsService,
|
|
81
|
-
exportOrchestratorService,
|
|
82
|
-
} from "@umituz/react-native-video-editor";
|
|
83
|
-
|
|
84
|
-
// Use services for business logic
|
|
85
|
-
layerOperationsService.duplicateLayer(layerId);
|
|
86
|
-
sceneOperationsService.addScene(sceneData);
|
|
87
|
-
exportOrchestratorService.exportVideo(options);
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
## License
|
|
91
|
-
|
|
92
|
-
MIT
|