@umituz/react-native-video-editor 1.1.62 → 1.1.64

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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * SubtitleListHeader Component
3
+ * Header component for subtitle list panel
4
+ */
5
+
6
+ import React from "react";
7
+ import { View, TouchableOpacity } 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
+
11
+ interface SubtitleListHeaderProps {
12
+ count: number;
13
+ onAdd: () => void;
14
+ title?: string;
15
+ }
16
+
17
+ export const SubtitleListHeader: React.FC<SubtitleListHeaderProps> = ({
18
+ count,
19
+ onAdd,
20
+ title,
21
+ }) => {
22
+ const tokens = useAppDesignTokens();
23
+
24
+ const styles = {
25
+ header: {
26
+ flexDirection: "row" as const,
27
+ alignItems: "center" as const,
28
+ justifyContent: "space-between" as const,
29
+ paddingHorizontal: tokens.spacing.md,
30
+ paddingVertical: tokens.spacing.sm,
31
+ borderBottomWidth: 1,
32
+ borderBottomColor: tokens.colors.border,
33
+ },
34
+ addBtn: {
35
+ width: 34,
36
+ height: 34,
37
+ borderRadius: 17,
38
+ backgroundColor: tokens.colors.primary,
39
+ alignItems: "center" as const,
40
+ justifyContent: "center" as const,
41
+ },
42
+ };
43
+
44
+ return (
45
+ <View style={styles.header}>
46
+ <AtomicText fontWeight="semibold" color="textPrimary">
47
+ {title || "Subtitles"} ({count})
48
+ </AtomicText>
49
+ <TouchableOpacity
50
+ style={styles.addBtn}
51
+ onPress={onAdd}
52
+ accessibilityRole="button"
53
+ >
54
+ <AtomicIcon name="add" size="sm" color="onPrimary" />
55
+ </TouchableOpacity>
56
+ </View>
57
+ );
58
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * SubtitleListItem Component
3
+ * Individual subtitle item in the list
4
+ */
5
+
6
+ import React from "react";
7
+ import { View, TouchableOpacity } 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 { formatTimeDetailed } from "../../../infrastructure/utils/srt.utils";
11
+ import type { Subtitle } from "../../../domain/entities/video-project.types";
12
+
13
+ interface SubtitleListItemProps {
14
+ subtitle: Subtitle;
15
+ isActive: boolean;
16
+ onEdit: (subtitle: Subtitle) => void;
17
+ onSeek: (time: number) => void;
18
+ }
19
+
20
+ export const SubtitleListItem: React.FC<SubtitleListItemProps> = ({
21
+ subtitle,
22
+ isActive,
23
+ onEdit,
24
+ onSeek,
25
+ }) => {
26
+ const tokens = useAppDesignTokens();
27
+
28
+ const styles = {
29
+ item: {
30
+ flexDirection: "row" as const,
31
+ alignItems: "center" as const,
32
+ borderRadius: tokens.borders.radius.md,
33
+ marginHorizontal: tokens.spacing.md,
34
+ marginBottom: tokens.spacing.sm,
35
+ borderWidth: 1,
36
+ borderColor: isActive ? tokens.colors.primary : tokens.colors.border,
37
+ backgroundColor: isActive ? tokens.colors.primaryContainer : tokens.colors.surface,
38
+ overflow: "hidden" as const,
39
+ },
40
+ itemBar: {
41
+ width: 4,
42
+ alignSelf: "stretch" as const,
43
+ backgroundColor: isActive ? tokens.colors.primary : tokens.colors.surfaceVariant,
44
+ },
45
+ itemContent: {
46
+ flex: 1,
47
+ padding: tokens.spacing.sm,
48
+ },
49
+ itemTimeRow: {
50
+ flexDirection: "row" as const,
51
+ alignItems: "center" as const,
52
+ gap: tokens.spacing.xs,
53
+ marginBottom: 2,
54
+ },
55
+ itemEditBtn: {
56
+ width: 40,
57
+ height: 40,
58
+ alignItems: "center" as const,
59
+ justifyContent: "center" as const,
60
+ },
61
+ };
62
+
63
+ return (
64
+ <View style={styles.item}>
65
+ <View style={styles.itemBar} />
66
+ <TouchableOpacity
67
+ style={styles.itemContent}
68
+ onPress={() => onSeek(subtitle.startTime)}
69
+ activeOpacity={0.7}
70
+ >
71
+ <View style={styles.itemTimeRow}>
72
+ <AtomicText color="textSecondary">
73
+ {formatTimeDetailed(subtitle.startTime)}
74
+ </AtomicText>
75
+ <AtomicText color="textSecondary">
76
+
77
+ </AtomicText>
78
+ <AtomicText color="textSecondary">
79
+ {formatTimeDetailed(subtitle.endTime)}
80
+ </AtomicText>
81
+ </View>
82
+ <AtomicText color="textPrimary" numberOfLines={2}>
83
+ {subtitle.text}
84
+ </AtomicText>
85
+ </TouchableOpacity>
86
+ <TouchableOpacity
87
+ style={styles.itemEditBtn}
88
+ onPress={() => onEdit(subtitle)}
89
+ accessibilityRole="button"
90
+ accessibilityLabel="Edit subtitle"
91
+ >
92
+ <AtomicIcon name="edit" size="sm" color="textSecondary" />
93
+ </TouchableOpacity>
94
+ </View>
95
+ );
96
+ };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * SubtitleModal Component
3
+ * Modal for adding/editing subtitles
4
+ */
5
+
6
+ import React from "react";
7
+ import { View, Modal, TextInput, TouchableOpacity, ScrollView } 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 { SubtitleTimeInput } from "../SubtitleTimeInput";
11
+ import { SubtitleStylePicker } from "../SubtitleStylePicker";
12
+ import type { SubtitleStyle } from "../../../domain/entities/video-project.types";
13
+
14
+ interface SubtitleModalProps {
15
+ visible: boolean;
16
+ editing: boolean;
17
+ text: string;
18
+ startTime: number;
19
+ endTime: number;
20
+ style: SubtitleStyle;
21
+ onChangeText: (text: string) => void;
22
+ onChangeStartTime: (time: number) => void;
23
+ onChangeEndTime: (time: number) => void;
24
+ onChangeStyle: (style: SubtitleStyle) => void;
25
+ onSave: () => void;
26
+ onCancel: () => void;
27
+ }
28
+
29
+ export const SubtitleModal: React.FC<SubtitleModalProps> = ({
30
+ visible,
31
+ editing,
32
+ text,
33
+ startTime,
34
+ endTime,
35
+ style,
36
+ onChangeText,
37
+ onChangeStartTime,
38
+ onChangeEndTime,
39
+ onChangeStyle,
40
+ onSave,
41
+ onCancel,
42
+ }) => {
43
+ const tokens = useAppDesignTokens();
44
+
45
+ const styles = {
46
+ overlay: {
47
+ flex: 1,
48
+ backgroundColor: "rgba(0,0,0,0.5)",
49
+ justifyContent: "flex-end" as const,
50
+ },
51
+ modal: {
52
+ backgroundColor: tokens.colors.surface,
53
+ borderTopLeftRadius: tokens.borders.radius.xl,
54
+ borderTopRightRadius: tokens.borders.radius.xl,
55
+ paddingHorizontal: tokens.spacing.md,
56
+ paddingTop: tokens.spacing.md,
57
+ paddingBottom: tokens.spacing.xl,
58
+ maxHeight: "90%" as const,
59
+ },
60
+ handle: {
61
+ width: 36,
62
+ height: 4,
63
+ borderRadius: 2,
64
+ backgroundColor: tokens.colors.border,
65
+ alignSelf: "center" as const,
66
+ marginBottom: tokens.spacing.md,
67
+ },
68
+ textInput: {
69
+ backgroundColor: tokens.colors.surfaceVariant,
70
+ borderRadius: tokens.borders.radius.md,
71
+ padding: tokens.spacing.md,
72
+ color: tokens.colors.textPrimary,
73
+ fontSize: 16,
74
+ minHeight: 80,
75
+ textAlignVertical: "top" as const,
76
+ marginBottom: tokens.spacing.md,
77
+ borderWidth: 1,
78
+ borderColor: tokens.colors.border,
79
+ },
80
+ timeRow: {
81
+ flexDirection: "row" as const,
82
+ gap: tokens.spacing.md,
83
+ marginBottom: tokens.spacing.md,
84
+ },
85
+ actionRow: {
86
+ flexDirection: "row" as const,
87
+ gap: tokens.spacing.md,
88
+ marginTop: tokens.spacing.md,
89
+ },
90
+ cancelBtn: {
91
+ flex: 1,
92
+ paddingVertical: tokens.spacing.md,
93
+ borderRadius: tokens.borders.radius.md,
94
+ backgroundColor: tokens.colors.surfaceVariant,
95
+ alignItems: "center" as const,
96
+ },
97
+ saveBtn: {
98
+ flex: 1,
99
+ paddingVertical: tokens.spacing.md,
100
+ borderRadius: tokens.borders.radius.md,
101
+ backgroundColor: tokens.colors.primary,
102
+ alignItems: "center" as const,
103
+ },
104
+ saveBtnDisabled: { opacity: 0.4 },
105
+ };
106
+
107
+ const isValid = text.trim().length > 0 && endTime > startTime;
108
+
109
+ return (
110
+ <Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
111
+ <View style={styles.overlay}>
112
+ <View style={styles.modal}>
113
+ <View style={styles.handle} />
114
+
115
+ <ScrollView>
116
+ <TextInput
117
+ style={styles.textInput}
118
+ placeholder="Enter subtitle text..."
119
+ placeholderTextColor={tokens.colors.textSecondary}
120
+ value={text}
121
+ onChangeText={onChangeText}
122
+ multiline
123
+ autoFocus
124
+ />
125
+
126
+ <View style={styles.timeRow}>
127
+ <SubtitleTimeInput
128
+ label="Start"
129
+ value={startTime}
130
+ onChange={onChangeStartTime}
131
+ />
132
+ <SubtitleTimeInput
133
+ label="End"
134
+ value={endTime}
135
+ onChange={onChangeEndTime}
136
+ />
137
+ </View>
138
+
139
+ <SubtitleStylePicker style={style} previewText={text} onChange={onChangeStyle} t={(key) => key} />
140
+
141
+ <View style={styles.actionRow}>
142
+ <TouchableOpacity style={styles.cancelBtn} onPress={onCancel}>
143
+ <AtomicText fontWeight="medium" color="textPrimary">
144
+ Cancel
145
+ </AtomicText>
146
+ </TouchableOpacity>
147
+ <TouchableOpacity
148
+ style={[styles.saveBtn, !isValid && styles.saveBtnDisabled]}
149
+ onPress={onSave}
150
+ disabled={!isValid}
151
+ >
152
+ <AtomicText fontWeight="semibold" color="onPrimary">
153
+ {editing ? "Update" : "Add"}
154
+ </AtomicText>
155
+ </TouchableOpacity>
156
+ </View>
157
+ </ScrollView>
158
+ </View>
159
+ </View>
160
+ </Modal>
161
+ );
162
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * useSubtitleForm Hook
3
+ * Manages subtitle form state and operations
4
+ */
5
+
6
+ import { useState, useCallback } from "react";
7
+ import { DEFAULT_SUBTITLE_STYLE } from "../../../infrastructure/constants/subtitle.constants";
8
+ import type { Subtitle, SubtitleStyle } from "../../../domain/entities/video-project.types";
9
+
10
+ interface UseSubtitleFormReturn {
11
+ text: string;
12
+ startTime: number;
13
+ endTime: number;
14
+ style: SubtitleStyle;
15
+ editing: Subtitle | null;
16
+ showModal: boolean;
17
+ openAdd: (currentTime: number) => void;
18
+ openEdit: (subtitle: Subtitle) => void;
19
+ close: () => void;
20
+ save: (onAdd: (text: string, start: number, end: number, style: SubtitleStyle) => void, onUpdate: (id: string, patch: Partial<Omit<Subtitle, "id">>) => void) => void;
21
+ setText: (text: string) => void;
22
+ setStartTime: (time: number) => void;
23
+ setEndTime: (time: number) => void;
24
+ setStyle: (style: SubtitleStyle) => void;
25
+ }
26
+
27
+ export function useSubtitleForm(): UseSubtitleFormReturn {
28
+ const [editing, setEditing] = useState<Subtitle | null>(null);
29
+ const [showModal, setShowModal] = useState(false);
30
+ const [text, setText] = useState("");
31
+ const [startTime, setStartTime] = useState(0);
32
+ const [endTime, setEndTime] = useState(3);
33
+ const [style, setStyle] = useState<SubtitleStyle>({ ...DEFAULT_SUBTITLE_STYLE });
34
+
35
+ const openAdd = useCallback((currentTime: number) => {
36
+ setEditing(null);
37
+ setText("");
38
+ setStartTime(Math.floor(currentTime));
39
+ setEndTime(Math.floor(currentTime) + 3);
40
+ setStyle({ ...DEFAULT_SUBTITLE_STYLE });
41
+ setShowModal(true);
42
+ }, []);
43
+
44
+ const openEdit = useCallback((subtitle: Subtitle) => {
45
+ setEditing(subtitle);
46
+ setText(subtitle.text);
47
+ setStartTime(subtitle.startTime);
48
+ setEndTime(subtitle.endTime);
49
+ setStyle({ ...subtitle.style });
50
+ setShowModal(true);
51
+ }, []);
52
+
53
+ const close = useCallback(() => {
54
+ setShowModal(false);
55
+ }, []);
56
+
57
+ const save = useCallback((
58
+ onAdd: (text: string, start: number, end: number, style: SubtitleStyle) => void,
59
+ onUpdate: (id: string, patch: Partial<Omit<Subtitle, "id">>) => void,
60
+ ) => {
61
+ if (!text.trim()) return;
62
+
63
+ const resolvedEnd = Math.max(endTime, startTime + 0.5);
64
+
65
+ if (editing) {
66
+ onUpdate(editing.id, {
67
+ text: text.trim(),
68
+ startTime,
69
+ endTime: resolvedEnd,
70
+ style,
71
+ });
72
+ } else {
73
+ onAdd(text.trim(), startTime, resolvedEnd, style);
74
+ }
75
+
76
+ close();
77
+ }, [text, editing, startTime, endTime, style, close]);
78
+
79
+ return {
80
+ text,
81
+ startTime,
82
+ endTime,
83
+ style,
84
+ editing,
85
+ showModal,
86
+ openAdd,
87
+ openEdit,
88
+ close,
89
+ save,
90
+ setText,
91
+ setStartTime,
92
+ setEndTime,
93
+ setStyle,
94
+ };
95
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Generic Layer Form Hook
3
+ * Type-safe form management for layer editors
4
+ * Eliminates code duplication across layer form hooks
5
+ */
6
+
7
+ import { useState, useCallback } from "react";
8
+ import type { Layer, ImageLayer, TextLayer } from "../../../domain/entities/video-project.types";
9
+
10
+ /**
11
+ * Form field validator function type
12
+ */
13
+ export type ValidatorFn<T, K extends keyof T = keyof T> = (value: T[K]) => string | null;
14
+
15
+ /**
16
+ * Layer form configuration
17
+ */
18
+ export interface UseLayerFormConfig<T extends Record<string, unknown>> {
19
+ initialValues: Partial<T>;
20
+ validators?: Record<string, (value: unknown) => string | null>;
21
+ buildData: (formState: T) => Partial<Layer> | Partial<ImageLayer> | Partial<TextLayer>;
22
+ }
23
+
24
+ /**
25
+ * Layer form return type
26
+ */
27
+ export interface UseLayerFormReturn<T extends Record<string, unknown>, R = Partial<Layer>> {
28
+ formState: T;
29
+ updateField: <K extends keyof T>(field: K, value: T[K]) => void;
30
+ setFormState: (state: T | ((prev: T) => T)) => void;
31
+ buildLayerData: () => R;
32
+ isValid: boolean;
33
+ errors: Partial<Record<keyof T, string | null>>;
34
+ validateField: <K extends keyof T>(field: K) => string | null;
35
+ validateAll: () => boolean;
36
+ }
37
+
38
+ /**
39
+ * Generic hook for managing layer form state
40
+ * Provides type-safe form management with validation support
41
+ */
42
+ export function useLayerForm<T extends Record<string, unknown>, R = Partial<Layer>>(
43
+ config: UseLayerFormConfig<T>,
44
+ ): UseLayerFormReturn<T, R> {
45
+ const { initialValues, validators = {}, buildData } = config;
46
+
47
+ const [formState, setFormState] = useState<T>(
48
+ initialValues as T,
49
+ );
50
+
51
+ const [errors, setErrors] = useState<Partial<Record<keyof T, string | null>>>(
52
+ {},
53
+ );
54
+
55
+ const updateField = useCallback(
56
+ <K extends keyof T>(field: K, value: T[K]) => {
57
+ setFormState((prev) => ({
58
+ ...prev,
59
+ [field]: value,
60
+ }));
61
+
62
+ // Clear error for this field
63
+ if (errors[field as keyof typeof errors]) {
64
+ setErrors((prev) => ({
65
+ ...prev,
66
+ [field]: null,
67
+ }));
68
+ }
69
+ },
70
+ [errors],
71
+ );
72
+
73
+ const validateField = useCallback(
74
+ <K extends keyof T>(field: K): string | null => {
75
+ const validator = validators[String(field)];
76
+ if (!validator) return null;
77
+
78
+ const error = validator(formState[field]);
79
+ setErrors((prev) => ({
80
+ ...prev,
81
+ [field]: error,
82
+ }));
83
+
84
+ return error;
85
+ },
86
+ [formState, validators],
87
+ );
88
+
89
+ const validateAll = useCallback((): boolean => {
90
+ let hasError = false;
91
+ const newErrors: Partial<Record<keyof T, string | null>> = {};
92
+
93
+ for (const field in validators) {
94
+ const validator = validators[field];
95
+ if (validator) {
96
+ const error = validator(formState[field as keyof T]);
97
+ if (error) {
98
+ newErrors[field as keyof T] = error;
99
+ hasError = true;
100
+ }
101
+ }
102
+ }
103
+
104
+ setErrors(newErrors);
105
+ return !hasError;
106
+ }, [formState, validators]);
107
+
108
+ const buildLayerData = useCallback((): R => {
109
+ return buildData(formState) as R;
110
+ }, [formState, buildData]);
111
+
112
+ const isValid = Object.values(errors).every((error) => error === null);
113
+
114
+ return {
115
+ formState,
116
+ updateField,
117
+ setFormState,
118
+ buildLayerData,
119
+ isValid,
120
+ errors,
121
+ validateField,
122
+ validateAll,
123
+ };
124
+ }
@@ -3,12 +3,13 @@
3
3
  * Manages form state for image layer editor
4
4
  */
5
5
 
6
- import { useState, useCallback } from "react";
7
6
  import type { ImageLayer } from "../../domain/entities/video-project.types";
7
+ import { useLayerForm, type UseLayerFormConfig } from "./generic/use-layer-form.hook";
8
8
 
9
9
  interface ImageLayerFormState {
10
10
  imageUri: string;
11
11
  opacity: number;
12
+ [key: string]: unknown;
12
13
  }
13
14
 
14
15
  interface UseImageLayerFormReturn {
@@ -25,33 +26,35 @@ interface UseImageLayerFormReturn {
25
26
  export function useImageLayerForm(
26
27
  initialLayer?: ImageLayer,
27
28
  ): UseImageLayerFormReturn {
28
- const [formState, setFormState] = useState<ImageLayerFormState>({
29
- imageUri: initialLayer?.uri || "",
30
- opacity: initialLayer?.opacity || 1,
31
- });
32
-
33
- const setImageUri = useCallback((uri: string) => {
34
- setFormState((prev) => ({ ...prev, imageUri: uri }));
35
- }, []);
36
-
37
- const setOpacity = useCallback((opacity: number) => {
38
- setFormState((prev) => ({ ...prev, opacity }));
39
- }, []);
40
-
41
- const buildLayerData = useCallback((): Partial<ImageLayer> => {
42
- return {
29
+ const config: UseLayerFormConfig<ImageLayerFormState> = {
30
+ initialValues: {
31
+ imageUri: initialLayer?.uri || "",
32
+ opacity: initialLayer?.opacity || 1,
33
+ },
34
+ validators: {
35
+ imageUri: (value: unknown) => {
36
+ if (!value || (typeof value === "string" && value.trim().length === 0)) {
37
+ return "Image URI is required";
38
+ }
39
+ return null;
40
+ },
41
+ },
42
+ buildData: (formState) => ({
43
43
  uri: formState.imageUri,
44
44
  opacity: formState.opacity,
45
- };
46
- }, [formState]);
45
+ } as Partial<ImageLayer>),
46
+ };
47
+
48
+ const form = useLayerForm<ImageLayerFormState, Partial<ImageLayer>>(config);
47
49
 
48
- const isValid = formState.imageUri.length > 0;
50
+ const setImageUri = (uri: string) => form.updateField("imageUri", uri);
51
+ const setOpacity = (opacity: number) => form.updateField("opacity", opacity);
49
52
 
50
53
  return {
51
- formState,
54
+ formState: form.formState,
52
55
  setImageUri,
53
56
  setOpacity,
54
- buildLayerData,
55
- isValid,
57
+ buildLayerData: form.buildLayerData,
58
+ isValid: form.isValid,
56
59
  };
57
60
  }