Compare commits
6 Commits
bump-react
...
loewy/stop
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1e074e0f | |||
| b269e9c493 | |||
| 5fe7f35127 | |||
| 61863149c0 | |||
| 09b50938d2 | |||
| a158ed8350 |
@@ -15,6 +15,7 @@ import android.util.Log
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.WindowManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.mrousavy.camera.core.capture.RepeatingCaptureRequest
|
||||
@@ -425,6 +426,21 @@ class CameraSession(private val context: Context, private val cameraManager: Cam
|
||||
|
||||
val fps = configuration?.fps ?: 30
|
||||
|
||||
// Get actual device rotation from WindowManager since the React Native orientation hook
|
||||
// doesn't update when rotating between landscape-left and landscape-right on Android.
|
||||
// Map device rotation to the correct orientationHint for video recording:
|
||||
// - Counter-clockwise (ROTATION_90) → 270° hint
|
||||
// - Clockwise (ROTATION_270) → 90° hint
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val deviceRotation = windowManager.defaultDisplay.rotation
|
||||
val recordingOrientation = when (deviceRotation) {
|
||||
Surface.ROTATION_0 -> Orientation.PORTRAIT
|
||||
Surface.ROTATION_90 -> Orientation.LANDSCAPE_RIGHT
|
||||
Surface.ROTATION_180 -> Orientation.PORTRAIT_UPSIDE_DOWN
|
||||
Surface.ROTATION_270 -> Orientation.LANDSCAPE_LEFT
|
||||
else -> Orientation.PORTRAIT
|
||||
}
|
||||
|
||||
val recording = RecordingSession(
|
||||
context,
|
||||
cameraId,
|
||||
|
||||
@@ -9,8 +9,10 @@ import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.view.PixelCopy
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.mrousavy.camera.extensions.resize
|
||||
import com.mrousavy.camera.extensions.rotatedBy
|
||||
@@ -150,6 +152,8 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) :
|
||||
val width = frame.width()
|
||||
val height = frame.height()
|
||||
|
||||
// Create bitmap matching surface frame dimensions for PixelCopy
|
||||
// The original code swapped dimensions assuming landscape input - keep that for consistency
|
||||
val bitmap = Bitmap.createBitmap(height, width, Bitmap.Config.ARGB_8888)
|
||||
|
||||
// Use a coroutine to suspend until the PixelCopy request is complete
|
||||
@@ -159,7 +163,23 @@ class PreviewView(context: Context, callback: SurfaceHolder.Callback) :
|
||||
bitmap,
|
||||
{ copyResult ->
|
||||
if (copyResult == PixelCopy.SUCCESS) {
|
||||
continuation.resume(rotateBitmap90CounterClockwise(bitmap))
|
||||
// Get actual device rotation from WindowManager instead of relying on
|
||||
// the orientation prop, which may not update on Android when rotating
|
||||
// between landscape-left and landscape-right.
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val deviceRotation = windowManager.defaultDisplay.rotation
|
||||
|
||||
val actualOrientation = when (deviceRotation) {
|
||||
Surface.ROTATION_0 -> Orientation.PORTRAIT
|
||||
Surface.ROTATION_90 -> Orientation.LANDSCAPE_LEFT
|
||||
Surface.ROTATION_180 -> Orientation.PORTRAIT_UPSIDE_DOWN
|
||||
Surface.ROTATION_270 -> Orientation.LANDSCAPE_RIGHT
|
||||
else -> Orientation.PORTRAIT
|
||||
}
|
||||
|
||||
Log.i(TAG, "getBitmap: orientation prop = $orientation, deviceRotation = $deviceRotation, actualOrientation = $actualOrientation")
|
||||
|
||||
continuation.resume(bitmap.transformBitmap(actualOrientation))
|
||||
} else {
|
||||
continuation.resumeWithException(
|
||||
RuntimeException("PixelCopy failed with error code $copyResult")
|
||||
|
||||
@@ -38,11 +38,27 @@ extension CameraSession {
|
||||
// Callback for when new chunks are ready
|
||||
let onChunkReady: (ChunkedRecorder.Chunk) -> Void = { chunk in
|
||||
guard let delegate = self.delegate else {
|
||||
ReactLogger.log(level: .warning, message: "Chunk ready but delegate is nil, dropping chunk: \(chunk)")
|
||||
return
|
||||
}
|
||||
delegate.onVideoChunkReady(chunk: chunk)
|
||||
}
|
||||
|
||||
// Callback for when a chunk write fails (e.g. init file write failure)
|
||||
let onChunkError: (Error) -> Void = { error in
|
||||
ReactLogger.log(level: .error, message: "Chunk write error, stopping recording: \(error.localizedDescription)")
|
||||
// Stop recording immediately
|
||||
if let session = self.recordingSession {
|
||||
session.stop(clock: self.captureSession.clock)
|
||||
}
|
||||
// Surface error to RN
|
||||
if let cameraError = error as? CameraError {
|
||||
onError(cameraError)
|
||||
} else {
|
||||
onError(.capture(.fileError))
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for when the recording ends
|
||||
let onFinish = { (recordingSession: RecordingSession, status: AVAssetWriter.Status, error: Error?) in
|
||||
defer {
|
||||
@@ -98,6 +114,7 @@ extension CameraSession {
|
||||
let recordingSession = try RecordingSession(outputDiretory: filePath,
|
||||
fileType: options.fileType,
|
||||
onChunkReady: onChunkReady,
|
||||
onChunkError: onChunkError,
|
||||
completion: onFinish)
|
||||
|
||||
// Init Audio + Activate Audio Session (optional)
|
||||
|
||||
@@ -24,12 +24,14 @@ class ChunkedRecorder: NSObject {
|
||||
|
||||
let outputURL: URL
|
||||
let onChunkReady: ((Chunk) -> Void)
|
||||
let onError: ((Error) -> Void)?
|
||||
|
||||
private var chunkIndex: UInt64 = 0
|
||||
|
||||
init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void)) throws {
|
||||
init(outputURL: URL, onChunkReady: @escaping ((Chunk) -> Void), onError: ((Error) -> Void)? = nil) throws {
|
||||
self.outputURL = outputURL
|
||||
self.onChunkReady = onChunkReady
|
||||
self.onError = onError
|
||||
guard FileManager.default.fileExists(atPath: outputURL.path) else {
|
||||
throw CameraError.unknown(message: "output directory does not exist at: \(outputURL.path)", cause: nil)
|
||||
}
|
||||
@@ -56,28 +58,36 @@ extension ChunkedRecorder: AVAssetWriterDelegate {
|
||||
|
||||
private func saveInitSegment(_ data: Data) {
|
||||
let url = outputURL.appendingPathComponent("init.mp4")
|
||||
save(data: data, url: url)
|
||||
onChunkReady(url: url, type: .initialization)
|
||||
do {
|
||||
try data.write(to: url)
|
||||
onChunkReady(url: url, type: .initialization)
|
||||
} catch {
|
||||
ReactLogger.log(level: .error, message: "Failed to write init file \(url): \(error.localizedDescription)")
|
||||
onError?(CameraError.capture(.fileError))
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSegment(_ data: Data, report: AVAssetSegmentReport?) {
|
||||
let name = "\(chunkIndex).mp4"
|
||||
let url = outputURL.appendingPathComponent(name)
|
||||
save(data: data, url: url)
|
||||
let duration = report?
|
||||
.trackReports
|
||||
.filter { $0.mediaType == .video }
|
||||
.first?
|
||||
.duration
|
||||
onChunkReady(url: url, type: .data(index: chunkIndex, duration: duration))
|
||||
chunkIndex += 1
|
||||
if save(data: data, url: url) {
|
||||
let duration = report?
|
||||
.trackReports
|
||||
.filter { $0.mediaType == .video }
|
||||
.first?
|
||||
.duration
|
||||
onChunkReady(url: url, type: .data(index: chunkIndex, duration: duration))
|
||||
chunkIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func save(data: Data, url: URL) {
|
||||
private func save(data: Data, url: URL) -> Bool {
|
||||
do {
|
||||
try data.write(to: url)
|
||||
return true
|
||||
} catch {
|
||||
ReactLogger.log(level: .error, message: "Unable to write \(url): \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,12 +74,13 @@ class RecordingSession {
|
||||
init(outputDiretory: String,
|
||||
fileType: AVFileType,
|
||||
onChunkReady: @escaping ((ChunkedRecorder.Chunk) -> Void),
|
||||
onChunkError: ((Error) -> Void)? = nil,
|
||||
completion: @escaping (RecordingSession, AVAssetWriter.Status, Error?) -> Void) throws {
|
||||
completionHandler = completion
|
||||
|
||||
do {
|
||||
let outputURL = URL(fileURLWithPath: outputDiretory)
|
||||
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady)
|
||||
recorder = try ChunkedRecorder(outputURL: outputURL, onChunkReady: onChunkReady, onError: onChunkError)
|
||||
assetWriter = AVAssetWriter(contentType: UTType(fileType.rawValue)!)
|
||||
assetWriter.shouldOptimizeForNetworkUse = false
|
||||
assetWriter.outputFileTypeProfile = .mpeg4AppleHLS
|
||||
|
||||
Reference in New Issue
Block a user