@igorpache/expo-video-editor 0.1.0
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/.prettierrc +8 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/android/build.gradle +27 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/videoeditor/ColorMatrixEffect.kt +25 -0
- package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorModule.kt +61 -0
- package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorView.kt +109 -0
- package/android/src/main/java/expo/modules/videoeditor/ExportRunner.kt +302 -0
- package/android/src/main/java/expo/modules/videoeditor/GifOverlay.kt +27 -0
- package/android/src/main/java/expo/modules/videoeditor/Records.kt +44 -0
- package/eslint.config.cjs +16 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioMixBuilder.swift +66 -0
- package/ios/ColorMatrixFilter.swift +24 -0
- package/ios/ExpoVideoEditor.podspec +23 -0
- package/ios/ExpoVideoEditorModule.swift +55 -0
- package/ios/ExpoVideoEditorView.swift +144 -0
- package/ios/ExportRunner.swift +135 -0
- package/ios/FrameComposer.swift +94 -0
- package/ios/ImageVideoWriter.swift +144 -0
- package/ios/Records.swift +41 -0
- package/package.json +36 -0
- package/src/components/EditorRoot.tsx +180 -0
- package/src/components/ExportOverlay.tsx +44 -0
- package/src/components/PreviewSurface.tsx +49 -0
- package/src/components/VideoEditor.tsx +51 -0
- package/src/components/audio/AudioPanel.tsx +69 -0
- package/src/components/audio/AudioScrubber.tsx +177 -0
- package/src/components/audio/Slider.tsx +60 -0
- package/src/components/filters/FilterCarousel.tsx +44 -0
- package/src/components/giphy/GiphyGrid.tsx +48 -0
- package/src/components/giphy/GiphyPicker.tsx +112 -0
- package/src/components/overlays/GifOverlayView.tsx +53 -0
- package/src/components/overlays/OverlayLayer.tsx +24 -0
- package/src/components/overlays/TextEditorSheet.tsx +135 -0
- package/src/components/overlays/TextOverlayView.tsx +61 -0
- package/src/components/timeline/ThumbnailStrip.tsx +63 -0
- package/src/components/timeline/TrimTimeline.tsx +152 -0
- package/src/errors.ts +33 -0
- package/src/filters/presets.ts +84 -0
- package/src/hooks/useAudioPreview.ts +94 -0
- package/src/hooks/useExport.ts +41 -0
- package/src/hooks/useOverlayGestures.ts +90 -0
- package/src/index.ts +29 -0
- package/src/native/EditorVideoView.tsx +27 -0
- package/src/native/VideoEditorModule.ts +51 -0
- package/src/overlays/captureRegistry.ts +12 -0
- package/src/services/demoCatalog.ts +30 -0
- package/src/services/exportService.ts +199 -0
- package/src/services/feedfmCatalog.ts +44 -0
- package/src/services/giphyClient.ts +69 -0
- package/src/services/mediaCache.ts +18 -0
- package/src/services/musicCatalog.ts +23 -0
- package/src/state/store.ts +200 -0
- package/src/theme.ts +26 -0
- package/src/types.ts +104 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
4
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
5
|
+
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
|
|
6
|
+
|
|
7
|
+
import { useEditorStore } from '../../state/store';
|
|
8
|
+
import { useTheme } from '../../theme';
|
|
9
|
+
|
|
10
|
+
const HANDLE_W = 20;
|
|
11
|
+
const TRACK_H = 56;
|
|
12
|
+
const MIN_GAP_S = 0.5;
|
|
13
|
+
|
|
14
|
+
export function AudioScrubber() {
|
|
15
|
+
const audio = useEditorStore((s) => s.audio);
|
|
16
|
+
const updateAudio = useEditorStore((s) => s.updateAudio);
|
|
17
|
+
const theme = useTheme();
|
|
18
|
+
|
|
19
|
+
const player = useAudioPlayer(audio ? { uri: audio.uri } : null);
|
|
20
|
+
const status = useAudioPlayerStatus(player);
|
|
21
|
+
const [playing, setPlaying] = useState(false);
|
|
22
|
+
const [w, setW] = useState(0);
|
|
23
|
+
|
|
24
|
+
const duration = audio?.duration && audio.duration > 0 ? audio.duration : status.duration || 0;
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (audio && (!audio.duration || audio.duration <= 0) && status.duration > 0) {
|
|
28
|
+
updateAudio({
|
|
29
|
+
duration: status.duration,
|
|
30
|
+
trimEnd: audio.trimEnd > 0 ? audio.trimEnd : status.duration,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}, [status.duration, audio, updateAudio]);
|
|
34
|
+
|
|
35
|
+
const startX = useSharedValue(0);
|
|
36
|
+
const endX = useSharedValue(0);
|
|
37
|
+
const baseStart = useSharedValue(0);
|
|
38
|
+
const baseEnd = useSharedValue(0);
|
|
39
|
+
const draggingRef = useRef(false);
|
|
40
|
+
|
|
41
|
+
const pxPerSec = duration > 0 && w > 0 ? w / duration : 0;
|
|
42
|
+
const minGapPx = MIN_GAP_S * pxPerSec;
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (draggingRef.current || pxPerSec === 0 || !audio) return;
|
|
46
|
+
startX.value = audio.trimStart * pxPerSec;
|
|
47
|
+
endX.value = audio.trimEnd * pxPerSec;
|
|
48
|
+
}, [audio, pxPerSec, startX, endX]);
|
|
49
|
+
|
|
50
|
+
const setDragging = (v: boolean) => {
|
|
51
|
+
draggingRef.current = v;
|
|
52
|
+
};
|
|
53
|
+
const commit = () => {
|
|
54
|
+
if (pxPerSec > 0)
|
|
55
|
+
updateAudio({ trimStart: startX.value / pxPerSec, trimEnd: endX.value / pxPerSec });
|
|
56
|
+
};
|
|
57
|
+
const seekSolo = (px: number) => {
|
|
58
|
+
if (pxPerSec > 0) {
|
|
59
|
+
try {
|
|
60
|
+
player.seekTo(px / pxPerSec);
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const startPan = Gesture.Pan()
|
|
66
|
+
.onStart(() => {
|
|
67
|
+
baseStart.value = startX.value;
|
|
68
|
+
runOnJS(setDragging)(true);
|
|
69
|
+
})
|
|
70
|
+
.onUpdate((e) => {
|
|
71
|
+
let v = baseStart.value + e.translationX;
|
|
72
|
+
v = Math.max(0, Math.min(v, endX.value - minGapPx));
|
|
73
|
+
startX.value = v;
|
|
74
|
+
runOnJS(seekSolo)(v);
|
|
75
|
+
})
|
|
76
|
+
.onEnd(() => {
|
|
77
|
+
runOnJS(commit)();
|
|
78
|
+
runOnJS(setDragging)(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const endPan = Gesture.Pan()
|
|
82
|
+
.onStart(() => {
|
|
83
|
+
baseEnd.value = endX.value;
|
|
84
|
+
runOnJS(setDragging)(true);
|
|
85
|
+
})
|
|
86
|
+
.onUpdate((e) => {
|
|
87
|
+
let v = baseEnd.value + e.translationX;
|
|
88
|
+
v = Math.min(w, Math.max(v, startX.value + minGapPx));
|
|
89
|
+
endX.value = v;
|
|
90
|
+
})
|
|
91
|
+
.onEnd(() => {
|
|
92
|
+
runOnJS(commit)();
|
|
93
|
+
runOnJS(setDragging)(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!playing || !audio) return;
|
|
98
|
+
const id = setInterval(() => {
|
|
99
|
+
try {
|
|
100
|
+
if (player.currentTime >= audio.trimEnd || player.currentTime < audio.trimStart - 0.1) {
|
|
101
|
+
player.seekTo(audio.trimStart);
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
}, 150);
|
|
105
|
+
return () => clearInterval(id);
|
|
106
|
+
}, [playing, audio, player]);
|
|
107
|
+
|
|
108
|
+
useEffect(
|
|
109
|
+
() => () => {
|
|
110
|
+
try {
|
|
111
|
+
player.pause();
|
|
112
|
+
} catch {}
|
|
113
|
+
},
|
|
114
|
+
[player]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const togglePlay = () => {
|
|
118
|
+
try {
|
|
119
|
+
if (playing) {
|
|
120
|
+
player.pause();
|
|
121
|
+
setPlaying(false);
|
|
122
|
+
} else {
|
|
123
|
+
player.seekTo(audio?.trimStart ?? 0);
|
|
124
|
+
player.play();
|
|
125
|
+
setPlaying(true);
|
|
126
|
+
}
|
|
127
|
+
} catch {}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const selStyle = useAnimatedStyle(() => ({
|
|
131
|
+
left: startX.value,
|
|
132
|
+
width: Math.max(0, endX.value - startX.value),
|
|
133
|
+
}));
|
|
134
|
+
const leftHandle = useAnimatedStyle(() => ({ left: startX.value - HANDLE_W }));
|
|
135
|
+
const rightHandle = useAnimatedStyle(() => ({ left: endX.value }));
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<View style={styles.row}>
|
|
139
|
+
<Pressable onPress={togglePlay} style={[styles.playBtn, { backgroundColor: theme.accent }]}>
|
|
140
|
+
<Text style={styles.playIcon}>{playing ? '❚❚' : '▶'}</Text>
|
|
141
|
+
</Pressable>
|
|
142
|
+
<View
|
|
143
|
+
style={[styles.track, { backgroundColor: theme.surface }]}
|
|
144
|
+
onLayout={(e) => setW(e.nativeEvent.layout.width)}>
|
|
145
|
+
<Animated.View
|
|
146
|
+
style={[
|
|
147
|
+
styles.selection,
|
|
148
|
+
{ borderColor: theme.accent, backgroundColor: 'rgba(124,92,255,0.18)' },
|
|
149
|
+
selStyle,
|
|
150
|
+
]}
|
|
151
|
+
pointerEvents="none"
|
|
152
|
+
/>
|
|
153
|
+
<GestureDetector gesture={startPan}>
|
|
154
|
+
<Animated.View style={[styles.handle, { backgroundColor: theme.accent }, leftHandle]} />
|
|
155
|
+
</GestureDetector>
|
|
156
|
+
<GestureDetector gesture={endPan}>
|
|
157
|
+
<Animated.View style={[styles.handle, { backgroundColor: theme.accent }, rightHandle]} />
|
|
158
|
+
</GestureDetector>
|
|
159
|
+
</View>
|
|
160
|
+
</View>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const styles = StyleSheet.create({
|
|
165
|
+
row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
|
166
|
+
playBtn: {
|
|
167
|
+
width: 44,
|
|
168
|
+
height: 44,
|
|
169
|
+
borderRadius: 22,
|
|
170
|
+
alignItems: 'center',
|
|
171
|
+
justifyContent: 'center',
|
|
172
|
+
},
|
|
173
|
+
playIcon: { color: '#fff', fontSize: 16 },
|
|
174
|
+
track: { flex: 1, height: TRACK_H, borderRadius: 8, justifyContent: 'center' },
|
|
175
|
+
selection: { position: 'absolute', top: 0, bottom: 0, borderWidth: 2, borderRadius: 8 },
|
|
176
|
+
handle: { position: 'absolute', top: 0, bottom: 0, width: HANDLE_W, borderRadius: 6 },
|
|
177
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
4
|
+
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
|
|
5
|
+
|
|
6
|
+
import { useTheme } from '../../theme';
|
|
7
|
+
|
|
8
|
+
const THUMB = 18;
|
|
9
|
+
|
|
10
|
+
export function Slider({ value, onChange }: { value: number; onChange: (v: number) => void }) {
|
|
11
|
+
const theme = useTheme();
|
|
12
|
+
const [w, setW] = useState(0);
|
|
13
|
+
const x = useSharedValue(0);
|
|
14
|
+
const start = useSharedValue(0);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (w > 0) x.value = Math.max(0, Math.min(1, value)) * w;
|
|
18
|
+
}, [value, w, x]);
|
|
19
|
+
|
|
20
|
+
const commit = (px: number) => {
|
|
21
|
+
if (w > 0) onChange(Math.max(0, Math.min(1, px / w)));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const pan = Gesture.Pan()
|
|
25
|
+
.onStart(() => {
|
|
26
|
+
start.value = x.value;
|
|
27
|
+
})
|
|
28
|
+
.onUpdate((e) => {
|
|
29
|
+
x.value = Math.max(0, Math.min(w, start.value + e.translationX));
|
|
30
|
+
runOnJS(commit)(x.value);
|
|
31
|
+
})
|
|
32
|
+
.onEnd(() => runOnJS(commit)(x.value));
|
|
33
|
+
|
|
34
|
+
const fill = useAnimatedStyle(() => ({ width: x.value }));
|
|
35
|
+
const thumb = useAnimatedStyle(() => ({ transform: [{ translateX: x.value - THUMB / 2 }] }));
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<GestureDetector gesture={pan}>
|
|
39
|
+
<View style={styles.track} onLayout={(e) => setW(e.nativeEvent.layout.width)}>
|
|
40
|
+
<View style={styles.bg} />
|
|
41
|
+
<Animated.View style={[styles.fill, { backgroundColor: theme.accent }, fill]} />
|
|
42
|
+
<Animated.View style={[styles.thumb, { backgroundColor: theme.accent }, thumb]} />
|
|
43
|
+
</View>
|
|
44
|
+
</GestureDetector>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const styles = StyleSheet.create({
|
|
49
|
+
track: { height: THUMB + 12, justifyContent: 'center' },
|
|
50
|
+
bg: {
|
|
51
|
+
position: 'absolute',
|
|
52
|
+
left: 0,
|
|
53
|
+
right: 0,
|
|
54
|
+
height: 4,
|
|
55
|
+
borderRadius: 2,
|
|
56
|
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
57
|
+
},
|
|
58
|
+
fill: { position: 'absolute', left: 0, height: 4, borderRadius: 2 },
|
|
59
|
+
thumb: { width: THUMB, height: THUMB, borderRadius: THUMB / 2 },
|
|
60
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Pressable, ScrollView, StyleSheet, Text } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { FILTER_PRESETS } from '../../filters/presets';
|
|
4
|
+
import { useEditorStore } from '../../state/store';
|
|
5
|
+
import { useTheme } from '../../theme';
|
|
6
|
+
|
|
7
|
+
export function FilterCarousel() {
|
|
8
|
+
const filter = useEditorStore((s) => s.filter);
|
|
9
|
+
const setFilter = useEditorStore((s) => s.setFilter);
|
|
10
|
+
const theme = useTheme();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<ScrollView
|
|
14
|
+
horizontal
|
|
15
|
+
showsHorizontalScrollIndicator={false}
|
|
16
|
+
contentContainerStyle={styles.row}>
|
|
17
|
+
{FILTER_PRESETS.map((p) => {
|
|
18
|
+
const active = p.key === filter;
|
|
19
|
+
return (
|
|
20
|
+
<Pressable
|
|
21
|
+
key={p.key}
|
|
22
|
+
onPress={() => setFilter(p.key)}
|
|
23
|
+
style={[
|
|
24
|
+
styles.chip,
|
|
25
|
+
{
|
|
26
|
+
backgroundColor: theme.surface,
|
|
27
|
+
borderColor: active ? theme.accent : 'transparent',
|
|
28
|
+
},
|
|
29
|
+
]}>
|
|
30
|
+
<Text style={[styles.label, { color: active ? theme.accent : theme.textMuted }]}>
|
|
31
|
+
{p.label}
|
|
32
|
+
</Text>
|
|
33
|
+
</Pressable>
|
|
34
|
+
);
|
|
35
|
+
})}
|
|
36
|
+
</ScrollView>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const styles = StyleSheet.create({
|
|
41
|
+
row: { gap: 8, paddingHorizontal: 4, paddingVertical: 2 },
|
|
42
|
+
chip: { paddingHorizontal: 16, paddingVertical: 10, borderRadius: 16, borderWidth: 2 },
|
|
43
|
+
label: { fontSize: 13, fontWeight: '600' },
|
|
44
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Image } from 'expo-image';
|
|
2
|
+
import { FlatList, Pressable, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import type { GiphyItem } from '../../services/giphyClient';
|
|
5
|
+
|
|
6
|
+
const COLUMNS = 3;
|
|
7
|
+
const GAP = 6;
|
|
8
|
+
|
|
9
|
+
export function GiphyGrid({
|
|
10
|
+
items,
|
|
11
|
+
onPick,
|
|
12
|
+
onEndReached,
|
|
13
|
+
}: {
|
|
14
|
+
items: GiphyItem[];
|
|
15
|
+
onPick: (item: GiphyItem) => void;
|
|
16
|
+
onEndReached: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<FlatList
|
|
20
|
+
data={items}
|
|
21
|
+
keyExtractor={(it, i) => `${it.id}-${i}`}
|
|
22
|
+
numColumns={COLUMNS}
|
|
23
|
+
contentContainerStyle={styles.content}
|
|
24
|
+
columnWrapperStyle={styles.row}
|
|
25
|
+
onEndReached={onEndReached}
|
|
26
|
+
onEndReachedThreshold={0.6}
|
|
27
|
+
keyboardShouldPersistTaps="handled"
|
|
28
|
+
renderItem={({ item }) => (
|
|
29
|
+
<Pressable style={styles.cell} onPress={() => onPick(item)}>
|
|
30
|
+
<Image source={{ uri: item.previewUrl }} style={styles.img} contentFit="cover" />
|
|
31
|
+
</Pressable>
|
|
32
|
+
)}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const styles = StyleSheet.create({
|
|
38
|
+
content: { padding: GAP, gap: GAP },
|
|
39
|
+
row: { gap: GAP },
|
|
40
|
+
cell: {
|
|
41
|
+
flex: 1 / COLUMNS,
|
|
42
|
+
aspectRatio: 1,
|
|
43
|
+
borderRadius: 8,
|
|
44
|
+
overflow: 'hidden',
|
|
45
|
+
backgroundColor: '#222',
|
|
46
|
+
},
|
|
47
|
+
img: { width: '100%', height: '100%' },
|
|
48
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Modal,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
TextInput,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { GiphyGrid } from './GiphyGrid';
|
|
13
|
+
import { searchGifs, trendingGifs, type GiphyItem } from '../../services/giphyClient';
|
|
14
|
+
import { useEditorStore } from '../../state/store';
|
|
15
|
+
import { useTheme } from '../../theme';
|
|
16
|
+
|
|
17
|
+
const PAGE = 24;
|
|
18
|
+
|
|
19
|
+
export function GiphyPicker() {
|
|
20
|
+
const activePanel = useEditorStore((s) => s.activePanel);
|
|
21
|
+
const setActivePanel = useEditorStore((s) => s.setActivePanel);
|
|
22
|
+
const apiKey = useEditorStore((s) => s.giphyApiKey);
|
|
23
|
+
const addGifOverlay = useEditorStore((s) => s.addGifOverlay);
|
|
24
|
+
const theme = useTheme();
|
|
25
|
+
|
|
26
|
+
const [query, setQuery] = useState('');
|
|
27
|
+
const [items, setItems] = useState<GiphyItem[]>([]);
|
|
28
|
+
const [loading, setLoading] = useState(false);
|
|
29
|
+
const offsetRef = useRef(0);
|
|
30
|
+
|
|
31
|
+
const visible = activePanel === 'giphy' && !!apiKey;
|
|
32
|
+
|
|
33
|
+
const load = useCallback(
|
|
34
|
+
async (reset: boolean, q: string) => {
|
|
35
|
+
if (!apiKey) return;
|
|
36
|
+
setLoading(true);
|
|
37
|
+
try {
|
|
38
|
+
const offset = reset ? 0 : offsetRef.current;
|
|
39
|
+
const page = q
|
|
40
|
+
? await searchGifs(apiKey, q, { offset, limit: PAGE })
|
|
41
|
+
: await trendingGifs(apiKey, { offset, limit: PAGE });
|
|
42
|
+
offsetRef.current = offset + PAGE;
|
|
43
|
+
setItems((prev) => (reset ? page : [...prev, ...page]));
|
|
44
|
+
} catch {
|
|
45
|
+
if (reset) setItems([]);
|
|
46
|
+
} finally {
|
|
47
|
+
setLoading(false);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
[apiKey]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!visible) return;
|
|
55
|
+
const t = setTimeout(() => load(true, query.trim()), query ? 350 : 0);
|
|
56
|
+
return () => clearTimeout(t);
|
|
57
|
+
}, [visible, query, load]);
|
|
58
|
+
|
|
59
|
+
if (!visible) return null;
|
|
60
|
+
const close = () => setActivePanel('none');
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Modal transparent visible animationType="slide" onRequestClose={close}>
|
|
64
|
+
<View style={[styles.sheet, { backgroundColor: theme.background }]}>
|
|
65
|
+
<View style={styles.header}>
|
|
66
|
+
<TextInput
|
|
67
|
+
value={query}
|
|
68
|
+
onChangeText={setQuery}
|
|
69
|
+
placeholder="Search GIPHY"
|
|
70
|
+
placeholderTextColor={theme.textMuted}
|
|
71
|
+
autoCorrect={false}
|
|
72
|
+
style={[styles.input, { backgroundColor: theme.surface, color: theme.text }]}
|
|
73
|
+
/>
|
|
74
|
+
<Pressable onPress={close} hitSlop={12}>
|
|
75
|
+
<Text style={[styles.close, { color: theme.accent }]}>Close</Text>
|
|
76
|
+
</Pressable>
|
|
77
|
+
</View>
|
|
78
|
+
|
|
79
|
+
<GiphyGrid
|
|
80
|
+
items={items}
|
|
81
|
+
onPick={(item) => {
|
|
82
|
+
addGifOverlay({
|
|
83
|
+
previewUri: item.previewUrl,
|
|
84
|
+
exportUri: item.originalUrl,
|
|
85
|
+
aspectRatio: item.height > 0 ? item.width / item.height : 1,
|
|
86
|
+
});
|
|
87
|
+
close();
|
|
88
|
+
}}
|
|
89
|
+
onEndReached={() => !loading && load(false, query.trim())}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
{loading ? <ActivityIndicator style={styles.loader} color={theme.accent} /> : null}
|
|
93
|
+
<Text style={[styles.attribution, { color: theme.textMuted }]}>Powered by GIPHY</Text>
|
|
94
|
+
</View>
|
|
95
|
+
</Modal>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const styles = StyleSheet.create({
|
|
100
|
+
sheet: { flex: 1, paddingTop: 56 },
|
|
101
|
+
header: {
|
|
102
|
+
flexDirection: 'row',
|
|
103
|
+
alignItems: 'center',
|
|
104
|
+
gap: 12,
|
|
105
|
+
paddingHorizontal: 16,
|
|
106
|
+
paddingBottom: 10,
|
|
107
|
+
},
|
|
108
|
+
input: { flex: 1, height: 40, borderRadius: 20, paddingHorizontal: 16, fontSize: 15 },
|
|
109
|
+
close: { fontSize: 15, fontWeight: '700' },
|
|
110
|
+
loader: { position: 'absolute', top: '50%', alignSelf: 'center' },
|
|
111
|
+
attribution: { textAlign: 'center', fontSize: 11, paddingVertical: 8, letterSpacing: 1 },
|
|
112
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Image } from 'expo-image';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { GestureDetector } from 'react-native-gesture-handler';
|
|
4
|
+
import Animated from 'react-native-reanimated';
|
|
5
|
+
|
|
6
|
+
import { useOverlayGestures } from '../../hooks/useOverlayGestures';
|
|
7
|
+
import { useEditorStore } from '../../state/store';
|
|
8
|
+
import { useTheme } from '../../theme';
|
|
9
|
+
import type { StickerOverlay } from '../../types';
|
|
10
|
+
|
|
11
|
+
const BASE_WIDTH = 120;
|
|
12
|
+
|
|
13
|
+
export function GifOverlayView({ overlay }: { overlay: StickerOverlay }) {
|
|
14
|
+
const { gesture, animatedStyle } = useOverlayGestures(overlay);
|
|
15
|
+
const updateOverlay = useEditorStore((s) => s.updateOverlay);
|
|
16
|
+
const selectOverlay = useEditorStore((s) => s.selectOverlay);
|
|
17
|
+
const selected = useEditorStore((s) => s.selectedOverlayId === overlay.id);
|
|
18
|
+
const theme = useTheme();
|
|
19
|
+
|
|
20
|
+
const width = BASE_WIDTH;
|
|
21
|
+
const height = BASE_WIDTH / (overlay.aspectRatio || 1);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<GestureDetector gesture={gesture}>
|
|
25
|
+
<Animated.View style={[styles.wrapper, animatedStyle]}>
|
|
26
|
+
<View
|
|
27
|
+
collapsable={false}
|
|
28
|
+
onLayout={(e) => {
|
|
29
|
+
const { width: w, height: h } = e.nativeEvent.layout;
|
|
30
|
+
if (
|
|
31
|
+
Math.abs((overlay.baseWidth ?? 0) - w) > 0.5 ||
|
|
32
|
+
Math.abs((overlay.baseHeight ?? 0) - h) > 0.5
|
|
33
|
+
) {
|
|
34
|
+
updateOverlay(overlay.id, { baseWidth: w, baseHeight: h });
|
|
35
|
+
}
|
|
36
|
+
}}
|
|
37
|
+
onTouchEnd={() => selectOverlay(overlay.id)}
|
|
38
|
+
style={[styles.content, selected && { borderColor: theme.accent }]}>
|
|
39
|
+
<Image
|
|
40
|
+
source={{ uri: overlay.previewUri }}
|
|
41
|
+
style={{ width, height }}
|
|
42
|
+
contentFit="contain"
|
|
43
|
+
/>
|
|
44
|
+
</View>
|
|
45
|
+
</Animated.View>
|
|
46
|
+
</GestureDetector>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
wrapper: { position: 'absolute', top: 0, left: 0 },
|
|
52
|
+
content: { borderWidth: 1, borderColor: 'transparent', borderRadius: 6 },
|
|
53
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { StyleSheet, View } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { GifOverlayView } from './GifOverlayView';
|
|
4
|
+
import { TextOverlayView } from './TextOverlayView';
|
|
5
|
+
import { useEditorStore } from '../../state/store';
|
|
6
|
+
|
|
7
|
+
export function OverlayLayer() {
|
|
8
|
+
const overlays = useEditorStore((s) => s.overlays);
|
|
9
|
+
const activePanel = useEditorStore((s) => s.activePanel);
|
|
10
|
+
const selectedOverlayId = useEditorStore((s) => s.selectedOverlayId);
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
|
14
|
+
{overlays.map((o) => {
|
|
15
|
+
if (o.type === 'text' && activePanel === 'text' && o.id === selectedOverlayId) return null;
|
|
16
|
+
return o.type === 'text' ? (
|
|
17
|
+
<TextOverlayView key={o.id} overlay={o} />
|
|
18
|
+
) : (
|
|
19
|
+
<GifOverlayView key={o.id} overlay={o} />
|
|
20
|
+
);
|
|
21
|
+
})}
|
|
22
|
+
</View>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
2
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
3
|
+
|
|
4
|
+
import { useEditorStore } from '../../state/store';
|
|
5
|
+
import { useTheme } from '../../theme';
|
|
6
|
+
|
|
7
|
+
const COLORS = [
|
|
8
|
+
'#ffffff',
|
|
9
|
+
'#000000',
|
|
10
|
+
'#ff5c7c',
|
|
11
|
+
'#7c5cff',
|
|
12
|
+
'#4cd964',
|
|
13
|
+
'#ffd60a',
|
|
14
|
+
'#5ac8fa',
|
|
15
|
+
'#ff9f0a',
|
|
16
|
+
];
|
|
17
|
+
const ALIGNS = ['left', 'center', 'right'] as const;
|
|
18
|
+
const ALIGN_LABELS: Record<(typeof ALIGNS)[number], string> = {
|
|
19
|
+
left: 'Esquerda',
|
|
20
|
+
center: 'Centro',
|
|
21
|
+
right: 'Direita',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function TextEditorSheet() {
|
|
25
|
+
const activePanel = useEditorStore((s) => s.activePanel);
|
|
26
|
+
const setActivePanel = useEditorStore((s) => s.setActivePanel);
|
|
27
|
+
const overlay = useEditorStore((s) => s.overlays.find((o) => o.id === s.selectedOverlayId));
|
|
28
|
+
const updateOverlay = useEditorStore((s) => s.updateOverlay);
|
|
29
|
+
const removeOverlay = useEditorStore((s) => s.removeOverlay);
|
|
30
|
+
const theme = useTheme();
|
|
31
|
+
const insets = useSafeAreaInsets();
|
|
32
|
+
|
|
33
|
+
if (activePanel !== 'text' || !overlay || overlay.type !== 'text') return null;
|
|
34
|
+
const close = () => setActivePanel('none');
|
|
35
|
+
const cycleAlign = () => {
|
|
36
|
+
const next = ALIGNS[(ALIGNS.indexOf(overlay.align) + 1) % ALIGNS.length];
|
|
37
|
+
updateOverlay(overlay.id, { align: next });
|
|
38
|
+
};
|
|
39
|
+
const toggleBg = () =>
|
|
40
|
+
updateOverlay(overlay.id, {
|
|
41
|
+
backgroundColor: overlay.backgroundColor ? undefined : 'rgba(0,0,0,0.5)',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Modal transparent visible animationType="fade" onRequestClose={close}>
|
|
46
|
+
<View style={styles.backdrop}>
|
|
47
|
+
<View style={styles.topbar}>
|
|
48
|
+
<Pressable
|
|
49
|
+
hitSlop={12}
|
|
50
|
+
onPress={() => {
|
|
51
|
+
removeOverlay(overlay.id);
|
|
52
|
+
close();
|
|
53
|
+
}}>
|
|
54
|
+
<Text style={[styles.action, { color: theme.danger }]}>Excluir</Text>
|
|
55
|
+
</Pressable>
|
|
56
|
+
<Pressable hitSlop={12} onPress={close}>
|
|
57
|
+
<Text style={[styles.action, { color: theme.accent }]}>Concluir</Text>
|
|
58
|
+
</Pressable>
|
|
59
|
+
</View>
|
|
60
|
+
|
|
61
|
+
<View style={styles.center}>
|
|
62
|
+
<TextInput
|
|
63
|
+
autoFocus
|
|
64
|
+
multiline
|
|
65
|
+
value={overlay.text}
|
|
66
|
+
onChangeText={(t) => updateOverlay(overlay.id, { text: t })}
|
|
67
|
+
placeholder="Digite…"
|
|
68
|
+
placeholderTextColor="rgba(255,255,255,0.4)"
|
|
69
|
+
style={[styles.input, { color: overlay.color, textAlign: overlay.align }]}
|
|
70
|
+
/>
|
|
71
|
+
</View>
|
|
72
|
+
|
|
73
|
+
<View style={[styles.tools, { paddingBottom: insets.bottom + 40 }]}>
|
|
74
|
+
<Pressable style={styles.toolChip} onPress={cycleAlign}>
|
|
75
|
+
<Text style={styles.toolChipText}>{ALIGN_LABELS[overlay.align]}</Text>
|
|
76
|
+
</Pressable>
|
|
77
|
+
<Pressable
|
|
78
|
+
style={[
|
|
79
|
+
styles.toolChip,
|
|
80
|
+
overlay.backgroundColor ? { backgroundColor: theme.accent } : null,
|
|
81
|
+
]}
|
|
82
|
+
onPress={toggleBg}>
|
|
83
|
+
<Text style={styles.toolChipText}>Fundo</Text>
|
|
84
|
+
</Pressable>
|
|
85
|
+
<ScrollView
|
|
86
|
+
horizontal
|
|
87
|
+
showsHorizontalScrollIndicator={false}
|
|
88
|
+
contentContainerStyle={styles.swatches}>
|
|
89
|
+
{COLORS.map((c) => (
|
|
90
|
+
<Pressable
|
|
91
|
+
key={c}
|
|
92
|
+
onPress={() => updateOverlay(overlay.id, { color: c })}
|
|
93
|
+
style={[
|
|
94
|
+
styles.swatch,
|
|
95
|
+
{
|
|
96
|
+
backgroundColor: c,
|
|
97
|
+
borderColor: overlay.color === c ? theme.accent : 'rgba(255,255,255,0.3)',
|
|
98
|
+
},
|
|
99
|
+
]}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</ScrollView>
|
|
103
|
+
</View>
|
|
104
|
+
</View>
|
|
105
|
+
</Modal>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const styles = StyleSheet.create({
|
|
110
|
+
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.85)' },
|
|
111
|
+
topbar: {
|
|
112
|
+
flexDirection: 'row',
|
|
113
|
+
justifyContent: 'space-between',
|
|
114
|
+
paddingHorizontal: 20,
|
|
115
|
+
paddingTop: 60,
|
|
116
|
+
},
|
|
117
|
+
action: { fontSize: 16, fontWeight: '700' },
|
|
118
|
+
center: { flex: 1, justifyContent: 'center', paddingHorizontal: 24 },
|
|
119
|
+
input: { fontSize: 32, fontWeight: '700', minHeight: 60 },
|
|
120
|
+
tools: {
|
|
121
|
+
paddingHorizontal: 16,
|
|
122
|
+
gap: 12,
|
|
123
|
+
flexDirection: 'row',
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
},
|
|
126
|
+
toolChip: {
|
|
127
|
+
paddingHorizontal: 14,
|
|
128
|
+
paddingVertical: 8,
|
|
129
|
+
borderRadius: 14,
|
|
130
|
+
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
131
|
+
},
|
|
132
|
+
toolChipText: { color: '#fff', fontWeight: '600', fontSize: 13, textTransform: 'capitalize' },
|
|
133
|
+
swatches: { gap: 10, alignItems: 'center', paddingRight: 12 },
|
|
134
|
+
swatch: { width: 28, height: 28, borderRadius: 14, borderWidth: 2 },
|
|
135
|
+
});
|