tauri-plugin-video 0.3.0

Native video playback for Tauri using platform media engines
Documentation
package io.github.taurivideo.plugin

import android.content.Context
import android.net.Uri
import android.os.SystemClock
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min

/** Explicit LibVLC backend that renders directly into a SurfaceView. */
internal class VlcPlayer(
    context: Context,
    private val videoLayout: VLCVideoLayout,
    private val config: VlcPlayerConfig,
) {
    private val libVlc: LibVLC
    private val player: MediaPlayer
    private val trackTargets = HashMap<Int, VlcTrackTarget>()
    private var bufferingPercent = 0f
    private var lastPresentedFrames = 0L
    private var lastFrameSampleNs = 0L
    private var measuredFps = 0.0
    private var startPositionApplied = false
    private var playingEventReceived = false
    private var videoOutputReceived = false
    private var ended = false
    private var cachedTracks: List<VlcTrack> = emptyList()

    init {
        // LibVLC appends its selected audio output and display chroma to this
        // list during construction, so Kotlin's immutable buildList result is
        // not accepted by the Java API.
        val options = ArrayList(buildLibVlcOptions(config))
        val createdLibVlc = LibVLC(context.applicationContext, options)
        try {
            player = MediaPlayer(createdLibVlc)
            libVlc = createdLibVlc
        } catch (error: Throwable) {
            createdLibVlc.release()
            throw error
        }
    }

    fun open(onRenderable: () -> Unit, onError: (String) -> Unit) {
        val completed = AtomicBoolean(false)
        try {
            player.attachViews(videoLayout, null, true, false)
            player.setVideoScale(MediaPlayer.ScaleType.SURFACE_BEST_FIT)
            // LibVLC's VLCObject dispatches public events through a Handler on the main
            // looper. VideoPlugin invokes snapshots and controls on that same looper, so
            // the cached track list and target map remain thread-confined without locks.
            player.setEventListener { event ->
                when (event.type) {
                    MediaPlayer.Event.Buffering -> bufferingPercent = event.buffering.coerceIn(0f, 100f)
                    MediaPlayer.Event.Playing -> {
                        ended = false
                        playingEventReceived = true
                        applyStartPosition()
                        resolveWhenRenderable(completed, onRenderable)
                    }
                    MediaPlayer.Event.Vout -> {
                        videoOutputReceived = event.voutCount > 0
                        refreshTracks()
                        resolveWhenRenderable(completed, onRenderable)
                    }
                    MediaPlayer.Event.MediaChanged,
                    MediaPlayer.Event.ESAdded,
                    MediaPlayer.Event.ESDeleted,
                    MediaPlayer.Event.ESSelected -> refreshTracks()
                    MediaPlayer.Event.EncounteredError -> {
                        if (completed.compareAndSet(false, true)) {
                            onError("LibVLC could not decode or read the stream")
                        }
                    }
                    MediaPlayer.Event.EndReached -> ended = true
                }
            }
            val media = Media(libVlc, Uri.parse(config.uri)).apply {
                setHWDecoderEnabled(true, true)
                config.networkCachingMs?.let { addOption(":network-caching=$it") }
                addOption(":clock-jitter=0")
                addOption(":clock-synchro=0")
                config.userAgent?.takeIf(String::isNotBlank)?.let {
                    addOption(":http-user-agent=$it")
                }
                config.referrer?.takeIf(String::isNotBlank)?.let {
                    addOption(":http-referrer=$it")
                }
                config.cookies?.takeIf(String::isNotBlank)?.let {
                    addOption(":http-cookie=$it")
                    addOption(":http-forward-cookies")
                }
            }
            player.media = media
            media.release()
            player.volume = (config.initialVolume.coerceIn(0f, 1f) * 100f).toInt()
            player.play()
        } catch (error: Throwable) {
            if (completed.compareAndSet(false, true)) {
                onError(error.message ?: "LibVLC alternative backend failed to start")
            }
        }
    }

    private fun resolveWhenRenderable(completed: AtomicBoolean, onRenderable: () -> Unit) {
        if (!playingEventReceived || !videoOutputReceived || !completed.compareAndSet(false, true)) return
        refreshTracks()
        if (!config.autoplay) player.pause()
        onRenderable()
    }

    private fun applyStartPosition() {
        if (startPositionApplied) return
        startPositionApplied = true
        config.startPositionMs?.takeIf { it > 0L }?.let { player.setTime(it, true) }
    }

    fun play() {
        if (ended) {
            ended = false
            player.setTime(0L, true)
        }
        player.play()
    }

    fun pause() = player.pause()

    fun seekTo(positionMs: Long) {
        ended = false
        player.setTime(positionMs.coerceAtLeast(0L), true)
    }

    fun setVolume(value: Float) {
        player.volume = (value.coerceIn(0f, 1f) * 100f).toInt()
    }

    fun selectTrack(target: Int) {
        when (val track = trackTargets[target] ?: return) {
            is VlcTrackTarget.Video -> player.setVideoTrack(track.id)
            is VlcTrackTarget.Audio -> player.setAudioTrack(track.id)
            is VlcTrackTarget.Subtitle -> player.setSpuTrack(track.id)
        }
        refreshTracks()
    }

    fun deselectTrack(target: Int) {
        when (trackTargets[target] ?: return) {
            is VlcTrackTarget.Video -> player.setVideoTrackEnabled(false)
            is VlcTrackTarget.Audio -> player.setAudioTrack(-1)
            is VlcTrackTarget.Subtitle -> player.setSpuTrack(-1)
        }
        refreshTracks()
    }

    fun setFit(mode: String) {
        player.setVideoScale(
            when (mode) {
                "crop" -> MediaPlayer.ScaleType.SURFACE_FIT_SCREEN
                "stretch" -> MediaPlayer.ScaleType.SURFACE_FILL
                else -> MediaPlayer.ScaleType.SURFACE_BEST_FIT
            }
        )
    }

    fun setZoom(value: Float) {
        val zoom = value.coerceIn(1f, 2f)
        videoLayout.scaleX = zoom
        videoLayout.scaleY = zoom
    }

    fun snapshot(container: String): VlcSnapshot {
        val media = player.media
        val stats = try {
            media?.stats
        } finally {
            media?.release()
        }
        val presented = stats?.displayedPictures?.toLong()?.coerceAtLeast(0L) ?: 0L
        val dropped = stats?.lostPictures?.toLong()?.coerceAtLeast(0L) ?: 0L
        updateMeasuredFps(presented)
        val durationMs = player.length.coerceAtLeast(0L)
        val currentMs = player.time.coerceAtLeast(0L)
        val estimatedReserveMs = ((config.networkCachingMs ?: 0) * (bufferingPercent / 100f)).toLong()
        val bufferedMs = if (durationMs > 0L) min(durationMs, currentMs + estimatedReserveMs)
            else currentMs + estimatedReserveMs
        val video = player.currentVideoTrack
        return VlcSnapshot(
            durationSeconds = durationMs / 1000.0,
            currentTimeSeconds = currentMs / 1000.0,
            bufferedSeconds = bufferedMs / 1000.0,
            playing = player.isPlaying,
            videoWidth = video?.width ?: 0,
            videoHeight = video?.height ?: 0,
            presentedFrames = presented,
            droppedFrames = dropped,
            measuredFps = measuredFps,
            hardwareBackend = "android-libvlc:hardware:surface-view",
            encodedBytesBuffered = 0L,
            averageFrameProcessingUs = 0.0,
            container = container,
            tracks = cachedTracks,
        )
    }

    private fun updateMeasuredFps(presented: Long) {
        val nowNs = SystemClock.elapsedRealtimeNanos()
        if (lastFrameSampleNs == 0L) {
            lastFrameSampleNs = nowNs
            lastPresentedFrames = presented
        } else if (nowNs - lastFrameSampleNs >= 500_000_000L) {
            val elapsedSeconds = (nowNs - lastFrameSampleNs) / 1_000_000_000.0
            measuredFps = (presented - lastPresentedFrames).coerceAtLeast(0L) / elapsedSeconds
            lastFrameSampleNs = nowNs
            lastPresentedFrames = presented
        }
    }

    private fun refreshTracks() {
        val descriptions = buildList {
            player.videoTracks?.filter { it.id >= 0 }?.forEach { add(Triple("video", it.id, it.name)) }
            player.audioTracks?.filter { it.id >= 0 }?.forEach { add(Triple("audio", it.id, it.name)) }
            player.spuTracks?.filter { it.id >= 0 }?.forEach { add(Triple("subtitle", it.id, it.name)) }
        }
        val media = player.media
        val mediaTracks = try {
            buildMap<Int, IMedia.Track> {
                if (media != null) {
                    for (index in 0 until media.trackCount) {
                        media.getTrack(index)?.let { put(it.id, it) }
                    }
                }
            }
        } finally {
            media?.release()
        }
        trackTargets.clear()
        cachedTracks = descriptions.mapIndexed { target, (kind, id, name) ->
            trackTargets[target] = when (kind) {
                "video" -> VlcTrackTarget.Video(id)
                "audio" -> VlcTrackTarget.Audio(id)
                else -> VlcTrackTarget.Subtitle(id)
            }
            val track = mediaTracks[id]
            val selected = when (kind) {
                "video" -> player.videoTrack == id
                "audio" -> player.audioTrack == id
                else -> player.spuTrack == id
            }
            VlcTrack(
                id = target,
                kind = kind,
                language = track?.language ?: "und",
                label = name?.takeIf(String::isNotBlank) ?: track?.description ?: kind,
                codec = track?.codec ?: track?.originalCodec ?: "",
                selected = selected,
            )
        }
    }

    fun release() {
        runCatching { player.stop() }
        runCatching { player.detachViews() }
        player.release()
        libVlc.release()
        trackTargets.clear()
        cachedTracks = emptyList()
    }
}

internal data class VlcPlayerConfig(
    val uri: String,
    val autoplay: Boolean,
    val initialVolume: Float,
    val startPositionMs: Long?,
    val networkCachingMs: Int?,
    val userAgent: String?,
    val referrer: String?,
    val cookies: String?,
    val caFile: File?,
)

internal data class VlcSnapshot(
    val durationSeconds: Double,
    val currentTimeSeconds: Double,
    val bufferedSeconds: Double,
    val playing: Boolean,
    val videoWidth: Int,
    val videoHeight: Int,
    val presentedFrames: Long,
    val droppedFrames: Long,
    val measuredFps: Double,
    val hardwareBackend: String,
    val encodedBytesBuffered: Long,
    val averageFrameProcessingUs: Double,
    val container: String,
    val tracks: List<VlcTrack>,
)

internal data class VlcTrack(
    val id: Int,
    val kind: String,
    val language: String,
    val label: String,
    val codec: String,
    val selected: Boolean,
)

private sealed class VlcTrackTarget(open val id: Int) {
    data class Video(override val id: Int) : VlcTrackTarget(id)
    data class Audio(override val id: Int) : VlcTrackTarget(id)
    data class Subtitle(override val id: Int) : VlcTrackTarget(id)
}

private fun buildLibVlcOptions(config: VlcPlayerConfig): List<String> = buildList {
    add("--no-video-title-show")
    config.networkCachingMs?.let {
        add("--network-caching=$it")
        add("--file-caching=$it")
        add("--live-caching=$it")
    }
    add("--codec=mediacodec_ndk,mediacodec_jni,avcodec,all")
    config.caFile?.parentFile?.let { add("--gnutls-dir-trust=${it.absolutePath}") }
}