feat: MPV player for both Android and iOS with added HW decoding PiP (with subtitles) (#1332)

Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
This commit is contained in:
Fredrik Burmester
2026-01-10 19:35:27 +01:00
committed by GitHub
parent df2f44e086
commit f1575ca48b
98 changed files with 3257 additions and 7448 deletions

View File

@@ -1,2 +1,9 @@
<manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for network streaming -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Picture-in-Picture feature -->
<uses-feature
android:name="android.software.picture_in_picture"
android:required="false" />
</manifest>

View File

@@ -0,0 +1,552 @@
package expo.modules.mpvplayer
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
/**
* MPV renderer that wraps libmpv for video playback.
* This mirrors the iOS MPVLayerRenderer implementation.
*/
class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
companion object {
private const val TAG = "MPVLayerRenderer"
// Property observation format types
const val MPV_FORMAT_NONE = 0
const val MPV_FORMAT_STRING = 1
const val MPV_FORMAT_OSD_STRING = 2
const val MPV_FORMAT_FLAG = 3
const val MPV_FORMAT_INT64 = 4
const val MPV_FORMAT_DOUBLE = 5
const val MPV_FORMAT_NODE = 6
}
interface Delegate {
fun onPositionChanged(position: Double, duration: Double)
fun onPauseChanged(isPaused: Boolean)
fun onLoadingChanged(isLoading: Boolean)
fun onReadyToSeek()
fun onTracksReady()
fun onError(message: String)
fun onVideoDimensionsChanged(width: Int, height: Int)
}
var delegate: Delegate? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var surface: Surface? = null
private var isRunning = false
private var isStopping = false
// Cached state
private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0
private var _isPaused: Boolean = true
private var _isLoading: Boolean = false
private var _playbackSpeed: Double = 1.0
private var isReadyToSeek: Boolean = false
// Video dimensions
private var _videoWidth: Int = 0
private var _videoHeight: Int = 0
val videoWidth: Int
get() = _videoWidth
val videoHeight: Int
get() = _videoHeight
// Current video config
private var currentUrl: String? = null
private var currentHeaders: Map<String, String>? = null
private var pendingExternalSubtitles: List<String> = emptyList()
private var initialSubtitleId: Int? = null
private var initialAudioId: Int? = null
val isPausedState: Boolean
get() = _isPaused
val currentPosition: Double
get() = cachedPosition
val duration: Double
get() = cachedDuration
fun start() {
if (isRunning) return
try {
MPVLib.create(context)
MPVLib.addObserver(this)
// Configure mpv options before initialization (based on Findroid)
MPVLib.setOptionString("vo", "gpu")
MPVLib.setOptionString("gpu-context", "android")
MPVLib.setOptionString("opengl-es", "yes")
// Hardware video decoding
MPVLib.setOptionString("hwdec", "mediacodec-copy")
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
// Cache settings for better network streaming
MPVLib.setOptionString("cache", "yes")
MPVLib.setOptionString("cache-pause-initial", "yes")
MPVLib.setOptionString("demuxer-max-bytes", "150MiB")
MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB")
MPVLib.setOptionString("demuxer-readahead-secs", "20")
// Seeking optimization - faster seeking at the cost of less precision
// Use keyframe seeking by default (much faster for network streams)
MPVLib.setOptionString("hr-seek", "no")
// Drop frames during seeking for faster response
MPVLib.setOptionString("hr-seek-framedrop", "yes")
// Subtitle settings
MPVLib.setOptionString("sub-scale-with-window", "yes")
MPVLib.setOptionString("sub-use-margins", "no")
MPVLib.setOptionString("subs-match-os-language", "yes")
MPVLib.setOptionString("subs-fallback", "yes")
// Important: Start with force-window=no, will be set to yes when surface is attached
MPVLib.setOptionString("force-window", "no")
MPVLib.setOptionString("keep-open", "always")
MPVLib.initialize()
// Observe properties
observeProperties()
isRunning = true
Log.i(TAG, "MPV renderer started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start MPV renderer: ${e.message}")
delegate?.onError("Failed to start renderer: ${e.message}")
}
}
fun stop() {
if (isStopping) return
if (!isRunning) return
isStopping = true
isRunning = false
try {
MPVLib.removeObserver(this)
MPVLib.detachSurface()
MPVLib.destroy()
} catch (e: Exception) {
Log.e(TAG, "Error stopping MPV: ${e.message}")
}
isStopping = false
}
/**
* Attach surface and re-enable video output.
* Based on Findroid's implementation.
*/
fun attachSurface(surface: Surface) {
this.surface = surface
if (isRunning) {
MPVLib.attachSurface(surface)
// Re-enable video output after attaching surface (Findroid approach)
MPVLib.setOptionString("force-window", "yes")
MPVLib.setOptionString("vo", "gpu")
Log.i(TAG, "Surface attached, video output re-enabled")
}
}
/**
* Detach surface and disable video output.
* Based on Findroid's implementation.
*/
fun detachSurface() {
this.surface = null
if (isRunning) {
try {
// Disable video output before detaching surface (Findroid approach)
MPVLib.setOptionString("vo", "null")
MPVLib.setOptionString("force-window", "no")
Log.i(TAG, "Video output disabled before surface detach")
} catch (e: Exception) {
Log.e(TAG, "Failed to disable video output: ${e.message}")
}
MPVLib.detachSurface()
}
}
/**
* Updates the surface size. Called from surfaceChanged.
* Based on Findroid's implementation.
*/
fun updateSurfaceSize(width: Int, height: Int) {
if (isRunning) {
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
Log.i(TAG, "Surface size updated: ${width}x$height")
}
}
fun load(
url: String,
headers: Map<String, String>? = null,
startPosition: Double? = null,
externalSubtitles: List<String>? = null,
initialSubtitleId: Int? = null,
initialAudioId: Int? = null
) {
currentUrl = url
currentHeaders = headers
pendingExternalSubtitles = externalSubtitles ?: emptyList()
this.initialSubtitleId = initialSubtitleId
this.initialAudioId = initialAudioId
_isLoading = true
isReadyToSeek = false
mainHandler.post { delegate?.onLoadingChanged(true) }
// Stop previous playback
MPVLib.command(arrayOf("stop"))
// Set HTTP headers if provided
updateHttpHeaders(headers)
// Set start position
if (startPosition != null && startPosition > 0) {
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
} else {
MPVLib.setPropertyString("start", "0")
}
// Set initial audio track if specified
if (initialAudioId != null && initialAudioId > 0) {
setAudioTrack(initialAudioId)
}
// Set initial subtitle track if no external subs
if (pendingExternalSubtitles.isEmpty()) {
if (initialSubtitleId != null) {
setSubtitleTrack(initialSubtitleId)
} else {
disableSubtitles()
}
} else {
disableSubtitles()
}
// Load the file
MPVLib.command(arrayOf("loadfile", url, "replace"))
}
fun reloadCurrentItem() {
currentUrl?.let { url ->
load(url, currentHeaders)
}
}
private fun updateHttpHeaders(headers: Map<String, String>?) {
if (headers.isNullOrEmpty()) {
// Clear headers
return
}
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
MPVLib.setPropertyString("http-header-fields", headerString)
}
private fun observeProperties() {
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
// Video dimensions for PiP aspect ratio
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
}
// MARK: - Playback Controls
fun play() {
MPVLib.setPropertyBoolean("pause", false)
}
fun pause() {
MPVLib.setPropertyBoolean("pause", true)
}
fun togglePause() {
if (_isPaused) play() else pause()
}
fun seekTo(seconds: Double) {
val clamped = maxOf(0.0, seconds)
cachedPosition = clamped
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
}
fun seekBy(seconds: Double) {
val newPosition = maxOf(0.0, cachedPosition + seconds)
cachedPosition = newPosition
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
}
fun setSpeed(speed: Double) {
_playbackSpeed = speed
MPVLib.setPropertyDouble("speed", speed)
}
fun getSpeed(): Double {
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
}
// MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>()
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) {
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
if (trackType != "sub") continue
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
val track = mutableMapOf<String, Any>("id" to trackId)
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected
tracks.add(track)
}
return tracks
}
fun setSubtitleTrack(trackId: Int) {
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
if (trackId < 0) {
MPVLib.setPropertyString("sid", "no")
} else {
MPVLib.setPropertyInt("sid", trackId)
}
}
fun disableSubtitles() {
MPVLib.setPropertyString("sid", "no")
}
fun getCurrentSubtitleTrack(): Int {
return MPVLib.getPropertyInt("sid") ?: 0
}
fun addSubtitleFile(url: String, select: Boolean = true) {
val flag = if (select) "select" else "cached"
MPVLib.command(arrayOf("sub-add", url, flag))
}
// MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) {
MPVLib.setPropertyInt("sub-pos", position)
}
fun setSubtitleScale(scale: Double) {
MPVLib.setPropertyDouble("sub-scale", scale)
}
fun setSubtitleMarginY(margin: Int) {
MPVLib.setPropertyInt("sub-margin-y", margin)
}
fun setSubtitleAlignX(alignment: String) {
MPVLib.setPropertyString("sub-align-x", alignment)
}
fun setSubtitleAlignY(alignment: String) {
MPVLib.setPropertyString("sub-align-y", alignment)
}
fun setSubtitleFontSize(size: Int) {
MPVLib.setPropertyInt("sub-font-size", size)
}
// MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> {
val tracks = mutableListOf<Map<String, Any>>()
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
for (i in 0 until trackCount) {
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
if (trackType != "audio") continue
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
val track = mutableMapOf<String, Any>("id" to trackId)
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
if (channels != null && channels > 0) {
track["channels"] = channels
}
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
track["selected"] = selected
tracks.add(track)
}
return tracks
}
fun setAudioTrack(trackId: Int) {
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
MPVLib.setPropertyInt("aid", trackId)
}
fun getCurrentAudioTrack(): Int {
return MPVLib.getPropertyInt("aid") ?: 0
}
// MARK: - Video Scaling
fun setZoomedToFill(zoomed: Boolean) {
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
val panscanValue = if (zoomed) 1.0 else 0.0
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
MPVLib.setPropertyDouble("panscan", panscanValue)
}
// MARK: - MPVLib.EventObserver
override fun eventProperty(property: String) {
// Property changed but no value provided
}
override fun eventProperty(property: String, value: Long) {
when (property) {
"track-list/count" -> {
if (value > 0) {
Log.i(TAG, "Track list updated: $value tracks available")
mainHandler.post { delegate?.onTracksReady() }
}
}
"video-params/w" -> {
val width = value.toInt()
if (width > 0 && width != _videoWidth) {
_videoWidth = width
notifyVideoDimensionsIfReady()
}
}
"video-params/h" -> {
val height = value.toInt()
if (height > 0 && height != _videoHeight) {
_videoHeight = height
notifyVideoDimensionsIfReady()
}
}
}
}
private fun notifyVideoDimensionsIfReady() {
if (_videoWidth > 0 && _videoHeight > 0) {
Log.i(TAG, "Video dimensions: ${_videoWidth}x${_videoHeight}")
mainHandler.post { delegate?.onVideoDimensionsChanged(_videoWidth, _videoHeight) }
}
}
override fun eventProperty(property: String, value: Boolean) {
when (property) {
"pause" -> {
if (value != _isPaused) {
_isPaused = value
mainHandler.post { delegate?.onPauseChanged(value) }
}
}
"paused-for-cache" -> {
if (value != _isLoading) {
_isLoading = value
mainHandler.post { delegate?.onLoadingChanged(value) }
}
}
}
}
override fun eventProperty(property: String, value: String) {
// Handle string properties if needed
}
override fun eventProperty(property: String, value: Double) {
when (property) {
"duration" -> {
cachedDuration = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
}
"time-pos" -> {
cachedPosition = value
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
}
}
}
override fun event(eventId: Int) {
when (eventId) {
MPVLib.MPV_EVENT_FILE_LOADED -> {
// Add external subtitles now that file is loaded
if (pendingExternalSubtitles.isNotEmpty()) {
for (subUrl in pendingExternalSubtitles) {
MPVLib.command(arrayOf("sub-add", subUrl))
}
pendingExternalSubtitles = emptyList()
// Set subtitle after external subs are added
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
}
if (!isReadyToSeek) {
isReadyToSeek = true
mainHandler.post { delegate?.onReadyToSeek() }
}
if (_isLoading) {
_isLoading = false
mainHandler.post { delegate?.onLoadingChanged(false) }
}
}
MPVLib.MPV_EVENT_SEEK -> {
// Seek started - show loading indicator
if (!_isLoading) {
_isLoading = true
mainHandler.post { delegate?.onLoadingChanged(true) }
}
}
MPVLib.MPV_EVENT_PLAYBACK_RESTART -> {
// Video playback has started/restarted (including after seek)
if (_isLoading) {
_isLoading = false
mainHandler.post { delegate?.onLoadingChanged(false) }
}
}
MPVLib.MPV_EVENT_END_FILE -> {
Log.i(TAG, "Playback ended")
}
MPVLib.MPV_EVENT_SHUTDOWN -> {
Log.w(TAG, "MPV shutdown")
}
}
}
}

View File

@@ -0,0 +1,220 @@
package expo.modules.mpvplayer
import android.content.Context
import android.util.Log
import android.view.Surface
import dev.jdtech.mpv.MPVLib as LibMPV
/**
* Wrapper around the dev.jdtech.mpv.MPVLib class.
* This provides a consistent interface for the rest of the app.
*/
object MPVLib {
private const val TAG = "MPVLib"
private var initialized = false
// Event observer interface
interface EventObserver {
fun eventProperty(property: String)
fun eventProperty(property: String, value: Long)
fun eventProperty(property: String, value: Boolean)
fun eventProperty(property: String, value: String)
fun eventProperty(property: String, value: Double)
fun event(eventId: Int)
}
private val observers = mutableListOf<EventObserver>()
// Library event observer that forwards to our observers
private val libObserver = object : LibMPV.EventObserver {
override fun eventProperty(property: String) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property)
}
}
}
override fun eventProperty(property: String, value: Long) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property, value)
}
}
}
override fun eventProperty(property: String, value: Boolean) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property, value)
}
}
}
override fun eventProperty(property: String, value: String) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property, value)
}
}
}
override fun eventProperty(property: String, value: Double) {
synchronized(observers) {
for (observer in observers) {
observer.eventProperty(property, value)
}
}
}
override fun event(eventId: Int) {
synchronized(observers) {
for (observer in observers) {
observer.event(eventId)
}
}
}
}
fun addObserver(observer: EventObserver) {
synchronized(observers) {
observers.add(observer)
}
}
fun removeObserver(observer: EventObserver) {
synchronized(observers) {
observers.remove(observer)
}
}
// MPV Event IDs
const val MPV_EVENT_NONE = 0
const val MPV_EVENT_SHUTDOWN = 1
const val MPV_EVENT_LOG_MESSAGE = 2
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
const val MPV_EVENT_COMMAND_REPLY = 5
const val MPV_EVENT_START_FILE = 6
const val MPV_EVENT_END_FILE = 7
const val MPV_EVENT_FILE_LOADED = 8
const val MPV_EVENT_IDLE = 11
const val MPV_EVENT_TICK = 14
const val MPV_EVENT_CLIENT_MESSAGE = 16
const val MPV_EVENT_VIDEO_RECONFIG = 17
const val MPV_EVENT_AUDIO_RECONFIG = 18
const val MPV_EVENT_SEEK = 20
const val MPV_EVENT_PLAYBACK_RESTART = 21
const val MPV_EVENT_PROPERTY_CHANGE = 22
const val MPV_EVENT_QUEUE_OVERFLOW = 24
// End file reason
const val MPV_END_FILE_REASON_EOF = 0
const val MPV_END_FILE_REASON_STOP = 2
const val MPV_END_FILE_REASON_QUIT = 3
const val MPV_END_FILE_REASON_ERROR = 4
const val MPV_END_FILE_REASON_REDIRECT = 5
/**
* Create and initialize the MPV library
*/
fun create(context: Context, configDir: String? = null) {
if (initialized) return
try {
LibMPV.create(context)
LibMPV.addObserver(libObserver)
initialized = true
Log.i(TAG, "libmpv created successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to create libmpv: ${e.message}")
throw e
}
}
fun initialize() {
LibMPV.init()
}
fun destroy() {
if (!initialized) return
try {
LibMPV.removeObserver(libObserver)
LibMPV.destroy()
} catch (e: Exception) {
Log.e(TAG, "Error destroying mpv: ${e.message}")
}
initialized = false
}
fun isInitialized(): Boolean = initialized
fun attachSurface(surface: Surface) {
LibMPV.attachSurface(surface)
}
fun detachSurface() {
LibMPV.detachSurface()
}
fun command(cmd: Array<String?>) {
LibMPV.command(cmd)
}
fun setOptionString(name: String, value: String): Int {
return LibMPV.setOptionString(name, value)
}
fun getPropertyInt(name: String): Int? {
return try {
LibMPV.getPropertyInt(name)
} catch (e: Exception) {
null
}
}
fun getPropertyDouble(name: String): Double? {
return try {
LibMPV.getPropertyDouble(name)
} catch (e: Exception) {
null
}
}
fun getPropertyBoolean(name: String): Boolean? {
return try {
LibMPV.getPropertyBoolean(name)
} catch (e: Exception) {
null
}
}
fun getPropertyString(name: String): String? {
return try {
LibMPV.getPropertyString(name)
} catch (e: Exception) {
null
}
}
fun setPropertyInt(name: String, value: Int) {
LibMPV.setPropertyInt(name, value)
}
fun setPropertyDouble(name: String, value: Double) {
LibMPV.setPropertyDouble(name, value)
}
fun setPropertyBoolean(name: String, value: Boolean) {
LibMPV.setPropertyBoolean(name, value)
}
fun setPropertyString(name: String, value: String) {
LibMPV.setPropertyString(name, value)
}
fun observeProperty(name: String, format: Int) {
LibMPV.observeProperty(name, format)
}
}

View File

@@ -2,49 +2,179 @@ package expo.modules.mpvplayer
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.net.URL
class MpvPlayerModule : Module() {
// Each module class must implement the definition function. The definition consists of components
// that describes the module's functionality and behavior.
// See https://docs.expo.dev/modules/module-api for more details about available components.
override fun definition() = ModuleDefinition {
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
// The module will be accessible from `requireNativeModule('MpvPlayer')` in JavaScript.
Name("MpvPlayer")
override fun definition() = ModuleDefinition {
Name("MpvPlayer")
// Defines constant property on the module.
Constant("PI") {
Math.PI
// Defines event names that the module can send to JavaScript.
Events("onChange")
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
Function("hello") {
"Hello from MPV Player! 👋"
}
// Defines a JavaScript function that always returns a Promise and whose native code
// is by default dispatched on the different thread than the JavaScript runtime runs on.
AsyncFunction("setValueAsync") { value: String ->
sendEvent("onChange", mapOf("value" to value))
}
// Enables the module to be used as a native view.
View(MpvPlayerView::class) {
// All video load options are passed via a single "source" prop
Prop("source") { view: MpvPlayerView, source: Map<String, Any?>? ->
if (source == null) return@Prop
val urlString = source["url"] as? String ?: return@Prop
@Suppress("UNCHECKED_CAST")
val config = VideoLoadConfig(
url = urlString,
headers = source["headers"] as? Map<String, String>,
externalSubtitles = source["externalSubtitles"] as? List<String>,
startPosition = (source["startPosition"] as? Number)?.toDouble(),
autoplay = (source["autoplay"] as? Boolean) ?: true,
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
initialAudioId = (source["initialAudioId"] as? Number)?.toInt()
)
view.loadVideo(config)
}
// Async function to play video
AsyncFunction("play") { view: MpvPlayerView ->
view.play()
}
// Async function to pause video
AsyncFunction("pause") { view: MpvPlayerView ->
view.pause()
}
// Async function to seek to position
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
view.seekTo(position)
}
// Async function to seek by offset
AsyncFunction("seekBy") { view: MpvPlayerView, offset: Double ->
view.seekBy(offset)
}
// Async function to set playback speed
AsyncFunction("setSpeed") { view: MpvPlayerView, speed: Double ->
view.setSpeed(speed)
}
// Function to get current speed
AsyncFunction("getSpeed") { view: MpvPlayerView ->
view.getSpeed()
}
// Function to check if paused
AsyncFunction("isPaused") { view: MpvPlayerView ->
view.isPaused()
}
// Function to get current position
AsyncFunction("getCurrentPosition") { view: MpvPlayerView ->
view.getCurrentPosition()
}
// Function to get duration
AsyncFunction("getDuration") { view: MpvPlayerView ->
view.getDuration()
}
// Picture in Picture functions
AsyncFunction("startPictureInPicture") { view: MpvPlayerView ->
view.startPictureInPicture()
}
AsyncFunction("stopPictureInPicture") { view: MpvPlayerView ->
view.stopPictureInPicture()
}
AsyncFunction("isPictureInPictureSupported") { view: MpvPlayerView ->
view.isPictureInPictureSupported()
}
AsyncFunction("isPictureInPictureActive") { view: MpvPlayerView ->
view.isPictureInPictureActive()
}
// Subtitle functions
AsyncFunction("getSubtitleTracks") { view: MpvPlayerView ->
view.getSubtitleTracks()
}
AsyncFunction("setSubtitleTrack") { view: MpvPlayerView, trackId: Int ->
view.setSubtitleTrack(trackId)
}
AsyncFunction("disableSubtitles") { view: MpvPlayerView ->
view.disableSubtitles()
}
AsyncFunction("getCurrentSubtitleTrack") { view: MpvPlayerView ->
view.getCurrentSubtitleTrack()
}
AsyncFunction("addSubtitleFile") { view: MpvPlayerView, url: String, select: Boolean ->
view.addSubtitleFile(url, select)
}
// Subtitle positioning functions
AsyncFunction("setSubtitlePosition") { view: MpvPlayerView, position: Int ->
view.setSubtitlePosition(position)
}
AsyncFunction("setSubtitleScale") { view: MpvPlayerView, scale: Double ->
view.setSubtitleScale(scale)
}
AsyncFunction("setSubtitleMarginY") { view: MpvPlayerView, margin: Int ->
view.setSubtitleMarginY(margin)
}
AsyncFunction("setSubtitleAlignX") { view: MpvPlayerView, alignment: String ->
view.setSubtitleAlignX(alignment)
}
AsyncFunction("setSubtitleAlignY") { view: MpvPlayerView, alignment: String ->
view.setSubtitleAlignY(alignment)
}
AsyncFunction("setSubtitleFontSize") { view: MpvPlayerView, size: Int ->
view.setSubtitleFontSize(size)
}
// Audio track functions
AsyncFunction("getAudioTracks") { view: MpvPlayerView ->
view.getAudioTracks()
}
AsyncFunction("setAudioTrack") { view: MpvPlayerView, trackId: Int ->
view.setAudioTrack(trackId)
}
AsyncFunction("getCurrentAudioTrack") { view: MpvPlayerView ->
view.getCurrentAudioTrack()
}
// Video scaling functions
AsyncFunction("setZoomedToFill") { view: MpvPlayerView, zoomed: Boolean ->
view.setZoomedToFill(zoomed)
}
AsyncFunction("isZoomedToFill") { view: MpvPlayerView ->
view.isZoomedToFill()
}
// Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
}
}
// Defines event names that the module can send to JavaScript.
Events("onChange")
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
Function("hello") {
"Hello world! 👋"
}
// Defines a JavaScript function that always returns a Promise and whose native code
// is by default dispatched on the different thread than the JavaScript runtime runs on.
AsyncFunction("setValueAsync") { value: String ->
// Send an event to JavaScript.
sendEvent("onChange", mapOf(
"value" to value
))
}
// Enables the module to be used as a native view. Definition components that are accepted as part of
// the view definition: Prop, Events.
View(MpvPlayerView::class) {
// Defines a setter for the `url` prop.
Prop("url") { view: MpvPlayerView, url: URL ->
view.webView.loadUrl(url.toString())
}
// Defines an event that the view can send to JavaScript.
Events("onLoad")
}
}
}

View File

@@ -1,30 +1,398 @@
package expo.modules.mpvplayer
import android.content.Context
import android.webkit.WebView
import android.webkit.WebViewClient
import android.graphics.Color
import android.os.Build
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.widget.FrameLayout
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
// Creates and initializes an event dispatcher for the `onLoad` event.
// The name of the event is inferred from the value and needs to match the event name defined in the module.
private val onLoad by EventDispatcher()
/**
* Configuration for loading a video
*/
data class VideoLoadConfig(
val url: String,
val headers: Map<String, String>? = null,
val externalSubtitles: List<String>? = null,
val startPosition: Double? = null,
val autoplay: Boolean = true,
val initialSubtitleId: Int? = null,
val initialAudioId: Int? = null
)
// Defines a WebView that will be used as the root subview.
internal val webView = WebView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
// Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
onLoad(mapOf("url" to url))
}
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
* This mirrors the iOS MpvPlayerView implementation.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
companion object {
private const val TAG = "MpvPlayerView"
/**
* Detect if running on an Android emulator.
* MPV player has EGL/OpenGL compatibility issues on emulators.
*/
private fun isEmulator(): Boolean {
return (Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.MODEL.contains("google_sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK built for x86")
|| Build.MANUFACTURER.contains("Genymotion")
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
|| "google_sdk" == Build.PRODUCT
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu"))
}
}
// Event dispatchers
val onLoad by EventDispatcher()
val onPlaybackStateChange by EventDispatcher()
val onProgress by EventDispatcher()
val onError by EventDispatcher()
val onTracksReady by EventDispatcher()
private var surfaceView: SurfaceView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
private var currentUrl: String? = null
private var cachedPosition: Double = 0.0
private var cachedDuration: Double = 0.0
private var intendedPlayState: Boolean = false
private var surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null
init {
setBackgroundColor(Color.BLACK)
// Create SurfaceView for video rendering
surfaceView = SurfaceView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerView)
}
addView(surfaceView)
// Initialize renderer
renderer = MPVLayerRenderer(context)
renderer?.delegate = this
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
}
override fun onPause() {
pause()
}
override fun onSeekBy(seconds: Double) {
seekBy(seconds)
}
}
// Start the renderer (skip on emulators to avoid EGL crashes)
if (isEmulator()) {
Log.w(TAG, "Running on emulator - MPV player disabled due to EGL/OpenGL compatibility issues")
// Don't start renderer on emulator, will show error when trying to play
} else {
try {
renderer?.start()
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
}
}
}
}
init {
// Adds the WebView to the view hierarchy.
addView(webView)
}
private var isOnEmulator: Boolean = isEmulator()
// MARK: - SurfaceHolder.Callback
override fun surfaceCreated(holder: SurfaceHolder) {
Log.i(TAG, "Surface created")
surfaceReady = true
renderer?.attachSurface(holder.surface)
// If we have a pending load, execute it now
pendingConfig?.let { config ->
loadVideoInternal(config)
pendingConfig = null
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
Log.i(TAG, "Surface changed: ${width}x${height}")
// Update MPV with the new surface size (Findroid approach)
renderer?.updateSurfaceSize(width, height)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.i(TAG, "Surface destroyed")
surfaceReady = false
renderer?.detachSurface()
}
// MARK: - Video Loading
fun loadVideo(config: VideoLoadConfig) {
// Block video loading on emulators
if (isOnEmulator) {
Log.w(TAG, "Cannot load video on emulator - MPV player not supported")
onError(mapOf("error" to "MPV player is not supported on emulators. Please test on a real device."))
return
}
// Skip reload if same URL is already playing
if (currentUrl == config.url) {
return
}
if (!surfaceReady) {
// Surface not ready, store config and load when ready
pendingConfig = config
return
}
loadVideoInternal(config)
}
private fun loadVideoInternal(config: VideoLoadConfig) {
currentUrl = config.url
renderer?.load(
url = config.url,
headers = config.headers,
startPosition = config.startPosition,
externalSubtitles = config.externalSubtitles,
initialSubtitleId = config.initialSubtitleId,
initialAudioId = config.initialAudioId
)
if (config.autoplay) {
play()
}
onLoad(mapOf("url" to config.url))
}
// Convenience method for simple loads
fun loadVideo(url: String, headers: Map<String, String>? = null) {
loadVideo(VideoLoadConfig(url = url, headers = headers))
}
// MARK: - Playback Controls
fun play() {
intendedPlayState = true
renderer?.play()
pipController?.setPlaybackRate(1.0)
}
fun pause() {
intendedPlayState = false
renderer?.pause()
pipController?.setPlaybackRate(0.0)
}
fun seekTo(position: Double) {
renderer?.seekTo(position)
}
fun seekBy(offset: Double) {
renderer?.seekBy(offset)
}
fun setSpeed(speed: Double) {
renderer?.setSpeed(speed)
}
fun getSpeed(): Double {
return renderer?.getSpeed() ?: 1.0
}
fun isPaused(): Boolean {
return renderer?.isPausedState ?: true
}
fun getCurrentPosition(): Double {
return cachedPosition
}
fun getDuration(): Double {
return cachedDuration
}
// MARK: - Picture in Picture
fun startPictureInPicture() {
Log.i(TAG, "startPictureInPicture called")
pipController?.startPictureInPicture()
}
fun stopPictureInPicture() {
pipController?.stopPictureInPicture()
}
fun isPictureInPictureSupported(): Boolean {
return pipController?.isPictureInPictureSupported() ?: false
}
fun isPictureInPictureActive(): Boolean {
return pipController?.isPictureInPictureActive() ?: false
}
// MARK: - Subtitle Controls
fun getSubtitleTracks(): List<Map<String, Any>> {
return renderer?.getSubtitleTracks() ?: emptyList()
}
fun setSubtitleTrack(trackId: Int) {
renderer?.setSubtitleTrack(trackId)
}
fun disableSubtitles() {
renderer?.disableSubtitles()
}
fun getCurrentSubtitleTrack(): Int {
return renderer?.getCurrentSubtitleTrack() ?: 0
}
fun addSubtitleFile(url: String, select: Boolean = true) {
renderer?.addSubtitleFile(url, select)
}
// MARK: - Subtitle Positioning
fun setSubtitlePosition(position: Int) {
renderer?.setSubtitlePosition(position)
}
fun setSubtitleScale(scale: Double) {
renderer?.setSubtitleScale(scale)
}
fun setSubtitleMarginY(margin: Int) {
renderer?.setSubtitleMarginY(margin)
}
fun setSubtitleAlignX(alignment: String) {
renderer?.setSubtitleAlignX(alignment)
}
fun setSubtitleAlignY(alignment: String) {
renderer?.setSubtitleAlignY(alignment)
}
fun setSubtitleFontSize(size: Int) {
renderer?.setSubtitleFontSize(size)
}
// MARK: - Audio Track Controls
fun getAudioTracks(): List<Map<String, Any>> {
return renderer?.getAudioTracks() ?: emptyList()
}
fun setAudioTrack(trackId: Int) {
renderer?.setAudioTrack(trackId)
}
fun getCurrentAudioTrack(): Int {
return renderer?.getCurrentAudioTrack() ?: 0
}
// MARK: - Video Scaling
private var _isZoomedToFill: Boolean = false
fun setZoomedToFill(zoomed: Boolean) {
_isZoomedToFill = zoomed
renderer?.setZoomedToFill(zoomed)
}
fun isZoomedToFill(): Boolean {
return _isZoomedToFill
}
// MARK: - MPVLayerRenderer.Delegate
override fun onPositionChanged(position: Double, duration: Double) {
cachedPosition = position
cachedDuration = duration
// Update PiP progress
if (pipController?.isPictureInPictureActive() == true) {
pipController?.setCurrentTime(position, duration)
}
onProgress(mapOf(
"position" to position,
"duration" to duration,
"progress" to if (duration > 0) position / duration else 0.0
))
}
override fun onPauseChanged(isPaused: Boolean) {
// Sync PiP playback rate
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
onPlaybackStateChange(mapOf(
"isPaused" to isPaused,
"isPlaying" to !isPaused
))
}
override fun onLoadingChanged(isLoading: Boolean) {
onPlaybackStateChange(mapOf(
"isLoading" to isLoading
))
}
override fun onReadyToSeek() {
onPlaybackStateChange(mapOf(
"isReadyToSeek" to true
))
}
override fun onTracksReady() {
onTracksReady(emptyMap<String, Any>())
}
override fun onVideoDimensionsChanged(width: Int, height: Int) {
// Update PiP controller with video dimensions for proper aspect ratio
pipController?.setVideoDimensions(width, height)
}
override fun onError(message: String) {
onError(mapOf("error" to message))
}
// MARK: - Cleanup
fun cleanup() {
pipController?.stopPictureInPicture()
renderer?.stop()
surfaceView.holder.removeCallback(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
cleanup()
}
}

View File

@@ -0,0 +1,263 @@
package expo.modules.mpvplayer
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Rect
import android.os.Build
import android.util.Log
import android.util.Rational
import android.view.View
import androidx.annotation.RequiresApi
import expo.modules.kotlin.AppContext
/**
* Picture-in-Picture controller for Android.
* This mirrors the iOS PiPController implementation.
*/
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
companion object {
private const val TAG = "PiPController"
private const val DEFAULT_ASPECT_WIDTH = 16
private const val DEFAULT_ASPECT_HEIGHT = 9
}
interface Delegate {
fun onPlay()
fun onPause()
fun onSeekBy(seconds: Double)
}
var delegate: Delegate? = null
private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0
// Video dimensions for proper aspect ratio
private var videoWidth: Int = 0
private var videoHeight: Int = 0
// Reference to the player view for source rect
private var playerView: View? = null
/**
* Check if Picture-in-Picture is supported on this device
*/
fun isPictureInPictureSupported(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
} else {
false
}
}
/**
* Check if Picture-in-Picture is currently active
*/
fun isPictureInPictureActive(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
return activity?.isInPictureInPictureMode ?: false
}
return false
}
/**
* Start Picture-in-Picture mode
*/
fun startPictureInPicture() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity == null) {
Log.e(TAG, "Cannot start PiP: no activity found")
return
}
if (!isPictureInPictureSupported()) {
Log.e(TAG, "PiP not supported on this device")
return
}
try {
val params = buildPiPParams(forEntering = true)
activity.enterPictureInPictureMode(params)
Log.i(TAG, "Entered PiP mode")
} catch (e: Exception) {
Log.e(TAG, "Failed to enter PiP: ${e.message}")
}
} else {
Log.w(TAG, "PiP requires Android O or higher")
}
}
/**
* Stop Picture-in-Picture mode
*/
fun stopPictureInPicture() {
// On Android, exiting PiP is typically done by the user
// or by finishing the activity. We can request to move task to back.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
// Move task to back which will exit PiP
activity.moveTaskToBack(false)
}
}
}
/**
* Update the current playback position and duration
* Note: We don't update PiP params here as we're not using progress in PiP controls
*/
fun setCurrentTime(position: Double, duration: Double) {
currentPosition = position
currentDuration = duration
}
/**
* Set the playback rate (0.0 for paused, 1.0 for playing)
*/
fun setPlaybackRate(rate: Double) {
playbackRate = rate
// Update PiP params to reflect play/pause state
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
try {
activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) {
Log.e(TAG, "Failed to update PiP params: ${e.message}")
}
}
}
}
/**
* Set the video dimensions for proper aspect ratio calculation
*/
fun setVideoDimensions(width: Int, height: Int) {
if (width > 0 && height > 0) {
videoWidth = width
videoHeight = height
Log.i(TAG, "Video dimensions set: ${width}x${height}")
// Update PiP params if active
updatePiPParamsIfNeeded()
}
}
/**
* Set the player view reference for source rect hint
*/
fun setPlayerView(view: View?) {
playerView = view
}
private fun updatePiPParamsIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
try {
activity.setPictureInPictureParams(buildPiPParams())
} catch (e: Exception) {
Log.e(TAG, "Failed to update PiP params: ${e.message}")
}
}
}
}
/**
* Build Picture-in-Picture params for the current player state.
* Calculates proper aspect ratio and source rect based on video and view dimensions.
*/
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
val view = playerView
val viewWidth = view?.width ?: 0
val viewHeight = view?.height ?: 0
// Display aspect ratio from view (exactly like Findroid)
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
Rational(
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
videoHeight.coerceAtMost((videoWidth * 2.39f).toInt())
)
} else {
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
}
// Source rect hint calculation (exactly like Findroid)
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
if (displayAspectRatio < aspectRatio) {
// Letterboxing - black bars top/bottom
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
Rect(
0,
space,
viewWidth,
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
)
} else {
// Pillarboxing - black bars left/right
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
Rect(
space,
0,
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
viewHeight
)
}
} else {
null
}
val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
sourceRectHint?.let { builder.setSourceRectHint(it) }
// On Android 12+, enable auto-enter (like Findroid)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(true)
}
return builder.build()
}
private fun getActivity(): Activity? {
// First try Expo's AppContext (preferred in React Native)
appContext?.currentActivity?.let { return it }
// Fallback: Try to get from context wrapper chain
var ctx = context
while (ctx is android.content.ContextWrapper) {
if (ctx is Activity) {
return ctx
}
ctx = ctx.baseContext
}
return null
}
/**
* Handle PiP action (called from activity when user taps PiP controls)
*/
fun handlePiPAction(action: String) {
when (action) {
"play" -> delegate?.onPlay()
"pause" -> delegate?.onPause()
"skip_forward" -> delegate?.onSeekBy(10.0)
"skip_backward" -> delegate?.onSeekBy(-10.0)
}
}
}