Skip to content

Instantly share code, notes, and snippets.

@padmalcom
Last active April 23, 2025 15:15
Show Gist options
  • Save padmalcom/39a3cdd9d2d2e4d905804a600480038b to your computer and use it in GitHub Desktop.
Save padmalcom/39a3cdd9d2d2e4d905804a600480038b to your computer and use it in GitHub Desktop.
Recording videos in iOS in a Kotlin Multiplatform project
// 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