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 lifecycleOwner = CustomLifecycleOwner()
|
||||||
private val previewSink = PreviewSink(registry, context)
|
private val previewSink = PreviewSink(registry, context)
|
||||||
private val photo = PhotoCapture()
|
private val photo = PhotoCapture()
|
||||||
|
private val video = VideoCapture()
|
||||||
private val orientation = DeviceOrientationBridge(context)
|
private val orientation = DeviceOrientationBridge(context)
|
||||||
|
|
||||||
private var cameraProvider: ProcessCameraProvider? = null
|
private var cameraProvider: ProcessCameraProvider? = null
|
||||||
@@ -113,6 +114,10 @@ class CameraInstance(
|
|||||||
fun dispose() {
|
fun dispose() {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
disposed = true
|
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()
|
orientation.stop()
|
||||||
cameraProvider?.unbindAll()
|
cameraProvider?.unbindAll()
|
||||||
cameraProvider = null
|
cameraProvider = null
|
||||||
@@ -150,6 +155,26 @@ class CameraInstance(
|
|||||||
photo.take(context, snapshotRotation, onResult)
|
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) {
|
private fun bind(provider: ProcessCameraProvider) {
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
|
|
||||||
@@ -167,11 +192,17 @@ class CameraInstance(
|
|||||||
.also { it.setSurfaceProvider(previewSink::provideSurface) }
|
.also { it.setSurfaceProvider(previewSink::provideSurface) }
|
||||||
this.preview = preview
|
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(
|
camera = provider.bindToLifecycle(
|
||||||
lifecycleOwner,
|
lifecycleOwner,
|
||||||
selector,
|
selector,
|
||||||
preview,
|
preview,
|
||||||
photo.useCase,
|
photo.useCase,
|
||||||
|
video.useCase,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,12 +135,8 @@ class CameraPlugin :
|
|||||||
"lockCaptureOrientation" -> result.success(null) // preview always portrait
|
"lockCaptureOrientation" -> result.success(null) // preview always portrait
|
||||||
"unlockCaptureOrientation" -> result.success(null)
|
"unlockCaptureOrientation" -> result.success(null)
|
||||||
"takePicture" -> handleTakePicture(call, result)
|
"takePicture" -> handleTakePicture(call, result)
|
||||||
"startVideoRecording" -> result.error(
|
"startVideoRecording" -> handleStartVideo(call, result)
|
||||||
"unsupported_format", "video recording not yet on Android", null,
|
"stopVideoRecording" -> handleStopVideo(call, result)
|
||||||
)
|
|
||||||
"stopVideoRecording" -> result.error(
|
|
||||||
"unsupported_format", "video recording not yet on Android", null,
|
|
||||||
)
|
|
||||||
"audioPermissionStatus" -> result.success(isAudioGranted())
|
"audioPermissionStatus" -> result.success(isAudioGranted())
|
||||||
"openSettings" -> handleOpenSettings(result)
|
"openSettings" -> handleOpenSettings(result)
|
||||||
else -> result.notImplemented()
|
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) {
|
private fun handleOpenSettings(result: MethodChannel.Result) {
|
||||||
val act = activity
|
val act = activity
|
||||||
?: return result.error("no_activity", "plugin not attached", null)
|
?: 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