Last active
April 23, 2025 15:15
-
-
Save padmalcom/39a3cdd9d2d2e4d905804a600480038b to your computer and use it in GitHub Desktop.
Recording videos in iOS in a Kotlin Multiplatform project
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Remember to request permissions for picture gallery, image capture and audio recording | |
@OptIn(ExperimentalForeignApi::class) | |
@Composable | |
fun VideoScreen() { | |
var isRecording by remember { mutableStateOf(false) } | |
val output = remember { AVCaptureMovieFileOutput() } | |
var device = remember { | |
AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo).firstOrNull { device -> | |
(device as AVCaptureDevice).position == AVCaptureDevicePositionBack | |
}!! as AVCaptureDevice | |
} | |
var input = remember { AVCaptureDeviceInput.deviceInputWithDevice(device, null) as AVCaptureDeviceInput } | |
val session = remember { AVCaptureSession() } | |
session.addInput(input) | |
session.addOutput(output) | |
val previewLayer = remember { AVCaptureVideoPreviewLayer(session = session) } | |
previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill | |
DisposableEffect(Unit) { | |
onDispose { | |
if (session.isRunning()) { | |
session.stopRunning() | |
} | |
} | |
} | |
Scaffold( | |
modifier = Modifier.fillMaxSize(), | |
topBar = {}, | |
bottomBar = { | |
VideoMenu( | |
isRecording, | |
onCameraToggle = { | |
if (!isRecording) { | |
device = if (device.position == AVCaptureDevicePositionBack) { | |
AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo) | |
.firstOrNull { device -> | |
(device as AVCaptureDevice).position == AVCaptureDevicePositionFront | |
}!! as AVCaptureDevice | |
} else { | |
AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo) | |
.firstOrNull { device -> | |
(device as AVCaptureDevice).position == AVCaptureDevicePositionBack | |
}!! as AVCaptureDevice | |
} | |
session.removeInput(input) | |
input = AVCaptureDeviceInput.deviceInputWithDevice( | |
device, | |
null | |
) as AVCaptureDeviceInput | |
session.addInput(input) | |
} | |
}, | |
onRecordClick = { | |
isRecording = !isRecording | |
if (output.isRecording()) { | |
output.stopRecording() | |
} else { | |
val ts = Clock.System.now().epochSeconds.toString() | |
val outputPath = "${NSTemporaryDirectory()}$ts.mov" | |
val outputURL = NSURL.fileURLWithPath(outputPath) | |
output.startRecordingToOutputFileURL( | |
outputURL, | |
recordingDelegate = object : NSObject(), | |
AVCaptureFileOutputRecordingDelegateProtocol { | |
override fun captureOutput( | |
output: AVCaptureFileOutput, | |
didFinishRecordingToOutputFileAtURL: NSURL, | |
fromConnections: List<*>, | |
error: NSError? | |
) { | |
// handle captured video in didFinishRecordingToOutputFileAtURL.path | |
} | |
} | |
) | |
} | |
} | |
) | |
}, | |
content = { padding -> | |
UIKitView( | |
modifier = Modifier.fillMaxSize().background(color = Color.Black).padding(padding), | |
factory = { | |
val container = object : UIView(frame = CGRectZero.readValue()) { | |
override fun layoutSubviews() { | |
CATransaction.begin() | |
CATransaction.setValue(true, kCATransactionDisableActions) | |
layer.setFrame(frame) | |
previewLayer.setFrame(frame) | |
CATransaction.commit() | |
} | |
} | |
container.layer.addSublayer(previewLayer) | |
previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill | |
session.startRunning() | |
container | |
} | |
) | |
} | |
) | |
} | |
@Composable | |
fun VideoMenu( | |
isRecording: Boolean, | |
onCameraToggle: () -> Unit, | |
onRecordClick: () -> Unit | |
) { | |
val scope = rememberCoroutineScope() | |
val galleryManager = rememberVideoGalleryManager { | |
scope.launch { | |
withContext(Dispatchers.Default) { | |
// handle choosen video in it | |
} | |
} | |
} | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(color = Color.Black) | |
.padding(16.dp), | |
horizontalArrangement = Arrangement.SpaceAround, | |
verticalAlignment = Alignment.Bottom | |
) { | |
Column( | |
modifier = Modifier.fillMaxWidth() | |
) { | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
horizontalArrangement = Arrangement.SpaceAround | |
) { | |
FloatingActionButton( | |
onClick = { | |
if (!isRecording) { | |
scope.launch { | |
galleryManager.launch() | |
} | |
} | |
}, | |
shape = CircleShape, | |
containerColor = Color.DarkGray | |
) { | |
Icon( | |
imageVector = Icons.Outlined.Photo, | |
contentDescription = null, | |
tint = Color.White | |
) | |
} | |
FloatingActionButton( | |
onClick = { | |
onRecordClick() | |
}, | |
shape = CircleShape, | |
containerColor = Color.Red, | |
modifier = Modifier.border( | |
width = 4.dp, | |
color = Color.LightGray, | |
shape = CircleShape | |
) | |
) { | |
if (isRecording) { | |
Icon( | |
imageVector = Icons.Default.Stop, | |
contentDescription = "Stop recording", | |
tint = Color.White | |
) | |
} | |
} | |
FloatingActionButton( | |
onClick = { | |
onCameraToggle() | |
}, | |
shape = CircleShape, | |
containerColor = Color.DarkGray | |
) { | |
Icon( | |
imageVector = Icons.Default.Cameraswitch, | |
contentDescription = null, | |
tint = Color.White | |
) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun rememberVideoGalleryManager(onResult: (NSURL?) -> Unit): VideoGalleryManager { | |
val moviePicker = UIImagePickerController() | |
val galleryDelegate = remember { | |
object : NSObject(), UIImagePickerControllerDelegateProtocol, | |
UINavigationControllerDelegateProtocol { | |
override fun imagePickerController( | |
picker: UIImagePickerController, didFinishPickingMediaWithInfo: Map<Any?, *> | |
) { | |
val movieUrl = didFinishPickingMediaWithInfo.getValue( | |
UIImagePickerControllerImageURL | |
) as? NSURL | |
onResult.invoke(movieUrl) | |
picker.dismissViewControllerAnimated(true, null) | |
} | |
} | |
} | |
return remember { | |
VideoGalleryManager { | |
moviePicker.setSourceType(UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypePhotoLibrary) | |
moviePicker.setMediaTypes(listOf("public.movie")) | |
moviePicker.setAllowsEditing(true) | |
moviePicker.setDelegate(galleryDelegate) | |
UIApplication.sharedApplication.keyWindow?.rootViewController?.presentViewController( | |
moviePicker, true, null | |
) | |
} | |
} | |
} | |
class VideoGalleryManager(private val onLaunch: () -> Unit) { | |
fun launch() { | |
onLaunch() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment