@umituz/react-native-video-editor 1.1.39 → 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 +1 -1
- package/src/VideoEditor.tsx +210 -0
- package/src/domain/entities/video-project.types.ts +15 -0
- package/src/index.ts +9 -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/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.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",
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoEditor Component
|
|
3
|
+
* Self-contained full-screen video editor.
|
|
4
|
+
* Mirrors PhotoEditor API: accepts videoUri, onClose, onSave.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useState, useMemo, useRef, useCallback } from "react";
|
|
8
|
+
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
|
9
|
+
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
10
|
+
import { BottomSheetModal } from "@umituz/react-native-design-system/molecules";
|
|
11
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
12
|
+
import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
|
|
13
|
+
|
|
14
|
+
import { VideoPlayer } from "./player/presentation/components/VideoPlayer";
|
|
15
|
+
import { VideoFilterPicker } from "./presentation/components/VideoFilterPicker";
|
|
16
|
+
import { SpeedControlPanel } from "./presentation/components/SpeedControlPanel";
|
|
17
|
+
import { FILTER_PRESETS, DEFAULT_FILTER } from "./infrastructure/constants/filter.constants";
|
|
18
|
+
import { DEFAULT_PLAYBACK_RATE } from "./infrastructure/constants/speed.constants";
|
|
19
|
+
import type { FilterPreset } from "./domain/entities/video-project.types";
|
|
20
|
+
|
|
21
|
+
export interface VideoEditorProps {
|
|
22
|
+
videoUri: string;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
onSave?: (uri: string, filter: FilterPreset, playbackRate: number) => void;
|
|
25
|
+
title?: string;
|
|
26
|
+
t: (key: string) => string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ActiveTool = "filters" | "speed" | null;
|
|
30
|
+
|
|
31
|
+
export const VideoEditor: React.FC<VideoEditorProps> = ({
|
|
32
|
+
videoUri,
|
|
33
|
+
onClose,
|
|
34
|
+
onSave,
|
|
35
|
+
title,
|
|
36
|
+
t,
|
|
37
|
+
}) => {
|
|
38
|
+
const tokens = useAppDesignTokens();
|
|
39
|
+
const insets = useSafeAreaInsets();
|
|
40
|
+
|
|
41
|
+
const [activeFilter, setActiveFilter] = useState<FilterPreset>(DEFAULT_FILTER);
|
|
42
|
+
const [playbackRate, setPlaybackRate] = useState(DEFAULT_PLAYBACK_RATE);
|
|
43
|
+
const [activeTool, setActiveTool] = useState<ActiveTool>(null);
|
|
44
|
+
|
|
45
|
+
const filterSheetRef = useRef<{ present: () => void; dismiss: () => void }>(null);
|
|
46
|
+
const speedSheetRef = useRef<{ present: () => void; dismiss: () => void }>(null);
|
|
47
|
+
|
|
48
|
+
const handleToggleTool = useCallback((tool: Exclude<ActiveTool, null>) => {
|
|
49
|
+
if (activeTool === tool) {
|
|
50
|
+
setActiveTool(null);
|
|
51
|
+
if (tool === "filters") filterSheetRef.current?.dismiss();
|
|
52
|
+
else speedSheetRef.current?.dismiss();
|
|
53
|
+
} else {
|
|
54
|
+
setActiveTool(tool);
|
|
55
|
+
if (tool === "filters") filterSheetRef.current?.present();
|
|
56
|
+
else speedSheetRef.current?.present();
|
|
57
|
+
}
|
|
58
|
+
}, [activeTool]);
|
|
59
|
+
|
|
60
|
+
const handleSave = useCallback(() => {
|
|
61
|
+
onSave?.(videoUri, activeFilter, playbackRate);
|
|
62
|
+
onClose();
|
|
63
|
+
}, [onSave, onClose, videoUri, activeFilter, playbackRate]);
|
|
64
|
+
|
|
65
|
+
const styles = useMemo(() => StyleSheet.create({
|
|
66
|
+
container: {
|
|
67
|
+
flex: 1,
|
|
68
|
+
backgroundColor: tokens.colors.backgroundPrimary,
|
|
69
|
+
},
|
|
70
|
+
header: {
|
|
71
|
+
flexDirection: "row",
|
|
72
|
+
alignItems: "center",
|
|
73
|
+
justifyContent: "space-between",
|
|
74
|
+
paddingTop: insets.top + tokens.spacing.sm,
|
|
75
|
+
paddingBottom: tokens.spacing.sm,
|
|
76
|
+
paddingHorizontal: tokens.spacing.md,
|
|
77
|
+
backgroundColor: tokens.colors.surface,
|
|
78
|
+
},
|
|
79
|
+
headerTitle: {
|
|
80
|
+
flex: 1,
|
|
81
|
+
textAlign: "center",
|
|
82
|
+
},
|
|
83
|
+
headerBtn: {
|
|
84
|
+
width: 40,
|
|
85
|
+
height: 40,
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "center",
|
|
88
|
+
},
|
|
89
|
+
videoArea: {
|
|
90
|
+
flex: 1,
|
|
91
|
+
justifyContent: "center",
|
|
92
|
+
alignItems: "center",
|
|
93
|
+
backgroundColor: tokens.colors.backgroundPrimary,
|
|
94
|
+
},
|
|
95
|
+
toolbar: {
|
|
96
|
+
flexDirection: "row",
|
|
97
|
+
justifyContent: "space-around",
|
|
98
|
+
paddingVertical: tokens.spacing.md,
|
|
99
|
+
paddingHorizontal: tokens.spacing.md,
|
|
100
|
+
paddingBottom: insets.bottom + tokens.spacing.md,
|
|
101
|
+
backgroundColor: tokens.colors.surface,
|
|
102
|
+
borderTopWidth: 1,
|
|
103
|
+
borderTopColor: tokens.colors.border,
|
|
104
|
+
},
|
|
105
|
+
toolBtn: {
|
|
106
|
+
alignItems: "center",
|
|
107
|
+
gap: tokens.spacing.xs,
|
|
108
|
+
paddingHorizontal: tokens.spacing.lg,
|
|
109
|
+
},
|
|
110
|
+
toolBtnActive: {
|
|
111
|
+
opacity: 1,
|
|
112
|
+
},
|
|
113
|
+
}), [tokens, insets]);
|
|
114
|
+
|
|
115
|
+
const TOOLS: { id: Exclude<ActiveTool, null>; icon: string; labelKey: string }[] = [
|
|
116
|
+
{ id: "filters", icon: "sparkles", labelKey: "editor.tools.filters" },
|
|
117
|
+
{ id: "speed", icon: "flash", labelKey: "editor.tools.speed" },
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<View style={styles.container}>
|
|
122
|
+
{/* Header */}
|
|
123
|
+
<View style={styles.header}>
|
|
124
|
+
<TouchableOpacity
|
|
125
|
+
style={styles.headerBtn}
|
|
126
|
+
onPress={onClose}
|
|
127
|
+
accessibilityLabel="Close"
|
|
128
|
+
accessibilityRole="button"
|
|
129
|
+
>
|
|
130
|
+
<AtomicIcon name="close" size="md" color="textPrimary" />
|
|
131
|
+
</TouchableOpacity>
|
|
132
|
+
|
|
133
|
+
<AtomicText type="headlineSmall" style={styles.headerTitle}>
|
|
134
|
+
{title || t("editor.video.title") || "Edit Video"}
|
|
135
|
+
</AtomicText>
|
|
136
|
+
|
|
137
|
+
<TouchableOpacity
|
|
138
|
+
style={styles.headerBtn}
|
|
139
|
+
onPress={handleSave}
|
|
140
|
+
accessibilityLabel="Save"
|
|
141
|
+
accessibilityRole="button"
|
|
142
|
+
>
|
|
143
|
+
<AtomicText fontWeight="bold" color="primary">
|
|
144
|
+
{t("common.save") || "Save"}
|
|
145
|
+
</AtomicText>
|
|
146
|
+
</TouchableOpacity>
|
|
147
|
+
</View>
|
|
148
|
+
|
|
149
|
+
{/* Video Preview */}
|
|
150
|
+
<View style={styles.videoArea}>
|
|
151
|
+
<VideoPlayer
|
|
152
|
+
source={videoUri}
|
|
153
|
+
autoPlay
|
|
154
|
+
loop
|
|
155
|
+
nativeControls={false}
|
|
156
|
+
contentFit="contain"
|
|
157
|
+
playbackRate={playbackRate}
|
|
158
|
+
filterOverlay={activeFilter.id !== "none" ? activeFilter : undefined}
|
|
159
|
+
/>
|
|
160
|
+
</View>
|
|
161
|
+
|
|
162
|
+
{/* Toolbar */}
|
|
163
|
+
<View style={styles.toolbar}>
|
|
164
|
+
{TOOLS.map((tool) => {
|
|
165
|
+
const isActive = activeTool === tool.id;
|
|
166
|
+
return (
|
|
167
|
+
<TouchableOpacity
|
|
168
|
+
key={tool.id}
|
|
169
|
+
style={styles.toolBtn}
|
|
170
|
+
onPress={() => handleToggleTool(tool.id)}
|
|
171
|
+
accessibilityLabel={t(tool.labelKey) || tool.id}
|
|
172
|
+
accessibilityRole="button"
|
|
173
|
+
accessibilityState={{ selected: isActive }}
|
|
174
|
+
>
|
|
175
|
+
<AtomicIcon
|
|
176
|
+
name={tool.icon}
|
|
177
|
+
size="md"
|
|
178
|
+
color={isActive ? "primary" : "textSecondary"}
|
|
179
|
+
/>
|
|
180
|
+
<AtomicText
|
|
181
|
+
type="labelSmall"
|
|
182
|
+
color={isActive ? "primary" : "textSecondary"}
|
|
183
|
+
>
|
|
184
|
+
{t(tool.labelKey) || tool.id}
|
|
185
|
+
</AtomicText>
|
|
186
|
+
</TouchableOpacity>
|
|
187
|
+
);
|
|
188
|
+
})}
|
|
189
|
+
</View>
|
|
190
|
+
|
|
191
|
+
{/* Filter Bottom Sheet */}
|
|
192
|
+
<BottomSheetModal ref={filterSheetRef} snapPoints={["40%"]}>
|
|
193
|
+
<VideoFilterPicker
|
|
194
|
+
activeFilter={activeFilter}
|
|
195
|
+
onSelectFilter={setActiveFilter}
|
|
196
|
+
t={t}
|
|
197
|
+
/>
|
|
198
|
+
</BottomSheetModal>
|
|
199
|
+
|
|
200
|
+
{/* Speed Bottom Sheet */}
|
|
201
|
+
<BottomSheetModal ref={speedSheetRef} snapPoints={["30%"]}>
|
|
202
|
+
<SpeedControlPanel
|
|
203
|
+
playbackRate={playbackRate}
|
|
204
|
+
onChangeRate={setPlaybackRate}
|
|
205
|
+
t={t}
|
|
206
|
+
/>
|
|
207
|
+
</BottomSheetModal>
|
|
208
|
+
</View>
|
|
209
|
+
);
|
|
210
|
+
};
|
|
@@ -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
|
// =============================================================================
|
|
@@ -60,6 +62,9 @@ export {
|
|
|
60
62
|
// PRESENTATION LAYER - Components & Hooks
|
|
61
63
|
// =============================================================================
|
|
62
64
|
|
|
65
|
+
export { VideoEditor } from "./VideoEditor";
|
|
66
|
+
export type { VideoEditorProps } from "./VideoEditor";
|
|
67
|
+
|
|
63
68
|
export {
|
|
64
69
|
EditorHeader,
|
|
65
70
|
EditorPreviewArea,
|
|
@@ -103,6 +108,10 @@ export { useMenuActions } from "./presentation/hooks/useMenuActions";
|
|
|
103
108
|
export { useExportActions } from "./presentation/hooks/useExportActions";
|
|
104
109
|
export { useCollageEditor } from "./presentation/hooks/useCollageEditor";
|
|
105
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";
|
|
106
115
|
|
|
107
116
|
// =============================================================================
|
|
108
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
|
+
}
|