@umituz/react-native-video-editor 1.1.40 → 1.1.41

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-video-editor",
3
- "version": "1.1.40",
3
+ "version": "1.1.41",
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
@@ -12,3 +12,4 @@ export * from "./export.constants";
12
12
  export * from "./filter.constants";
13
13
  export * from "./speed.constants";
14
14
  export * from "./collage.constants";
15
+ export * from "./subtitle.constants";
@@ -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
+ }