Desktop player on mpv: floating window, system integration

This commit is contained in:
2026-10-27 12:09:01 +03:00
parent 01c1b304ef
commit 371c63c0c3
35 changed files with 5663 additions and 125 deletions

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -677,7 +678,9 @@ class PlaybackSession(
private fun reportStopAsync(positionMs: Long, failed: Boolean) {
// The owner's scope is being cancelled at this point; reporting must still reach the server.
@Suppress("OPT_IN_USAGE")
kotlinx.coroutines.GlobalScope.launch { reportStop(positionMs, failed) }
val job = kotlinx.coroutines.GlobalScope.launch { reportStop(positionMs, failed) }
pendingStops += job
job.invokeOnCompletion { pendingStops -= job }
}
private fun log(message: String) {
@@ -691,17 +694,22 @@ class PlaybackSession(
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
}
private companion object {
const val TICKS_PER_MS = 10_000L
const val MAX_BITRATE = 400_000_000
const val TICK_MS = 500L
const val PROGRESS_INTERVAL_MS = 10_000L
const val FIRST_FRAME_TIMEOUT_MS = 60_000L
const val STALL_TICK_MS = 1000L
const val STALL_TIMEOUT_MS = 8_000L
companion object {
private val pendingStops: MutableSet<Job> = java.util.Collections.synchronizedSet(mutableSetOf())
/** For an app about to exit (the desktop): waits until the stop reports already sent off are done. */
suspend fun awaitStopReports() = synchronized(pendingStops) { pendingStops.toList() }.joinAll()
private const val TICKS_PER_MS = 10_000L
private const val MAX_BITRATE = 400_000_000
private const val TICK_MS = 500L
private const val PROGRESS_INTERVAL_MS = 10_000L
private const val FIRST_FRAME_TIMEOUT_MS = 60_000L
private const val STALL_TICK_MS = 1000L
private const val STALL_TIMEOUT_MS = 8_000L
/** Seeking rebuffers and drops a few frames: give the engine time to settle before judging it. */
const val SEEK_GRACE_MS = 5_000L
const val DROP_WINDOW_TICKS = 10
const val MAX_DROPPED_SHARE = 0.25
private const val SEEK_GRACE_MS = 5_000L
private const val DROP_WINDOW_TICKS = 10
private const val MAX_DROPPED_SHARE = 0.25
}
}

View File

@@ -0,0 +1,21 @@
// The desktop's playback engine (LINUX_PLAN.md §2.3): libmpv through JNA, video drawn offscreen and shown in Compose.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.multiplatform)
}
kotlin { jvmToolchain(21) }
dependencies {
implementation(project(":playback:api"))
implementation(project(":playback:core"))
implementation(project(":core:common"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.ui)
implementation(libs.jna)
implementation(libs.koin.core)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,89 @@
package ru.stenfredd.sleepyowl.playback.mpv
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.ptr.IntByReference
import com.sun.jna.ptr.PointerByReference
/** EGL 1.5 of libEGL (glvnd): just enough for an invisible OpenGL context. */
internal object Egl {
const val NONE = 0x3038
const val SURFACE_TYPE = 0x3033
const val PBUFFER_BIT = 0x0001
const val RENDERABLE_TYPE = 0x3040
const val OPENGL_BIT = 0x0008
const val RED_SIZE = 0x3024
const val GREEN_SIZE = 0x3023
const val BLUE_SIZE = 0x3022
const val WIDTH = 0x3057
const val HEIGHT = 0x3056
const val OPENGL_API = 0x30A2
const val VENDOR = 0x3053
const val VERSION = 0x3054
init { Native.register("EGL") }
@JvmStatic external fun eglGetDisplay(nativeDisplay: Pointer?): Pointer?
@JvmStatic external fun eglInitialize(dpy: Pointer, major: IntByReference?, minor: IntByReference?): Int
@JvmStatic external fun eglBindAPI(api: Int): Int
@JvmStatic external fun eglChooseConfig(dpy: Pointer, attribs: IntArray, configs: PointerByReference, size: Int, num: IntByReference): Int
@JvmStatic external fun eglCreateContext(dpy: Pointer, config: Pointer, share: Pointer?, attribs: IntArray): Pointer?
@JvmStatic external fun eglCreatePbufferSurface(dpy: Pointer, config: Pointer, attribs: IntArray): Pointer?
@JvmStatic external fun eglMakeCurrent(dpy: Pointer, draw: Pointer?, read: Pointer?, ctx: Pointer?): Int
@JvmStatic external fun eglDestroyContext(dpy: Pointer, ctx: Pointer): Int
@JvmStatic external fun eglDestroySurface(dpy: Pointer, surface: Pointer): Int
@JvmStatic external fun eglGetProcAddress(name: String): Pointer?
@JvmStatic external fun eglGetError(): Int
@JvmStatic external fun eglQueryString(dpy: Pointer, name: Int): String?
}
/** OpenGL of libOpenGL (glvnd), dispatched to the driver of the current context. */
internal object Gl {
const val FRAMEBUFFER = 0x8D40
const val FRAMEBUFFER_COMPLETE = 0x8CD5
const val COLOR_ATTACHMENT0 = 0x8CE0
const val TEXTURE_2D = 0x0DE1
const val TEXTURE_MIN_FILTER = 0x2801
const val TEXTURE_MAG_FILTER = 0x2800
const val LINEAR = 0x2601
const val RGBA8 = 0x8058
const val BGRA = 0x80E1
const val UNSIGNED_INT_8_8_8_8_REV = 0x8367
const val PIXEL_PACK_BUFFER = 0x88EB
const val MAP_READ_BIT = 0x0001
const val MAP_PERSISTENT_BIT = 0x0040
const val MAP_COHERENT_BIT = 0x0080
const val CLIENT_STORAGE_BIT = 0x0200
const val PACK_ALIGNMENT = 0x0D05
const val SYNC_GPU_COMMANDS_COMPLETE = 0x9117
const val SYNC_FLUSH_COMMANDS_BIT = 0x1
const val RENDERER = 0x1F01
const val VERSION = 0x1F02
init { Native.register("OpenGL") }
@JvmStatic external fun glGetString(name: Int): String?
@JvmStatic external fun glGenFramebuffers(n: Int, ids: IntArray)
@JvmStatic external fun glDeleteFramebuffers(n: Int, ids: IntArray)
@JvmStatic external fun glBindFramebuffer(target: Int, fb: Int)
@JvmStatic external fun glFramebufferTexture2D(target: Int, attachment: Int, texTarget: Int, tex: Int, level: Int)
@JvmStatic external fun glCheckFramebufferStatus(target: Int): Int
@JvmStatic external fun glGenTextures(n: Int, ids: IntArray)
@JvmStatic external fun glDeleteTextures(n: Int, ids: IntArray)
@JvmStatic external fun glBindTexture(target: Int, tex: Int)
@JvmStatic external fun glTexParameteri(target: Int, name: Int, value: Int)
@JvmStatic external fun glTexImage2D(target: Int, level: Int, internalFormat: Int, w: Int, h: Int, border: Int, format: Int, type: Int, data: Pointer?)
@JvmStatic external fun glGenBuffers(n: Int, ids: IntArray)
@JvmStatic external fun glDeleteBuffers(n: Int, ids: IntArray)
@JvmStatic external fun glBindBuffer(target: Int, buf: Int)
/** GL 4.4 / ARB_buffer_storage: immutable storage that can stay mapped. */
@JvmStatic external fun glBufferStorage(target: Int, size: Long, data: Pointer?, flags: Int)
@JvmStatic external fun glMapBufferRange(target: Int, offset: Long, length: Long, access: Int): Pointer?
@JvmStatic external fun glUnmapBuffer(target: Int): Byte
@JvmStatic external fun glPixelStorei(name: Int, value: Int)
/** [offset] is into the bound PIXEL_PACK_BUFFER. */
@JvmStatic external fun glReadPixels(x: Int, y: Int, w: Int, h: Int, format: Int, type: Int, offset: Long)
@JvmStatic external fun glFenceSync(condition: Int, flags: Int): Pointer?
@JvmStatic external fun glClientWaitSync(sync: Pointer, flags: Int, timeoutNs: Long): Int
@JvmStatic external fun glDeleteSync(sync: Pointer)
}

View File

@@ -0,0 +1,88 @@
package ru.stenfredd.sleepyowl.playback.mpv
import com.sun.jna.Callback
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.ptr.PointerByReference
/** The part of libmpv the engine uses (client.h, render.h, render_gl.h; API 2.x, mpv 0.37 of Mint's repository). */
@Suppress("FunctionName")
internal interface LibMpv : Library {
fun mpv_client_api_version(): Long
fun mpv_error_string(error: Int): String
fun mpv_free(data: Pointer?)
fun mpv_create(): Pointer?
fun mpv_initialize(ctx: Pointer): Int
fun mpv_terminate_destroy(ctx: Pointer)
fun mpv_set_option_string(ctx: Pointer, name: String, data: String): Int
fun mpv_set_property_string(ctx: Pointer, name: String, data: String): Int
fun mpv_get_property_string(ctx: Pointer, name: String): Pointer?
/** [args] ends with null. */
fun mpv_command_async(ctx: Pointer, userdata: Long, args: Array<String?>): Int
fun mpv_observe_property(ctx: Pointer, userdata: Long, name: String, format: Int): Int
fun mpv_request_log_messages(ctx: Pointer, minLevel: String): Int
fun mpv_wait_event(ctx: Pointer, timeout: Double): Pointer
fun mpv_wakeup(ctx: Pointer)
fun mpv_render_context_create(res: PointerByReference, mpv: Pointer, params: Pointer): Int
fun mpv_render_context_set_update_callback(ctx: Pointer, callback: UpdateCallback?, callbackCtx: Pointer?)
fun mpv_render_context_update(ctx: Pointer): Long
fun mpv_render_context_render(ctx: Pointer, params: Pointer): Int
fun mpv_render_context_report_swap(ctx: Pointer)
fun mpv_render_context_free(ctx: Pointer)
fun interface UpdateCallback : Callback { fun invoke(ctx: Pointer?) }
fun interface GetProcAddress : Callback { fun invoke(ctx: Pointer?, name: String): Pointer? }
companion object {
val INSTANCE: LibMpv by lazy {
// libmpv needs LC_NUMERIC=C; the JVM sets the user's locale (ru_RU writes decimals with a comma).
CLib.setlocale(CLib.LC_NUMERIC, "C")
Native.load("mpv", LibMpv::class.java)
}
const val FORMAT_NONE = 0
const val FORMAT_STRING = 1
const val FORMAT_FLAG = 3
const val FORMAT_INT64 = 4
const val FORMAT_DOUBLE = 5
const val EVENT_NONE = 0
const val EVENT_SHUTDOWN = 1
const val EVENT_LOG_MESSAGE = 2
const val EVENT_START_FILE = 6
const val EVENT_END_FILE = 7
const val EVENT_FILE_LOADED = 8
const val EVENT_SEEK = 20
const val EVENT_PLAYBACK_RESTART = 21
const val EVENT_PROPERTY_CHANGE = 22
const val END_FILE_REASON_ERROR = 4
const val ERROR_LOADING_FAILED = -13
const val ERROR_AO_INIT_FAILED = -15
const val ERROR_VO_INIT_FAILED = -14
const val ERROR_NOTHING_TO_PLAY = -16
const val ERROR_UNKNOWN_FORMAT = -17
const val RENDER_PARAM_API_TYPE = 1
const val RENDER_PARAM_OPENGL_INIT_PARAMS = 2
const val RENDER_PARAM_OPENGL_FBO = 3
const val RENDER_PARAM_ADVANCED_CONTROL = 10
const val RENDER_UPDATE_FRAME = 1L
}
}
/** The libc functions the engine needs. */
internal object CLib {
const val LC_NUMERIC = 1
init { Native.register("c") }
@JvmStatic external fun setlocale(category: Int, locale: String?): String?
@JvmStatic external fun memcpy(dst: Pointer, src: Pointer, n: Long): Pointer
@JvmStatic external fun setenv(name: String, value: String, overwrite: Int): Int
}

View File

@@ -0,0 +1,509 @@
package ru.stenfredd.sleepyowl.playback.mpv
import com.sun.jna.Pointer
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.playback.api.AudioDelayCapable
import ru.stenfredd.sleepyowl.playback.api.EngineError
import ru.stenfredd.sleepyowl.playback.api.EngineEvent
import ru.stenfredd.sleepyowl.playback.api.EngineState
import ru.stenfredd.sleepyowl.playback.api.EngineStats
import ru.stenfredd.sleepyowl.playback.api.LoadRequest
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
import ru.stenfredd.sleepyowl.playback.api.SubtitleDelayCapable
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
import ru.stenfredd.sleepyowl.playback.api.videoStream
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.math.roundToInt
/**
* libmpv engine of the desktop (LINUX_PLAN.md §2.3). One mpv core per playback, without a window of its own
* (`vo=libmpv`): the picture comes out of [video] and is shown by [MpvVideoSurface].
*
* Commands go to mpv asynchronously, so the UI thread never waits on the core; mpv's events and observed properties are
* read on the engine's own thread and land in volatile fields and state flows.
*/
class MpvEngine internal constructor() : PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
override val id = MpvEngineProvider.ID
private val lib = LibMpv.INSTANCE
private val handle: Pointer = lib.mpv_create() ?: error("mpv_create returned NULL")
val video = MpvVideo(handle, onReady = ::onRenderReady, onFirstFrame = ::onFirstFrame, onFailure = { t ->
reportError(EngineError(EngineError.Kind.NO_VIDEO_OUTPUT, "mpv: нет вывода видео (${t.message})", t))
})
private val _state = MutableStateFlow(EngineState())
override val state = _state.asStateFlow()
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val events = _events.asSharedFlow()
private val _stats = MutableStateFlow(EngineStats(videoDecoder = "mpv"))
override val stats = _stats.asStateFlow()
@Volatile private var position = 0L
@Volatile private var duration = 0L
@Volatile private var cacheEnd = -1L
@Volatile private var seekable = true
override val positionMs get() = position
override val durationMs get() = duration
override val isSeekable get() = seekable
override val bufferedPositionMs get() = cacheEnd
private val lock = Object()
private var renderReady = false
/** loadfile and the options before it, waiting for the render context. */
private var pendingLoad: List<Array<String>>? = null
@Volatile private var request: LoadRequest? = null
@Volatile private var fileLoaded = false
/** A seek asked for before the file was open: done once it is. */
@Volatile private var pendingSeekMs = -1L
@Volatile private var tracks: List<Track> = emptyList()
private var audioIndex: Int? = null
private var subtitleIndex: Int? = null
private var paused = true
private var pausedForCache = false
private var seeking = false
private var eof = false
private val props = HashMap<String, String>()
private var lastStatsAt = 0L
@Volatile private var running = true
@Volatile private var released = false
private var errorReported = false
private val eventThread: Thread
init {
// SLEEPYOWL_MPV_OPTIONS="mute=yes,hwdec=no": extra options for checks and measurements.
val extra = System.getenv("SLEEPYOWL_MPV_OPTIONS").orEmpty().split(',').mapNotNull { o ->
o.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0].trim() to it[1].trim() }
}
val audio = listOf("volume" to "$startVolume", "mute" to if (startMuted) "yes" else "no")
for ((name, value) in OPTIONS + audio + extra) {
val rc = lib.mpv_set_option_string(handle, name, value)
if (rc < 0) Log.w("mpv option %s=%s: %s", name, value, lib.mpv_error_string(rc))
}
lib.mpv_request_log_messages(handle, System.getenv("SLEEPYOWL_MPV_LOG") ?: "warn")
val rc = lib.mpv_initialize(handle)
check(rc >= 0) { "mpv_initialize: ${lib.mpv_error_string(rc)}" }
for ((name, format) in OBSERVED) lib.mpv_observe_property(handle, 0, name, format)
eventThread = thread(name = "mpv-events", isDaemon = true) { eventLoop() }
video.start()
}
override fun load(request: LoadRequest) {
this.request = request
audioIndex = request.audioStreamIndex
subtitleIndex = request.subtitleStreamIndex
val direct = request.playMethod == PlayMethod.DIRECT_PLAY
val commands = mutableListOf<Array<String>>()
commands += arrayOf("set", "start", if (request.startPositionMs > 0) seconds(request.startPositionMs) else "none")
commands += arrayOf("set", "deinterlace", if (request.mediaSource.videoStream?.isInterlaced == true) "yes" else "no")
// Until the file's tracks are known, embedded tracks by their place among the same type; checked on file-loaded.
val aid = request.audioStreamIndex?.takeIf { direct }?.let { request.mediaSource.embeddedOrdinal(it, MediaStreamType.AUDIO) }
commands += arrayOf("set", "aid", aid?.let { "${it + 1}" } ?: "auto")
val sid = request.subtitleStreamIndex?.takeIf { direct }?.let { request.mediaSource.embeddedOrdinal(it, MediaStreamType.SUBTITLE) }
commands += arrayOf("set", "sid", sid?.let { "${it + 1}" } ?: "no")
commands += arrayOf("change-list", "sub-files", "clr", "")
// One at a time: a list value would be split at ':' (the path list separator), and URLs are full of them.
for (sub in request.externalSubtitles) commands += arrayOf("change-list", "sub-files", "append", sub.url)
commands += arrayOf("loadfile", request.url)
_state.value = EngineState(EngineState.Phase.BUFFERING, false)
val now = synchronized(lock) {
if (!renderReady) pendingLoad = commands
renderReady
}
if (now) commands.forEach(::command)
}
/** On the render thread: mpv can open its video output now. */
private fun onRenderReady() {
val commands = synchronized(lock) {
renderReady = true
pendingLoad.also { pendingLoad = null }
}
if (!released) commands?.forEach(::command)
}
private fun onFirstFrame() {
if (released) return
Log.i("mpv: first frame (%s, %s)", video.renderSize, video.glInfo)
_events.tryEmit(EngineEvent.FirstFrameRendered)
}
override fun play() {
command("set", "pause", "no")
_state.update { it.copy(playWhenReady = true) }
}
override fun pause() {
command("set", "pause", "yes")
_state.update { it.copy(playWhenReady = false) }
}
override fun seekTo(positionMs: Long) {
position = positionMs
if (!fileLoaded) {
pendingSeekMs = positionMs
return
}
// hr-seek=yes: absolute seeks land on the exact frame.
command("seek", seconds(positionMs), "absolute")
}
override fun selectAudio(streamIndex: Int) {
audioIndex = streamIndex
if (fileLoaded) applyTracks()
}
override fun selectSubtitle(streamIndex: Int?) {
subtitleIndex = streamIndex
if (fileLoaded) applyTracks()
}
/**
* Jellyfin's stream index → mpv's track id. Embedded tracks carry ffmpeg's stream index (`ff-index`), which is
* Jellyfin's index too (both come from ffprobe) after the external files; external subtitles are found by their URL. In a transcode the
* server has already picked the audio, so only subtitles are chosen.
*/
private fun applyTracks() {
val req = request ?: return
val list = tracks
val direct = req.playMethod == PlayMethod.DIRECT_PLAY
val aid = if (direct) audioIndex?.let { index ->
trackId(req, list, index, "audio").also { if (it == null) Log.w("mpv: audio stream %d not found among %s", index, list) }
} else {
null
}
aid?.let { command("set", "aid", "$it") }
val sub = subtitleIndex
val sid = when {
sub == null -> "no"
else -> trackId(req, list, sub, "sub")?.toString().also { if (it == null) Log.w("mpv: subtitle stream %d not found among %s", sub, list) }
}
sid?.let { command("set", "sid", it) }
Log.i("mpv: audio %s → aid %s, subtitles %s → sid %s", audioIndex, aid, sub, sid)
}
private fun trackId(req: LoadRequest, list: List<Track>, streamIndex: Int, type: String): Int? {
val external = req.externalSubtitles.indexOfFirst { it.streamIndex == streamIndex }
if (external >= 0) {
val url = req.externalSubtitles[external].url
return list.firstOrNull { it.external && it.filename == url }?.id
?: list.filter { it.type == type && it.external }.getOrNull(external)?.id
}
if (req.playMethod != PlayMethod.DIRECT_PLAY) return null
// Jellyfin numbers external files first, then the container's streams by ffmpeg's index.
val ffIndex = streamIndex - req.mediaSource.mediaStreams.orEmpty().count { it.isExternal && it.index < streamIndex }
list.firstOrNull { it.type == type && !it.external && it.ffIndex == ffIndex }?.let { return it.id }
val mediaType = if (type == "audio") MediaStreamType.AUDIO else MediaStreamType.SUBTITLE
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, mediaType) ?: return null
return list.filter { it.type == type && !it.external }.getOrNull(ordinal)?.id
}
/** 0…[MAX_VOLUME] %; the player keeps it in its settings and sets [startVolume] for the next engine. */
fun setVolume(percent: Int) = command("set", "volume", "${percent.coerceIn(0, MAX_VOLUME)}")
fun setMuted(muted: Boolean) = command("set", "mute", if (muted) "yes" else "no")
override fun setAudioDelay(delayMs: Long) = command("set", "audio-delay", seconds(delayMs))
override fun setSubtitleDelay(delayMs: Long) = command("set", "sub-delay", seconds(delayMs))
/** Text subtitles only: ASS keeps its own styles except for the size (`sub-ass-override=scale`). */
override fun setSubtitleStyle(style: SubtitleStyle): Boolean {
command("set", "sub-scale", "%.2f".format(Locale.ROOT, style.size.scale))
command("set", "sub-color", color(style.color))
when (style.background) {
SubtitleStyle.Background.NONE -> {
command("set", "sub-border-style", "outline-and-shadow")
command("set", "sub-outline-size", "0")
command("set", "sub-shadow-offset", "0")
}
SubtitleStyle.Background.SHADOW -> {
command("set", "sub-border-style", "outline-and-shadow")
command("set", "sub-outline-size", "1.65")
command("set", "sub-outline-color", "#FF000000")
command("set", "sub-shadow-offset", "1.5")
command("set", "sub-back-color", "#A0000000")
}
SubtitleStyle.Background.BOX -> {
// The box of opaque-box is drawn in the outline colour.
command("set", "sub-border-style", "opaque-box")
command("set", "sub-outline-size", "4")
command("set", "sub-outline-color", "#B8000000")
command("set", "sub-shadow-offset", "0")
}
}
return true
}
/**
* `sub-pos` (100 = the bottom, % of the picture's height): unlike `sub-margin-y`, it moves ASS subtitles too — libass
* leaves the signs placed by the script where they are.
*/
override fun setSubtitleLift(fraction: Float) {
command("set", "sub-pos", "${(100 - fraction * 100).roundToInt().coerceIn(0, 100)}")
}
override fun release() {
if (released) return
released = true
// Teardown waits for the render thread and for mpv's own threads (a stalled network read can hold them):
// never on the UI thread.
thread(name = "mpv-release") {
val started = monotonicMs()
running = false
lib.mpv_wakeup(handle)
eventThread.join(2000)
video.stop()
lib.mpv_terminate_destroy(handle)
val frames = video.rendered.get()
Log.i(
"mpv released in %d ms; %d frames drawn, %.1f ms render, %.1f ms copy on average, %d replaced before shown",
monotonicMs() - started, frames, video.renderNs.get() / 1e6 / frames.coerceAtLeast(1),
video.readbackNs.get() / 1e6 / frames.coerceAtLeast(1), video.frames.lost,
)
}
}
private fun command(vararg args: String) {
if (released) return
lib.mpv_command_async(handle, 0, arrayOf(*args, null))
}
private fun eventLoop() {
while (running) {
val ev = lib.mpv_wait_event(handle, 0.5)
if (!running) return
when (ev.getInt(0)) {
LibMpv.EVENT_NONE -> Unit
LibMpv.EVENT_SHUTDOWN -> return
LibMpv.EVENT_LOG_MESSAGE -> {
val msg = ev.getPointer(16)
val prefix = msg.getPointer(0).getString(0)
val level = msg.getPointer(8).getString(0)
val text = msg.getPointer(16).getString(0, "UTF-8").trimEnd()
if (level == "error" || level == "fatal") lastError = "$prefix: $text"
Log.i("mpv [%s] %s: %s", prefix, level, text)
}
LibMpv.EVENT_PROPERTY_CHANGE -> onProperty(ev.getPointer(16))
LibMpv.EVENT_FILE_LOADED -> onFileLoaded()
LibMpv.EVENT_END_FILE -> {
val data = ev.getPointer(16)
if (data.getInt(0) == LibMpv.END_FILE_REASON_ERROR) onLoadError(data.getInt(4))
}
}
val now = monotonicMs()
if (now - lastStatsAt >= 1000) {
lastStatsAt = now
publishStats()
}
}
}
private var lastError: String? = null
private fun onFileLoaded() {
tracks = readTracks()
Log.i("mpv: file loaded, tracks %s", tracks)
fileLoaded = true
applyTracks()
val seek = pendingSeekMs
if (seek >= 0) {
pendingSeekMs = -1
command("seek", seconds(seek), "absolute")
}
publishState()
}
private fun onLoadError(code: Int) {
val kind = when (code) {
LibMpv.ERROR_LOADING_FAILED -> EngineError.Kind.NETWORK
LibMpv.ERROR_UNKNOWN_FORMAT, LibMpv.ERROR_NOTHING_TO_PLAY -> EngineError.Kind.FORMAT
LibMpv.ERROR_AO_INIT_FAILED -> EngineError.Kind.AUDIO_OUTPUT
LibMpv.ERROR_VO_INIT_FAILED -> EngineError.Kind.NO_VIDEO_OUTPUT
else -> EngineError.Kind.UNKNOWN
}
reportError(EngineError(kind, "mpv: ${lib.mpv_error_string(code)}" + (lastError?.let { " ($it)" } ?: "")))
}
/** Synchronous reads, but on the event thread: the core never waits for it. */
private fun readTracks(): List<Track> {
val count = getString("track-list/count")?.toIntOrNull() ?: return emptyList()
return (0 until count).mapNotNull { i ->
val id = getString("track-list/$i/id")?.toIntOrNull() ?: return@mapNotNull null
Track(
id = id,
type = getString("track-list/$i/type").orEmpty(),
ffIndex = getString("track-list/$i/ff-index")?.toIntOrNull(),
external = getString("track-list/$i/external") == "yes",
filename = getString("track-list/$i/external-filename"),
)
}
}
private fun getString(name: String): String? {
val p = lib.mpv_get_property_string(handle, name) ?: return null
return try {
p.getString(0, "UTF-8")
} finally {
lib.mpv_free(p)
}
}
private fun onProperty(p: Pointer) {
val name = p.getPointer(0).getString(0)
val format = p.getInt(8)
val data = p.getPointer(16)
val value: Any? = when {
data == null || format == LibMpv.FORMAT_NONE -> null
format == LibMpv.FORMAT_DOUBLE -> data.getDouble(0)
format == LibMpv.FORMAT_FLAG -> data.getInt(0) != 0
format == LibMpv.FORMAT_INT64 -> data.getLong(0)
format == LibMpv.FORMAT_STRING -> data.getPointer(0)?.getString(0, "UTF-8")
else -> null
}
when (name) {
"pause" -> { paused = value == true; publishState() }
"paused-for-cache" -> { pausedForCache = value == true; publishState() }
"seeking" -> { seeking = value == true; publishState() }
"eof-reached" -> { eof = value == true; publishState() }
"time-pos" -> (value as? Double)?.let { position = (it * 1000).toLong() }
"duration" -> duration = ((value as? Double) ?: 0.0).times(1000).toLong()
"demuxer-cache-time" -> cacheEnd = (value as? Double)?.times(1000)?.toLong() ?: -1
"seekable" -> seekable = value != false
"dwidth", "dheight" -> {
props[name] = value?.toString().orEmpty()
video.setVideoSize(props["dwidth"]?.toIntOrNull() ?: 0, props["dheight"]?.toIntOrNull() ?: 0)
}
else -> props[name] = when (value) {
null -> ""
is Double -> "%.3f".format(Locale.ROOT, value)
else -> value.toString()
}
}
}
private fun publishState() {
val phase = when {
eof -> EngineState.Phase.ENDED
!fileLoaded || pausedForCache || seeking -> EngineState.Phase.BUFFERING
else -> EngineState.Phase.READY
}
_state.update { it.copy(phase = phase, playWhenReady = !paused) }
}
private fun publishStats() {
fun p(name: String) = props[name]?.takeIf { it.isNotEmpty() }
val frames = video.rendered.get().coerceAtLeast(1)
_stats.value = EngineStats(
videoDecoder = "mpv · " + (p("hwdec-current")?.takeIf { it != "no" }?.let { "аппаратно ($it)" } ?: "программно"),
videoFormat = listOfNotNull(
p("video-codec"),
p("video-params/w")?.let { w -> "${w}x${p("video-params/h")}" },
p("video-params/pixelformat"),
p("container-fps")?.let { "${it.trimEnd('0').trimEnd('.')} fps" },
).joinToString(" ").ifEmpty { null },
audioDecoder = p("audio-codec-name"),
audioFormat = listOfNotNull(
p("audio-params/channel-count")?.let { "$it кан." },
p("audio-params/samplerate")?.let { "$it Гц" },
).joinToString(", ").ifEmpty { null },
audioOutput = p("current-ao"),
droppedFrames = (p("frame-drop-count")?.toIntOrNull() ?: 0) + (p("decoder-frame-drop-count")?.toIntOrNull() ?: 0),
extra = listOf(
"GPU" to video.glInfo,
"кадр в памяти" to video.renderSize,
"отрисовка / копия" to "%.1f / %.1f мс".format(Locale.ROOT, video.renderNs.get() / 1e6 / frames, video.readbackNs.get() / 1e6 / frames),
"потеряно в окне" to "${video.frames.lost}",
).toMap(),
)
}
private fun reportError(error: EngineError) {
if (errorReported || released) return
errorReported = true
Log.w("mpv error: %s", error.message)
_events.tryEmit(EngineEvent.Error(error))
}
internal data class Track(val id: Int, val type: String, val ffIndex: Int?, val external: Boolean, val filename: String?) {
override fun toString() = "$type#$id" + (ffIndex?.let { "/ff$it" } ?: "") + if (external) "/ext" else ""
}
companion object {
/** mpv's own default `volume-max`. */
const val MAX_VOLUME = 130
/** Volume and mute an engine starts with (from the first sample on: no loud blip before the player sets them). */
@Volatile var startVolume = 100
@Volatile var startMuted = false
private fun seconds(ms: Long) = "%.3f".format(Locale.ROOT, ms / 1000.0)
/** ARGB → mpv's `#AARRGGBB`. */
private fun color(argb: Int) = "#%08X".format(argb)
private val OPTIONS = listOf(
"vo" to "libmpv",
"hwdec" to "auto-safe",
"config" to "no",
"terminal" to "no",
"input-default-bindings" to "no",
"input-vo-keyboard" to "no",
"osc" to "no",
"ytdl" to "no",
"idle" to "yes",
"keep-open" to "yes",
"hr-seek" to "yes",
"cache" to "yes",
"demuxer-max-bytes" to "150MiB",
"demuxer-max-back-bytes" to "50MiB",
"audio-channels" to "auto-safe",
"ao" to "pipewire,pulse,alsa",
"audio-client-name" to "SleepyOwl",
"tone-mapping" to "auto",
"sub-auto" to "no",
"sub-ass-override" to "scale",
"user-agent" to "SleepyOwl",
)
private val OBSERVED = listOf(
"pause" to LibMpv.FORMAT_FLAG,
"paused-for-cache" to LibMpv.FORMAT_FLAG,
"seeking" to LibMpv.FORMAT_FLAG,
"eof-reached" to LibMpv.FORMAT_FLAG,
"seekable" to LibMpv.FORMAT_FLAG,
"time-pos" to LibMpv.FORMAT_DOUBLE,
"duration" to LibMpv.FORMAT_DOUBLE,
"demuxer-cache-time" to LibMpv.FORMAT_DOUBLE,
"dwidth" to LibMpv.FORMAT_INT64,
"dheight" to LibMpv.FORMAT_INT64,
"hwdec-current" to LibMpv.FORMAT_STRING,
"video-codec" to LibMpv.FORMAT_STRING,
"video-params/w" to LibMpv.FORMAT_INT64,
"video-params/h" to LibMpv.FORMAT_INT64,
"video-params/pixelformat" to LibMpv.FORMAT_STRING,
"container-fps" to LibMpv.FORMAT_DOUBLE,
"audio-codec-name" to LibMpv.FORMAT_STRING,
"audio-params/channel-count" to LibMpv.FORMAT_INT64,
"audio-params/samplerate" to LibMpv.FORMAT_INT64,
"current-ao" to LibMpv.FORMAT_STRING,
"frame-drop-count" to LibMpv.FORMAT_INT64,
"decoder-frame-drop-count" to LibMpv.FORMAT_INT64,
)
}
}

View File

@@ -0,0 +1,71 @@
package ru.stenfredd.sleepyowl.playback.mpv
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
import org.koin.dsl.bind
import org.koin.dsl.module
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
import ru.stenfredd.sleepyowl.playback.api.EngineId
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.playback.api.Verdict
import ru.stenfredd.sleepyowl.playback.api.videoStream
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
/** Koin: the desktop's only engine and the desktop's device probe. */
val mpvEngineModule = module {
single { MpvEngineProvider() } bind EngineProvider::class
single<DeviceCapabilitiesProbe> { PcCapabilitiesProbe() }
}
/**
* mpv plays everything ffmpeg demuxes and decodes, in hardware where the GPU can (NVDEC, VAAPI), otherwise on the CPU,
* which a PC can afford. HDR and Dolby Vision are tone-mapped to SDR (the desktop has no HDR output, LINUX_PLAN.md §0).
*/
class MpvEngineProvider : EngineProvider {
override val id = ID
override val displayName = "mpv"
override val priority = 0
override fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict {
val range = source.videoStream?.videoRangeType?.serialName
val notes = listOfNotNull(range?.takeIf { it != "SDR" && it != "Unknown" }?.let { "$it → SDR" })
return Verdict.Direct(notes)
}
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile = buildDeviceProfile {
name = "SleepyOwl / mpv"
maxStreamingBitrate = MAX_BITRATE
maxStaticBitrate = MAX_BITRATE
directPlayProfile { type = DlnaProfileType.VIDEO }
directPlayProfile { type = DlnaProfileType.AUDIO }
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
protocol = MediaStreamProtocol.HLS
container = "ts"
videoCodec("h264", "hevc")
audioCodec("aac", "ac3", "eac3", "mp3", "opus", "flac")
minSegments = 1
}
for (format in listOf("srt", "subrip", "ass", "ssa", "pgssub", "dvdsub", "dvbsub", "mov_text", "vtt", "webvtt", "sub", "sup", "idx")) {
subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
}
for (format in listOf("srt", "subrip", "ass", "ssa", "vtt", "webvtt", "sub", "sup", "pgssub")) {
subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
}
}
override fun create(device: DeviceCapabilities): PlaybackEngine = MpvEngine()
companion object {
val ID = EngineId("mpv")
private const val MAX_BITRATE = 400_000_000
}
}

View File

@@ -0,0 +1,371 @@
package ru.stenfredd.sleepyowl.playback.mpv
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.IntSize
import com.sun.jna.CallbackReference
import com.sun.jna.CallbackThreadInitializer
import com.sun.jna.Memory
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.ptr.IntByReference
import com.sun.jna.ptr.PointerByReference
import org.jetbrains.skia.Data
import ru.stenfredd.sleepyowl.core.common.Log
import java.util.concurrent.atomic.AtomicLong
import kotlin.concurrent.thread
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* mpv's picture, variant B of LINUX_PLAN.md §2.3: mpv draws in its own invisible OpenGL context (EGL pbuffer) on its
* own thread, into an FBO; the frame is read back through a persistently mapped PBO into memory that Compose shows
* ([MpvVideoSurface]). mpv knows nothing about the window, so the window can be rebuilt freely.
*
* The FBO is the size of the video area, but never larger than the video itself (L1: Skia uploads every frame, a
* 1080p film drawn 3440×1440 costs 2.6 times the bytes for nothing): above the video's own size Skia scales it up.
*/
class MpvVideo internal constructor(
private val mpv: Pointer,
private val onReady: () -> Unit,
private val onFirstFrame: () -> Unit,
private val onFailure: (Throwable) -> Unit,
) {
internal val frames = FramePool()
/** Grows with every new frame; read while drawing, it makes Compose redraw. */
internal val serial = mutableIntStateOf(0)
/** The video's display size, [IntSize.Zero] until known: the mini window keeps its proportions. */
var displaySize by mutableStateOf(IntSize.Zero)
private set
// Written by the render thread, read for the stream info.
internal val rendered = AtomicLong()
internal val renderNs = AtomicLong()
internal val readbackNs = AtomicLong()
@Volatile internal var glInfo = ""
@Volatile internal var renderSize = ""
private val lock = Object()
private var areaW = 0
private var areaH = 0
private var videoW = 0
private var videoH = 0
private var pendingUpdate = false
private var pendingResize = false
private var stopping = false
private var firstFrameSent = false
private var thread: Thread? = null
private val updateCallback = LibMpv.UpdateCallback {
synchronized(lock) { pendingUpdate = true; lock.notifyAll() }
}
private val getProcAddress = LibMpv.GetProcAddress { _, name -> Egl.eglGetProcAddress(name) }
/** Starts the render thread; [onReady] is called on it once mpv has its render context (before that loadfile fails). */
internal fun start() {
// mpv calls back from its own threads: keep them attached to the JVM instead of attaching on every call.
Native.setCallbackThreadInitializer(updateCallback, CallbackThreadInitializer(true, false, "mpv-update"))
thread = thread(name = "mpv-render", isDaemon = true) {
try {
run()
} catch (t: Throwable) {
Log.e(t, "mpv render thread failed")
onFailure(t)
}
}
}
/** Size of the video area in window pixels. */
fun resize(w: Int, h: Int) = synchronized(lock) {
if (w == areaW && h == areaH) return
areaW = w; areaH = h; pendingResize = true; lock.notifyAll()
}
/** The video's display size (mpv's `dwidth`/`dheight`); 0 until known. A known size also arms the first-frame event. */
internal fun setVideoSize(w: Int, h: Int) = synchronized(lock) {
if (w == videoW && h == videoH) return
videoW = w; videoH = h; pendingResize = true; lock.notifyAll()
displaySize = IntSize(w, h)
}
/** Frees the render context and the GL objects; must run before `mpv_terminate_destroy`. Blocks up to 3 s. */
internal fun stop() {
synchronized(lock) { stopping = true; lock.notifyAll() }
thread?.join(3000)
}
private fun run() {
val egl = EglContext.create()
glInfo = egl.info
val lib = LibMpv.INSTANCE
val initParams = Memory(16).apply {
setPointer(0, CallbackReference.getFunctionPointer(getProcAddress))
setPointer(8, null)
}
val one = Memory(4).apply { setInt(0, 1) }
val apiType = Memory(8).apply { setString(0, "opengl") }
val createParams = params(
LibMpv.RENDER_PARAM_API_TYPE to apiType,
LibMpv.RENDER_PARAM_OPENGL_INIT_PARAMS to initParams,
// Allowed: this thread only calls mpv_render_* and never waits on the core.
LibMpv.RENDER_PARAM_ADVANCED_CONTROL to one,
)
val ref = PointerByReference()
val rc = lib.mpv_render_context_create(ref, mpv, createParams)
if (rc < 0) {
egl.destroy()
error("mpv_render_context_create: ${lib.mpv_error_string(rc)}")
}
val ctx = ref.value
lib.mpv_render_context_set_update_callback(ctx, updateCallback, null)
onReady()
val fbo = Memory(16)
// No FLIP_Y: glReadPixels reads bottom-up, so the frame lands in memory top-down, as Skia wants it.
val renderParams = params(LibMpv.RENDER_PARAM_OPENGL_FBO to fbo)
val target = RenderTarget()
try {
while (true) {
var update: Boolean
var resized: Boolean
var w: Int
var h: Int
var hasVideo: Boolean
synchronized(lock) {
while (!pendingUpdate && !pendingResize && !stopping) lock.wait()
if (stopping) return
update = pendingUpdate; resized = pendingResize
pendingUpdate = false; pendingResize = false
val size = renderSize(areaW, areaH, videoW, videoH)
w = size.first; h = size.second
hasVideo = videoW > 0 && videoH > 0
}
var newFrame = false
if (update) newFrame = lib.mpv_render_context_update(ctx) and LibMpv.RENDER_UPDATE_FRAME != 0L
if (!(newFrame || resized) || w <= 0 || h <= 0) continue
target.ensure(w, h)
val sizeText = "${w}x$h"
if (sizeText != renderSize && hasVideo) Log.d("mpv: frame %s for the area %dx%d", sizeText, areaW, areaH)
renderSize = sizeText
fbo.setInt(0, target.fbo); fbo.setInt(4, w); fbo.setInt(8, h); fbo.setInt(12, Gl.RGBA8)
val t0 = System.nanoTime()
lib.mpv_render_context_render(ctx, renderParams)
val t1 = System.nanoTime()
val frame = frames.acquire(w, h)
target.readInto(frame.pointer!!, w, h)
val t2 = System.nanoTime()
frames.publish(frame)
lib.mpv_render_context_report_swap(ctx)
renderNs.addAndGet(t1 - t0); readbackNs.addAndGet(t2 - t1); rendered.incrementAndGet()
serial.intValue++
if (newFrame && hasVideo && !firstFrameSent) {
firstFrameSent = true
onFirstFrame()
}
}
} finally {
lib.mpv_render_context_set_update_callback(ctx, null, null)
lib.mpv_render_context_free(ctx)
target.release()
egl.destroy()
}
}
private fun params(vararg p: Pair<Int, Pointer>): Memory {
val m = Memory(16L * (p.size + 1)).apply { clear() }
p.forEachIndexed { i, (type, data) -> m.setInt(16L * i, type); m.setPointer(16L * i + 8, data) }
return m
}
/** FBO with its texture and the readback buffer. Lives on the render thread. */
private class RenderTarget {
var fbo = 0
private var tex = 0
private var pbo = 0
/** The PBO stays mapped for its whole life (GL 4.4 buffer storage); null when the driver refused that. */
private var mapped: Pointer? = null
private var w = 0
private var h = 0
fun ensure(nw: Int, nh: Int) {
if (nw == w && nh == h && fbo != 0) return
release()
w = nw; h = nh
val id = IntArray(1)
Gl.glGenTextures(1, id); tex = id[0]
Gl.glBindTexture(Gl.TEXTURE_2D, tex)
Gl.glTexParameteri(Gl.TEXTURE_2D, Gl.TEXTURE_MIN_FILTER, Gl.LINEAR)
Gl.glTexParameteri(Gl.TEXTURE_2D, Gl.TEXTURE_MAG_FILTER, Gl.LINEAR)
Gl.glTexImage2D(Gl.TEXTURE_2D, 0, Gl.RGBA8, w, h, 0, Gl.BGRA, Gl.UNSIGNED_INT_8_8_8_8_REV, null)
Gl.glGenFramebuffers(1, id); fbo = id[0]
Gl.glBindFramebuffer(Gl.FRAMEBUFFER, fbo)
Gl.glFramebufferTexture2D(Gl.FRAMEBUFFER, Gl.COLOR_ATTACHMENT0, Gl.TEXTURE_2D, tex, 0)
val status = Gl.glCheckFramebufferStatus(Gl.FRAMEBUFFER)
check(status == Gl.FRAMEBUFFER_COMPLETE) { "FBO incomplete: 0x${status.toString(16)}" }
val size = 4L * w * h
val flags = Gl.MAP_READ_BIT or Gl.MAP_PERSISTENT_BIT or Gl.MAP_COHERENT_BIT
Gl.glGenBuffers(1, id); pbo = id[0]
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, pbo)
// Client storage: the driver keeps it in cached system memory, which the CPU reads fast.
Gl.glBufferStorage(Gl.PIXEL_PACK_BUFFER, size, null, flags or Gl.CLIENT_STORAGE_BIT)
mapped = Gl.glMapBufferRange(Gl.PIXEL_PACK_BUFFER, 0, size, flags)
if (mapped == null) Log.w("mpv: the readback buffer can't stay mapped, mapping it per frame")
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, 0)
}
/** Synchronous: the frame is needed now, or the picture runs a frame behind the sound. */
fun readInto(dst: Pointer, w: Int, h: Int) {
val size = 4L * w * h
Gl.glBindFramebuffer(Gl.FRAMEBUFFER, fbo)
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, pbo)
Gl.glPixelStorei(Gl.PACK_ALIGNMENT, 4)
Gl.glReadPixels(0, 0, w, h, Gl.BGRA, Gl.UNSIGNED_INT_8_8_8_8_REV, 0L)
val sync = Gl.glFenceSync(Gl.SYNC_GPU_COMMANDS_COMPLETE, 0)
if (sync != null) {
Gl.glClientWaitSync(sync, Gl.SYNC_FLUSH_COMMANDS_BIT, 200_000_000L)
Gl.glDeleteSync(sync)
}
val src = mapped
if (src != null) {
CLib.memcpy(dst, src, size)
} else {
Gl.glMapBufferRange(Gl.PIXEL_PACK_BUFFER, 0, size, Gl.MAP_READ_BIT)?.let {
CLib.memcpy(dst, it, size)
Gl.glUnmapBuffer(Gl.PIXEL_PACK_BUFFER)
}
}
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, 0)
}
fun release() {
if (fbo != 0) Gl.glDeleteFramebuffers(1, intArrayOf(fbo))
if (tex != 0) Gl.glDeleteTextures(1, intArrayOf(tex))
if (pbo != 0) {
if (mapped != null) {
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, pbo)
Gl.glUnmapBuffer(Gl.PIXEL_PACK_BUFFER)
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, 0)
}
Gl.glDeleteBuffers(1, intArrayOf(pbo))
}
fbo = 0; tex = 0; pbo = 0; mapped = null
}
}
/** An invisible OpenGL context through EGL: all drawing goes to our own FBOs, the 16×16 pbuffer is only there to bind. */
internal class EglContext(val dpy: Pointer, val surface: Pointer?, val ctx: Pointer, val info: String) {
fun destroy() {
Egl.eglMakeCurrent(dpy, null, null, null)
surface?.let { Egl.eglDestroySurface(dpy, it) }
Egl.eglDestroyContext(dpy, ctx)
}
companion object {
fun create(): EglContext {
val dpy = Egl.eglGetDisplay(null) ?: error("eglGetDisplay")
check(Egl.eglInitialize(dpy, IntByReference(), IntByReference()) != 0) { "eglInitialize 0x${Egl.eglGetError().toString(16)}" }
check(Egl.eglBindAPI(Egl.OPENGL_API) != 0) { "eglBindAPI" }
val egl = "EGL ${Egl.eglQueryString(dpy, Egl.VERSION)} ${Egl.eglQueryString(dpy, Egl.VENDOR)}"
val attribs = intArrayOf(
Egl.SURFACE_TYPE, Egl.PBUFFER_BIT, Egl.RENDERABLE_TYPE, Egl.OPENGL_BIT,
Egl.RED_SIZE, 8, Egl.GREEN_SIZE, 8, Egl.BLUE_SIZE, 8, Egl.NONE,
)
val configs = PointerByReference()
val num = IntByReference()
check(Egl.eglChooseConfig(dpy, attribs, configs, 1, num) != 0 && num.value > 0) { "eglChooseConfig ($egl)" }
val config = configs.value
val ctx = Egl.eglCreateContext(dpy, config, null, intArrayOf(Egl.NONE)) ?: error("eglCreateContext 0x${Egl.eglGetError().toString(16)} ($egl)")
var surface = Egl.eglCreatePbufferSurface(dpy, config, intArrayOf(Egl.WIDTH, 16, Egl.HEIGHT, 16, Egl.NONE))
if (surface == null || Egl.eglMakeCurrent(dpy, surface, surface, ctx) == 0) {
Log.w("mpv: EGL pbuffer refused (0x%s, %s), trying without a surface", Egl.eglGetError().toString(16), egl)
surface?.let { Egl.eglDestroySurface(dpy, it) }
surface = null
check(Egl.eglMakeCurrent(dpy, null, null, ctx) != 0) { "eglMakeCurrent 0x${Egl.eglGetError().toString(16)} ($egl)" }
}
val info = "${Gl.glGetString(Gl.RENDERER)} · GL ${Gl.glGetString(Gl.VERSION)?.substringBefore(' ')}"
return EglContext(dpy, surface, ctx, info)
}
}
}
companion object {
/**
* Call first thing in `main`, before any window. libglvnd rewrites the GL entry points for the first vendor that
* makes a context current — Skiko's GLX context of the first Compose window — and then refuses to make current a
* context of another API: on NVIDIA mpv's EGL context fails in eglMakeCurrent with no error code (Mesa doesn't
* mind, the L1 prototype made its EGL context before its window). Disallowing the rewrite costs nothing
* measurable; libGLdispatch reads the variable when it loads, which is when Skiko first draws.
*/
fun prepareProcess() {
CLib.setenv("__GLVND_DISALLOW_PATCHING", "1", 1)
}
/**
* The FBO for a video area of [areaW]×[areaH]: the area itself, scaled down when the video would be drawn larger
* than its own size, so mpv never upscales into extra bytes. The area's proportions are kept: mpv still draws
* the black bars, and Skia stretches the frame evenly.
*/
internal fun renderSize(areaW: Int, areaH: Int, videoW: Int, videoH: Int): Pair<Int, Int> {
if (areaW <= 0 || areaH <= 0 || videoW <= 0 || videoH <= 0) return areaW to areaH
val scale = min(areaW.toDouble() / videoW, areaH.toDouble() / videoH)
if (scale <= 1.0) return areaW to areaH
return max(1, (areaW / scale).roundToInt()) to max(1, (areaH / scale).roundToInt())
}
}
}
/**
* Three frame buffers: the one shown (held by the UI), the latest ready one and the one being written. A frame replaced
* before the UI got to show it counts as [lost].
*
* The memory is Skia's own [Data], not the mapped PBO: an Image over the PBO would outlive the buffer whenever Compose
* replays a recorded picture after the engine is gone; Data is reference-counted by the images using it.
*/
internal class FramePool {
class Frame {
var data: Data? = null
var pointer: Pointer? = null
var width = 0
var height = 0
var serial = 0L
}
private val all = List(3) { Frame() }
private var latest: Frame? = null
private var shown: Frame? = null
private var nextSerial = 1L
var lost = 0L
private set
@Synchronized fun acquire(w: Int, h: Int): Frame {
val f = all.first { it !== latest && it !== shown }
val size = 4 * w * h
if (f.data == null || f.data!!.size < size) {
f.data?.close() // an Image in the UI keeps its own reference to the old Data
val d = Data.makeUninitialized(size)
f.data = d
f.pointer = Pointer(d.writableData())
}
f.width = w
f.height = h
return f
}
@Synchronized fun publish(f: Frame) {
if (latest != null && latest !== shown) lost++
f.serial = nextSerial++
latest = f
}
/** For the UI: the latest frame. The one shown before goes back to the writer. */
@Synchronized fun take(): Frame? {
latest?.let { shown = it }
return shown
}
}

View File

@@ -0,0 +1,61 @@
package ru.stenfredd.sleepyowl.playback.mpv
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.layout.onSizeChanged
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageInfo
import org.jetbrains.skia.Paint
import org.jetbrains.skia.Rect
import org.jetbrains.skia.SamplingMode
/** The latest frame of [video], filling the composable; the composable's pixel size is what mpv draws for. */
@Composable
fun MpvVideoSurface(video: MpvVideo, modifier: Modifier = Modifier) {
val holder = remember(video) { ImageHolder() }
DisposableEffect(holder) { onDispose { holder.close() } }
Canvas(modifier.onSizeChanged { video.resize(it.width, it.height) }) {
video.serial.intValue
drawRect(Color.Black)
val frame = video.frames.take() ?: return@Canvas
val image = holder.imageFor(frame)
drawIntoCanvas { canvas ->
val exact = frame.width == size.width.toInt() && frame.height == size.height.toInt()
// The frame matches the area: no resampling. Smaller (a small video in a big window, or a frame of the old
// size while the window is resized): Skia scales it up.
canvas.nativeCanvas.drawImageRect(
image, Rect.makeWH(frame.width.toFloat(), frame.height.toFloat()), Rect.makeWH(size.width, size.height),
if (exact) SamplingMode.DEFAULT else SamplingMode.CATMULL_ROM, holder.paint, true,
)
}
}
}
/** A Skia Image over the frame's memory, without a copy; made anew for every new frame. */
private class ImageHolder {
val paint = Paint()
private var image: Image? = null
private var serial = -1L
fun imageFor(frame: FramePool.Frame): Image {
image?.let { if (serial == frame.serial) return it }
image?.close()
val info = ImageInfo(frame.width, frame.height, ColorType.BGRA_8888, ColorAlphaType.OPAQUE)
val data = frame.data!!.makeSubset(0, 4 * frame.width * frame.height)
return Image.makeRaster(info, data, 4 * frame.width).also { image = it; serial = frame.serial; data.close() }
}
fun close() {
image?.close()
image = null
paint.close()
}
}

View File

@@ -0,0 +1,30 @@
package ru.stenfredd.sleepyowl.playback.mpv
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import java.io.File
/**
* The PC as the stream info shows it. Decoders are mpv's business (it picks NVDEC/VAAPI or the CPU per file), so
* there is nothing to probe for the engine choice: only names for the diagnostics.
*/
class PcCapabilitiesProbe : DeviceCapabilitiesProbe {
override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
private fun probe() = DeviceCapabilities(
manufacturer = "PC",
model = read("/etc/hostname") ?: "Linux",
soc = read("/proc/cpuinfo")?.lineSequence()?.firstOrNull { it.startsWith("model name") }?.substringAfter(':')?.trim()
?: System.getProperty("os.arch"),
sdkInt = 0,
abis = listOf(System.getProperty("os.arch")),
videoDecoders = emptyList(),
audioDecoders = emptyList(),
displayHdrTypes = emptySet(),
displayModes = emptyList(),
passthroughEncodings = emptySet(),
)
private fun read(path: String) = runCatching { File(path).readText().trim() }.getOrNull()?.ifEmpty { null }
}

View File

@@ -0,0 +1,41 @@
package ru.stenfredd.sleepyowl.playback.mpv
/**
* Manual check: `java -cp … ru.stenfredd.sleepyowl.playback.mpv.EglProbeKt [mpv] [awt]` — creates mpv's EGL context
* alone, after an mpv core (`mpv`), after an AWT window (`awt`) or a Compose one (`compose`); `prepare` calls
* [MpvVideo.prepareProcess] first.
*/
fun main(args: Array<String>) {
if ("prepare" in args) MpvVideo.prepareProcess()
if ("awt" in args) {
javax.swing.SwingUtilities.invokeAndWait {
javax.swing.JFrame("probe").apply { setSize(200, 100); isVisible = true }
}
}
if ("compose" in args) {
javax.swing.SwingUtilities.invokeAndWait {
androidx.compose.ui.awt.ComposeWindow().apply {
setSize(300, 200)
focusableWindowState = false
setLocation(3700, 1500)
setContent { androidx.compose.foundation.text.BasicText("probe") }
isVisible = true
}
}
Thread.sleep(1500)
}
if ("mpv" in args) {
val lib = LibMpv.INSTANCE
val h = lib.mpv_create()!!
lib.mpv_set_option_string(h, "vo", "libmpv")
println("mpv init ${lib.mpv_initialize(h)}")
}
val t = Thread {
val c = MpvVideo.EglContext.create()
println("OK ${c.info} surface=${c.surface}")
c.destroy()
}
t.start()
t.join()
System.exit(0)
}

View File

@@ -0,0 +1,25 @@
package ru.stenfredd.sleepyowl.playback.mpv
import org.junit.Assert.assertEquals
import org.junit.Test
class RenderSizeTest {
@Test
fun smallVideoInBigWindowIsDrawnAtItsOwnSize() {
// A 2.4:1 film on 3440×1440 (2.39:1): the FBO keeps the window's shape, the film fills its width.
assertEquals(1728 to 723, MpvVideo.renderSize(3440, 1440, 1728, 720))
// 16:9 1080p: the film fills the height, the bars are left and right.
assertEquals(2580 to 1080, MpvVideo.renderSize(3440, 1440, 1920, 1080))
}
@Test
fun windowSmallerThanVideoIsDrawnAtWindowSize() {
assertEquals(640 to 360, MpvVideo.renderSize(640, 360, 3840, 2160))
assertEquals(3440 to 1440, MpvVideo.renderSize(3440, 1440, 3840, 1600))
}
@Test
fun unknownVideoSizeTakesTheWindow() {
assertEquals(1024 to 576, MpvVideo.renderSize(1024, 576, 0, 0))
}
}