@technotoil/image-video-editor 0.1.1 → 0.1.3

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 (47) 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 +103 -37
  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/components/Icon.js +31 -0
  10. package/lib/commonjs/components/Icon.js.map +1 -0
  11. package/lib/commonjs/components/VideoEditor.js +97 -32
  12. package/lib/commonjs/components/VideoEditor.js.map +1 -1
  13. package/lib/commonjs/icons.js +35 -0
  14. package/lib/commonjs/icons.js.map +1 -0
  15. package/lib/commonjs/screens/CropScreen.js +5 -3
  16. package/lib/commonjs/screens/CropScreen.js.map +1 -1
  17. package/lib/commonjs/screens/EditorScreen.js +269 -84
  18. package/lib/commonjs/screens/EditorScreen.js.map +1 -1
  19. package/lib/commonjs/screens/PickScreen.js +221 -129
  20. package/lib/commonjs/screens/PickScreen.js.map +1 -1
  21. package/lib/module/components/Icon.js +25 -0
  22. package/lib/module/components/Icon.js.map +1 -0
  23. package/lib/module/components/VideoEditor.js +98 -33
  24. package/lib/module/components/VideoEditor.js.map +1 -1
  25. package/lib/module/icons.js +31 -0
  26. package/lib/module/icons.js.map +1 -0
  27. package/lib/module/screens/CropScreen.js +5 -3
  28. package/lib/module/screens/CropScreen.js.map +1 -1
  29. package/lib/module/screens/EditorScreen.js +230 -45
  30. package/lib/module/screens/EditorScreen.js.map +1 -1
  31. package/lib/module/screens/PickScreen.js +216 -124
  32. package/lib/module/screens/PickScreen.js.map +1 -1
  33. package/lib/typescript/src/components/Icon.d.ts +11 -0
  34. package/lib/typescript/src/components/VideoEditor.d.ts +10 -2
  35. package/lib/typescript/src/icons.d.ts +28 -0
  36. package/lib/typescript/src/screens/CropScreen.d.ts +2 -1
  37. package/lib/typescript/src/screens/EditorScreen.d.ts +2 -1
  38. package/lib/typescript/src/screens/PickScreen.d.ts +4 -1
  39. package/lib/typescript/src/types.d.ts +1 -0
  40. package/package.json +4 -4
  41. package/src/components/Icon.tsx +19 -0
  42. package/src/components/VideoEditor.tsx +67 -11
  43. package/src/icons.ts +28 -0
  44. package/src/screens/CropScreen.tsx +8 -3
  45. package/src/screens/EditorScreen.tsx +228 -62
  46. package/src/screens/PickScreen.tsx +198 -120
  47. package/src/types.ts +1 -0
@@ -12,8 +12,9 @@ import {
12
12
  StyleSheet,
13
13
  Text,
14
14
  View,
15
+ Dimensions,
15
16
  } from 'react-native';
16
- import Ionicons from 'react-native-vector-icons/Ionicons';
17
+ import { Icon as Ionicons } from '../components/Icon';
17
18
  import ImagePicker from 'react-native-image-crop-picker';
18
19
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
19
20
  import { CameraView, CameraViewRef } from '../native/CameraView';
@@ -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,
@@ -52,6 +54,8 @@ export function PickScreen({
52
54
  defaultCameraMode,
53
55
  maxSelection = 1,
54
56
  aspectRatio = 'free',
57
+ mediaType = 'any',
58
+ mediaTabs = ['GALLERY', 'PHOTO', 'VIDEO'],
55
59
  }: {
56
60
  items: MediaItem[];
57
61
  onPicked: (items: MediaItem[]) => void;
@@ -64,9 +68,12 @@ export function PickScreen({
64
68
  defaultCameraMode?: string;
65
69
  maxSelection?: number;
66
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;
67
74
  }) {
68
75
  const insets = useSafeAreaInsets();
69
- const [tab, setTab] = useState<'GALLERY' | 'PHOTO' | 'VIDEO'>('GALLERY');
76
+ const [tab, setTab] = useState<'GALLERY' | 'PHOTO' | 'VIDEO'>(mediaTabs[0] || 'GALLERY');
70
77
  const [activeAlbum, setActiveAlbum] = useState<Album>({ id: 'all', title: 'Recents' });
71
78
  const [showAlbumPicker, setShowAlbumPicker] = useState(false);
72
79
  const [postType, setPostType] = useState<'POST' | 'STORY' | 'REEL'>('POST');
@@ -176,18 +183,21 @@ export function PickScreen({
176
183
 
177
184
  const loadMedia = async (albumId?: string) => {
178
185
  try {
186
+ console.log('loadMedia called with albumId:', albumId);
179
187
  setLoading(true);
180
188
  const assets = await listMedia({
181
189
  limit: 200,
182
190
  offset: 0,
183
- type: 'all',
191
+ type: mediaType === 'photo' ? 'image' : mediaType === 'video' ? 'video' : 'all',
184
192
  albumId: albumId === 'all' ? undefined : albumId,
185
193
  });
194
+ console.log('loadMedia got assets:', assets.length);
186
195
  setLibrary(assets);
187
196
  if (assets[0] && !multiSelect) {
188
197
  setSelectedMedia(assets[0]);
189
198
  }
190
199
  } catch (err: any) {
200
+ console.error('loadMedia error:', err);
191
201
  Alert.alert('Library error', err?.message ?? 'Failed to load library.');
192
202
  } finally {
193
203
  setLoading(false);
@@ -195,9 +205,11 @@ export function PickScreen({
195
205
  };
196
206
 
197
207
  useEffect(() => {
208
+ console.log('PickScreen mounted!');
198
209
  (async () => {
199
210
  try {
200
211
  const ok = await requestMediaAccess();
212
+ console.log('requestMediaAccess ok?', ok);
201
213
  if (!ok) {
202
214
  Alert.alert('Permission needed', 'Allow photo access to continue.');
203
215
  return;
@@ -224,6 +236,20 @@ export function PickScreen({
224
236
  return [cameraItem, ...list];
225
237
  }, [library, tab]);
226
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
+
227
253
  useEffect(() => {
228
254
  let cancelled = false;
229
255
  setVideoPaused(false);
@@ -236,6 +262,7 @@ export function PickScreen({
236
262
  setPreviewUri(selectedMedia.uri);
237
263
  return;
238
264
  }
265
+ setPreviewUri(null);
239
266
  try {
240
267
  const fileUri = await exportAsset(selectedMedia.id);
241
268
  if (!cancelled) setPreviewUri(fileUri);
@@ -250,25 +277,36 @@ export function PickScreen({
250
277
 
251
278
  const playableUri = useMemo(() => {
252
279
  if (!selectedMedia) return null;
253
- if (previewUri && !previewUri.startsWith('ph://') && !previewUri.startsWith('content://')) return previewUri;
254
- if (selectedMedia.uri && !selectedMedia.uri.startsWith('ph://') && !selectedMedia.uri.startsWith('content://')) return selectedMedia.uri;
255
- return null;
280
+ if (previewUri) return previewUri;
281
+ return selectedMedia.uri;
256
282
  }, [previewUri, selectedMedia]);
257
283
 
258
284
  const toggleMultiSelect = () => {
259
- setMultiSelect((v) => !v);
260
- setSelectedItems([]);
285
+ setMultiSelect((v) => {
286
+ if (!v && selectedMedia) {
287
+ setSelectedItems([selectedMedia]);
288
+ } else {
289
+ setSelectedItems([]);
290
+ }
291
+ return !v;
292
+ });
261
293
  };
262
294
 
263
295
  const handleSelectItem = (item: MediaItem) => {
264
- setSelectedMedia(item);
265
296
  if (multiSelect) {
266
297
  setSelectedItems((prev) => {
267
298
  const exists = prev.find((i) => i.id === item.id);
268
- if (exists) return prev.filter((i) => i.id !== item.id);
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
+ }
269
304
  if (prev.length >= maxSelection) return prev;
305
+ setSelectedMedia(item);
270
306
  return [...prev, item];
271
307
  });
308
+ } else {
309
+ setSelectedMedia(item);
272
310
  }
273
311
 
274
312
  Animated.sequence([
@@ -497,127 +535,167 @@ export function PickScreen({
497
535
  </View>
498
536
  </Modal>
499
537
 
500
- <Animated.View
501
- style={[
502
- styles.preview,
503
- { transform: [{ scale: scaleAnim }] },
504
- isRatioLocked && previewAspectRatio
505
- ? { height: undefined, aspectRatio: previewAspectRatio }
506
- : {},
507
- ]}
508
- >
509
- {selectedMedia?.type === 'video' ? (
510
- <Pressable style={styles.previewImage} onPress={() => setVideoPaused((v) => !v)}>
511
- {playableUri ? (
512
- <VideoPreview
513
- uri={playableUri}
514
- paused={videoPaused}
515
- muted={false}
516
- style={styles.previewImage}
517
- resizeMode="cover"
518
- />
519
- ) : (
520
- <Image
521
- source={{ uri: selectedMedia.thumbnailUri || selectedMedia.uri }}
522
- style={styles.previewImage}
523
- resizeMode="cover"
524
- />
525
- )}
526
- <View style={styles.previewOverlay}>
527
- <View style={styles.playPauseCircle}>
528
- <Ionicons name={videoPaused ? 'play' : 'pause'} size={24} color="#fff" />
529
- </View>
530
- </View>
531
- </Pressable>
532
- ) : (
533
- <Image
534
- source={{ uri: previewUri ?? selectedMedia?.uri }}
535
- style={[styles.previewImage, cropMode === '1:1' ? styles.squareCrop : styles.originalCrop]}
536
- resizeMode={isRatioLocked ? 'cover' : (cropMode === '1:1' ? 'cover' : 'contain')}
537
- />
538
- )}
539
-
540
- <View style={styles.previewControls}>
541
- {/* Ratio lock badge */}
542
- {isRatioLocked && (
543
- <View style={styles.ratioBadge}>
544
- <Text style={styles.ratioBadgeText}>{aspectRatio}</Text>
545
- </View>
546
- )}
547
-
548
- {/* Crop toggle — hidden when ratio is locked */}
549
- {!isRatioLocked && (
550
- <Pressable
551
- style={styles.cropToggle}
552
- onPress={() => setCropMode((v) => (v === '1:1' ? 'original' : '1:1'))}
553
- >
554
- <Ionicons
555
- name={cropMode === '1:1' ? 'square-outline' : 'expand-outline'}
556
- size={20}
557
- color="#fff"
558
- />
559
- </Pressable>
560
- )}
561
- </View>
562
- </Animated.View>
563
-
564
- <View style={styles.albumRow}>
565
- <Pressable
566
- style={styles.albumSelector}
567
- onPress={() => setShowAlbumPicker((v) => !v)}
568
- >
569
- <Text style={styles.albumTitle}>{activeAlbum.title}</Text>
570
- <Ionicons name="chevron-down" size={16} color="#fff" style={{ marginLeft: 6 }} />
571
- </Pressable>
572
-
573
- {/* Only show Select/Done button when maxSelection > 1 */}
574
- {maxSelection > 1 && (
575
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
576
- {multiSelect && (
577
- <Text style={{ color: '#3b82f6', fontSize: 12, fontWeight: '700' }}>
578
- {selectedItems.length}/{maxSelection}
579
- </Text>
580
- )}
581
- <Pressable
582
- style={[
583
- styles.multiSelectBtn,
584
- multiSelect && styles.multiSelectBtnActive,
585
- multiSelect && selectedItems.length === 0 && { opacity: 0.35 },
586
- ]}
587
- onPress={() => {
588
- if (multiSelect) {
589
- handleNext();
590
- } else {
591
- toggleMultiSelect();
592
- }
593
- }}
594
- disabled={multiSelect && selectedItems.length === 0}
595
- >
596
- <Text style={[
597
- styles.multiSelectText,
598
- multiSelect && styles.multiSelectTextActive,
599
- ]}>{multiSelect ? 'Done' : 'Select'}</Text>
600
- </Pressable>
601
- </View>
602
- )}
603
- </View>
604
-
605
538
  <FlatList
606
- 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
+ }}
607
553
  keyExtractor={(item) => item.id}
608
- numColumns={4}
554
+ stickyHeaderIndices={[1]}
609
555
  style={styles.libraryList}
610
- renderItem={renderThumb}
611
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}
612
572
  ListEmptyComponent={
613
573
  <View style={styles.empty}>
614
574
  <Text style={styles.emptyText}>{loading ? 'Loading…' : 'No media found'}</Text>
615
575
  </View>
616
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
+ }}
617
695
  />
618
696
 
619
697
  <View style={styles.tabBar}>
620
- {TABS.map((t) => (
698
+ {mediaTabs.filter(t => mediaType === 'any' || (mediaType === 'photo' && t !== 'VIDEO') || (mediaType === 'video' && t !== 'PHOTO')).map((t) => (
621
699
  <Pressable key={t} onPress={() => setTab(t)}>
622
700
  <Text style={[styles.tab, tab === t && styles.tabActive]}>{t}</Text>
623
701
  </Pressable>
@@ -876,7 +954,7 @@ const styles = StyleSheet.create({
876
954
  justifyContent: 'space-between',
877
955
  alignItems: 'center',
878
956
  paddingHorizontal: 16,
879
- paddingVertical: 10,
957
+ height: 48,
880
958
  },
881
959
  albumSelector: { flexDirection: 'row', alignItems: 'center' },
882
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 = {