Desktop player on mpv: floating window, system integration
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
21
playback/engine-mpv/build.gradle.kts
Normal file
21
playback/engine-mpv/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user