@technotoil/image-video-editor 0.1.0 → 0.1.2

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.
Files changed (63) hide show
  1. package/README.md +17 -3
  2. package/android/src/main/java/com/technotoil/image_videoeditor/FrameGrabberModule.kt +2 -6
  3. package/android/src/main/java/com/technotoil/image_videoeditor/MediaEditorModule.kt +75 -35
  4. package/android/src/main/java/com/technotoil/image_videoeditor/MediaLibraryModule.kt +51 -35
  5. package/android/src/main/java/com/technotoil/image_videoeditor/RNVideoPreviewManager.kt +98 -117
  6. package/ios/RNMediaEditor.m +38 -7
  7. package/ios/RNMediaLibrary.m +19 -15
  8. package/ios/RNVideoPreviewManager.m +2 -0
  9. package/lib/commonjs/assets/frames/film_vintage.png +0 -0
  10. package/lib/commonjs/assets/frames/floral_gold.png +0 -0
  11. package/lib/commonjs/assets/frames/minimal_double.png +0 -0
  12. package/lib/commonjs/assets/frames/polaroid_white.png +0 -0
  13. package/lib/commonjs/assets/frames/watercolor_floral.png +0 -0
  14. package/lib/commonjs/components/VideoEditor.js +235 -0
  15. package/lib/commonjs/components/VideoEditor.js.map +1 -0
  16. package/lib/commonjs/index.js +14 -0
  17. package/lib/commonjs/index.js.map +1 -0
  18. package/lib/commonjs/native/CameraView.js +109 -0
  19. package/lib/commonjs/native/CameraView.js.map +1 -0
  20. package/lib/commonjs/native/FrameGrabber.js +17 -0
  21. package/lib/commonjs/native/FrameGrabber.js.map +1 -0
  22. package/lib/commonjs/native/MediaEditor.js +24 -0
  23. package/lib/commonjs/native/MediaEditor.js.map +1 -0
  24. package/lib/commonjs/native/MediaLibrary.js +45 -0
  25. package/lib/commonjs/native/MediaLibrary.js.map +1 -0
  26. package/lib/commonjs/native/MediaPicker.js +17 -0
  27. package/lib/commonjs/native/MediaPicker.js.map +1 -0
  28. package/lib/commonjs/native/MediaPlayer.js +17 -0
  29. package/lib/commonjs/native/MediaPlayer.js.map +1 -0
  30. package/lib/commonjs/native/VideoPreview.js +17 -0
  31. package/lib/commonjs/native/VideoPreview.js.map +1 -0
  32. package/lib/commonjs/package.json +1 -0
  33. package/lib/commonjs/screens/CropScreen.js +1233 -0
  34. package/lib/commonjs/screens/CropScreen.js.map +1 -0
  35. package/lib/commonjs/screens/EditorScreen.js +6043 -0
  36. package/lib/commonjs/screens/EditorScreen.js.map +1 -0
  37. package/lib/commonjs/screens/ExportScreen.js +294 -0
  38. package/lib/commonjs/screens/ExportScreen.js.map +1 -0
  39. package/lib/commonjs/screens/GalleryScreen.js +510 -0
  40. package/lib/commonjs/screens/GalleryScreen.js.map +1 -0
  41. package/lib/commonjs/screens/PickScreen.js +1353 -0
  42. package/lib/commonjs/screens/PickScreen.js.map +1 -0
  43. package/lib/commonjs/types.js +2 -0
  44. package/lib/commonjs/types.js.map +1 -0
  45. package/lib/module/components/VideoEditor.js +104 -31
  46. package/lib/module/components/VideoEditor.js.map +1 -1
  47. package/lib/module/screens/CropScreen.js +26 -9
  48. package/lib/module/screens/CropScreen.js.map +1 -1
  49. package/lib/module/screens/EditorScreen.js +371 -86
  50. package/lib/module/screens/EditorScreen.js.map +1 -1
  51. package/lib/module/screens/PickScreen.js +245 -93
  52. package/lib/module/screens/PickScreen.js.map +1 -1
  53. package/lib/typescript/src/components/VideoEditor.d.ts +18 -2
  54. package/lib/typescript/src/screens/CropScreen.d.ts +3 -1
  55. package/lib/typescript/src/screens/EditorScreen.d.ts +2 -1
  56. package/lib/typescript/src/screens/PickScreen.d.ts +6 -1
  57. package/lib/typescript/src/types.d.ts +1 -0
  58. package/package.json +17 -8
  59. package/src/components/VideoEditor.tsx +82 -11
  60. package/src/screens/CropScreen.tsx +54 -33
  61. package/src/screens/EditorScreen.tsx +366 -106
  62. package/src/screens/PickScreen.tsx +231 -76
  63. package/src/types.ts +1 -0
@@ -12,6 +12,7 @@ import {
12
12
  StyleSheet,
13
13
  Text,
14
14
  View,
15
+ Dimensions,
15
16
  } from 'react-native';
16
17
  import Ionicons from 'react-native-vector-icons/Ionicons';
17
18
  import ImagePicker from 'react-native-image-crop-picker';
@@ -41,6 +42,7 @@ function formatDuration(ms?: number) {
41
42
  }
42
43
 
43
44
  export function PickScreen({
45
+ isActive = true,
44
46
  items,
45
47
  onPicked,
46
48
  onNext,
@@ -50,6 +52,10 @@ export function PickScreen({
50
52
  cameraModes = ['POST', 'STORY', 'REEL'],
51
53
  onCameraModeChange,
52
54
  defaultCameraMode,
55
+ maxSelection = 1,
56
+ aspectRatio = 'free',
57
+ mediaType = 'any',
58
+ mediaTabs = ['GALLERY', 'PHOTO', 'VIDEO'],
53
59
  }: {
54
60
  items: MediaItem[];
55
61
  onPicked: (items: MediaItem[]) => void;
@@ -60,9 +66,14 @@ export function PickScreen({
60
66
  cameraModes?: string[];
61
67
  onCameraModeChange?: (mode: string) => void;
62
68
  defaultCameraMode?: string;
69
+ maxSelection?: number;
70
+ aspectRatio?: '1:1' | '4:3' | '4:5' | '16:9' | '9:16' | 'free';
71
+ mediaType?: 'photo' | 'video' | 'any';
72
+ mediaTabs?: ('GALLERY' | 'PHOTO' | 'VIDEO')[];
73
+ isActive?: boolean;
63
74
  }) {
64
75
  const insets = useSafeAreaInsets();
65
- const [tab, setTab] = useState<'GALLERY' | 'PHOTO' | 'VIDEO'>('GALLERY');
76
+ const [tab, setTab] = useState<'GALLERY' | 'PHOTO' | 'VIDEO'>(mediaTabs[0] || 'GALLERY');
66
77
  const [activeAlbum, setActiveAlbum] = useState<Album>({ id: 'all', title: 'Recents' });
67
78
  const [showAlbumPicker, setShowAlbumPicker] = useState(false);
68
79
  const [postType, setPostType] = useState<'POST' | 'STORY' | 'REEL'>('POST');
@@ -73,7 +84,21 @@ export function PickScreen({
73
84
  const [albums, setAlbums] = useState<Album[]>([]);
74
85
  const [loading, setLoading] = useState(false);
75
86
  const [previewUri, setPreviewUri] = useState<string | null>(null);
76
- const [cropMode, setCropMode] = useState<'1:1' | 'original'>('1:1');
87
+
88
+ // Aspect ratio logic
89
+ const isRatioLocked = aspectRatio !== 'free';
90
+ const ratioMap: Record<string, number> = {
91
+ '1:1': 1,
92
+ '4:3': 4 / 3,
93
+ '4:5': 4 / 5,
94
+ '16:9': 16 / 9,
95
+ '9:16': 9 / 16,
96
+ };
97
+ const previewAspectRatio = isRatioLocked ? ratioMap[aspectRatio] ?? 1 : undefined;
98
+ // When ratio is locked, always use 'cover' (fills the fixed frame)
99
+ const [cropMode, setCropMode] = useState<'1:1' | 'original'>(
100
+ isRatioLocked ? '1:1' : '1:1'
101
+ );
77
102
  const [videoPaused, setVideoPaused] = useState(false);
78
103
  const scaleAnim = useRef(new Animated.Value(1)).current;
79
104
 
@@ -158,18 +183,21 @@ export function PickScreen({
158
183
 
159
184
  const loadMedia = async (albumId?: string) => {
160
185
  try {
186
+ console.log('loadMedia called with albumId:', albumId);
161
187
  setLoading(true);
162
188
  const assets = await listMedia({
163
189
  limit: 200,
164
190
  offset: 0,
165
- type: 'all',
191
+ type: mediaType === 'photo' ? 'image' : mediaType === 'video' ? 'video' : 'all',
166
192
  albumId: albumId === 'all' ? undefined : albumId,
167
193
  });
194
+ console.log('loadMedia got assets:', assets.length);
168
195
  setLibrary(assets);
169
196
  if (assets[0] && !multiSelect) {
170
197
  setSelectedMedia(assets[0]);
171
198
  }
172
199
  } catch (err: any) {
200
+ console.error('loadMedia error:', err);
173
201
  Alert.alert('Library error', err?.message ?? 'Failed to load library.');
174
202
  } finally {
175
203
  setLoading(false);
@@ -177,9 +205,11 @@ export function PickScreen({
177
205
  };
178
206
 
179
207
  useEffect(() => {
208
+ console.log('PickScreen mounted!');
180
209
  (async () => {
181
210
  try {
182
211
  const ok = await requestMediaAccess();
212
+ console.log('requestMediaAccess ok?', ok);
183
213
  if (!ok) {
184
214
  Alert.alert('Permission needed', 'Allow photo access to continue.');
185
215
  return;
@@ -206,6 +236,20 @@ export function PickScreen({
206
236
  return [cameraItem, ...list];
207
237
  }, [library, tab]);
208
238
 
239
+ const listData = useMemo(() => {
240
+ const rows = Array.from({ length: Math.ceil(filtered.length / 4) }).map((_, i) => ({
241
+ id: `row_${i}`,
242
+ type: 'row',
243
+ items: filtered.slice(i * 4, i * 4 + 4),
244
+ }));
245
+
246
+ return [
247
+ { id: 'header_preview', type: 'preview' },
248
+ { id: 'header_albumRow', type: 'albumRow' },
249
+ ...rows
250
+ ];
251
+ }, [filtered]);
252
+
209
253
  useEffect(() => {
210
254
  let cancelled = false;
211
255
  setVideoPaused(false);
@@ -218,6 +262,7 @@ export function PickScreen({
218
262
  setPreviewUri(selectedMedia.uri);
219
263
  return;
220
264
  }
265
+ setPreviewUri(null);
221
266
  try {
222
267
  const fileUri = await exportAsset(selectedMedia.id);
223
268
  if (!cancelled) setPreviewUri(fileUri);
@@ -232,25 +277,36 @@ export function PickScreen({
232
277
 
233
278
  const playableUri = useMemo(() => {
234
279
  if (!selectedMedia) return null;
235
- if (previewUri && !previewUri.startsWith('ph://') && !previewUri.startsWith('content://')) return previewUri;
236
- if (selectedMedia.uri && !selectedMedia.uri.startsWith('ph://') && !selectedMedia.uri.startsWith('content://')) return selectedMedia.uri;
237
- return null;
280
+ if (previewUri) return previewUri;
281
+ return selectedMedia.uri;
238
282
  }, [previewUri, selectedMedia]);
239
283
 
240
284
  const toggleMultiSelect = () => {
241
- setMultiSelect((v) => !v);
242
- setSelectedItems([]);
285
+ setMultiSelect((v) => {
286
+ if (!v && selectedMedia) {
287
+ setSelectedItems([selectedMedia]);
288
+ } else {
289
+ setSelectedItems([]);
290
+ }
291
+ return !v;
292
+ });
243
293
  };
244
294
 
245
295
  const handleSelectItem = (item: MediaItem) => {
246
- setSelectedMedia(item);
247
296
  if (multiSelect) {
248
297
  setSelectedItems((prev) => {
249
298
  const exists = prev.find((i) => i.id === item.id);
250
- if (exists) return prev.filter((i) => i.id !== item.id);
251
- if (prev.length >= 10) return prev;
299
+ if (exists) {
300
+ const newItems = prev.filter((i) => i.id !== item.id);
301
+ setSelectedMedia(newItems.length > 0 ? newItems[newItems.length - 1] : null);
302
+ return newItems;
303
+ }
304
+ if (prev.length >= maxSelection) return prev;
305
+ setSelectedMedia(item);
252
306
  return [...prev, item];
253
307
  });
308
+ } else {
309
+ setSelectedMedia(item);
254
310
  }
255
311
 
256
312
  Animated.sequence([
@@ -479,82 +535,167 @@ export function PickScreen({
479
535
  </View>
480
536
  </Modal>
481
537
 
482
- <Animated.View style={[styles.preview, { transform: [{ scale: scaleAnim }] }]}>
483
- {selectedMedia?.type === 'video' ? (
484
- <Pressable style={styles.previewImage} onPress={() => setVideoPaused((v) => !v)}>
485
- {playableUri ? (
486
- <VideoPreview
487
- uri={playableUri}
488
- paused={videoPaused}
489
- muted={false}
490
- style={styles.previewImage}
491
- resizeMode={cropMode === '1:1' ? 'cover' : 'contain'}
492
- />
493
- ) : (
494
- <Image
495
- source={{ uri: selectedMedia.thumbnailUri || selectedMedia.uri }}
496
- style={styles.previewImage}
497
- resizeMode="cover"
498
- />
499
- )}
500
- <View style={styles.previewOverlay}>
501
- <View style={styles.playPauseCircle}>
502
- <Ionicons name={videoPaused ? 'play' : 'pause'} size={24} color="#fff" />
503
- </View>
504
- </View>
505
- </Pressable>
506
- ) : (
507
- <Image
508
- source={{ uri: previewUri ?? selectedMedia?.uri }}
509
- style={[styles.previewImage, cropMode === '1:1' ? styles.squareCrop : styles.originalCrop]}
510
- resizeMode={cropMode === '1:1' ? 'cover' : 'contain'}
511
- />
512
- )}
513
-
514
- <View style={styles.previewControls}>
515
- <Pressable
516
- style={styles.cropToggle}
517
- onPress={() => setCropMode((v) => (v === '1:1' ? 'original' : '1:1'))}
518
- >
519
- <Ionicons
520
- name={cropMode === '1:1' ? 'square-outline' : 'expand-outline'}
521
- size={20}
522
- color="#fff"
523
- />
524
- </Pressable>
525
- </View>
526
- </Animated.View>
527
-
528
- <View style={styles.albumRow}>
529
- <Pressable
530
- style={styles.albumSelector}
531
- onPress={() => setShowAlbumPicker((v) => !v)}
532
- >
533
- <Text style={styles.albumTitle}>{activeAlbum.title}</Text>
534
- <Ionicons name="chevron-down" size={16} color="#fff" style={{ marginLeft: 6 }} />
535
- </Pressable>
536
-
537
- <Pressable style={[styles.multiSelectBtn, multiSelect && styles.multiSelectBtnActive]} onPress={toggleMultiSelect}>
538
- <Text style={[styles.multiSelectText, multiSelect && styles.multiSelectTextActive]}>{multiSelect ? 'Done' : 'Select'}</Text>
539
- </Pressable>
540
- </View>
541
-
542
538
  <FlatList
543
- data={filtered}
539
+ data={listData}
540
+ extraData={{
541
+ selectedMedia,
542
+ previewUri,
543
+ playableUri,
544
+ videoPaused,
545
+ cropMode,
546
+ isRatioLocked,
547
+ activeAlbum,
548
+ multiSelect,
549
+ selectedItems,
550
+ showAlbumPicker,
551
+ loading
552
+ }}
544
553
  keyExtractor={(item) => item.id}
545
- numColumns={4}
554
+ stickyHeaderIndices={[1]}
546
555
  style={styles.libraryList}
547
- renderItem={renderThumb}
548
556
  contentContainerStyle={styles.grid}
557
+ getItemLayout={(data, index) => {
558
+ const previewHeight = isRatioLocked && previewAspectRatio
559
+ ? Dimensions.get('window').width / previewAspectRatio
560
+ : 420;
561
+ const albumRowHeight = 48;
562
+ const rowHeight = Dimensions.get('window').width / 4;
563
+
564
+ if (index === 0) return { length: previewHeight, offset: 0, index };
565
+ if (index === 1) return { length: albumRowHeight, offset: previewHeight, index };
566
+
567
+ const offset = previewHeight + albumRowHeight + (index - 2) * rowHeight;
568
+ return { length: rowHeight, offset, index };
569
+ }}
570
+ removeClippedSubviews={false}
571
+ windowSize={11}
549
572
  ListEmptyComponent={
550
573
  <View style={styles.empty}>
551
574
  <Text style={styles.emptyText}>{loading ? 'Loading…' : 'No media found'}</Text>
552
575
  </View>
553
576
  }
577
+ renderItem={({ item }) => {
578
+ if (item.type === 'preview') {
579
+ const previewStyleHeight = isRatioLocked && previewAspectRatio
580
+ ? Dimensions.get('window').width / previewAspectRatio
581
+ : 420;
582
+
583
+ return (
584
+ <Animated.View
585
+ style={[
586
+ styles.preview,
587
+ { transform: [{ scale: scaleAnim }], height: previewStyleHeight },
588
+ ]}
589
+ >
590
+ {selectedMedia?.type === 'video' ? (
591
+ <Pressable style={styles.previewImage} onPress={() => setVideoPaused((v) => !v)}>
592
+ {playableUri && isActive ? (
593
+ <VideoPreview
594
+ uri={playableUri}
595
+ paused={videoPaused || !isActive}
596
+ muted={false}
597
+ style={styles.previewImage}
598
+ resizeMode="cover"
599
+ />
600
+ ) : (
601
+ <Image
602
+ source={{ uri: selectedMedia.thumbnailUri || selectedMedia.uri }}
603
+ style={styles.previewImage}
604
+ resizeMode="cover"
605
+ />
606
+ )}
607
+ <View style={styles.previewOverlay}>
608
+ <View style={styles.playPauseCircle}>
609
+ <Ionicons name={videoPaused ? 'play' : 'pause'} size={24} color="#fff" />
610
+ </View>
611
+ </View>
612
+ </Pressable>
613
+ ) : (
614
+ <Image
615
+ source={{ uri: previewUri ?? selectedMedia?.uri }}
616
+ style={[styles.previewImage, cropMode === '1:1' ? styles.squareCrop : styles.originalCrop]}
617
+ resizeMode={isRatioLocked ? 'cover' : (cropMode === '1:1' ? 'cover' : 'contain')}
618
+ />
619
+ )}
620
+
621
+ <View style={styles.previewControls}>
622
+ {isRatioLocked && (
623
+ <View style={styles.ratioBadge}>
624
+ <Text style={styles.ratioBadgeText}>{aspectRatio}</Text>
625
+ </View>
626
+ )}
627
+
628
+ {!isRatioLocked && (
629
+ <Pressable
630
+ style={styles.cropToggle}
631
+ onPress={() => setCropMode((v) => (v === '1:1' ? 'original' : '1:1'))}
632
+ >
633
+ <Ionicons
634
+ name={cropMode === '1:1' ? 'square-outline' : 'expand-outline'}
635
+ size={20}
636
+ color="#fff"
637
+ />
638
+ </Pressable>
639
+ )}
640
+ </View>
641
+ </Animated.View>
642
+ );
643
+ }
644
+
645
+ if (item.type === 'albumRow') {
646
+ return (
647
+ <View style={[styles.albumRow, { backgroundColor: '#0b0f1a', zIndex: 999, elevation: 5 }]}>
648
+ <Pressable
649
+ style={styles.albumSelector}
650
+ onPress={() => setShowAlbumPicker((v) => !v)}
651
+ >
652
+ <Text style={styles.albumTitle}>{activeAlbum.title}</Text>
653
+ <Ionicons name="chevron-down" size={16} color="#fff" style={{ marginLeft: 6 }} />
654
+ </Pressable>
655
+
656
+ {maxSelection > 1 && (
657
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
658
+ {multiSelect && (
659
+ <Text style={{ color: '#3b82f6', fontSize: 12, fontWeight: '700' }}>
660
+ {selectedItems.length}/{maxSelection}
661
+ </Text>
662
+ )}
663
+ <Pressable
664
+ style={[
665
+ styles.multiSelectBtn,
666
+ multiSelect && styles.multiSelectBtnActive,
667
+ multiSelect && selectedItems.length === 0 && { opacity: 0.35 },
668
+ ]}
669
+ onPress={() => {
670
+ if (multiSelect) {
671
+ handleNext();
672
+ } else {
673
+ toggleMultiSelect();
674
+ }
675
+ }}
676
+ disabled={multiSelect && selectedItems.length === 0}
677
+ >
678
+ <Text style={[
679
+ styles.multiSelectText,
680
+ multiSelect && styles.multiSelectTextActive,
681
+ ]}>{multiSelect ? 'Done' : 'Select multiple'}</Text>
682
+ </Pressable>
683
+ </View>
684
+ )}
685
+ </View>
686
+ );
687
+ }
688
+
689
+ return (
690
+ <View style={{ flexDirection: 'row' }}>
691
+ {(item as any).items.map((mediaItem: any) => renderThumb({ item: mediaItem }))}
692
+ </View>
693
+ );
694
+ }}
554
695
  />
555
696
 
556
697
  <View style={styles.tabBar}>
557
- {TABS.map((t) => (
698
+ {mediaTabs.filter(t => mediaType === 'any' || (mediaType === 'photo' && t !== 'VIDEO') || (mediaType === 'video' && t !== 'PHOTO')).map((t) => (
558
699
  <Pressable key={t} onPress={() => setTab(t)}>
559
700
  <Text style={[styles.tab, tab === t && styles.tabActive]}>{t}</Text>
560
701
  </Pressable>
@@ -794,12 +935,26 @@ const styles = StyleSheet.create({
794
935
  justifyContent: 'center',
795
936
  },
796
937
  cropIcon: { color: '#fff', fontSize: 16 },
938
+ ratioBadge: {
939
+ backgroundColor: 'rgba(0,0,0,0.65)',
940
+ paddingHorizontal: 10,
941
+ paddingVertical: 5,
942
+ borderRadius: 12,
943
+ borderWidth: 1,
944
+ borderColor: 'rgba(255,255,255,0.3)',
945
+ },
946
+ ratioBadgeText: {
947
+ color: '#fff',
948
+ fontSize: 12,
949
+ fontWeight: '700',
950
+ letterSpacing: 0.5,
951
+ },
797
952
  albumRow: {
798
953
  flexDirection: 'row',
799
954
  justifyContent: 'space-between',
800
955
  alignItems: 'center',
801
956
  paddingHorizontal: 16,
802
- paddingVertical: 10,
957
+ height: 48,
803
958
  },
804
959
  albumSelector: { flexDirection: 'row', alignItems: 'center' },
805
960
  albumTitle: { fontSize: 16, fontWeight: '700', color: '#fff' },
package/src/types.ts CHANGED
@@ -51,6 +51,7 @@ export type VideoTrimOptions = ImageEditOptions & {
51
51
  mute?: boolean;
52
52
  isImage?: boolean;
53
53
  musicUri?: string;
54
+ musicOffsetMs?: number;
54
55
  };
55
56
 
56
57
  export type FrameCaptureOptions = {