react-native-attachment-kit 1.1.1 → 1.2.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.
- package/CHANGELOG.md +36 -0
- package/README.md +77 -5
- package/android/src/main/libs/rnattachmentkit-release.aar +0 -0
- package/android/src/newarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +28 -1
- package/android/src/oldarch/java/com/rnattachmentkit/RNAttachmentKitModule.kt +28 -1
- package/ios/RNAttachmentKit.swift +21 -3
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.abi.json +7188 -4555
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.private.swiftinterface +37 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios.swiftinterface +37 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeResources +10 -10
- package/ios/RNAttachmentKit.xcframework/ios-arm64/RNAttachmentKit.framework/_CodeSignature/CodeSignature +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Assets.car +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.abi.json +7188 -4555
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +37 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface +37 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.abi.json +7188 -4555
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +37 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/Modules/RNAttachmentKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +37 -1
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/RNAttachmentKit +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeDirectory +0 -0
- package/ios/RNAttachmentKit.xcframework/ios-arm64_x86_64-simulator/RNAttachmentKit.framework/_CodeSignature/CodeResources +22 -22
- package/package.json +1 -1
- package/src/index.ts +24 -3
- package/src/types.ts +64 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.2.2 — 2026-11-14
|
|
4
|
+
|
|
5
|
+
- **Viewer: a video opened with the UI hidden no longer flashes the centre button while it starts (iOS + Android)**. Opening a remote video with `startWithUiHidden: true` (e.g. from a feed card, with `startTime`) showed the 64 pt glass button for about half a second before playback began, as the loading circle with ⏸ showing through it.
|
|
6
|
+
- iOS: while seeking to `startTime` the player is still paused, so the "stopped → show the button" rule showed it at once. Now, from the moment the viewer starts the video with the UI hidden until it actually plays (seeking to the start position included), the button stays hidden; only if loading takes longer than **1.5 s** (instead of 0.4 s) does the loading circle appear, without the ⏸/▶ icon. It ends as soon as the video plays, stops, ends, fails, or the user taps to show the UI — after that everything works as before (buffering mid-playback still shows the circle after 0.4 s). Also applies to a video page reached by swiping while the UI is hidden.
|
|
7
|
+
- iOS: `ViewerPlayButton` — changing the icon (`setPause`) reset the icon's alpha, so ⏸/▶ showed through the loading circle whenever the icon changed during loading (also with the UI visible). The button now keeps the icon hidden while loading.
|
|
8
|
+
- Android (`ViewerVideoController`): the ⏸ flash did not happen (the player starts with `playWhenReady`), but a slow stream showed the loading circle after 0.4 s; with the UI hidden the first start now uses the same 1.5 s threshold.
|
|
9
|
+
- Checked on the iOS simulator (harness `-viewerResumeTest -resumeHiddenUI -resumeLogButton`, local server with a throttled 12 s mp4, button opacity logged every 0.05 s): `startTime` 7.5 s on a fast link — before: button visible 0.45 → 1.45 s with circle + ⏸; after: never visible. Slow link (start takes ~2 s): only the circle appears at 1.5 s after loading began, fades out when playback starts, no ⏸. From the beginning without `startTime`: no button on the fast link. UI shown: unchanged (circle while loading, ⏸ once playing — the icon no longer shows through the circle). Android: compiled only, not run on a device (harness gained `--ez resumeHiddenUI true`).
|
|
10
|
+
|
|
11
|
+
## 1.2.1 — 2026-11-14
|
|
12
|
+
|
|
13
|
+
- **Viewer: `ViewerVideo.startTime` — open a video page at a given position, and `onViewerWillClose` tells where each video stopped (iOS + Android `useOverlayViewer`)**. For a feed that autoplays a muted preview in the card and opens the full video in the viewer: pass the card's position as `startTime` (seconds) and the viewer continues from there; on close, `onViewerWillClose({ currentIndex, videoPositions })` gives the last position of every video page played (keys are page indexes), so the card can continue from there. Without `startTime` nothing changes, and code that calls `onViewerWillClose` without reading the argument keeps working.
|
|
14
|
+
- Only the first time the page is shown; swiping away and back starts from the beginning as before. 0, negative, or ≥ the length (the given `duration`, or the real length once loaded) = from the start. The progress pill starts at that position.
|
|
15
|
+
- iOS: the player waits for `readyToPlay`, seeks exactly (zero tolerance), then plays; until then the page shows the poster (`ViewerVideoItemView.startHeld`) and the centre button shows loading, so neither the first frames nor the first sound come through. `ViewerVideoItem.startTime`, `RemoteImageViewerViewController.willCloseEventBody()` (the event body, also usable from native screens).
|
|
16
|
+
- Android: `ExoPlayer.setMediaItem / setMediaSource(…, startPositionMs)` — the first rendered frame is already the start position (the poster hides on the first frame as before). `ViewerVideoSource.startTimeMs` / `durationMs`, bundle keys `videoStartTimes` / `videoDurations`; `VIEWER_WILL_CLOSE` carries `currentIndex` and a `videoPositions` bundle.
|
|
17
|
+
- JS: `onViewerWillClose` now runs once per close (on Android the event can arrive through both emitters).
|
|
18
|
+
- Checked on the iOS simulator and an Android emulator with a 12 s test video that shows its own time in every frame, recorded on screen: start 7.5 s streaming (download into `cacheFile` still running, 1 MB/s) and from the cached file — the first video frame on screen is 07.50 on both platforms, no earlier frame shows; positions reported on close match the last frame on screen within 0.05 s; no `startTime` starts at 00.00 as before; `startTime: 20` on the 12 s video starts at 00.00.
|
|
19
|
+
|
|
20
|
+
## 1.2.0 — 2026-11-14
|
|
21
|
+
|
|
22
|
+
- **Video: `video.preview` — a small silent preview mp4 next to the video you send (iOS + Android)**. For feeds that autoplay videos in a list: `preview: true` or `{ height, bitrate, maxFps, maxDurationSec, codec }` (defaults 360 short side, 450 000 bps, 24 fps, full length, h264). The preview is encoded **from the finished send file** (so trim, crop, rotation, drawing and mute are already in it; a remuxed send file works too), video only, keyframe every second, moov before mdat. Result fields `previewUri`, `previewWidth`, `previewHeight`, `previewDuration`, `previewFileSize`, `previewBitrate`; `previewSkipped: "SOURCE_SMALL"` when the send file is already as small (short side ≤ `height`, or its file ≤ the expected preview size); `previewError: "ENCODE_FAILED"` when only the preview failed (the send file is returned as usual). Also in `onImageProcessed`. Progress stays one value per video (send file 0–0.9, preview 0.9–1) and the HUD closes after the preview; cancelling (`cancelVideoProcessing`, HUD cancel) deletes the send file, poster and preview. File `<baseName>.preview.mp4` in the same folder. Without `preview` nothing changes.
|
|
23
|
+
- iOS: `VideoPreviewOptions` / `VideoPreviewResult` / `VideoExporter.makePreview(from:options:to:progress:)` (AVAssetReader + Writer, `shouldOptimizeForNetworkUse`), `previewWorthMaking`; `AttachmentResult.preview*`.
|
|
24
|
+
- Android: same names in `VideoExporter.kt` (`makePreview` with media3 Transformer — `setRemoveAudio`, `Presentation`, `setiFrameIntervalSeconds(1)`, CBR bitrate mode with a VBR retry when the encoder has no CBR; `maxFps` is not applied — media3 1.4.1's `FrameDropEffect` did not drop frames in Transformer; portrait previews carry a 90° rotation tag like the Android send file), and `Mp4FastStart` moves the moov box to the front (MediaMuxer writes it at the end); `AttachmentProcessor.Result.preview*`. The existing transcode now shares `runTransformer` with the preview (same settings as before).
|
|
25
|
+
- `createVideoPreview(fileUri)` (stand-alone, step 2 of the request) is not included.
|
|
26
|
+
|
|
27
|
+
- **Gallery / editor: the "video too long" message says seconds when the limit is under a minute or not whole minutes (iOS + Android)**. The message was always built as minutes, rounded and at least 1, so a 15 s limit said "up to 1 minute". New string `video_too_long_seconds` (`turbo_video_too_long_seconds` on Android) in all 59 languages; the choice lives in one helper per platform, `VideoExporter.tooLongText` (seconds if the limit is under 60 s or not a multiple of 60, otherwise minutes as before). Used by the gallery's too-long alert / toast and the editor's trim-bar warning and send-issue text.
|
|
28
|
+
- **Viewer (iOS): a video page with no poster image now shows the video**. The player layer sits inside the page's image view, and that view only got a size from a loaded image, so with an empty poster URL or a failed poster download the layer stayed 0×0: the video played (sound, position moving) on an empty page. `ViewerVideoItemView` now sizes the area from `presentationSize` (rotation metadata included), or the page size until that is known. Once a poster arrives its size is used, as before.
|
|
29
|
+
- Checked on the iOS simulator (test harness): empty poster and 404 poster with local / remote / download-to-`cacheFile` videos, landscape, portrait and 90°-rotation-metadata files. Poster pages have the same frames as before; a poster arriving late, swiping away and back, pinch zoom, and the zoom close from the current frame all work.
|
|
30
|
+
|
|
31
|
+
## 1.1.2 — 2026-11-03
|
|
32
|
+
|
|
33
|
+
- **Viewer: `underlayViewTag` — the photo moves *behind* the app's translucent bar during the zoom open / close (iOS + Android `useOverlayViewer`)**. With 1.1.1's clip the moving photo was only cut at the bar, so the part under the bar was simply missing while it moved (and an app that kept its cell visible showed that part already in place). Now the app places an empty view right below the bar and passes its RN tag; the kit draws a copy of the moving photo and of the source mask inside that view: the bar's live blur shows the photo sliding under it and the source cell's under-bar part is hidden by the copied mask from the first frame until the close ends. The clipped photo in the viewer window stays as before (so it never covers the bar). The first version of this option (`closeBelowViewTag`, never published) inserted the copy into the bar's parent; that shifted React's child indexes and debug builds crashed with "Attempt to unmount a view which has a different index", so the copy now only goes inside the app's empty view.
|
|
34
|
+
- iOS: `ZoomTransitionAnimator.underView` (weak, the empty view) — under layer + copy mask (follows `updateMaskView`) + copy image animated with the same frames; removed when the close ends (zoom, fade or slide). The bridge resolves the tag with `viewWithTag` (Fabric sets component views' `tag` to the RN tag).
|
|
35
|
+
- Android: `ImageViewerOverlayView` finds the empty view by id (= RN tag) and adds a `FrameLayout` inside it, laid out by hand (RN view groups don't lay out foreign children); the copy image has `activityRoot`'s size/place so it reuses the dummy's matrix and outline each frame; the copy mask follows the mask (position, drawable, visibility). Removed in `cleanup()`.
|
|
36
|
+
- Android: the copy image also gets `clipBounds` = this frame's cell. The app's blur view captures the screen on a software canvas, where `clipToOutline` is ignored, so the whole uncut photo went into the blur and the bar turned lighter until the copy was removed at the end of the close (seen with a large single photo; iOS not affected). `clipBounds` applies in both drawing paths. Measured on an emulator, same card, dark mode, mean brightness of the title row: settled 24.1, last frames of the close 30.9 before; settled 24.6, last frames 24.7 – 24.9 after.
|
|
37
|
+
- Checked on the iOS simulator and an Android emulator (HeyTalk feed).
|
|
38
|
+
|
|
3
39
|
## 1.1.1 — 2026-11-03
|
|
4
40
|
|
|
5
41
|
- **Viewer: `sourceClipInsets` / `sourceClipRect` options — the zoom open / close stays behind translucent bars (iOS + Android `useOverlayViewer`)**. The moving photo and the `hideSourceImage` mask are drawn in the window above the app, so a thumbnail half under the app's translucent top bar flew back *over* the bar and the mask covered the bar too. With a clip area (screen-edge insets, a rect in `sourceRect` coordinates, or both = intersection) they are drawn only inside it: on open the photo starts cut at the bar and the clip widens to the full screen during the second half (the viewer itself is never cut); on close it narrows back in the first 20 % (the viewer background turns transparent at once on close, so the photo never covers the bar), and the part under the bar disappears behind it. Checked on the iOS simulator (HeyTalk feed): a cell half under the translucent top bar opens from behind the bar and closes back behind it; a cell clear of the bar animates as before. `updateSourceRect` takes the same keys (changed only when passed, `null` = no clip) for paging to another thumbnail. Without the options nothing changes.
|
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ A fully native attachment toolkit for React Native chat apps: a Telegram-style p
|
|
|
8
8
|
|
|
9
9
|
- **Pure native UI** (Swift / Kotlin) — smooth on both platforms, New Architecture (Turbo Modules) and old architecture supported.
|
|
10
10
|
- **Photo picker** — bottom-sheet gallery, single or multi selection, albums, camera cell, optional editor step, profile (1:1) crop mode.
|
|
11
|
+
- In-app camera: tap the shutter for a photo, **hold it to record a video** (when `mediaType` is `'all'` / `'video'`); a ratio pill (Full / 9:16 / 3:4 / 1:1) sets the framing for both, and a resolution pill sets the video quality (HD / FHD default / 4K when supported). The screen stays portrait; holding the phone sideways captures a landscape photo or video (only the controls rotate). A timer (3s/10s), tap-to-focus with an exposure handle, and a ⚙ menu (grid, level, stabilization, selfie mirror, 30/60 fps) are built in; the front camera uses the screen as a flash. Captures go to the photo library and are inserted at the top of the grid, selected.
|
|
11
12
|
- **Attach menu** — Gallery / **Location** / **File** tabs under the gallery (Telegram style).
|
|
12
13
|
- Location: map with a center pin, "send my current location", place search, "Share my location" switch, map snapshot image.
|
|
13
14
|
- File: system file picker, files copied into the app's temp folder.
|
|
@@ -65,6 +66,9 @@ Rebuild the app after installing or updating (the module ships compiled native c
|
|
|
65
66
|
```xml
|
|
66
67
|
<key>NSCameraUsageDescription</key>
|
|
67
68
|
<string>We need access to your camera to take photos.</string>
|
|
69
|
+
<!-- In-app camera: hold the shutter to record a video with sound (without this key the recording has no audio) -->
|
|
70
|
+
<key>NSMicrophoneUsageDescription</key>
|
|
71
|
+
<string>We need the microphone to record sound in videos.</string>
|
|
68
72
|
<key>NSPhotoLibraryUsageDescription</key>
|
|
69
73
|
<string>We need access to your photo library to select images.</string>
|
|
70
74
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
|
@@ -72,8 +76,18 @@ Rebuild the app after installing or updating (the module ships compiled native c
|
|
|
72
76
|
<!-- Attach menu → Location ("send my current location", my-location dot) -->
|
|
73
77
|
<key>NSLocationWhenInUseUsageDescription</key>
|
|
74
78
|
<string>We use your location to share it in a chat.</string>
|
|
79
|
+
<!-- Map viewer → Directions: lets it tell whether each map app is installed -->
|
|
80
|
+
<key>LSApplicationQueriesSchemes</key>
|
|
81
|
+
<array>
|
|
82
|
+
<string>comgooglemaps</string>
|
|
83
|
+
<string>kakaomap</string>
|
|
84
|
+
<string>nmap</string>
|
|
85
|
+
<string>tmap</string>
|
|
86
|
+
</array>
|
|
75
87
|
```
|
|
76
88
|
|
|
89
|
+
The Directions menu shows Apple Maps and Google Maps, plus Kakao Map, Naver Map and TMAP when `languageCode` is Korean. Without the schemes above it still works: an app that isn't installed falls back to the web map (TMAP: App Store), and cancelling the system "Open in …?" prompt opens nothing.
|
|
90
|
+
|
|
77
91
|
### Android — Google Maps key
|
|
78
92
|
|
|
79
93
|
The Location tab, the map viewer and `renderLocationThumbnail` use Google Maps. The library ships **no key**; add your own to the app's `AndroidManifest.xml`:
|
|
@@ -155,7 +169,7 @@ Opens the gallery bottom sheet. Resolves with the picked images (`[]` when the u
|
|
|
155
169
|
| `onProcessingStart` | `(e: ProcessingStartEvent) => void` | — | Once, right after Send, before any processing. |
|
|
156
170
|
| `onImageProcessed` | `(e) => void` | — | Per item `{ index, total, image }` as soon as it is ready. |
|
|
157
171
|
| `mediaType` | `'photo' \| 'all' \| 'video'` | `'photo'` | `'all'` mixes photos and videos. |
|
|
158
|
-
| `video` | `VideoOptions` | — | `maxDurationSec`, `maxFileSizeMB`, `qualities`, `defaultQuality`, `codec`, `onProgress`, `showProgressHUD`. |
|
|
172
|
+
| `video` | `VideoOptions` | — | `maxDurationSec`, `maxFileSizeMB`, `qualities`, `defaultQuality`, `codec`, `posterFormat`, `preview`, `onProgress`, `showProgressHUD`. |
|
|
159
173
|
| `openDuration` / `closeDuration` | `number` (ms) | native | Animation durations. |
|
|
160
174
|
| `showAttachMenu` … | | | Attach menu options — see [Attach menu](#attach-menu-location--file). |
|
|
161
175
|
|
|
@@ -175,6 +189,25 @@ Opens the gallery bottom sheet. Resolves with the picked images (`[]` when the u
|
|
|
175
189
|
| `fileName?` / `fileExtension?` / `fileSize?` | | Output file info (bytes). |
|
|
176
190
|
| `thumbnailUri?` / `thumbnailWidth?` / `thumbnailHeight?` | | Photo: the thumbnail when the `thumbnail` option is set. Video: the poster (always, see `video.posterFormat`). |
|
|
177
191
|
| `thumbnailFileSize?` | `number` | Photo thumbnail file size (bytes). |
|
|
192
|
+
| `previewUri?` / `previewWidth?` / `previewHeight?` / `previewDuration?` / `previewFileSize?` / `previewBitrate?` | | Video with `video.preview`: the small silent preview mp4. |
|
|
193
|
+
| `previewError?` / `previewSkipped?` | `string` | Preview failed (`"ENCODE_FAILED"`, the video itself is fine) / not made (`"SOURCE_SMALL"`). |
|
|
194
|
+
|
|
195
|
+
#### Video previews (`video.preview`, 1.2.0)
|
|
196
|
+
|
|
197
|
+
For feeds that autoplay videos silently in a list, ask for a small preview file next to the one you send:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
const items = await AttachmentKit.openGallery({ mediaType: 'all', video: { preview: true } })
|
|
201
|
+
// or tune it: video: { preview: { height: 360, bitrate: 450_000, maxFps: 24, maxDurationSec: 0, codec: 'h264' } }
|
|
202
|
+
// items[i].previewUri → file://….preview.mp4 (no audio, 360p, ~450 kbps, moov first, keyframe every second)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- Made **from the video you send** after it is ready, so trims, crops, drawings and mute are all in it.
|
|
206
|
+
- `height` is the short side (default 360), `bitrate` default 450 000 bps, `maxFps` default 24 (iOS only — on Android media3 1.4.1 keeps the source frame rate; the size is still set by `bitrate`), `maxDurationSec` 0 = full length, `codec` default `h264`.
|
|
207
|
+
- Not made when the sent video is already as small (short side ≤ `height`, or its file ≤ the expected preview size): `previewSkipped: "SOURCE_SMALL"` — use the sent file.
|
|
208
|
+
- A failed preview never fails the video: `uri` is returned and `previewError: "ENCODE_FAILED"` is set.
|
|
209
|
+
- Progress stays one 0 → 1 value per video (send file 0–0.9, preview 0.9–1); the progress HUD closes after the preview. Cancelling removes the sent file, poster and preview.
|
|
210
|
+
- Saved next to the sent file as `<name>.preview.mp4`.
|
|
178
211
|
|
|
179
212
|
#### Photo thumbnails
|
|
180
213
|
|
|
@@ -210,7 +243,7 @@ Selection events outside `openGallery`; returns an unsubscribe function.
|
|
|
210
243
|
|
|
211
244
|
A chat can show every sent item at once and fill it in as it finishes (photos are resized, videos are encoded), the same way for photos and videos:
|
|
212
245
|
|
|
213
|
-
1. **`onProcessingStart`** `{ total, items }`: once, right after Send. Each item has `index`, `mediaType` (`'image' | 'video'`), `originalUri`, and the expected output `width` / `height` (for the aspect ratio). Videos also have `duration` (seconds, after trim) and `thumbnailUri` / `thumbnailWidth` / `thumbnailHeight` (a poster
|
|
246
|
+
1. **`onProcessingStart`** `{ total, items }`: once, right after Send. Each item has `index`, `mediaType` (`'image' | 'video'`), `originalUri`, and the expected output `width` / `height` (for the aspect ratio). Videos also have `duration` (seconds, after trim) and `thumbnailUri` / `thumbnailWidth` / `thumbnailHeight` (a poster image with crop and drawing applied — **webp** by default; set `video.posterFormat: 'jpg'` for jpg). Create the placeholders here.
|
|
214
247
|
2. **`onVideoProgress`** `{ index, total, progress }`: video encoding 0…1 (at most every 1% / 0.1 s). Draw a progress ring on that placeholder.
|
|
215
248
|
3. **`onImageProcessed`** `{ index, total, image }`: the item is ready. Replace the placeholder with `image.uri`. A video the app cancelled comes as `{ error: 'CANCELLED', cancelled: true }`; a failed one has `error`.
|
|
216
249
|
|
|
@@ -332,6 +365,24 @@ ref.current?.measure((x, y, width, height, pageX, pageY) => {
|
|
|
332
365
|
| `animationType` / `closeAnimationType` | `'zoom' \| 'fade' \| 'slide'` | `zoom` with `sourceRect`, else `slide` | Open / close animation. |
|
|
333
366
|
| `openDuration` / `closeDuration` | `number` (ms) | native | Animation durations. |
|
|
334
367
|
|
|
368
|
+
**Videos** — iOS viewer and Android `useOverlayViewer: true` only (the other Android viewers show the poster)
|
|
369
|
+
|
|
370
|
+
| Option | Type | Description |
|
|
371
|
+
|---|---|---|
|
|
372
|
+
| `videos` | `(ViewerVideo \| null)[]` | Same order as `images`. A non-null entry makes that page a video; `images[i]` is then its **poster** (shown until the first frame, and for the open animation). `ViewerVideo = { uri, duration?, cacheFile?, startTime? }` — `uri` is `https://` (mp4/mov, or an HLS `.m3u8` stream) or a local file; `cacheFile` works like `cacheFiles`: if the file exists it is played without network, otherwise the video streams and is downloaded there in the background for next time. `startTime` (seconds, 1.2.1): the first time that page is shown, play from there instead of the start — the poster stays until the player has moved to that point, so the opening frames never flash; 0, negative or ≥ the length = from the start; leaving the page and coming back starts from the beginning as before. Streaming and cached files both. |
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
AttachmentKit.openViewer({
|
|
376
|
+
images: [photoUrl, videoPosterUrl],
|
|
377
|
+
videos: [null, { uri: videoUrl, duration: 12.4, cacheFile: `${cacheDir}/v_123.mp4`, startTime: card.position }],
|
|
378
|
+
useOverlayViewer: true,
|
|
379
|
+
// where the user stopped watching (seconds), e.g. to continue the card's preview from there
|
|
380
|
+
onViewerWillClose: (e) => { const t = e?.videoPositions?.[1]; if (t != null) card.seek(t) },
|
|
381
|
+
})
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
On a video page: autoplay from the start (leaving the page pauses, no loop — at the end it pauses and shows ▶), a centre ▶/❚❚ button (spinner while buffering), a progress pill `0:03 ━━●── 0:12` (drag to seek), ⚙ playback speed (`0.5x` / Normal / `1.5x` / `2x`, kept while the viewer is open) and ⛶ full screen for landscape videos (the app's orientation is never changed — the video and its controls are rotated 90° to fill the screen; stand the phone up or tap ⛶ / Android back to leave). Tap toggles the controls (auto-hide after 3 s while playing); no pinch zoom; pull-to-dismiss moves only the video and closes on the current frame. Save / share handle videos as files: saved to the photo library as a video, shared as an mp4 (a remote video is downloaded first, with a small progress label; HLS streams cannot be saved). The sound follows the silent switch on iOS (default audio session).
|
|
385
|
+
|
|
335
386
|
**Sticky transition**
|
|
336
387
|
|
|
337
388
|
| Option | Type | Description |
|
|
@@ -343,6 +394,7 @@ ref.current?.measure((x, y, width, height, pageX, pageY) => {
|
|
|
343
394
|
| `hideSourceImage` | `boolean` | A native mask hides the source thumbnail while the viewer is open. |
|
|
344
395
|
| `sourceClipInsets` | `{ top?, bottom?, left?, right? }` | Screen-edge insets of the area where the **moving photo and the `hideSourceImage` mask** are drawn during the zoom open / close (e.g. `{ top: headerHeight, bottom: tabBarHeight }` for translucent bars). A thumbnail half under a translucent bar then slides **behind** the bar instead of over it. The clip widens to the full screen in the second half of the open (when the viewer background covers the bar) and narrows in the first 20 % of the close, so the viewer itself is never cut. iOS + Android `useOverlayViewer`. |
|
|
345
396
|
| `sourceClipRect` | `SourceRect` | Same, as a rect in `sourceRect` coordinates (e.g. a list area inside a sheet). With both, the intersection is used. Omit both = no clipping (as before). |
|
|
397
|
+
| `underlayViewTag` | `number` | `findNodeHandle` of an **empty** view the app places right below its translucent bar: full screen, no children, `collapsable={false}`, `pointerEvents="none"`, rendered after the list and before the bar. During the zoom open / close a copy of the moving photo and of the source mask is drawn inside it, so the bar's live blur shows the photo sliding behind it — clipping alone only hides the part under the bar. Use with `sourceClipInsets` / `sourceClipRect`. Never pass a view that has React children: native views added there shift React's child indexes and the app can crash. iOS + Android `useOverlayViewer`. |
|
|
346
398
|
|
|
347
399
|
**Actions**
|
|
348
400
|
|
|
@@ -359,7 +411,7 @@ ref.current?.measure((x, y, width, height, pageX, pageY) => {
|
|
|
359
411
|
| Option | Platform | Description |
|
|
360
412
|
|---|---|---|
|
|
361
413
|
| `onPageSelected(index)` | both | Page changed. |
|
|
362
|
-
| `onViewerWillClose()` | both | Close animation starts. |
|
|
414
|
+
| `onViewerWillClose(e?)` | both | Close animation starts. 1.2.1: `e = { currentIndex, videoPositions? }` — `videoPositions` maps each video page played in this viewer to its last position in seconds (the length if it played to the end); pages never played are absent. Old code that ignores the argument still works. On Android only the `useOverlayViewer` viewer sends `e` (the other Android viewers call it with no argument). |
|
|
363
415
|
| `onViewerOpened()` | Android | Open animation finished (safe moment for background scroll corrections). |
|
|
364
416
|
|
|
365
417
|
**Android viewer modes**
|
|
@@ -371,7 +423,7 @@ ref.current?.measure((x, y, width, height, pageX, pageY) => {
|
|
|
371
423
|
| `useOverlayViewer: true` | ⭐ View on top of the host Activity's window — no `AppState` blip, background changes render in real time. Wins over `useDialogViewer`. |
|
|
372
424
|
|
|
373
425
|
#### `updateSourceRect(rect: ViewerSourceUpdate): Promise<void>`
|
|
374
|
-
Updates where the viewer closes to (e.g. after paging to another thumbnail or scrolling). `ViewerSourceUpdate = SourceRect & { sourceBorderRadius?, sourceBorderCorners?, sourceClipRect?, sourceClipInsets? }` — the clip keys change only when passed (`null` = stop clipping).
|
|
426
|
+
Updates where the viewer closes to (e.g. after paging to another thumbnail or scrolling). `ViewerSourceUpdate = SourceRect & { sourceBorderRadius?, sourceBorderCorners?, sourceClipRect?, sourceClipInsets?, underlayViewTag? }` — the clip keys change only when passed (`null` = stop clipping).
|
|
375
427
|
|
|
376
428
|
#### `injectImageCache(url: string, localPath: string): Promise<boolean>`
|
|
377
429
|
Seeds the viewer's image cache with a local file for a remote URL, so it opens instantly without network.
|
|
@@ -482,7 +534,7 @@ A native grid built for thousands of photos. It opens like the picker (same bott
|
|
|
482
534
|
|
|
483
535
|
- **Native data source (recommended)**: implement `MediaGalleryDataSource` in your app's native code and register it with `MediaGalleryRegistry.register("name", source)` (iOS Swift / Android Kotlin). Pass `dataSource: "name"` and `dataSourceParams`. Pages are loaded natively, no JS round trip. Built-in demo: `dataSource: "turboDemo", dataSourceParams: { count: 10000 }`.
|
|
484
536
|
- **JS data source**: pass `initialItems`, `hasMore`, `nextCursor`; answer `onNeedPage` with `appendGalleryItems({ galleryId, items, nextCursor, hasMore })`.
|
|
485
|
-
- `MediaGalleryItem`: `{ id, groupId?, timestamp?, thumbUri? (file:// only — no base64), imageUri, cacheFile?, isMine?, width?, height? }`.
|
|
537
|
+
- `MediaGalleryItem`: `{ id, groupId?, timestamp?, thumbUri? (file:// only — no base64), imageUri, cacheFile?, isMine?, width?, height?, mediaType?, videoUri?, videoCacheFile?, duration? }`. A video item has `mediaType: 'video'` and `videoUri` (`imageUri` / `thumbUri` are its poster); the grid shows a "▶ 0:12" badge (from `duration`, seconds) and the viewer plays it (see `openViewer` → Videos).
|
|
486
538
|
- `title` (required), `gridSize: '3x3' | '4x4' | '5x5'` (default until the user picks one; the user's choice is saved on the device and wins), `spacing`, `theme`, `appearance`, `languageCode`, `emptyTitle`, `emptySubtitle`, `countFormat`.
|
|
487
539
|
- `viewer`: `{ themeColor, deleteOptionsForMine, deleteOptionsForOthers, titleFormat: 'date' | 'none', …openViewer text options }`.
|
|
488
540
|
- Events: `onItemDeleted({ galleryId, id, groupId, option })` (the module already removed that group from the screen — delete it in the app), `onNeedPage`, `onGridSizeChanged`, `onClosed`.
|
|
@@ -595,6 +647,26 @@ app.post('/ai/generate', auth, express.json({ limit: '20mb' }), async (req, res)
|
|
|
595
647
|
| Photo thumbnails (`thumbnail` option) | `Library/Caches/RNAttachmentKit/turbo_picker_thumb_*` | `cache/turbo_picker_thumb_*` | Deleted after 24 h (next gallery open). |
|
|
596
648
|
| Location thumbnails | `Library/Caches/RNAttachmentKit/LocationThumbs/` | `cache/RNAttachmentKitLocationThumbs/` | 300 max → 250, least recently used. `clearLocationThumbnailCache()`. |
|
|
597
649
|
| Reverse-geocode results | `Library/Caches/RNAttachmentKit/Geocode/` | `cache/RNAttachmentKitGeocode/` | 1000 max → 800, least recently used. `clearReverseGeocodeCache()`. |
|
|
650
|
+
| Viewer videos downloaded for save/share (no `cacheFile` given) | `Library/Caches/RNAttachmentKit/viewer_video/` | `cache/RNAttachmentKit/viewer_video/` | Reused for playback next time; the OS may purge. With `cacheFile` the file goes there instead (app-owned). |
|
|
651
|
+
| Viewer share copies (Android) | — | `<external files>/Pictures/viewer_share/` | Overwritten names per share; same folder as shared photos. |
|
|
652
|
+
|
|
653
|
+
---
|
|
654
|
+
|
|
655
|
+
## 🔧 Maintainers (source project)
|
|
656
|
+
|
|
657
|
+
This folder is the **source** project. Apps use the compiled distribution `../RNAttachmentKit-Dist` (published to npm as `react-native-attachment-kit`), where Kotlin/Swift are shipped as `.aar` / `.xcframework`.
|
|
658
|
+
|
|
659
|
+
- **Edit code only here.** After changing native code or `src/*.ts`, rebuild the distribution — apps see nothing until then:
|
|
660
|
+
```bash
|
|
661
|
+
cd .. && bash -l ./run_deploy.command # make_framework.rb + deploy.rb, log in ../deploy_last.log
|
|
662
|
+
cd RNAttachmentKit/ios && ruby restore_app.rb # back to app mode for the iOS harness
|
|
663
|
+
```
|
|
664
|
+
Hand-edit in Dist only `package.json`, the podspec, `react-native.config.js` and `README.md`. Keep this README and the Dist README in sync (same content; this section is source-only).
|
|
665
|
+
- **Native test apps**: iOS — `ios/RNAttachmentKit.xcodeproj` in app mode (`ruby restore_app.rb`; `ruby make_framework.rb` before building the framework). Android — open `android/` and run `app` (its Maps key lives in git-ignored `android/local.properties`, for the test app only). RN example app: `../RNAttachmentKitApp` (`portal:` to Dist).
|
|
666
|
+
- **Translations**: all strings live in `scripts/i18n/strings.json` (every language must have every key). Run `python3 scripts/i18n/generate.py` to regenerate `ios/RNAttachmentKit/Localizer.swift` and Android `res/values*/strings*.xml`.
|
|
667
|
+
- **Editor icons**: single source `scripts/editor_icons/icons.py` (Android vectors + iOS `ToolbarIcons` webp).
|
|
668
|
+
- **Docs**: version history in `CHANGELOG.md`; location-message integration notes in `docs/location-api.md`; old diagnostics in `docs/history/` (not authoritative).
|
|
669
|
+
- **Publishing**: bump `version` in both `package.json` files, rebuild Dist, then run `../publish_npm.sh` from a terminal (npm login + OTP).
|
|
598
670
|
|
|
599
671
|
---
|
|
600
672
|
|
|
Binary file
|
|
@@ -89,12 +89,24 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
89
89
|
// 채로 다음 이미지를 열면, 두 구독이 동시에 반응해서 "이전 이미지의 위치 정보"로
|
|
90
90
|
// updateSourceRect()가 잘못 호출되어 닫기 애니메이션이 엉뚱한(이전) 위치로 돌아가는
|
|
91
91
|
// 버그의 원인이었습니다.
|
|
92
|
+
/** 1.2.1: VIEWER_WILL_CLOSE 의 currentIndex·videoPositions → JS 본문 (오버레이 뷰어만 보냄, 다른 뷰어는 빈 본문) */
|
|
93
|
+
private fun viewerWillCloseBody(intent: android.content.Intent): com.facebook.react.bridge.WritableMap {
|
|
94
|
+
val body = com.facebook.react.bridge.Arguments.createMap()
|
|
95
|
+
if (intent.hasExtra("currentIndex")) body.putInt("currentIndex", intent.getIntExtra("currentIndex", 0))
|
|
96
|
+
intent.getBundleExtra("videoPositions")?.let { b ->
|
|
97
|
+
val m = com.facebook.react.bridge.Arguments.createMap()
|
|
98
|
+
for (k in b.keySet()) m.putDouble(k, b.getDouble(k))
|
|
99
|
+
body.putMap("videoPositions", m)
|
|
100
|
+
}
|
|
101
|
+
return body
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
private val viewerWillCloseReceiver = object : android.content.BroadcastReceiver() {
|
|
93
105
|
override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) {
|
|
94
106
|
if (intent?.action == "com.rnattachmentkit.VIEWER_WILL_CLOSE") {
|
|
95
107
|
reactApplicationContext
|
|
96
108
|
.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
97
|
-
.emit("onViewerWillClose",
|
|
109
|
+
.emit("onViewerWillClose", viewerWillCloseBody(intent))
|
|
98
110
|
}
|
|
99
111
|
}
|
|
100
112
|
}
|
|
@@ -215,6 +227,9 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
215
227
|
// 잘라내기 영역 — 넘긴 키만 바뀜 (useOverlayViewer 뷰어만 반영)
|
|
216
228
|
clipArrayOf(options, "sourceClipRect")?.let { intent.putExtra("sourceClipRect", it) }
|
|
217
229
|
clipArrayOf(options, "sourceClipInsets")?.let { intent.putExtra("sourceClipInsets", it) }
|
|
230
|
+
if (options.hasKey("underlayViewTag")) {
|
|
231
|
+
intent.putExtra("underlayViewTag", if (options.isNull("underlayViewTag")) 0 else options.getDouble("underlayViewTag").toInt())
|
|
232
|
+
}
|
|
218
233
|
androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(reactApplicationContext)
|
|
219
234
|
.sendBroadcast(intent)
|
|
220
235
|
|
|
@@ -688,6 +703,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
688
703
|
// 잘라내기 영역 (열림·닫힘 중 사진과 가림 판이 그려지는 곳) — 이 오버레이 뷰어만
|
|
689
704
|
clipArrayOf(options, "sourceClipRect")?.let { putFloatArray("sourceClipRect", it) }
|
|
690
705
|
clipArrayOf(options, "sourceClipInsets")?.let { putFloatArray("sourceClipInsets", it) }
|
|
706
|
+
// 앱 쪽 사본을 그릴 자리 — 앱이 둔 빈 RN 뷰 (태그 = view id)
|
|
707
|
+
if (options.hasKey("underlayViewTag") && !options.isNull("underlayViewTag")) {
|
|
708
|
+
putInt("underlayViewTag", options.getDouble("underlayViewTag").toInt())
|
|
709
|
+
}
|
|
691
710
|
// placeholderImages: 타입/뷰어 코드에는 있었지만 브릿지에서 한 번도 전달되지 않았던
|
|
692
711
|
// 필드. 네이티브 뷰어가 onlyRetrieveFromCache(true)로 첫 프레임을 즉시 그리려 할 때
|
|
693
712
|
// 쓰는 저해상도 대체 이미지 목록입니다(원본 다운로드 전 임시 표시용).
|
|
@@ -708,13 +727,21 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
708
727
|
if (videoArray != null) {
|
|
709
728
|
val uris = ArrayList<String>()
|
|
710
729
|
val caches = ArrayList<String>()
|
|
730
|
+
val starts = DoubleArray(videoArray.size())
|
|
731
|
+
val durations = DoubleArray(videoArray.size())
|
|
732
|
+
fun num(m: com.facebook.react.bridge.ReadableMap?, key: String): Double =
|
|
733
|
+
if (m != null && m.hasKey(key) && m.getType(key) == com.facebook.react.bridge.ReadableType.Number) m.getDouble(key) else 0.0
|
|
711
734
|
for (i in 0 until videoArray.size()) {
|
|
712
735
|
val m = if (videoArray.getType(i) == com.facebook.react.bridge.ReadableType.Map) videoArray.getMap(i) else null
|
|
713
736
|
uris.add(if (m != null && m.hasKey("uri") && !m.isNull("uri")) m.getString("uri") ?: "" else "")
|
|
714
737
|
caches.add(if (m != null && m.hasKey("cacheFile") && !m.isNull("cacheFile")) m.getString("cacheFile") ?: "" else "")
|
|
738
|
+
starts[i] = num(m, "startTime")
|
|
739
|
+
durations[i] = num(m, "duration")
|
|
715
740
|
}
|
|
716
741
|
putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_URIS, uris)
|
|
717
742
|
putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_CACHE_FILES, caches)
|
|
743
|
+
putDoubleArray(com.rnattachmentkit.ViewerVideoSource.ARG_START_TIMES, starts)
|
|
744
|
+
putDoubleArray(com.rnattachmentkit.ViewerVideoSource.ARG_DURATIONS, durations)
|
|
718
745
|
}
|
|
719
746
|
}
|
|
720
747
|
// 상단바 저장/공유/삭제 버튼과 그 문구들 (iOS와 동일한 옵션 키). 문구를 안 넘기면
|
|
@@ -78,12 +78,24 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
78
78
|
// Keep: Required for RN built in Event Emitter Calls.
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** 1.2.1: VIEWER_WILL_CLOSE 의 currentIndex·videoPositions → JS 본문 (오버레이 뷰어만 보냄, 다른 뷰어는 빈 본문) */
|
|
82
|
+
private fun viewerWillCloseBody(intent: android.content.Intent): com.facebook.react.bridge.WritableMap {
|
|
83
|
+
val body = com.facebook.react.bridge.Arguments.createMap()
|
|
84
|
+
if (intent.hasExtra("currentIndex")) body.putInt("currentIndex", intent.getIntExtra("currentIndex", 0))
|
|
85
|
+
intent.getBundleExtra("videoPositions")?.let { b ->
|
|
86
|
+
val m = com.facebook.react.bridge.Arguments.createMap()
|
|
87
|
+
for (k in b.keySet()) m.putDouble(k, b.getDouble(k))
|
|
88
|
+
body.putMap("videoPositions", m)
|
|
89
|
+
}
|
|
90
|
+
return body
|
|
91
|
+
}
|
|
92
|
+
|
|
81
93
|
private val viewerWillCloseReceiver = object : android.content.BroadcastReceiver() {
|
|
82
94
|
override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) {
|
|
83
95
|
if (intent?.action == "com.rnattachmentkit.VIEWER_WILL_CLOSE") {
|
|
84
96
|
reactApplicationContext
|
|
85
97
|
.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
86
|
-
.emit("onViewerWillClose",
|
|
98
|
+
.emit("onViewerWillClose", viewerWillCloseBody(intent))
|
|
87
99
|
}
|
|
88
100
|
}
|
|
89
101
|
}
|
|
@@ -525,6 +537,9 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
525
537
|
// 잘라내기 영역 — 넘긴 키만 바뀜 (useOverlayViewer 뷰어만 반영)
|
|
526
538
|
clipArrayOf(options, "sourceClipRect")?.let { intent.putExtra("sourceClipRect", it) }
|
|
527
539
|
clipArrayOf(options, "sourceClipInsets")?.let { intent.putExtra("sourceClipInsets", it) }
|
|
540
|
+
if (options.hasKey("underlayViewTag")) {
|
|
541
|
+
intent.putExtra("underlayViewTag", if (options.isNull("underlayViewTag")) 0 else options.getDouble("underlayViewTag").toInt())
|
|
542
|
+
}
|
|
528
543
|
androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(reactApplicationContext).sendBroadcast(intent)
|
|
529
544
|
promise.resolve(null)
|
|
530
545
|
}
|
|
@@ -697,6 +712,10 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
697
712
|
// 잘라내기 영역 (열림·닫힘 중 사진과 가림 판이 그려지는 곳) — 이 오버레이 뷰어만
|
|
698
713
|
clipArrayOf(options, "sourceClipRect")?.let { putFloatArray("sourceClipRect", it) }
|
|
699
714
|
clipArrayOf(options, "sourceClipInsets")?.let { putFloatArray("sourceClipInsets", it) }
|
|
715
|
+
// 앱 쪽 사본을 그릴 자리 — 앱이 둔 빈 RN 뷰 (태그 = view id)
|
|
716
|
+
if (options.hasKey("underlayViewTag") && !options.isNull("underlayViewTag")) {
|
|
717
|
+
putInt("underlayViewTag", options.getDouble("underlayViewTag").toInt())
|
|
718
|
+
}
|
|
700
719
|
// placeholderImages: 타입/뷰어 코드에는 있었지만 브릿지에서 한 번도 전달되지 않았던
|
|
701
720
|
// 필드. 네이티브 뷰어가 onlyRetrieveFromCache(true)로 첫 프레임을 즉시 그리려 할 때
|
|
702
721
|
// 쓰는 저해상도 대체 이미지 목록입니다(원본 다운로드 전 임시 표시용).
|
|
@@ -717,13 +736,21 @@ class RNAttachmentKitModule(reactContext: ReactApplicationContext) :
|
|
|
717
736
|
if (videoArray != null) {
|
|
718
737
|
val uris = ArrayList<String>()
|
|
719
738
|
val caches = ArrayList<String>()
|
|
739
|
+
val starts = DoubleArray(videoArray.size())
|
|
740
|
+
val durations = DoubleArray(videoArray.size())
|
|
741
|
+
fun num(m: com.facebook.react.bridge.ReadableMap?, key: String): Double =
|
|
742
|
+
if (m != null && m.hasKey(key) && m.getType(key) == com.facebook.react.bridge.ReadableType.Number) m.getDouble(key) else 0.0
|
|
720
743
|
for (i in 0 until videoArray.size()) {
|
|
721
744
|
val m = if (videoArray.getType(i) == com.facebook.react.bridge.ReadableType.Map) videoArray.getMap(i) else null
|
|
722
745
|
uris.add(if (m != null && m.hasKey("uri") && !m.isNull("uri")) m.getString("uri") ?: "" else "")
|
|
723
746
|
caches.add(if (m != null && m.hasKey("cacheFile") && !m.isNull("cacheFile")) m.getString("cacheFile") ?: "" else "")
|
|
747
|
+
starts[i] = num(m, "startTime")
|
|
748
|
+
durations[i] = num(m, "duration")
|
|
724
749
|
}
|
|
725
750
|
putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_URIS, uris)
|
|
726
751
|
putStringArrayList(com.rnattachmentkit.ViewerVideoSource.ARG_CACHE_FILES, caches)
|
|
752
|
+
putDoubleArray(com.rnattachmentkit.ViewerVideoSource.ARG_START_TIMES, starts)
|
|
753
|
+
putDoubleArray(com.rnattachmentkit.ViewerVideoSource.ARG_DURATIONS, durations)
|
|
727
754
|
}
|
|
728
755
|
}
|
|
729
756
|
// 상단바 저장/공유/삭제 버튼과 그 문구들 (iOS와 동일한 옵션 키). 문구를 안 넘기면
|
|
@@ -182,6 +182,9 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
182
182
|
if optDict.keys.contains("sourceClipInsets") {
|
|
183
183
|
self.zoomAnimator.sourceClipInsets = RNAttachmentKit.parseInsets(optDict["sourceClipInsets"])
|
|
184
184
|
}
|
|
185
|
+
if optDict.keys.contains("underlayViewTag") {
|
|
186
|
+
self.zoomAnimator.underView = RNAttachmentKit.viewForTag(optDict["underlayViewTag"])
|
|
187
|
+
}
|
|
185
188
|
self.zoomAnimator.updateMaskView()
|
|
186
189
|
|
|
187
190
|
resolve(true)
|
|
@@ -210,6 +213,19 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
210
213
|
return CGRect(x: x, y: y, width: w, height: h)
|
|
211
214
|
}
|
|
212
215
|
|
|
216
|
+
/// RN 뷰 태그(findNodeHandle) → 앱 창의 네이티브 뷰. Fabric 은 컴포넌트 뷰의 tag 에 RN 태그를 넣음. 없으면 nil (메인 스레드)
|
|
217
|
+
private static func viewForTag(_ v: Any?) -> UIView? {
|
|
218
|
+
guard let n = v as? NSNumber, n.intValue > 0 else { return nil }
|
|
219
|
+
let tag = n.intValue
|
|
220
|
+
for scene in UIApplication.shared.connectedScenes {
|
|
221
|
+
guard let ws = scene as? UIWindowScene else { continue }
|
|
222
|
+
for w in ws.windows {
|
|
223
|
+
if let found = w.viewWithTag(tag) { return found }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return nil
|
|
227
|
+
}
|
|
228
|
+
|
|
213
229
|
/// { top, bottom, left, right } (빠진 값 0). 객체가 아니면 nil
|
|
214
230
|
private static func parseInsets(_ v: Any?) -> UIEdgeInsets? {
|
|
215
231
|
guard let d = v as? NSDictionary else { return nil }
|
|
@@ -571,6 +587,7 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
571
587
|
// 애니메이터는 재사용되므로 이번 호출 값으로 덮어씀 (안 주면 자르지 않음)
|
|
572
588
|
self.zoomAnimator.sourceClipRect = sourceClipRect
|
|
573
589
|
self.zoomAnimator.sourceClipInsets = sourceClipInsets
|
|
590
|
+
self.zoomAnimator.underView = RNAttachmentKit.viewForTag(options["underlayViewTag"])
|
|
574
591
|
// Reset any sourceImage left over from a previous openViewer() call - it's only
|
|
575
592
|
// meant to carry this call's local placeholder (see below), and must not leak
|
|
576
593
|
// into a later call that has no placeholder of its own.
|
|
@@ -595,8 +612,9 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
595
612
|
// 공유된 zoomAnimator.sourceRect를 덮어써버렸습니다 - "스와이프 후 닫으면 전혀
|
|
596
613
|
// 다른 그룹 이미지 위치로 애니메이션되는" 버그의 근본 원인이었습니다. 이제 실제로
|
|
597
614
|
// 뷰어가 닫히기 시작할 때 이벤트를 보내서 JS가 제때 구독을 정리하도록 합니다.
|
|
598
|
-
|
|
599
|
-
|
|
615
|
+
// 1.2.1: { currentIndex, videoPositions } — 앱은 인자를 무시해도 됨
|
|
616
|
+
viewerVC.onViewerWillClose = { [weak self, weak viewerVC] () -> Void in
|
|
617
|
+
self?.sendEvent(withName: "onViewerWillClose", body: viewerVC?.willCloseEventBody())
|
|
600
618
|
}
|
|
601
619
|
viewerVC.onImageDeleted = { [weak self] (index: Int, url: String, option: String?) -> Void in
|
|
602
620
|
guard let self = self else { return }
|
|
@@ -1105,7 +1123,7 @@ class RNAttachmentKit: RCTEventEmitter {
|
|
|
1105
1123
|
self.processingJobs[processingId] = nil
|
|
1106
1124
|
if aborted {
|
|
1107
1125
|
// 변환 취소: 이미 만든 파일은 버림
|
|
1108
|
-
results.
|
|
1126
|
+
results.flatMap { [$0.path, $0.previewPath].compactMap { $0 } }.forEach { try? FileManager.default.removeItem(atPath: $0) }
|
|
1109
1127
|
reject("CANCELLED", "User cancelled video conversion", nil)
|
|
1110
1128
|
} else {
|
|
1111
1129
|
resolve(results.map { $0.dictionary })
|