Files
LibreMediaConverter/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt
T
JMR-devandClaude Opus 5 dd2fcf0a19 Rename the project to LibreMediaConverter
Done now rather than later: the application ID is permanent once published --
Play treats a change as an entirely different app -- so this is the last
cheap moment to choose it.

  applicationId / namespace  dev.jasonmross.mediaconverter -> org.libremediaconverter
  source tree                java/dev/jasonmross/mediaconverter -> java/org/libremediaconverter
  gradle project             AndroidMediaConverter -> LibreMediaConverter
  theme                      Theme.MediaConverter -> Theme.LibreMediaConverter
  compose theme              MediaConverterTheme -> LibreMediaConverterTheme
  display name               "Media Converter" -> "LibreMediaConverter"

org.* rather than dev.jasonmross.* because "Libre" signals a project rather
than a personal app, and a project-owned namespace lets maintainership move
later without the identifier contradicting reality.

The source trees moved with git mv so history follows the files instead of
showing 42 deletions beside 42 additions.

Verified after the rename: 66 unit tests, and 40 instrumented tests on an
API 36 emulator, 0 failures. The built APK reports org.libremediaconverter,
and no stale jasonmross, AndroidMediaConverter or MediaConverterTheme
identifiers remain anywhere in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:31:43 -05:00

132 lines
4.8 KiB
Kotlin

package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.HandlerThread
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.transformer.Composition
import androidx.media3.transformer.EditedMediaItem
import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.ProgressHolder
import androidx.media3.transformer.Transformer
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Hardware conversion via Media3 Transformer.
*
* ## Why the HandlerThread
*
* Transformer documents that instances "must be accessed from a single application
* thread", and both [Transformer.start] and [Transformer.cancel] throw
* [IllegalStateException] when called from anywhere else. The thread it binds to is
* whichever one had a Looper when the Builder was constructed — falling back to the
* *main* Looper if the constructing thread has none.
*
* That default is a trap for background execution. A WorkManager `Worker` runs on an
* executor thread with no Looper, so a Transformer built there silently binds to the
* main thread, and the subsequent `start()` from the worker thread throws.
*
* This class therefore owns a dedicated [HandlerThread], passes its Looper explicitly
* via `setLooper`, and marshals every Transformer call onto it. Callers get a plain
* suspending function and never have to think about it.
*/
@UnstableApi
class Media3Engine(private val context: Context) : HardwareTranscoder {
private val thread = HandlerThread("media3-transformer").apply { start() }
private val handler = Handler(thread.looper)
/**
* Transcodes [input] to [output], reporting progress 0..100.
*
* [output] must be a real filesystem path, not a SAF document. Writing through a
* SAF file descriptor breaks MP4 muxing, because faststart needs to seek back and
* rewrite the moov atom, and a SAF fd is not reliably seekable. Callers stage into
* app-private storage and publish afterwards.
*/
override suspend fun transcode(
input: Uri,
output: File,
videoMimeType: String,
onProgress: (Int) -> Unit,
): Unit = suspendCancellableCoroutine { cont ->
handler.post {
val transformer = buildTransformer(videoMimeType, cont)
val item = EditedMediaItem.Builder(MediaItem.fromUri(input)).build()
cont.invokeOnCancellation {
// cancel() has the same single-thread requirement as start().
handler.post { runCatching { transformer.cancel() } }
}
runCatching { transformer.start(item, output.absolutePath) }
.onFailure { cont.resumeWithException(it); return@post }
pollProgress(transformer, cont, onProgress)
}
}
private fun buildTransformer(
videoMimeType: String,
cont: CancellableContinuation<Unit>,
): Transformer = Transformer.Builder(context)
.setLooper(thread.looper)
.setVideoMimeType(videoMimeType)
.addListener(object : Transformer.Listener {
override fun onCompleted(composition: Composition, result: ExportResult) {
if (cont.isActive) cont.resume(Unit)
}
override fun onError(
composition: Composition,
result: ExportResult,
exception: ExportException,
) {
if (cont.isActive) cont.resumeWithException(exception)
}
})
.build()
/**
* Polls export progress on the Transformer's own thread.
*
* Deliberately ~4x/second: the underlying value updates far more often than a UI
* or a notification can usefully consume, and over-frequent notification updates
* will jank the system UI.
*/
private fun pollProgress(
transformer: Transformer,
cont: CancellableContinuation<Unit>,
onProgress: (Int) -> Unit,
) {
val holder = ProgressHolder()
val tick = object : Runnable {
override fun run() {
if (!cont.isActive) return
if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) {
onProgress(holder.progress)
}
handler.postDelayed(this, PROGRESS_INTERVAL_MS)
}
}
handler.postDelayed(tick, PROGRESS_INTERVAL_MS)
}
override fun close() {
thread.quitSafely()
}
private companion object {
const val PROGRESS_INTERVAL_MS = 250L
}
}