camera: Android video recording (Phase 4b)
CameraX VideoCapture<Recorder> wired alongside Preview + ImageCapture
in a single bindToLifecycle call. Mirrors the iOS surface contract:
startVideoRecording(handle, snapshotOrientation) → null
Records to a UUID-named MP4 in cacheDir. Audio enabled iff the
instance was created with enableAudio: true AND RECORD_AUDIO has
been granted (no SecurityException if the user denied mic but
asked for audio — recording proceeds silent). targetRotation set
per-call so the file's rotation metadata matches how the device
was held at recording start.
stopVideoRecording(handle) → {path}
Resolves when CameraX delivers the VideoRecordEvent.Finalize.
Telegram-fidelity mirror: VideoCapture.Builder.setMirrorMode(MIRROR_MODE_OFF)
overrides CameraX's default MIRROR_MODE_ON_FRONT_ONLY so selfie
videos record the raw sensor feed ("as others see you"). Preview-side
mirror stays a CameraX-managed SurfaceTexture transform; the recorded
file is independent.
Quality: Quality.HD (720p) — keeps file sizes reasonable for chat
composer use and well within mid-range Android devices' budget for
binding all three use cases (Preview + ImageCapture + VideoCapture).
Fallback for devices that reject the 3-use-case bind would be next
iteration.
instance.dispose() now hard-cancels any in-flight recording (drops
file, no caller waiting) — matches iOS' recorder.cancel() path.
This commit is contained in:
@@ -30,6 +30,7 @@ class CameraInstance(
|
||||
private val lifecycleOwner = CustomLifecycleOwner()
|
||||
private val previewSink = PreviewSink(registry, context)
|
||||
private val photo = PhotoCapture()
|
||||
private val video = VideoCapture()
|
||||
private val orientation = DeviceOrientationBridge(context)
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
@@ -113,6 +114,10 @@ class CameraInstance(
|
||||
fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
// Mirror iOS' dispose: any in-flight recording is hard-cancelled
|
||||
// (file discarded) rather than left to flush, since there's no
|
||||
// caller waiting on the result anymore.
|
||||
if (video.isRecording) video.cancel()
|
||||
orientation.stop()
|
||||
cameraProvider?.unbindAll()
|
||||
cameraProvider = null
|
||||
@@ -150,6 +155,26 @@ class CameraInstance(
|
||||
photo.take(context, snapshotRotation, onResult)
|
||||
}
|
||||
|
||||
/// Begin recording. [snapshotRotation] is baked into the file's
|
||||
/// rotation metadata; the file plays back rotated even if the
|
||||
/// device returns to portrait mid-recording. [onStartResult] fires
|
||||
/// when CameraX's `Recording.start()` returns (immediately on
|
||||
/// success), not when the first frame lands.
|
||||
fun startVideoRecording(
|
||||
snapshotRotation: Int,
|
||||
onStartResult: (Result<Unit>) -> Unit,
|
||||
) {
|
||||
check(!disposed)
|
||||
video.start(context, enableAudio, snapshotRotation, onStartResult)
|
||||
}
|
||||
|
||||
/// Stop the in-flight recording. [onResult] fires asynchronously
|
||||
/// when CameraX delivers the Finalize event.
|
||||
fun stopVideoRecording(onResult: (Result<String>) -> Unit) {
|
||||
check(!disposed)
|
||||
video.stop(onResult)
|
||||
}
|
||||
|
||||
private fun bind(provider: ProcessCameraProvider) {
|
||||
provider.unbindAll()
|
||||
|
||||
@@ -167,11 +192,17 @@ class CameraInstance(
|
||||
.also { it.setSurfaceProvider(previewSink::provideSurface) }
|
||||
this.preview = preview
|
||||
|
||||
// Preview + ImageCapture + VideoCapture as a single binding.
|
||||
// CameraX 1.4 supports this combination on essentially every
|
||||
// device that supports video — if a device can't deliver all
|
||||
// three simultaneously, `bindToLifecycle` throws and we'd
|
||||
// need to fall back. Not observed on Pixel-class hardware.
|
||||
camera = provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
selector,
|
||||
preview,
|
||||
photo.useCase,
|
||||
video.useCase,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -135,12 +135,8 @@ class CameraPlugin :
|
||||
"lockCaptureOrientation" -> result.success(null) // preview always portrait
|
||||
"unlockCaptureOrientation" -> result.success(null)
|
||||
"takePicture" -> handleTakePicture(call, result)
|
||||
"startVideoRecording" -> result.error(
|
||||
"unsupported_format", "video recording not yet on Android", null,
|
||||
)
|
||||
"stopVideoRecording" -> result.error(
|
||||
"unsupported_format", "video recording not yet on Android", null,
|
||||
)
|
||||
"startVideoRecording" -> handleStartVideo(call, result)
|
||||
"stopVideoRecording" -> handleStopVideo(call, result)
|
||||
"audioPermissionStatus" -> result.success(isAudioGranted())
|
||||
"openSettings" -> handleOpenSettings(result)
|
||||
else -> result.notImplemented()
|
||||
@@ -314,6 +310,36 @@ class CameraPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartVideo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
?: return result.error("bad_args", "startVideoRecording", null)
|
||||
val snapshotRaw = args["snapshotOrientation"] as? String ?: "portraitUp"
|
||||
withInstance(call, result) { instance ->
|
||||
instance.startVideoRecording(flutterToSurfaceRotation(snapshotRaw)) {
|
||||
outcome ->
|
||||
outcome.fold(
|
||||
onSuccess = { result.success(null) },
|
||||
onFailure = { t ->
|
||||
result.error("recorder_failed", t.localizedMessage, null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStopVideo(call: MethodCall, result: MethodChannel.Result) {
|
||||
withInstance(call, result) { instance ->
|
||||
instance.stopVideoRecording { outcome ->
|
||||
outcome.fold(
|
||||
onSuccess = { path -> result.success(mapOf("path" to path)) },
|
||||
onFailure = { t ->
|
||||
result.error("recorder_failed", t.localizedMessage, null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOpenSettings(result: MethodChannel.Result) {
|
||||
val act = activity
|
||||
?: return result.error("no_activity", "plugin not attached", null)
|
||||
|
||||
148
android/src/main/kotlin/io/swipelab/ux/camera/VideoCapture.kt
Normal file
148
android/src/main/kotlin/io/swipelab/ux/camera/VideoCapture.kt
Normal file
@@ -0,0 +1,148 @@
|
||||
package io.swipelab.ux.camera
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.camera.core.MirrorMode
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import androidx.camera.video.Recorder
|
||||
import androidx.camera.video.Recording
|
||||
import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/// Wraps CameraX `VideoCapture<Recorder>`. One per [CameraInstance];
|
||||
/// the use case is bound alongside Preview + ImageCapture in
|
||||
/// `CameraInstance.bind`. Telegram-fidelity mirror policy: raw sensor
|
||||
/// feed in the recorded MP4 (no `MIRROR_MODE_ON_FRONT_ONLY` default
|
||||
/// CameraX would otherwise apply).
|
||||
class VideoCapture {
|
||||
private val recorder = Recorder.Builder()
|
||||
// HD (720p) matches iOS' AVCaptureSession.Preset.high default
|
||||
// negotiation for 1280×720; keeps file sizes reasonable for
|
||||
// chat composer use and well within the 3-use-case binding
|
||||
// budget for mid-range Android devices.
|
||||
.setQualitySelector(QualitySelector.from(Quality.HD))
|
||||
.build()
|
||||
|
||||
val useCase: androidx.camera.video.VideoCapture<Recorder> =
|
||||
androidx.camera.video.VideoCapture.Builder(recorder)
|
||||
// Raw sensor capture — selfie videos are "as others see
|
||||
// you"; the preview-only mirror lives in UxCameraPreview.
|
||||
.setMirrorMode(MirrorMode.MIRROR_MODE_OFF)
|
||||
.build()
|
||||
|
||||
private var recording: Recording? = null
|
||||
private var stopCompletion: ((Result<String>) -> Unit)? = null
|
||||
private var outFile: File? = null
|
||||
|
||||
val isRecording: Boolean get() = recording != null
|
||||
|
||||
/// Begin recording into a UUID-named MP4 in [context]'s cache dir.
|
||||
/// [snapshotRotation] is a `Surface.ROTATION_*`. Audio is enabled
|
||||
/// only when [enableAudio] AND the RECORD_AUDIO permission is
|
||||
/// already granted — calling `withAudioEnabled()` without the
|
||||
/// permission throws `SecurityException`.
|
||||
///
|
||||
/// [onStartResult] fires synchronously after `Recording.start()`
|
||||
/// returns (with success) — CameraX's `Recording` is "live" by
|
||||
/// then; the asynchronous Start/Status/Finalize events still flow
|
||||
/// to our listener but the caller has already moved on. Any
|
||||
/// failure raised by `start()` is surfaced as a failed result.
|
||||
/// The eventual Finalize event flows to the [stop] completion.
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(
|
||||
context: Context,
|
||||
enableAudio: Boolean,
|
||||
snapshotRotation: Int,
|
||||
onStartResult: (Result<Unit>) -> Unit,
|
||||
) {
|
||||
if (recording != null) {
|
||||
onStartResult(Result.failure(
|
||||
IllegalStateException("Recording already in flight"),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
useCase.targetRotation = snapshotRotation
|
||||
|
||||
val file = File(context.cacheDir, "ux_camera_${UUID.randomUUID()}.mp4")
|
||||
outFile = file
|
||||
val options = FileOutputOptions.Builder(file).build()
|
||||
|
||||
val audioGranted = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
var pending = recorder.prepareRecording(context, options)
|
||||
if (enableAudio && audioGranted) {
|
||||
pending = pending.withAudioEnabled()
|
||||
}
|
||||
|
||||
try {
|
||||
recording = pending.start(
|
||||
ContextCompat.getMainExecutor(context),
|
||||
) { event ->
|
||||
if (event is VideoRecordEvent.Finalize) {
|
||||
val cb = stopCompletion
|
||||
stopCompletion = null
|
||||
recording = null
|
||||
val target = outFile
|
||||
outFile = null
|
||||
if (event.hasError()) {
|
||||
cb?.invoke(Result.failure(
|
||||
RuntimeException(
|
||||
"VideoRecordEvent.Finalize error " +
|
||||
"${event.error}: ${event.cause?.localizedMessage}",
|
||||
),
|
||||
))
|
||||
} else {
|
||||
cb?.invoke(Result.success(target?.absolutePath ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
onStartResult(Result.success(Unit))
|
||||
} catch (t: Throwable) {
|
||||
recording = null
|
||||
outFile = null
|
||||
onStartResult(Result.failure(t))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the in-flight recording. [onResult] fires when the
|
||||
/// Finalize event lands — typically within ~100–300 ms of the
|
||||
/// stop call. If no recording is in flight, fires failure.
|
||||
fun stop(onResult: (Result<String>) -> Unit) {
|
||||
val r = recording
|
||||
if (r == null) {
|
||||
onResult(Result.failure(IllegalStateException("No recording in flight")))
|
||||
return
|
||||
}
|
||||
// Stash the completion BEFORE telling the recorder to stop —
|
||||
// the Finalize callback can fire before `stop()` returns on
|
||||
// fast hardware.
|
||||
stopCompletion = onResult
|
||||
r.stop()
|
||||
}
|
||||
|
||||
/// Drop any in-flight recording without delivering its file.
|
||||
/// Used by [CameraInstance.dispose] when the instance is torn
|
||||
/// down mid-recording — mirrors the iOS path's `recorder.cancel()`.
|
||||
fun cancel() {
|
||||
val r = recording
|
||||
if (r != null) {
|
||||
recording = null
|
||||
r.stop()
|
||||
}
|
||||
stopCompletion?.invoke(Result.failure(
|
||||
RuntimeException("Recording cancelled"),
|
||||
))
|
||||
stopCompletion = null
|
||||
outFile?.delete()
|
||||
outFile = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user