package com.margelo.nitro.com.mediatoolkit import androidx.media3.common.util.UnstableApi import com.margelo.nitro.NitroModules import com.margelo.nitro.com.mediatoolkit.CompressImageOptions import com.margelo.nitro.com.mediatoolkit.CompressVideoOptions import com.margelo.nitro.com.mediatoolkit.ConcatResult import com.margelo.nitro.com.mediatoolkit.CropOptions import com.margelo.nitro.com.mediatoolkit.HybridMediaToolkitSpec import com.margelo.nitro.com.mediatoolkit.MediaResult import com.margelo.nitro.com.mediatoolkit.TrimOptions import com.margelo.nitro.com.mediatoolkit.FlipOptions import com.margelo.nitro.com.mediatoolkit.ProcessImageOptions import com.margelo.nitro.com.mediatoolkit.ProcessVideoOptions import com.margelo.nitro.com.mediatoolkit.RotateOptions import com.margelo.nitro.com.mediatoolkit.SpeedOptions import com.margelo.nitro.com.mediatoolkit.ExtractAudioOptions import com.margelo.nitro.com.mediatoolkit.GeneratePreviewOptions import com.margelo.nitro.com.mediatoolkit.SplitImageOptions import com.margelo.nitro.com.mediatoolkit.ThumbnailOptions import com.margelo.nitro.com.mediatoolkit.ThumbnailResult import com.margelo.nitro.com.mediatoolkit.TrimAndCropOptions import com.margelo.nitro.com.mediatoolkit.VideoCropOptions import com.margelo.nitro.com.mediatoolkit.MediaMetadata import com.margelo.nitro.com.mediatoolkit.LocationData import com.margelo.nitro.core.Promise import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import android.media.ExifInterface import android.media.MediaMetadataRetriever import android.net.Uri import java.io.File /** * Nitro HybridObject implementation for MediaToolkit on Android. * Extends HybridMediaToolkitSpec generated by nitrogen. * ImageProcessor and VideoProcessor are kept unchanged — only this glue layer is rewritten. */ @UnstableApi class HybridMediaToolkit : HybridMediaToolkitSpec() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ctx get() = requireNotNull(NitroModules.applicationContext) { "ReactApplicationContext not available — is NitroModules installed?" } // ─── Image ──────────────────────────────────────────────────────────────── override fun cropImage(uri: String, options: CropOptions): Promise { return Promise.async(scope) { val raw = ImageProcessor.cropImage( ctx, uri, options.x, options.y, options.width, options.height, options.cornerRadius ?: 0.0, options.outputPath ) raw.toMediaResult() } } override fun compressImage(uri: String, options: CompressImageOptions): Promise { return Promise.async(scope) { val raw = ImageProcessor.compressImage( ctx, uri, (options.quality ?: 80.0).toInt(), (options.maxWidth ?: 0.0).toInt(), (options.maxHeight ?: 0.0).toInt(), options.format ?: "jpeg", options.cornerRadius ?: 0.0, options.outputPath ) raw.toMediaResult() } } override fun splitImage(uri: String, options: SplitImageOptions): Promise> { return Promise.async(scope) { ImageProcessor.splitImage( ctx, uri, options.rows.toInt(), options.columns.toInt(), options.format, (options.quality ?: 100.0).toInt(), options.outputDir, options.prefix ).map { it.toMediaResult() }.toTypedArray() } } override fun flipImage(uri: String, options: FlipOptions): Promise { return Promise.async(scope) { val raw = ImageProcessor.flipImage( ctx, uri, options.direction, options.cornerRadius ?: 0.0, options.outputPath ) raw.toMediaResult() } } override fun rotateImage(uri: String, options: RotateOptions): Promise { return Promise.async(scope) { val raw = ImageProcessor.rotateImage( ctx, uri, options.degrees, options.cornerRadius ?: 0.0, options.outputPath ) raw.toMediaResult() } } override fun processImage(uri: String, options: ProcessImageOptions): Promise { return Promise.async(scope) { val raw = ImageProcessor.processImage( ctx, uri, options.cropX ?: 0.0, options.cropY ?: 0.0, options.cropWidth ?: 0.0, options.cropHeight ?: 0.0, options.flip, options.rotation ?: 0.0, options.lutUri, options.cornerRadius ?: 0.0, options.outputPath ) raw.toMediaResult() } } // ─── Video ──────────────────────────────────────────────────────────────── override fun trimVideo(uri: String, options: TrimOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.trimVideo( ctx, uri, options.startTime.toLong(), options.endTime.toLong(), options.outputPath ) { /* progress ignored — Nitro doesn't use EventEmitter */ } raw.toMediaResult() } } override fun cropVideo(uri: String, options: VideoCropOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.cropVideo( ctx, uri, options.x.toFloat(), options.y.toFloat(), options.width.toFloat(), options.height.toFloat(), options.outputPath ) { /* progress ignored */ } raw.toMediaResult() } } override fun compressVideo(uri: String, options: CompressVideoOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.compressVideo( ctx, uri, options.quality ?: "medium", (options.bitrate ?: 0.0).toInt(), // 0 = use quality preset (mirrors iOS behaviour) options.targetSizeInMB ?: 0.0, options.minResolution ?: 0.0, (options.width ?: 0.0).toInt(), options.muteAudio ?: false, options.outputPath ) { /* progress ignored */ } raw.toMediaResult() } } override fun trimAndCropVideo(uri: String, options: TrimAndCropOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.trimAndCropVideo( ctx, uri, options.startTime.toLong(), options.endTime.toLong(), options.x.toFloat(), options.y.toFloat(), options.width.toFloat(), options.height.toFloat(), options.outputPath ) { /* progress ignored */ } raw.toMediaResult() } } override fun flipVideo(uri: String, options: FlipOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.flipVideo( ctx, uri, options.direction, options.outputPath ) { /* progress ignored */ } raw.toMediaResult() } } override fun rotateVideo(uri: String, options: RotateOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.rotateVideo( ctx, uri, options.degrees, options.outputPath ) { /* progress ignored */ } raw.toMediaResult() } } override fun processVideo(uri: String, options: ProcessVideoOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.processVideo( ctx, uri, (options.startTime ?: 0.0).toLong(), (options.endTime ?: 0.0).toLong(), (options.cropX ?: 0.0).toFloat(), (options.cropY ?: 0.0).toFloat(), (options.cropWidth ?: 0.0).toFloat(), (options.cropHeight ?: 0.0).toFloat(), options.flip, options.rotation ?: 0.0, options.lutUri, options.outputPath ) { /* progress ignored */ } raw.toMediaResult() } } override fun changeVideoSpeed(uri: String, options: SpeedOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.changeVideoSpeed( ctx, uri, options.speed, options.outputPath ) { /* progress ignored */ } raw.toMediaResult() } } override fun extractAudio(uri: String, options: ExtractAudioOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.extractAudio( ctx, uri, options.outputPath ) { /* progress ignored */ } // Override mime if not already set, since we extracted audio val result = raw.toMediaResult() MediaResult( uri = result.uri, size = result.size, width = result.width, height = result.height, duration = result.duration, mime = "audio/m4a" ) } } override fun concatVideos(clipPaths: Array, outputPath: String): Promise { return Promise.async(scope) { val durationSec = VideoProcessor.concatVideos(ctx, clipPaths, outputPath) ConcatResult(durationSec = durationSec) } } override fun generateVideoPreview(uri: String, options: GeneratePreviewOptions): Promise { return Promise.async(scope) { val raw = VideoProcessor.generateVideoPreview( ctx, uri, options.fps?.toInt() ?: 5, options.durationMs?.toInt() ?: 3000, options.maxWidth?.toInt() ?: 0, options.quality?.toInt() ?: 80, options.outputPath ) raw.toMediaResult() } } override fun getThumbnail(uri: String, options: ThumbnailOptions?): Promise { return Promise.async(scope) { val timeMs = options?.timeMs?.toLong() ?: 0L val quality = options?.quality?.toInt() ?: 80 val maxWidth = options?.maxWidth?.toInt() ?: 0 val cornerRadius = options?.cornerRadius ?: 0.0 val raw = VideoProcessor.getThumbnail(ctx, uri, timeMs, quality, maxWidth, cornerRadius, options?.outputPath) ThumbnailResult( uri = raw["uri"] as? String ?: "", size = (raw["size"] as? Number)?.toDouble() ?: 0.0, width = (raw["width"] as? Number)?.toDouble() ?: 0.0, height = (raw["height"] as? Number)?.toDouble() ?: 0.0, duration = (raw["duration"] as? Number)?.toDouble() ?: 0.0 ) } } override fun getMediaMetadata(uri: String): Promise { return Promise.async(scope) { val parsedUri = if (uri.startsWith("file://") || uri.startsWith("content://")) Uri.parse(uri) else Uri.fromFile(File(uri)) val isVideo = uri.lowercase().endsWith(".mp4") || uri.lowercase().endsWith(".mov") || uri.lowercase().endsWith(".m4a") if (isVideo) { val retriever = MediaMetadataRetriever() try { retriever.setDataSource(ctx, parsedUri) val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toDoubleOrNull() ?: 0.0 val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toDoubleOrNull() ?: 0.0 val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0 var finalW = width var finalH = height if (rotation == 90 || rotation == 270) { finalW = height finalH = width } val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0 val mime = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) ?: "video/mp4" val datetime = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE) ?: "" val locationStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) var locationData: LocationData? = null if (!locationStr.isNullOrEmpty()) { val locMatch = Regex("([+-][0-9.]+)([+-][0-9.]+)").find(locationStr) if (locMatch != null) { locationData = LocationData(locMatch.groupValues[1].toDouble(), locMatch.groupValues[2].toDouble()) } } val file = File(parsedUri.path ?: "") val size = if (file.exists()) file.length().toDouble() else 0.0 MediaMetadata( type = "video", width = finalW, height = finalH, size = size, duration = duration, mime = mime, make = null, model = null, datetime = datetime, location = locationData, aperture = null, exposureTime = null, iso = null, focalLength = null ) } finally { retriever.release() } } else { val stream = ctx.contentResolver.openInputStream(parsedUri) ?: throw Exception("Cannot open stream for URI: $uri") stream.use { s -> val exif = ExifInterface(s) var width = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0).toDouble() var height = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0).toDouble() val rotation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) if (rotation == ExifInterface.ORIENTATION_ROTATE_90 || rotation == ExifInterface.ORIENTATION_ROTATE_270) { val tmp = width width = height height = tmp } val make = exif.getAttribute(ExifInterface.TAG_MAKE) ?: "" val model = exif.getAttribute(ExifInterface.TAG_MODEL) ?: "" val datetime = exif.getAttribute(ExifInterface.TAG_DATETIME) ?: "" val latLong = FloatArray(2) var locationData: LocationData? = null if (exif.getLatLong(latLong)) { locationData = LocationData(latLong[0].toDouble(), latLong[1].toDouble()) } val aperture = exif.getAttributeDouble(ExifInterface.TAG_F_NUMBER, 0.0) val exposure = exif.getAttributeDouble(ExifInterface.TAG_EXPOSURE_TIME, 0.0) val isoStr = exif.getAttribute("ISOSpeedRatings") val iso = isoStr?.toDoubleOrNull() ?: 0.0 val focalLength = exif.getAttributeDouble(ExifInterface.TAG_FOCAL_LENGTH, 0.0) val file = File(parsedUri.path ?: "") val size = if (file.exists()) file.length().toDouble() else 0.0 MediaMetadata( type = "image", width = width, height = height, size = size, duration = 0.0, mime = "image/jpeg", make = make, model = model, datetime = datetime, location = locationData, aperture = if (aperture > 0) aperture else null, exposureTime = if (exposure > 0) exposure else null, iso = if (iso > 0) iso else null, focalLength = if (focalLength > 0) focalLength else null ) } } } } } // ─── Helpers ────────────────────────────────────────────────────────────────── private fun Map.toMediaResult(): MediaResult { return MediaResult( uri = this["uri"] as? String ?: "", size = (this["size"] as? Number)?.toDouble() ?: 0.0, width = (this["width"] as? Number)?.toDouble() ?: 0.0, height = (this["height"] as? Number)?.toDouble() ?: 0.0, duration = (this["duration"]as? Number)?.toDouble() ?: 0.0, mime = this["mime"] as? String ?: "" ) }