From 4207d5e1def4e2a16c163e7f6328750a9d9ca272 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 10 Mar 2024 17:55:59 +0900 Subject: [PATCH 01/14] feat(android): implement capture method within Android side --- .../brentvatne/common/toolbox/CaptureUtil.kt | 93 +++++++++++++++++++ .../brentvatne/react/VideoManagerModule.java | 26 ++++++ 2 files changed, 119 insertions(+) create mode 100644 android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt diff --git a/android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt b/android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt new file mode 100644 index 0000000000..45e1c4c970 --- /dev/null +++ b/android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt @@ -0,0 +1,93 @@ +package com.brentvatne.common.toolbox + +import android.content.ContentValues +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.view.PixelCopy +import android.view.SurfaceView +import android.view.TextureView +import android.view.View +import androidx.media3.common.util.Util +import com.facebook.react.bridge.ReactApplicationContext +import java.io.IOException +import java.io.OutputStream + + +object CaptureUtil { + @JvmStatic + fun capture(reactContext: ReactApplicationContext, view: View) { + val bitmap: Bitmap? + if (view is TextureView) { + bitmap = view.bitmap + try { + saveImageToStream(bitmap, reactContext) + } catch (e: IOException) { + throw e + } + } else if (Util.SDK_INT >= 24 && view is SurfaceView) { + bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888) + PixelCopy.request(view, bitmap, { copyResult: Int -> + if (copyResult == PixelCopy.SUCCESS) { + try { + saveImageToStream(bitmap, reactContext) + } catch (e: IOException) { + e.printStackTrace() + } + } + }, Handler(Looper.getMainLooper())) + } else { + // https://stackoverflow.com/questions/27817577/android-take-screenshot-of-surface-view-shows-black-screen/27824250#27824250 + throw RuntimeException("SurfaceView couldn't support capture under SDK 24") + } + } + + private fun saveImageToStream(bitmap: Bitmap?, reactContext: ReactApplicationContext) { + val isUnderQ = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + val values = contentValues() + val resolver = reactContext.contentResolver + var stream: OutputStream? = null + var uri: Uri? = null + try { + if (!isUnderQ) { + values.put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val imageCollection = MediaStore.Images.Media.getContentUri(if (isUnderQ) MediaStore.VOLUME_EXTERNAL else MediaStore.VOLUME_EXTERNAL_PRIMARY) + uri = resolver.insert(imageCollection, values) + if (uri == null) { + throw IOException("Failed to create new MediaStore record.") + } + stream = resolver.openOutputStream(uri) + if (stream == null) { + throw IOException("Failed to get output stream.") + } + if (!bitmap!!.compress(Bitmap.CompressFormat.PNG, 100, stream)) { + throw IOException("Failed to save bitmap.") + } + if (!isUnderQ) { + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } + } catch (e: IOException) { + if (uri != null) { + // Don't leave an orphan entry in the MediaStore + resolver.delete(uri, null, null) + } + throw e + } finally { + stream?.close() + } + } + + private fun contentValues(): ContentValues { + val values = ContentValues() + values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png") + values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000) + values.put(MediaStore.MediaColumns.DATE_TAKEN, System.currentTimeMillis()) + return values + } +} diff --git a/android/src/main/java/com/brentvatne/react/VideoManagerModule.java b/android/src/main/java/com/brentvatne/react/VideoManagerModule.java index 5bdebdb299..2d029d8571 100644 --- a/android/src/main/java/com/brentvatne/react/VideoManagerModule.java +++ b/android/src/main/java/com/brentvatne/react/VideoManagerModule.java @@ -4,12 +4,18 @@ import androidx.annotation.NonNull; +import com.brentvatne.common.toolbox.CaptureUtil; +import com.brentvatne.common.toolbox.DebugLog; +import com.brentvatne.exoplayer.ExoPlayerView; import com.brentvatne.exoplayer.ReactExoplayerView; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.uimanager.UIManagerModule; +import java.util.Objects; + public class VideoManagerModule extends ReactContextBaseJavaModule { private static final String REACT_CLASS = "VideoManager"; @@ -35,4 +41,24 @@ public void setPlayerPauseState(Boolean paused, int reactTag) { } }); } + + @ReactMethod + public void capture(int reactTag, Promise promise) { + final ReactApplicationContext context = getReactApplicationContext(); + final UIManagerModule uiManager = context.getNativeModule(UIManagerModule.class); + uiManager.prependUIBlock(manager -> { + View view = manager.resolveView(reactTag); + if (view instanceof ReactExoplayerView) { + try { + ReactExoplayerView videoView = (ReactExoplayerView) view; + ExoPlayerView exoPlayerView = videoView.exoPlayerView; + CaptureUtil.capture(context, exoPlayerView); + promise.resolve(null); + } catch (Exception e) { + promise.reject("CAPTURE_ERROR", e); + } + } + } + ); + } } From 9e04b2e4a4171c43957bbdd5cc5586bca17593c8 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 10 Mar 2024 18:15:13 +0900 Subject: [PATCH 02/14] feat(ios): implement capture method within iOS side --- ios/Video/Features/RCTVideoCapture.swift | 50 ++++++++++++++++++++++++ ios/Video/RCTVideo.swift | 15 +++++++ ios/Video/RCTVideoManager.m | 2 + ios/Video/RCTVideoManager.swift | 12 ++++++ 4 files changed, 79 insertions(+) create mode 100644 ios/Video/Features/RCTVideoCapture.swift diff --git a/ios/Video/Features/RCTVideoCapture.swift b/ios/Video/Features/RCTVideoCapture.swift new file mode 100644 index 0000000000..b4dd539234 --- /dev/null +++ b/ios/Video/Features/RCTVideoCapture.swift @@ -0,0 +1,50 @@ +import AVFoundation + +enum CaptureError: Error { + case emptyPlayerItem + case emptyPlayerItemOutput + case emptyBuffer + case emptyImg + case emtpyPngData + case emptyTmpDir +} + +enum RCTVideoCapture { + + static func capture( + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock, + playerItem: AVPlayerItem?, + playerOutput: AVPlayerItemVideoOutput? + ) { + DispatchQueue.global(qos: .userInitiated).async { + do { + let playerItem = try playerItem ?? { throw CaptureError.emptyPlayerItem }() + let playerOutput = try playerOutput ?? { throw CaptureError.emptyPlayerItemOutput }() + + let currentTime = playerItem.currentTime() + let settings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] + let output = AVPlayerItemVideoOutput.init(pixelBufferAttributes: settings) + let buffer = try playerOutput.copyPixelBuffer(forItemTime: currentTime, itemTimeForDisplay: nil) ?? { throw CaptureError.emptyBuffer }() + + let ciImage = CIImage(cvPixelBuffer: buffer) + let ctx = CIContext.init(options: nil) + let width = CVPixelBufferGetWidth(buffer) + let height = CVPixelBufferGetHeight(buffer) + let rect = CGRectMake(0, 0, CGFloat(width), CGFloat(height)) + let videoImage = try ctx.createCGImage(ciImage, from: rect) ?? { throw CaptureError.emptyImg }() + + let image = UIImage.init(cgImage: videoImage) + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + let data = try image.pngData() ?? { throw CaptureError.emtpyPngData }() + + let tmpDir = try RCTTempFilePath("png", nil) ?? { throw CaptureError.emptyTmpDir }() + + try data.write(to: URL(fileURLWithPath: tmpDir)) + resolve(nil) + } catch { + reject("RCTVideoCapture Error", "Capture failed: \(error)", nil) + } + } + } +} diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index fb465b67a6..326b757bce 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -68,6 +68,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _presentingViewController: UIViewController? private var _pictureInPictureEnabled = false private var _startPosition: Float64 = -1 + private var _playerOutput:AVPlayerItemVideoOutput? /* IMA Ads */ private var _adTagUrl: String? @@ -364,6 +365,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH self._player?.pause() self._playerItem = playerItem + + // for capture + let settings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] + self._playerOutput = AVPlayerItemVideoOutput.init(pixelBufferAttributes: settings) + self._playerObserver.playerItem = self._playerItem self.setPreferredForwardBufferDuration(self._preferredForwardBufferDuration) self.setPlaybackRange(playerItem, withVideoStart: self._source?.cropStart, withVideoEnd: self._source?.cropEnd) @@ -1121,6 +1127,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH onReadyForDisplay?([ "target": reactTag, ]) + // for capture + if let playerOutput = self._playerOutput, self._drm == nil { + self._playerItem?.add(playerOutput) + } } // When timeMetadata is read the event onTimedMetadata is triggered @@ -1373,6 +1383,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } } + @objc + func capture(resolve: @escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) { + RCTVideoCapture.capture(resolve: resolve, reject: reject, playerItem: _playerItem, playerOutput: _playerOutput) + } + @objc func handleAVPlayerAccess(notification: NSNotification!) { guard let accessLog = (notification.object as? AVPlayerItem)?.accessLog() else { diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index e30f6c9cb5..8216498587 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -84,4 +84,6 @@ @interface RCT_EXTERN_MODULE (RCTVideoManager, RCTViewManager) RCT_EXTERN_METHOD(dismissFullscreenPlayer : (nonnull NSNumber*)reactTag) +RCT_EXTERN_METHOD(capture : (nonnull NSNumber *)reactTag resolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) + @end diff --git a/ios/Video/RCTVideoManager.swift b/ios/Video/RCTVideoManager.swift index e2055c022d..3f98d7179e 100644 --- a/ios/Video/RCTVideoManager.swift +++ b/ios/Video/RCTVideoManager.swift @@ -84,6 +84,18 @@ class RCTVideoManager: RCTViewManager { } } + @objc(capture:resolver:rejecter:) + func capture(_ reactTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + bridge.uiManager.prependUIBlock { _, viewRegistry in + let view = viewRegistry?[reactTag] + if !(view is RCTVideo) { + RCTLogError("Invalid view returned from registry, expecting RCTVideo, got: %@", String(describing: view)) + } else if let view = view as? RCTVideo { + view.capture(resolve: resolve, reject: reject) + } + } + } + override class func requiresMainQueueSetup() -> Bool { return true } From 46f26ccc1d8257927010691f31a0febceff5a840 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 10 Mar 2024 18:15:39 +0900 Subject: [PATCH 03/14] feat: implement capture method within JS side --- src/Video.tsx | 7 +++++++ src/specs/VideoNativeComponent.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/src/Video.tsx b/src/Video.tsx index 0917481fdb..b11858dac2 100644 --- a/src/Video.tsx +++ b/src/Video.tsx @@ -55,6 +55,7 @@ export interface VideoRef { restore: boolean, ) => void; save: (options: object) => Promise; + capture: () => Promise; } const Video = forwardRef( @@ -265,6 +266,10 @@ const Video = forwardRef( return VideoManager.setPlayerPauseState(false, getReactTag(nativeRef)); }, []); + const capture = useCallback(() => { + return VideoManager.capture(getReactTag(nativeRef)); + }, []); + const restoreUserInterfaceForPictureInPictureStopCompleted = useCallback( (restored: boolean) => { setRestoreUserInterfaceForPIPStopCompletionHandler(restored); @@ -485,6 +490,7 @@ const Video = forwardRef( presentFullscreenPlayer, dismissFullscreenPlayer, save, + capture, pause, resume, restoreUserInterfaceForPictureInPictureStopCompleted, @@ -494,6 +500,7 @@ const Video = forwardRef( presentFullscreenPlayer, dismissFullscreenPlayer, save, + capture, pause, resume, restoreUserInterfaceForPictureInPictureStopCompleted, diff --git a/src/specs/VideoNativeComponent.ts b/src/specs/VideoNativeComponent.ts index dcebc63aef..f88053c59b 100644 --- a/src/specs/VideoNativeComponent.ts +++ b/src/specs/VideoNativeComponent.ts @@ -535,6 +535,7 @@ export type VideoSaveData = { export interface VideoManagerType { save: (option: object, reactTag: number) => Promise; + capture: (reactTag: number) => Promise; setPlayerPauseState: (paused: boolean, reactTag: number) => Promise; setLicenseResult: ( result: string, From 2e16f95edac5dc98698ef20b80c1ae836ff3075c Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 10 Mar 2024 18:15:49 +0900 Subject: [PATCH 04/14] docs: add capture method description --- docs/pages/component/methods.mdx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/pages/component/methods.mdx b/docs/pages/component/methods.mdx index 3c1363b2c6..1cd53e6053 100644 --- a/docs/pages/component/methods.mdx +++ b/docs/pages/component/methods.mdx @@ -64,6 +64,23 @@ Future: - Will support more formats in the future through options - Will support custom directory and file name through options +### `capture` + + + +`capture(): Promise` + +Save current frame as a PNG file. Returns promise. + +Notes: + +- For Android API level 29+ you will need to request the [WRITE_EXTERNAL_STORAGE permission](https://developer.android.com/reference/android/Manifest.permission#WRITE_EXTERNAL_STORAGE) manually using either the built-in react-native `PermissionsAndroid` APIs or a related module such as `react-native-permissions` +- Also you have to define below code within your `AndroidManifest.xml` file + +```xml + +``` + ### `restoreUserInterfaceForPictureInPictureStopCompleted` From d876811a74ea90a3d2d5f121765e175811d0a9e1 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 10 Mar 2024 18:29:19 +0900 Subject: [PATCH 05/14] fix: prevent capture method when drm enabled --- src/Video.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Video.tsx b/src/Video.tsx index b11858dac2..b50a786767 100644 --- a/src/Video.tsx +++ b/src/Video.tsx @@ -267,8 +267,11 @@ const Video = forwardRef( }, []); const capture = useCallback(() => { - return VideoManager.capture(getReactTag(nativeRef)); - }, []); + if (drm) { + throw Error('"capture" method can not be called with "drm" prop'); + } + return VideoManager.capture?.(getReactTag(nativeRef)); + }, [drm]); const restoreUserInterfaceForPictureInPictureStopCompleted = useCallback( (restored: boolean) => { From 6aa09adac04c9efab1f2088697024b50b572eac8 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 10 Mar 2024 18:31:23 +0900 Subject: [PATCH 06/14] docs: notify caution of capture method --- docs/pages/component/methods.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pages/component/methods.mdx b/docs/pages/component/methods.mdx index 1cd53e6053..53c49122b6 100644 --- a/docs/pages/component/methods.mdx +++ b/docs/pages/component/methods.mdx @@ -74,6 +74,8 @@ Save current frame as a PNG file. Returns promise. Notes: +- this method can not be used with encrypted video contents (with DRM) +- On Android API level 23 and below capture couldn't support Android `SurfaceView`. if you use SurfaceView, method will be throw an error (`useSurfaceView`, `useSecureView` and `drm` props are internally use SurfaceView). - For Android API level 29+ you will need to request the [WRITE_EXTERNAL_STORAGE permission](https://developer.android.com/reference/android/Manifest.permission#WRITE_EXTERNAL_STORAGE) manually using either the built-in react-native `PermissionsAndroid` APIs or a related module such as `react-native-permissions` - Also you have to define below code within your `AndroidManifest.xml` file From a168dbf610340e07d82297c21f77ee5bbbfb12f9 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 10 Mar 2024 18:53:16 +0900 Subject: [PATCH 07/14] fix: fix lint error --- .../brentvatne/common/toolbox/CaptureUtil.kt | 1 - ios/Video/Features/RCTVideoCapture.swift | 19 +++++++++++-------- ios/Video/RCTVideo.swift | 6 +++--- ios/Video/RCTVideoManager.m | 2 +- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt b/android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt index 45e1c4c970..68456739bf 100644 --- a/android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt +++ b/android/src/main/java/com/brentvatne/common/toolbox/CaptureUtil.kt @@ -16,7 +16,6 @@ import com.facebook.react.bridge.ReactApplicationContext import java.io.IOException import java.io.OutputStream - object CaptureUtil { @JvmStatic fun capture(reactContext: ReactApplicationContext, view: View) { diff --git a/ios/Video/Features/RCTVideoCapture.swift b/ios/Video/Features/RCTVideoCapture.swift index b4dd539234..c70bd13bba 100644 --- a/ios/Video/Features/RCTVideoCapture.swift +++ b/ios/Video/Features/RCTVideoCapture.swift @@ -1,5 +1,7 @@ import AVFoundation +// MARK: - CaptureError + enum CaptureError: Error { case emptyPlayerItem case emptyPlayerItemOutput @@ -9,8 +11,9 @@ enum CaptureError: Error { case emptyTmpDir } -enum RCTVideoCapture { +// MARK: - RCTVideoCapture +enum RCTVideoCapture { static func capture( resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock, @@ -24,20 +27,20 @@ enum RCTVideoCapture { let currentTime = playerItem.currentTime() let settings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] - let output = AVPlayerItemVideoOutput.init(pixelBufferAttributes: settings) + let output = AVPlayerItemVideoOutput(pixelBufferAttributes: settings) let buffer = try playerOutput.copyPixelBuffer(forItemTime: currentTime, itemTimeForDisplay: nil) ?? { throw CaptureError.emptyBuffer }() - + let ciImage = CIImage(cvPixelBuffer: buffer) - let ctx = CIContext.init(options: nil) + let ctx = CIContext(options: nil) let width = CVPixelBufferGetWidth(buffer) let height = CVPixelBufferGetHeight(buffer) - let rect = CGRectMake(0, 0, CGFloat(width), CGFloat(height)) + let rect = CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)) let videoImage = try ctx.createCGImage(ciImage, from: rect) ?? { throw CaptureError.emptyImg }() - - let image = UIImage.init(cgImage: videoImage) + + let image = UIImage(cgImage: videoImage) UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) let data = try image.pngData() ?? { throw CaptureError.emtpyPngData }() - + let tmpDir = try RCTTempFilePath("png", nil) ?? { throw CaptureError.emptyTmpDir }() try data.write(to: URL(fileURLWithPath: tmpDir)) diff --git a/ios/Video/RCTVideo.swift b/ios/Video/RCTVideo.swift index 326b757bce..bbeecedfd3 100644 --- a/ios/Video/RCTVideo.swift +++ b/ios/Video/RCTVideo.swift @@ -68,7 +68,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH private var _presentingViewController: UIViewController? private var _pictureInPictureEnabled = false private var _startPosition: Float64 = -1 - private var _playerOutput:AVPlayerItemVideoOutput? + private var _playerOutput: AVPlayerItemVideoOutput? /* IMA Ads */ private var _adTagUrl: String? @@ -368,7 +368,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH // for capture let settings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] - self._playerOutput = AVPlayerItemVideoOutput.init(pixelBufferAttributes: settings) + self._playerOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: settings) self._playerObserver.playerItem = self._playerItem self.setPreferredForwardBufferDuration(self._preferredForwardBufferDuration) @@ -1384,7 +1384,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH } @objc - func capture(resolve: @escaping RCTPromiseResolveBlock, reject:@escaping RCTPromiseRejectBlock) { + func capture(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { RCTVideoCapture.capture(resolve: resolve, reject: reject, playerItem: _playerItem, playerOutput: _playerOutput) } diff --git a/ios/Video/RCTVideoManager.m b/ios/Video/RCTVideoManager.m index 8216498587..960fca065c 100644 --- a/ios/Video/RCTVideoManager.m +++ b/ios/Video/RCTVideoManager.m @@ -84,6 +84,6 @@ @interface RCT_EXTERN_MODULE (RCTVideoManager, RCTViewManager) RCT_EXTERN_METHOD(dismissFullscreenPlayer : (nonnull NSNumber*)reactTag) -RCT_EXTERN_METHOD(capture : (nonnull NSNumber *)reactTag resolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(capture : (nonnull NSNumber*)reactTag resolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) @end From b35d5502d069c6aac7b1903958b79f054f061381 Mon Sep 17 00:00:00 2001 From: YangJH Date: Tue, 12 Mar 2024 01:08:41 +0900 Subject: [PATCH 08/14] fix(android): fix android exoplayer getter --- .../java/com/brentvatne/exoplayer/ReactExoplayerView.java | 4 ++++ .../main/java/com/brentvatne/react/VideoManagerModule.java | 5 +---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index d67493c02a..4940078613 100644 --- a/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -1817,6 +1817,10 @@ private int getGroupIndexForDefaultLocale(TrackGroupArray groups) { return groupIndex; } + public ExoPlayerView getExoPlayerView() { + return this.exoPlayerView; + } + public void setSelectedVideoTrack(String type, Dynamic value) { videoTrackType = type; videoTrackValue = value; diff --git a/android/src/main/java/com/brentvatne/react/VideoManagerModule.java b/android/src/main/java/com/brentvatne/react/VideoManagerModule.java index 2d029d8571..8dd41c5e7d 100644 --- a/android/src/main/java/com/brentvatne/react/VideoManagerModule.java +++ b/android/src/main/java/com/brentvatne/react/VideoManagerModule.java @@ -5,7 +5,6 @@ import androidx.annotation.NonNull; import com.brentvatne.common.toolbox.CaptureUtil; -import com.brentvatne.common.toolbox.DebugLog; import com.brentvatne.exoplayer.ExoPlayerView; import com.brentvatne.exoplayer.ReactExoplayerView; import com.facebook.react.bridge.Promise; @@ -14,8 +13,6 @@ import com.facebook.react.bridge.ReactMethod; import com.facebook.react.uimanager.UIManagerModule; -import java.util.Objects; - public class VideoManagerModule extends ReactContextBaseJavaModule { private static final String REACT_CLASS = "VideoManager"; @@ -51,7 +48,7 @@ public void capture(int reactTag, Promise promise) { if (view instanceof ReactExoplayerView) { try { ReactExoplayerView videoView = (ReactExoplayerView) view; - ExoPlayerView exoPlayerView = videoView.exoPlayerView; + ExoPlayerView exoPlayerView = videoView.getExoPlayerView(); CaptureUtil.capture(context, exoPlayerView); promise.resolve(null); } catch (Exception e) { From 604fc6f818f4e3ffe18ce4c0548bf65131cb384e Mon Sep 17 00:00:00 2001 From: YangJH Date: Tue, 12 Mar 2024 01:11:11 +0900 Subject: [PATCH 09/14] refactor: add permission checker --- ios/Video/Features/RCTVideoCapture.swift | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/ios/Video/Features/RCTVideoCapture.swift b/ios/Video/Features/RCTVideoCapture.swift index c70bd13bba..8005c081a8 100644 --- a/ios/Video/Features/RCTVideoCapture.swift +++ b/ios/Video/Features/RCTVideoCapture.swift @@ -1,8 +1,10 @@ import AVFoundation +import Photos // MARK: - CaptureError enum CaptureError: Error { + case permissionDenied case emptyPlayerItem case emptyPlayerItemOutput case emptyBuffer @@ -22,12 +24,11 @@ enum RCTVideoCapture { ) { DispatchQueue.global(qos: .userInitiated).async { do { + try RCTVideoCapture.checkPhotoAddPermission() let playerItem = try playerItem ?? { throw CaptureError.emptyPlayerItem }() let playerOutput = try playerOutput ?? { throw CaptureError.emptyPlayerItemOutput }() let currentTime = playerItem.currentTime() - let settings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] - let output = AVPlayerItemVideoOutput(pixelBufferAttributes: settings) let buffer = try playerOutput.copyPixelBuffer(forItemTime: currentTime, itemTimeForDisplay: nil) ?? { throw CaptureError.emptyBuffer }() let ciImage = CIImage(cvPixelBuffer: buffer) @@ -50,4 +51,19 @@ enum RCTVideoCapture { } } } + + static private func checkPhotoAddPermission() throws { + var status: PHAuthorizationStatus? + if #available(iOS 14, *) { + status = PHPhotoLibrary.authorizationStatus(for: .addOnly) + } else { + status = PHPhotoLibrary.authorizationStatus() + } + switch status { + case .restricted, .denied: + throw CaptureError.permissionDenied + default: + return; + } + } } From 4f7f989315227ef3615bed1a6b9fcc4c48815c48 Mon Sep 17 00:00:00 2001 From: YangJH Date: Tue, 12 Mar 2024 01:15:11 +0900 Subject: [PATCH 10/14] docs: add notify caution of capture method --- docs/pages/component/methods.mdx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/pages/component/methods.mdx b/docs/pages/component/methods.mdx index 53c49122b6..8c865ab469 100644 --- a/docs/pages/component/methods.mdx +++ b/docs/pages/component/methods.mdx @@ -83,6 +83,13 @@ Notes: ``` +- In order to save photos on iOS, user must accept permission to save photos. To enable this permission, you need to add the following to your `info.plist` + +```xml +NSPhotoLibraryAddUsageDescription +YOUR TEXT +``` + ### `restoreUserInterfaceForPictureInPictureStopCompleted` From f20b6d0351778867c7db10cae5251a36e96ec8d6 Mon Sep 17 00:00:00 2001 From: YangJH Date: Tue, 12 Mar 2024 01:18:52 +0900 Subject: [PATCH 11/14] docs: fix document typo --- docs/pages/component/methods.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/component/methods.mdx b/docs/pages/component/methods.mdx index 8c865ab469..186e635fd2 100644 --- a/docs/pages/component/methods.mdx +++ b/docs/pages/component/methods.mdx @@ -83,7 +83,7 @@ Notes: ``` -- In order to save photos on iOS, user must accept permission to save photos. To enable this permission, you need to add the following to your `info.plist` +- In order to save photos on iOS, user must accept permission to save photos. To enable this permission, you need to add the following to your `Info.plist` file. ```xml NSPhotoLibraryAddUsageDescription From 99754dc10c10da0957b044968ff5b5a4e004a686 Mon Sep 17 00:00:00 2001 From: YangJH Date: Tue, 12 Mar 2024 01:19:54 +0900 Subject: [PATCH 12/14] refactor(ios): apply swift lint --- ios/Video/Features/RCTVideoCapture.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ios/Video/Features/RCTVideoCapture.swift b/ios/Video/Features/RCTVideoCapture.swift index 8005c081a8..27241d8bc4 100644 --- a/ios/Video/Features/RCTVideoCapture.swift +++ b/ios/Video/Features/RCTVideoCapture.swift @@ -52,7 +52,7 @@ enum RCTVideoCapture { } } - static private func checkPhotoAddPermission() throws { + private static func checkPhotoAddPermission() throws { var status: PHAuthorizationStatus? if #available(iOS 14, *) { status = PHPhotoLibrary.authorizationStatus(for: .addOnly) @@ -60,10 +60,10 @@ enum RCTVideoCapture { status = PHPhotoLibrary.authorizationStatus() } switch status { - case .restricted, .denied: - throw CaptureError.permissionDenied - default: - return; + case .restricted, .denied: + throw CaptureError.permissionDenied + default: + return } } } From 9e3089385048d4a32eaa6bbba68a99029cbe6ba3 Mon Sep 17 00:00:00 2001 From: YangJH Date: Tue, 12 Mar 2024 01:38:22 +0900 Subject: [PATCH 13/14] fix(android): fix video view argument --- .../src/main/java/com/brentvatne/react/VideoManagerModule.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/src/main/java/com/brentvatne/react/VideoManagerModule.java b/android/src/main/java/com/brentvatne/react/VideoManagerModule.java index 8dd41c5e7d..f1e6487f54 100644 --- a/android/src/main/java/com/brentvatne/react/VideoManagerModule.java +++ b/android/src/main/java/com/brentvatne/react/VideoManagerModule.java @@ -49,7 +49,7 @@ public void capture(int reactTag, Promise promise) { try { ReactExoplayerView videoView = (ReactExoplayerView) view; ExoPlayerView exoPlayerView = videoView.getExoPlayerView(); - CaptureUtil.capture(context, exoPlayerView); + CaptureUtil.capture(context, exoPlayerView.getVideoSurfaceView()); promise.resolve(null); } catch (Exception e) { promise.reject("CAPTURE_ERROR", e); From a7feb7e2a20cb5c6d345946fd72b818d00ddce88 Mon Sep 17 00:00:00 2001 From: YangJH Date: Tue, 12 Mar 2024 01:50:17 +0900 Subject: [PATCH 14/14] docs: fix required permission range --- docs/pages/component/methods.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/pages/component/methods.mdx b/docs/pages/component/methods.mdx index 186e635fd2..3e8e1d6690 100644 --- a/docs/pages/component/methods.mdx +++ b/docs/pages/component/methods.mdx @@ -76,8 +76,7 @@ Notes: - this method can not be used with encrypted video contents (with DRM) - On Android API level 23 and below capture couldn't support Android `SurfaceView`. if you use SurfaceView, method will be throw an error (`useSurfaceView`, `useSecureView` and `drm` props are internally use SurfaceView). -- For Android API level 29+ you will need to request the [WRITE_EXTERNAL_STORAGE permission](https://developer.android.com/reference/android/Manifest.permission#WRITE_EXTERNAL_STORAGE) manually using either the built-in react-native `PermissionsAndroid` APIs or a related module such as `react-native-permissions` -- Also you have to define below code within your `AndroidManifest.xml` file +- On Android API level 28 and below you will need to request the [WRITE_EXTERNAL_STORAGE permission](https://developer.android.com/reference/android/Manifest.permission#WRITE_EXTERNAL_STORAGE) manually using either the built-in react-native `PermissionsAndroid` APIs or a related module such as `react-native-permissions`. also you have to define below code within your `AndroidManifest.xml` file ```xml