package com.nuvio.app.mpv import android.content.Context import android.graphics.SurfaceTexture import android.util.AttributeSet import android.util.Log import android.view.Surface import android.view.TextureView import dev.jdtech.mpv.MPVLib class MPVView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver { companion object { private const val TAG = "MPVView" } private var isMpvInitialized = false private var pendingDataSource: String? = null private var isPaused: Boolean = true private var surface: Surface? = null private var httpHeaders: Map? = null // Event listener for React Native var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null var onEndCallback: (() -> Unit)? = null var onErrorCallback: ((message: String) -> Unit)? = null var onTracksChangedCallback: ((audioTracks: List>, subtitleTracks: List>) -> Unit)? = null init { surfaceTextureListener = this isOpaque = false } override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { Log.d(TAG, "Surface texture available: ${width}x${height}") try { surface = Surface(surfaceTexture) MPVLib.create(context.applicationContext) initOptions() MPVLib.init() MPVLib.attachSurface(surface!!) MPVLib.addObserver(this) MPVLib.setPropertyString("android-surface-size", "${width}x${height}") observeProperties() isMpvInitialized = true // If a data source was set before surface was ready, load it now pendingDataSource?.let { url -> applyHttpHeaders() loadFile(url) pendingDataSource = null } } catch (e: Exception) { Log.e(TAG, "Failed to initialize MPV", e) onErrorCallback?.invoke("MPV initialization failed: ${e.message}") } } override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { Log.d(TAG, "Surface texture size changed: ${width}x${height}") if (isMpvInitialized) { MPVLib.setPropertyString("android-surface-size", "${width}x${height}") } } override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { Log.d(TAG, "Surface texture destroyed") if (isMpvInitialized) { MPVLib.removeObserver(this) MPVLib.detachSurface() MPVLib.destroy() isMpvInitialized = false } surface?.release() surface = null return true } override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { // Called when the SurfaceTexture is updated via updateTexImage() } private fun initOptions() { // Mobile-optimized profile MPVLib.setOptionString("profile", "fast") MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("opengl-es", "yes") // Hardware decoding - use mediacodec-copy to allow subtitle overlay // 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending MPVLib.setOptionString("hwdec", "auto") MPVLib.setOptionString("hwdec-codecs", "all") // Audio output MPVLib.setOptionString("ao", "audiotrack,opensles") // Network caching for streaming MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB MPVLib.setOptionString("cache", "yes") MPVLib.setOptionString("cache-secs", "30") // Network options MPVLib.setOptionString("network-timeout", "60") // 60 second timeout // Subtitle configuration - CRITICAL for Android MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default MPVLib.setOptionString("sub-font-size", "48") // Larger font size for mobile readability MPVLib.setOptionString("sub-pos", "95") // Position at bottom (0-100, 100 = very bottom) MPVLib.setOptionString("sub-color", "#FFFFFFFF") // White color MPVLib.setOptionString("sub-border-size", "3") // Thicker border for readability MPVLib.setOptionString("sub-border-color", "#FF000000") // Black border MPVLib.setOptionString("sub-shadow-offset", "2") // Add shadow for better visibility MPVLib.setOptionString("sub-shadow-color", "#80000000") // Semi-transparent black shadow // Font configuration - point to Android system fonts for all language support MPVLib.setOptionString("osd-fonts-dir", "/system/fonts") MPVLib.setOptionString("sub-fonts-dir", "/system/fonts") MPVLib.setOptionString("sub-font", "Roboto") // Default fallback font // Allow embedded fonts in ASS/SSA but fallback to system fonts MPVLib.setOptionString("embeddedfonts", "yes") // Language/encoding support for various subtitle formats MPVLib.setOptionString("sub-codepage", "auto") // Auto-detect encoding (supports UTF-8, Latin, CJK, etc.) MPVLib.setOptionString("osc", "no") // Disable on screen controller MPVLib.setOptionString("osd-level", "1") // Critical for subtitle rendering on Android GPU // blend-subtitles=no lets the GPU renderer handle subtitle overlay properly MPVLib.setOptionString("blend-subtitles", "no") MPVLib.setOptionString("sub-use-margins", "no") // Use 'scale' to allow ASS styling but with our scale and font overrides // This preserves styled subtitles while having font fallbacks MPVLib.setOptionString("sub-ass-override", "scale") MPVLib.setOptionString("sub-scale", "1.0") MPVLib.setOptionString("sub-fix-timing", "yes") // Fix timing for SRT subtitles // Force subtitle rendering MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track // Disable terminal/input MPVLib.setOptionString("terminal", "no") MPVLib.setOptionString("input-default-bindings", "no") } private fun observeProperties() { // MPV format constants (from MPVLib source) val MPV_FORMAT_NONE = 0 val MPV_FORMAT_FLAG = 3 val MPV_FORMAT_INT64 = 4 val MPV_FORMAT_DOUBLE = 5 MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) MPVLib.observeProperty("duration/full", MPV_FORMAT_DOUBLE) // Use /full for complete HLS duration MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG) MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE) MPVLib.observeProperty("width", MPV_FORMAT_INT64) MPVLib.observeProperty("height", MPV_FORMAT_INT64) MPVLib.observeProperty("track-list", MPV_FORMAT_NONE) // Observe subtitle properties for debugging MPVLib.observeProperty("sid", MPV_FORMAT_INT64) MPVLib.observeProperty("sub-visibility", MPV_FORMAT_FLAG) MPVLib.observeProperty("sub-text", MPV_FORMAT_NONE) } private fun loadFile(url: String) { Log.d(TAG, "Loading file: $url") MPVLib.command(arrayOf("loadfile", url)) } // Public API fun setDataSource(url: String) { if (isMpvInitialized) { // Apply headers before loading the file applyHttpHeaders() loadFile(url) } else { pendingDataSource = url } } fun setHeaders(headers: Map?) { httpHeaders = headers Log.d(TAG, "Headers set: $headers") } private fun applyHttpHeaders() { httpHeaders?.let { headers -> if (headers.isNotEmpty()) { // Format headers for MPV: comma-separated "Key: Value" pairs val headerList = headers.map { (key, value) -> "$key: $value" } val headerString = headerList.joinToString(",") Log.d(TAG, "Applying HTTP headers: $headerString") MPVLib.setOptionString("http-header-fields", headerString) } } } fun setPaused(paused: Boolean) { isPaused = paused if (isMpvInitialized) { MPVLib.setPropertyBoolean("pause", paused) } } fun seekTo(positionSeconds: Double) { Log.d(TAG, "seekTo called: positionSeconds=$positionSeconds, isMpvInitialized=$isMpvInitialized") if (isMpvInitialized) { Log.d(TAG, "Executing MPV seek command: seek $positionSeconds absolute") MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute")) } } fun setSpeed(speed: Double) { if (isMpvInitialized) { MPVLib.setPropertyDouble("speed", speed) } } fun setVolume(volume: Double) { if (isMpvInitialized) { // MPV volume is 0-100 MPVLib.setPropertyDouble("volume", volume * 100.0) } } fun setAudioTrack(trackId: Int) { if (isMpvInitialized) { if (trackId == -1) { MPVLib.setPropertyString("aid", "no") } else { MPVLib.setPropertyInt("aid", trackId) } } } fun setSubtitleTrack(trackId: Int) { Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized") if (isMpvInitialized) { if (trackId == -1) { Log.d(TAG, "Disabling subtitles (sid=no)") MPVLib.setPropertyString("sid", "no") MPVLib.setPropertyString("sub-visibility", "no") } else { Log.d(TAG, "Setting subtitle track to: $trackId") MPVLib.setPropertyInt("sid", trackId) // Ensure subtitles are visible MPVLib.setPropertyString("sub-visibility", "yes") // Debug: Verify the subtitle was set correctly val currentSid = MPVLib.getPropertyInt("sid") val subVisibility = MPVLib.getPropertyString("sub-visibility") val subDelay = MPVLib.getPropertyDouble("sub-delay") val subScale = MPVLib.getPropertyDouble("sub-scale") Log.d(TAG, "After setting - sid=$currentSid, sub-visibility=$subVisibility, sub-delay=$subDelay, sub-scale=$subScale") } } } fun setResizeMode(mode: String) { Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized") if (isMpvInitialized) { when (mode) { "contain" -> { // Letterbox - show entire video with black bars MPVLib.setPropertyDouble("panscan", 0.0) MPVLib.setPropertyString("keepaspect", "yes") } "cover" -> { // Fill/crop - zoom to fill, cropping edges MPVLib.setPropertyDouble("panscan", 1.0) MPVLib.setPropertyString("keepaspect", "yes") } "stretch" -> { // Stretch - disable aspect ratio MPVLib.setPropertyDouble("panscan", 0.0) MPVLib.setPropertyString("keepaspect", "no") } else -> { // Default to contain MPVLib.setPropertyDouble("panscan", 0.0) MPVLib.setPropertyString("keepaspect", "yes") } } } } // MPVLib.EventObserver implementation override fun eventProperty(property: String) { Log.d(TAG, "Property changed: $property") when (property) { "track-list" -> { // Parse track list and notify React Native parseAndSendTracks() } } } private fun parseAndSendTracks() { try { val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 Log.d(TAG, "Track count: $trackCount") val audioTracks = mutableListOf>() val subtitleTracks = mutableListOf>() for (i in 0 until trackCount) { val type = MPVLib.getPropertyString("track-list/$i/type") ?: continue val id = MPVLib.getPropertyInt("track-list/$i/id") ?: continue val title = MPVLib.getPropertyString("track-list/$i/title") ?: "" val lang = MPVLib.getPropertyString("track-list/$i/lang") ?: "" val codec = MPVLib.getPropertyString("track-list/$i/codec") ?: "" val trackName = when { title.isNotEmpty() -> title lang.isNotEmpty() -> lang.uppercase() else -> "Track $id" } val track = mapOf( "id" to id, "name" to trackName, "language" to lang, "codec" to codec ) when (type) { "audio" -> { Log.d(TAG, "Found audio track: $track") audioTracks.add(track) } "sub" -> { Log.d(TAG, "Found subtitle track: $track") subtitleTracks.add(track) } } } Log.d(TAG, "Sending tracks - Audio: ${audioTracks.size}, Subtitles: ${subtitleTracks.size}") onTracksChangedCallback?.invoke(audioTracks, subtitleTracks) } catch (e: Exception) { Log.e(TAG, "Error parsing tracks", e) } } override fun eventProperty(property: String, value: Long) { Log.d(TAG, "Property $property = $value (Long)") } override fun eventProperty(property: String, value: Double) { Log.d(TAG, "Property $property = $value (Double)") when (property) { "time-pos" -> { val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 onProgressCallback?.invoke(value, duration) } "duration/full", "duration" -> { val width = MPVLib.getPropertyInt("width") ?: 0 val height = MPVLib.getPropertyInt("height") ?: 0 onLoadCallback?.invoke(value, width, height) } } } override fun eventProperty(property: String, value: Boolean) { Log.d(TAG, "Property $property = $value (Boolean)") when (property) { "eof-reached" -> { if (value) { onEndCallback?.invoke() } } } } override fun eventProperty(property: String, value: String) { Log.d(TAG, "Property $property = $value (String)") } override fun event(eventId: Int) { Log.d(TAG, "Event: $eventId") // MPV event constants (from MPVLib source) val MPV_EVENT_FILE_LOADED = 8 val MPV_EVENT_END_FILE = 7 when (eventId) { MPV_EVENT_FILE_LOADED -> { // File is loaded, start playback if not paused if (!isPaused) { MPVLib.setPropertyBoolean("pause", false) } } MPV_EVENT_END_FILE -> { Log.d(TAG, "MPV_EVENT_END_FILE") // Heuristic: If duration is effectively 0 at end of file, it's a load error val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0 val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached") if (duration < 1.0 && !eofReached) { val customError = "Unable to play media. Source may be unreachable." Log.e(TAG, "Playback error detected (heuristic): $customError") onErrorCallback?.invoke(customError) } else { onEndCallback?.invoke() } } } } }