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 @@ kotlin { jvmToolchain(21) }
dependencies { dependencies {
implementation(project(":ui:shared")) implementation(project(":ui:shared"))
implementation(project(":playback:engine-mpv"))
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
@@ -17,6 +18,8 @@ dependencies {
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.coroutines.swing)
implementation(libs.jna) implementation(libs.jna)
implementation(libs.dbus.java.core)
implementation(libs.dbus.java.unixsocket)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.koin.test) testImplementation(libs.koin.test)
@@ -28,7 +31,7 @@ val awtOpens = listOf("--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED", "--add
compose.desktop { compose.desktop {
application { application {
mainClass = "ru.stenfredd.sleepyowl.desktop.MainKt" mainClass = "ru.stenfredd.sleepyowl.desktop.MainKt"
jvmArgs += awtOpens + "-Dsleepyowl.repo=${rootDir.path}" jvmArgs += awtOpens
} }
} }

View File

@@ -60,6 +60,19 @@ fun App(window: java.awt.Window, keys: BackKeys) {
Log.i("Session restored: %s", if (loggedIn) "logged in" else "login screen") Log.i("Session restored: %s", if (loggedIn) "logged in" else "login screen")
stack = NavStack(if (loggedIn) root(session, Tab.Home) else Screen.Login) stack = NavStack(if (loggedIn) root(session, Tab.Home) else Screen.Login)
} }
// "К сериалу" in the player: the page opens here, and this window comes up.
val player = koinInject<DesktopPlayer>()
DisposableEffect(player, stack) {
player.openPage = { screen ->
stack?.push(screen)
(window as? java.awt.Frame)?.let { SwingUtilities.invokeLater { raise(it) } }
}
player.showLibrary = { (window as? java.awt.Frame)?.let { SwingUtilities.invokeLater { raise(it) } } }
onDispose {
player.openPage = {}
player.showLibrary = {}
}
}
val dispatcher = remember { BackDispatcher() } val dispatcher = remember { BackDispatcher() }
val entry = remember { KeyboardEntry() } val entry = remember { KeyboardEntry() }
val onBack = { stack?.let { back(dispatcher, it) } } val onBack = { stack?.let { back(dispatcher, it) } }
@@ -150,7 +163,7 @@ private fun BackButton(window: java.awt.Window, onBack: () -> Unit) {
@Composable @Composable
private fun Navigation(stack: NavStack, session: JellyfinSession, onBack: () -> Unit) { private fun Navigation(stack: NavStack, session: JellyfinSession, onBack: () -> Unit) {
val player = koinInject<ProtoPlayer>() val player = koinInject<DesktopPlayer>()
fun open(screen: Screen) { fun open(screen: Screen) {
if (screen is Screen.Player) player.play(screen) else stack.push(screen) if (screen is Screen.Player) player.play(screen) else stack.push(screen)
} }

View File

@@ -16,22 +16,21 @@ import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.common.desktopAppDirs import ru.stenfredd.sleepyowl.core.common.desktopAppDirs
import ru.stenfredd.sleepyowl.core.jellyfin.DesktopJellyfinPlatform import ru.stenfredd.sleepyowl.core.jellyfin.DesktopJellyfinPlatform
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinPlatform import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinPlatform
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.sleepyowl.ui.UiPlatform import ru.stenfredd.sleepyowl.ui.UiPlatform
import java.io.File import java.io.File
/** /**
* Koin: the desktop parts of the shared core and UI. The shared modules are listed in [main]; the mpv engine comes * Koin: the desktop parts of the shared core and UI. The shared modules and the mpv engine (with the PC's device probe)
* with L6. * are listed in [main].
*/ */
fun desktopModule(scale: UiScale) = module { fun desktopModule() = module {
single { desktopAppDirs() } single { desktopAppDirs() }
singleOf(::PreferenceStores) singleOf(::PreferenceStores)
singleOf(::DesktopSettings)
single<JellyfinPlatform> { DesktopJellyfinPlatform() } single<JellyfinPlatform> { DesktopJellyfinPlatform() }
single<DeviceCapabilitiesProbe> { DesktopCapabilitiesProbe() } single<UiPlatform> { DesktopUiPlatform(get()) }
single<UiPlatform> { DesktopUiPlatform(scale) } singleOf(::DesktopPlayer)
singleOf(::ProtoPlayer) singleOf(::SystemMedia)
} }
/** The places of the app's files besides [AppDirs]: the log and the single-instance lock. */ /** The places of the app's files besides [AppDirs]: the log and the single-instance lock. */
@@ -51,40 +50,14 @@ object DesktopPaths {
File(System.getenv(variable)?.takeIf { it.isNotBlank() } ?: (System.getProperty("user.home") + "/" + default), "sleepyowl") File(System.getenv(variable)?.takeIf { it.isNotBlank() } ?: (System.getProperty("user.home") + "/" + default), "sleepyowl")
} }
/** private class DesktopUiPlatform(private val settings: DesktopSettings) : UiPlatform {
* The UI's size: 1 dp of the TV layouts is 1 px at 100 % (LINUX_PLAN.md §1.4), times the system's own scale (GDK_SCALE,
* Xft.dpi). [factor] is "Масштаб интерфейса"; until the settings have it (L9) it comes from `SLEEPYOWL_SCALE`.
*/
class UiScale(val factor: Float) {
companion object {
fun fromEnvironment() = UiScale(System.getenv("SLEEPYOWL_SCALE")?.toFloatOrNull()?.coerceIn(0.5f, 3f) ?: 1f)
}
}
private class DesktopUiPlatform(private val scale: UiScale) : UiPlatform {
override val density: Float override val density: Float
get() = scale.factor * systemScale() get() = settings.scaleFactor * systemScale()
override val version = "${BuildInfo.VERSION} · desktop" override val version = "${BuildInfo.VERSION} · desktop"
override val device = "${System.getProperty("os.name")} ${System.getProperty("os.version")} · ${hostname()}" override val device = "${System.getProperty("os.name")} ${System.getProperty("os.version")} · ${hostname()}"
override val tourAvailable = false override val tourAvailable = false
} }
/** Until the mpv engine (L6): the probe has nothing to report, every format is mpv's. */
private class DesktopCapabilitiesProbe : DeviceCapabilitiesProbe {
override val capabilities = DeviceCapabilities(
manufacturer = "PC",
model = hostname(),
soc = System.getProperty("os.arch"),
sdkInt = 0,
abis = listOf(System.getProperty("os.arch")),
videoDecoders = emptyList(),
audioDecoders = emptyList(),
displayHdrTypes = emptySet(),
displayModes = emptyList(),
passthroughEncodings = emptySet(),
)
}
object BuildInfo { object BuildInfo {
const val VERSION = "0.1.0" const val VERSION = "0.1.0"
} }

View File

@@ -0,0 +1,90 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
/** "Открывать плеер": in the last mode (§1.1) or always in one. */
enum class PlayerOpening(val mode: PlayerMode?) { Last(null), Mini(PlayerMode.Mini), Window(PlayerMode.Window), Fullscreen(PlayerMode.Fullscreen) }
/**
* The desktop's own settings (L9) in `desktop.preferences_pb`: read once before the first window, applied at once when
* changed. "На всех рабочих столах" lives with the player's places ([PlayerPlacement.sticky]).
*/
class DesktopSettings(stores: PreferenceStores) {
private val store = stores["desktop"]
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val saved: Preferences? = try {
runBlocking(Dispatchers.IO) { withTimeoutOrNull(2_000) { store.data.first() } }
} catch (e: Exception) {
Log.w(e, "Desktop settings not read")
null
}
/** "Масштаб интерфейса", %: 100 = the TV's dp as pixels (§1.4), times the system's own scale. */
var scalePercent by mutableIntStateOf(saved?.get(KEY_SCALE)?.takeIf { it in SCALES } ?: 100)
private set
/** `SLEEPYOWL_SCALE=1.25` overrides the setting (test runs). */
val scaleOverride: Float? = System.getenv("SLEEPYOWL_SCALE")?.toFloatOrNull()?.coerceIn(0.5f, 3f)
val scaleFactor: Float get() = scaleOverride ?: (scalePercent / 100f)
var playerOpening by mutableStateOf(saved?.get(KEY_PLAYER_OPENING)?.let { n -> PlayerOpening.entries.firstOrNull { it.name == n } } ?: PlayerOpening.Last)
private set
fun changeScale(percent: Int) {
scalePercent = percent
write { it[KEY_SCALE] = percent }
}
fun changePlayerOpening(opening: PlayerOpening) {
playerOpening = opening
write { it[KEY_PLAYER_OPENING] = opening.name }
}
private fun write(change: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) {
scope.launch {
try {
store.edit(change)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Desktop settings not saved")
}
}
}
companion object {
val SCALES = listOf(80, 90, 100, 110, 125, 150)
private val KEY_SCALE = intPreferencesKey("ui_scale")
private val KEY_PLAYER_OPENING = stringPreferencesKey("player_opening")
}
}
/** A window's content at "Масштаб интерфейса": changes apply at once. */
@Composable
fun ScaledContent(settings: DesktopSettings, content: @Composable () -> Unit) {
val system = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(system.density * settings.scaleFactor, system.fontScale), content = content)
}

View File

@@ -1,13 +1,10 @@
package ru.stenfredd.sleepyowl.desktop package ru.stenfredd.sleepyowl.desktop
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
@@ -21,50 +18,80 @@ import org.koin.mp.KoinPlatform
import ru.stenfredd.sleepyowl.core.common.Log import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.logToFile import ru.stenfredd.sleepyowl.core.common.logToFile
import ru.stenfredd.sleepyowl.core.data.dataModule import ru.stenfredd.sleepyowl.core.data.dataModule
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
import ru.stenfredd.sleepyowl.playback.core.playbackModule import ru.stenfredd.sleepyowl.playback.core.playbackModule
import ru.stenfredd.sleepyowl.playback.mpv.MpvVideo
import ru.stenfredd.sleepyowl.playback.mpv.mpvEngineModule
import ru.stenfredd.sleepyowl.ui.uiModule import ru.stenfredd.sleepyowl.ui.uiModule
import ru.stenfredd.sleepyowl.ui.Screen
import java.awt.Dimension import java.awt.Dimension
import java.util.UUID
import java.awt.Frame import java.awt.Frame
import java.awt.Toolkit import java.awt.Toolkit
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
fun main() { /**
* `--play <item id> [--start <seconds>]` opens the player on that item right away (tests and checks of the engine; the
* account must already be signed in).
*/
fun main(args: Array<String>) {
val instance = SingleInstance.acquire(DesktopPaths.runtime) ?: run { val instance = SingleInstance.acquire(DesktopPaths.runtime) ?: run {
System.err.println("SleepyOwl is already running: its window has been asked to show") System.err.println("SleepyOwl is already running: its window has been asked to show")
return return
} }
// Before any window: mpv's own GL context must be able to coexist with Skiko's.
MpvVideo.prepareProcess()
logToFile(DesktopPaths.log) logToFile(DesktopPaths.log)
Log.i("SleepyOwl %s, Java %s, %s", BuildInfo.VERSION, System.getProperty("java.version"), System.getenv("DISPLAY")) Log.i("SleepyOwl %s, Java %s, %s", BuildInfo.VERSION, System.getProperty("java.version"), System.getenv("DISPLAY"))
Toolkit.getDefaultToolkit() Toolkit.getDefaultToolkit()
X11App.setClassName("sleepyowl") X11App.setClassName("sleepyowl")
val scale = UiScale.fromEnvironment() val koin = startKoin { modules(jellyfinModule, dataModule, playbackModule, mpvEngineModule, uiModule, desktopModule()) }.koin
val koin = startKoin { modules(jellyfinModule, dataModule, playbackModule, uiModule, desktopModule(scale)) }.koin val settings = koin.get<DesktopSettings>()
SingletonImageLoader.setSafe { context -> imageLoader(context, koin.get(), koin.get()) } SingletonImageLoader.setSafe { context -> imageLoader(context, koin.get(), koin.get()) }
val icon = Main::class.java.getResourceAsStream("/icons/sleepyowl-256.png")?.use { BitmapPainter(Image.makeFromEncoded(it.readBytes()).toComposeImageBitmap()) } val icon = Main::class.java.getResourceAsStream("/icons/sleepyowl-256.png")?.use { BitmapPainter(Image.makeFromEncoded(it.readBytes()).toComposeImageBitmap()) }
val player = koin.get<DesktopPlayer>()
val media = koin.get<SystemMedia>().apply { start() }
playArgument(args)?.let { screen ->
// After the main window has restored the session.
@Suppress("OPT_IN_USAGE")
kotlinx.coroutines.GlobalScope.launch(Dispatchers.Main) {
koin.get<JellyfinSession>().session.first { it != null }
player.play(screen)
}
}
application(exitProcessOnExit = false) { application(exitProcessOnExit = false) {
player.screen?.let { PlayerWindow(player, it) }
val keys = remember { BackKeys() } val keys = remember { BackKeys() }
val state = rememberWindowState(size = DpSize(1280.dp, 800.dp), position = WindowPosition(Alignment.Center)) val state = rememberWindowState(size = DpSize(1280.dp, 800.dp), position = WindowPosition(Alignment.Center), isMinimized = TestMode.passive)
Window(onCloseRequest = ::exitApplication, state = state, title = "SleepyOwl", icon = icon, onPreviewKeyEvent = keys::onPreview, onKeyEvent = keys::onKey) { Window(
onCloseRequest = ::exitApplication, state = state, title = "SleepyOwl", icon = icon, focusable = !TestMode.passive,
onPreviewKeyEvent = keys::onPreview, onKeyEvent = keys::onKey,
) {
LaunchedEffect(window) { LaunchedEffect(window) {
window.minimumSize = Dimension(800, 500) window.minimumSize = Dimension(800, 500)
instance.listen { instance.listen { SwingUtilities.invokeLater { raise(window) } }
SwingUtilities.invokeLater {
if (window.extendedState and Frame.ICONIFIED != 0) window.extendedState = window.extendedState and Frame.ICONIFIED.inv()
window.toFront()
window.requestFocus()
}
}
}
val system = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(system.density * scale.factor, system.fontScale)) {
App(window, keys)
} }
ScaledContent(settings) { App(window, keys) }
} }
} }
Log.i("Exit") Log.i("Exit")
// The player window is gone with the application; its last position is still on its way to the server.
runBlocking {
withTimeoutOrNull(3000) {
PlaybackSession.awaitStopReports()
player.placement.flush()
}
}
media.close()
instance.release() instance.release()
KoinPlatform.stopKoin() KoinPlatform.stopKoin()
// Not exitProcess: on exit() the destructor of libGLX (glvnd) waits for AWT's X display lock, held by the AWT // Not exitProcess: on exit() the destructor of libGLX (glvnd) waits for AWT's X display lock, held by the AWT
@@ -74,5 +101,29 @@ fun main() {
CLib._exit(0) CLib._exit(0)
} }
/** Brings [window] up: out of the taskbar if minimized, on top, with the focus. */
internal fun raise(window: Frame) {
if (window.extendedState and Frame.ICONIFIED != 0) window.extendedState = window.extendedState and Frame.ICONIFIED.inv()
window.toFront()
window.requestFocus()
}
private fun playArgument(args: Array<String>): Screen.Player? {
val id = args.indexOf("--play").takeIf { it >= 0 }?.let { args.getOrNull(it + 1) } ?: return null
val start = args.indexOf("--start").takeIf { it >= 0 }?.let { args.getOrNull(it + 1)?.toDoubleOrNull() } ?: 0.0
val uuid = runCatching { UUID.fromString(id.replace(Regex("^(.{8})(.{4})(.{4})(.{4})(.{12})$"), "$1-$2-$3-$4-$5")) }.getOrNull() ?: return null
return Screen.Player(uuid, engine = null, startPositionMs = (start * 1000).toLong())
}
/**
* Automated runs on the user's own screen (the engine's measurements): `SLEEPYOWL_TEST_PASSIVE=1` keeps every window
* from taking the focus (a test window once took the user's typing, L1) and starts the main window minimized;
* `SLEEPYOWL_PLAYER_BOUNDS=x,y,w,h` places the player window (pixels).
*/
object TestMode {
val passive = System.getenv("SLEEPYOWL_TEST_PASSIVE") == "1"
val playerBounds: List<Int>? = System.getenv("SLEEPYOWL_PLAYER_BOUNDS")?.split(',')?.mapNotNull { it.trim().toIntOrNull() }?.takeIf { it.size == 4 }
}
/** The class of this file, for its resources. */ /** The class of this file, for its resources. */
private object Main private object Main

View File

@@ -0,0 +1,406 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
import ru.stenfredd.sleepyowl.playback.mpv.MpvEngine
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
import kotlin.math.roundToInt
/*
* The desktop player's controls: the TV's OSD look (translucent circles, gradient ⏯, chips) lit by the pointer instead
* of the remote's focus, and the timeline and volume for the mouse.
*/
internal object OsdColors {
val Fill = Color(0x14FFFFFF)
val Border = Color(0x24FFFFFF)
val Text = Color(0xFFE3E5F2)
val TopShade = Brush.verticalGradient(listOf(Color(0xD905060A), Color(0x0005060A)))
val BottomShade = Brush.verticalGradient(0f to Color(0x0005060A), 0.4f to Color(0x9905060A), 1f to Color(0xF705060A))
}
/** Icons the TV doesn't have, in the same 24-unit grid and stroke as [AppIcon]. */
internal enum class Glyph(path: String) {
Volume("M4 9h4l5-4v14l-5-4H4zM16 9a4 4 0 0 1 0 6M18.5 6.5a7.5 7.5 0 0 1 0 11"),
VolumeLow("M4 9h4l5-4v14l-5-4H4zM16 9a4 4 0 0 1 0 6"),
Muted("M4 9h4l5-4v14l-5-4H4zM16.5 9.5l5 5M21.5 9.5l-5 5"),
Fullscreen("M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"),
ExitFullscreen("M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"),
Close("M6 6l12 12M18 6L6 18"),
More("M5.5 12h.01M12 12h.01M18.5 12h.01"),
Episodes("M4 6h16M4 12h16M4 18h10"),
/** The mini window on top of everything: a small picture in a large one. */
OnTop("M3 5h18v14H3zM12.5 11.5h6v5h-6z"),
/** The normal window: a frame with a title bar. */
Windowed("M3 5h18v14H3zM3 9h18"),
/** "К медиатеке". */
Library("M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"),
;
val path by lazy { PathParser().parsePathString(path).toPath() }
}
@Composable
internal fun GlyphIcon(glyph: Glyph, color: Color, modifier: Modifier = Modifier, strokeWidth: Float = 2f) {
Spacer(
modifier.drawWithCache {
val k = size.minDimension / 24f
// The dots of "⋯" are zero-length lines: their round caps draw them, thicker than a line.
val width = if (glyph == Glyph.More) strokeWidth * 1.8f else strokeWidth
val stroke = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round)
onDrawBehind { scale(k, k, pivot = Offset.Zero) { drawPath(glyph.path, color, style = stroke) } }
},
)
}
/** Tracks the pointer over the element. */
@OptIn(ExperimentalComposeUiApi::class)
internal fun Modifier.onHover(onChange: (Boolean) -> Unit): Modifier = this
.onPointerEvent(PointerEventType.Enter) { onChange(true) }
.onPointerEvent(PointerEventType.Exit) { onChange(false) }
/**
* While the pointer is over this element the controls stay up (§1.3: buttons, the timeline, panels). [key] tells the
* elements apart; one that leaves the composition under the pointer lets go too.
*/
internal fun Modifier.holdsControls(ui: PlayerUiState, key: Any): Modifier = composed {
DisposableEffect(ui, key) { onDispose { ui.holders.remove(key) } }
onHover { if (it) ui.holders[key] = Unit else ui.holders.remove(key) }
}
/** ⟲ ⟳ ⏮ ⏭ and the other round buttons: translucent circle ([bare]: none), light fill under the pointer. */
@Composable
internal fun RoundButton(
size: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier,
bare: Boolean = false,
content: @Composable (Color) -> Unit,
) {
var hovered by remember { mutableStateOf(false) }
Box(
modifier
.size(size)
.onHover { hovered = it }
.background(if (hovered) AppColors.FocusFill else if (bare) Color.Transparent else OsdColors.Fill, CircleShape)
.then(if (bare && !hovered) Modifier else Modifier.border(0.5.dp, if (hovered) AppColors.FocusFill else OsdColors.Border, CircleShape))
.clickable(interactionSource = null, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
content(if (hovered) AppColors.OnFocusFill else Color.White)
}
}
@Composable
internal fun IconButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, bare: Boolean = false) {
RoundButton(size, onClick, modifier, bare) { Icon(icon, it, Modifier.size(iconSize), strokeWidth = 2.2f) }
}
@Composable
internal fun GlyphButton(glyph: Glyph, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, bare: Boolean = false) {
RoundButton(size, onClick, modifier, bare) { GlyphIcon(glyph, it, Modifier.size(iconSize), strokeWidth = 2f) }
}
/** The big gradient ⏯; a light ring under the pointer. */
@Composable
internal fun PlayButton(playing: Boolean, size: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) {
var hovered by remember { mutableStateOf(false) }
Box(
modifier
.size(size)
.onHover { hovered = it }
.background(AppColors.PrimaryGradient, CircleShape)
.then(if (hovered) Modifier.border(2.dp, Color(0xFFD9D2FF), CircleShape) else Modifier)
.clickable(interactionSource = null, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (playing) Icon(AppIcon.Pause, Color.White, Modifier.size(size * 0.41f)) else Icon(AppIcon.Play, Color.White, Modifier.size(size * 0.43f))
}
}
@Immutable
internal data class OsdChipModel(val panel: Panel, val dot: Color, val label: String)
/** "● Русский 7.1": opens a panel. */
@Composable
internal fun OsdChip(chip: OsdChipModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
var hovered by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(17.dp)
Row(
modifier
.height(34.dp)
.onHover { hovered = it }
.background(if (hovered) AppColors.FocusFill else OsdColors.Fill, shape)
.border(0.5.dp, if (hovered) AppColors.FocusFill else OsdColors.Border, shape)
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(Modifier.size(6.dp).background(chip.dot, CircleShape))
BasicText(
chip.label,
Modifier.widthIn(max = 170.dp),
style = AppType.Button.copy(fontSize = 11.5.sp, fontWeight = FontWeight.Normal, color = if (hovered) AppColors.OnFocusFill else OsdColors.Text),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* Track with the buffered part, skippable parts (intro, credits), the played part and chapter marks. Under the pointer
* it thickens, shows a knob and the time at the pointer; pressing or dragging scrubs ([onScrub] with the time under the
* pointer, [onRelease] when let go), the wheel seeks ±5 s ([onWheel] with −1 / +1). [thin]: the minimal layout's line.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun Timeline(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chaptersMs: List<Long>,
spans: List<MediaSegment>,
ui: PlayerUiState,
onScrub: (Long) -> Unit,
onRelease: () -> Unit,
onWheel: (Int) -> Unit,
modifier: Modifier = Modifier,
thin: Boolean = false,
) {
var hoverX by remember { mutableStateOf<Float?>(null) }
var width by remember { mutableStateOf(1f) }
val duration by rememberUpdatedState(durationMs)
val scrub by rememberUpdatedState(onScrub)
val release by rememberUpdatedState(onRelease)
fun at(x: Float): Long = if (duration > 0) ((x / width).coerceIn(0f, 1f) * duration).toLong() else 0L
val active = hoverX != null || ui.dragging
Box(
modifier
.fillMaxWidth()
.height(if (thin) 16.dp else 26.dp)
.onSizeChanged { width = it.width.toFloat().coerceAtLeast(1f) }
.holdsControls(ui, "timeline")
.onPointerEvent(PointerEventType.Move) { hoverX = it.changes.first().position.x }
.onPointerEvent(PointerEventType.Enter) { hoverX = it.changes.first().position.x }
.onPointerEvent(PointerEventType.Exit) { hoverX = null }
.onPointerEvent(PointerEventType.Scroll) { e ->
val dy = e.changes.first().scrollDelta.y
if (dy != 0f) onWheel(if (dy > 0) -1 else 1)
e.changes.forEach { it.consume() }
}
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
if (duration <= 0) return@awaitEachGesture
ui.dragging = true
try {
scrub(at(down.position.x))
down.consume()
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
hoverX = change.position.x
if (!change.pressed) break
scrub(at(change.position.x))
change.consume()
}
} finally {
ui.dragging = false
release()
}
}
},
) {
Spacer(
Modifier
.fillMaxWidth()
.height(if (thin) 16.dp else 26.dp)
.drawBehind {
val trackH = (if (active) (if (thin) 5.dp else 7.dp) else 3.dp).toPx()
val top = (size.height - trackH) / 2
val r = CornerRadius(trackH / 2)
val w = size.width
fun x(ms: Long) = if (durationMs > 0) (ms.toFloat() / durationMs).coerceIn(0f, 1f) * w else 0f
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, trackH), r)
if (bufferedMs > positionMs) drawRoundRect(Color(0x2EFFFFFF), Offset(0f, top), Size(x(bufferedMs), trackH), r)
for (span in spans) {
val from = x(span.startMs)
drawRect(SpanColor, Offset(from, top), Size(x(span.endMs) - from, trackH))
}
val played = x(positionMs)
if (played > 0f) {
drawRoundRect(
Brush.horizontalGradient(listOf(Color(0xFF4D8BFF), Color(0xFF9B6BFF)), endX = played.coerceAtLeast(1f)),
Offset(0f, top), Size(played, trackH), r,
)
}
val mark = 1.5.dp.toPx()
for (c in chaptersMs) {
if (c >= durationMs) continue
drawRect(Color(0xD90A0B10), Offset(x(c) - mark / 2, top), Size(mark, trackH))
}
if (active) {
val center = Offset(played, size.height / 2)
drawCircle(Color(0x80A594FF), (if (thin) 7.dp else 9.5.dp).toPx(), center)
drawCircle(Color.White, (if (thin) 5.5.dp else 7.5.dp).toPx(), center)
}
},
)
// The time under the pointer, above the track and kept inside it.
val shownX = if (ui.dragging) width * (if (durationMs > 0) positionMs.toFloat() / durationMs else 0f) else hoverX
if (shownX != null && durationMs > 0) {
BasicText(
PlayerLabels.time(if (ui.dragging) positionMs else at(shownX)),
Modifier
.layout { m, c ->
val p = m.measure(c.copy(minWidth = 0, minHeight = 0))
val x = (shownX - p.width / 2f).roundToInt().coerceIn(0, (c.maxWidth - p.width).coerceAtLeast(0))
layout(0, 0) { p.place(IntOffset(x, -p.height - 2.dp.roundToPx())) }
}
.background(Color(0xE60E1018), RoundedCornerShape(5.dp))
.border(0.5.dp, OsdColors.Border, RoundedCornerShape(5.dp))
.padding(horizontal = 7.dp, vertical = 3.dp),
style = AppType.MonoMeta.copy(fontSize = 11.sp, color = Color.White),
)
}
}
}
private val SpanColor = Color(0x598F6BFF)
/**
* 🔊 and a short slider: the icon mutes, pressing or dragging along the slider sets the volume. The slider runs to
* 100 %; louder (up to [MpvEngine.MAX_VOLUME]) only from the keys and the wheel.
*/
@Composable
internal fun VolumeControl(audio: PlayerAudio, ui: PlayerUiState, onChange: (Int) -> Unit, onMute: () -> Unit, modifier: Modifier = Modifier, slider: Boolean = true) {
val level = if (audio.muted) 0 else audio.volume
Row(modifier.holdsControls(ui, "volume"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
GlyphButton(
when {
audio.muted || audio.volume == 0 -> Glyph.Muted
audio.volume < 50 -> Glyph.VolumeLow
else -> Glyph.Volume
},
size = 34.dp, iconSize = 17.dp, onClick = onMute, bare = true,
)
if (slider) {
val change by rememberUpdatedState(onChange)
var w by remember { mutableStateOf(1f) }
Spacer(
Modifier
.width(72.dp)
.height(24.dp)
.onSizeChanged { w = it.width.toFloat().coerceAtLeast(1f) }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
ui.dragging = true
try {
change(((down.position.x / w).coerceIn(0f, 1f) * 100).roundToInt())
while (true) {
val c = awaitPointerEvent().changes.firstOrNull { it.id == down.id } ?: break
if (!c.pressed) break
change(((c.position.x / w).coerceIn(0f, 1f) * 100).roundToInt())
c.consume()
}
} finally {
ui.dragging = false
}
}
}
.drawBehind {
val h = 3.dp.toPx()
val top = (size.height - h) / 2
val filled = size.width * (level.coerceAtMost(100) / 100f)
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(size.width, h), CornerRadius(h / 2))
drawRoundRect(Color.White, Offset(0f, top), Size(filled, h), CornerRadius(h / 2))
drawCircle(Color.White, 5.dp.toPx(), Offset(filled, size.height / 2))
},
)
}
}
}
/** "1:02:11 / 1:48:30" */
@Composable
internal fun TimeLabel(positionMs: Long, durationMs: Long, modifier: Modifier = Modifier) {
BasicText(
PlayerLabels.time(positionMs) + if (durationMs > 0) " / " + PlayerLabels.time(durationMs) else "",
modifier,
style = AppType.MonoMeta.copy(fontSize = 11.sp, color = OsdColors.Text),
maxLines = 1,
)
}
/** Short message at the top of the video ("Громкость 70 %", "Аудио: Русский 5.1"). */
@Composable
internal fun Toast(text: String, modifier: Modifier = Modifier) {
BasicText(
text,
modifier
.background(Color(0xE60E1018), RoundedCornerShape(16.dp))
.border(0.5.dp, OsdColors.Border, RoundedCornerShape(16.dp))
.padding(horizontal = 14.dp, vertical = 7.dp),
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}

View File

@@ -0,0 +1,274 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.awt.ComposeWindow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.awt.event.MouseEvent
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import javax.imageio.ImageIO
import javax.swing.SwingUtilities
import javax.swing.WindowConstants
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* The player's window in its three modes (§1.1, §2.4), through the window manager ([X11Window]):
*
* - **mini** — no frame, on top of everything, moved by the video and resized by its edges (`_NET_WM_MOVERESIZE`),
* keeping the video's proportions (size hints);
* - **window** — the window manager's frame (`_MOTIF_WM_HINTS`; AWT keeps the window undecorated, L7);
* - **full screen** — `_NET_WM_STATE_FULLSCREEN` on the window's monitor.
*
* Each windowed mode keeps its own place ([PlayerPlacement]). The window takes the focus once, when it opens; mode
* changes and new episodes never take it back from another program (§1.2).
*/
internal class PlayerFrame(private val player: DesktopPlayer) {
lateinit var window: ComposeWindow
private set
private var x11: X11Window? = null
/** The mode the window has been put in; null until the window manager has taken it. */
private var applied: PlayerMode? = null
/** Until then the window's moves and resizes are a mode change settling, not the user's. */
private var busyUntil = Long.MAX_VALUE
private val busy get() = monotonicMs() < busyUntil
private var video = IntSize.Zero
private var hintsRestored = 0
private val saveTimer = javax.swing.Timer(SAVE_DELAY_MS) { savePlace() }.apply { isRepeats = false }
fun create(title: String): ComposeWindow = ComposeWindow().also { w ->
window = w
w.title = title
// The frame comes from the window manager (setDecorated): full screen can be left again (L7).
w.isUndecorated = true
w.focusableWindowState = !TestMode.passive
w.defaultCloseOperation = WindowConstants.DO_NOTHING_ON_CLOSE
w.addWindowListener(object : WindowAdapter() {
override fun windowClosing(e: WindowEvent) = player.close()
})
w.minimumSize = java.awt.Dimension(MIN_WIDTH, MIN_HEIGHT)
Icon?.let { w.iconImage = it }
val mode = player.mode
w.isAlwaysOnTop = mode == PlayerMode.Mini
w.bounds = TestMode.playerBounds?.let { (x, y, width, height) -> Rectangle(x, y, width, height) }
?: player.placement.bounds(if (mode == PlayerMode.Fullscreen) player.windowedMode else mode)
}
fun dispose() {
saveTimer.stop()
savePlace()
window.dispose()
}
/** Puts the window in the player's mode as it changes, and the mini window in the video's proportions. */
@Composable
fun Modes(videoSize: IntSize) {
LaunchedEffect(Unit) { attach() }
LaunchedEffect(player.mode) { if (applied != null) apply(player.mode) }
LaunchedEffect(videoSize) { onVideoSize(videoSize) }
LaunchedEffect(player.placement.sticky) {
if (applied == PlayerMode.Mini) x11?.setSticky(player.placement.sticky)
}
DisposableEffect(Unit) {
val listener = object : ComponentAdapter() {
override fun componentMoved(e: ComponentEvent) = onMovedOrResized()
override fun componentResized(e: ComponentEvent) = onMovedOrResized()
}
window.addComponentListener(listener)
onDispose { window.removeComponentListener(listener) }
}
}
/** The mini window is dragged by the video: the window manager moves it from here on. */
fun startMove(e: MouseEvent, moved: Offset) {
// The window manager moves the window by the pointer's moves from the given point: from the press, the window
// keeps the spot under the pointer (from here it would lag by the way to the drag threshold).
val scale = window.graphicsConfiguration?.defaultTransform?.scaleX ?: 1.0
startOp(e, X11Window.MOVE, e.xOnScreen - (moved.x / scale).roundToInt(), e.yOnScreen - (moved.y / scale).roundToInt())
}
/** One of `X11Window.SIZE_*`: the window manager resizes it from here on. */
fun startResize(e: MouseEvent, direction: Int) = startOp(e, direction)
private suspend fun attach() {
val x = X11Window.of(window) ?: return
// Requests to a window the window manager hasn't taken yet get lost.
withTimeoutOrNull(1_000) { while (!x.isManaged) delay(16) }
x11 = x
apply(player.mode, first = true)
if (player.mode != applied) apply(player.mode)
// The player takes the focus once, when it opens (§1.2); an undecorated AWT window doesn't get it on its own.
if (!TestMode.passive) {
window.toFront()
window.requestFocus()
// The frame itself gets the focus, not Compose's canvas in it (a click moves it there): keys would be lost.
canvasOf(window)?.requestFocusInWindow()
}
}
private suspend fun apply(new: PlayerMode, first: Boolean = false) {
val x = x11 ?: return
val old = applied
if (old == new && !first) return
if (old != null && old != PlayerMode.Fullscreen && !busy && !x.isFullscreen) player.placement.save(old, x.geometry())
applied = new
busyUntil = Long.MAX_VALUE
Log.i("Player window: %s", new)
try {
if (new == PlayerMode.Fullscreen) {
window.isAlwaysOnTop = false
x.setSizeHints(0, 0, MIN_WIDTH, MIN_HEIGHT)
if (!x.isFullscreen) {
x.setFullscreen(true)
waitFor { x.isFullscreen }
}
} else {
if (x.isFullscreen) {
// The window manager puts back the size from before full screen, and later than us: wait till it has.
x.setFullscreen(false)
waitFor { !x.isFullscreen }
delay(30)
}
// The frame and the hints first, then the place: else the window manager fits the size to the old ones.
x.setDecorated(new == PlayerMode.Window)
window.isAlwaysOnTop = new == PlayerMode.Mini
x.setSticky(new == PlayerMode.Mini && player.placement.sticky)
setHints(new)
val target = if (first && TestMode.playerBounds != null) x.geometry() else player.placement.bounds(new)
place(x, if (new == PlayerMode.Mini) fit(target) else target)
}
} finally {
busyUntil = monotonicMs() + SETTLE_MS
}
}
/**
* Puts the client area at [target]. While the window manager adds or takes away the frame it may ignore the
* request, so it is repeated until the window is there; Muffin places the frame's corner rather than the client
* area's, so the request moves by the difference (L1).
*/
private suspend fun place(x: X11Window, target: Rectangle) {
val request = Rectangle(target)
x.moveResize(request)
repeat(PLACE_TRIES) {
delay(PLACE_RETRY_MS)
val g = x.geometry()
if (g.location == target.location && abs(g.width - target.width) <= 1 && abs(g.height - target.height) <= 1) return
if (abs(g.width - target.width) <= 1 && abs(g.height - target.height) <= 1) request.translate(target.x - g.x, target.y - g.y)
x.moveResize(request)
}
Log.w("Player window at %s, wanted %s", x.geometry(), target)
}
/** The mini window takes the video's proportions: same width, the bottom right corner stays (§1.1). */
private fun fit(r: Rectangle): Rectangle {
val v = video
if (v.width <= 0 || v.height <= 0) return r
var w = r.width
var h = (w.toLong() * v.height / v.width).toInt()
if (h < MIN_HEIGHT) {
h = MIN_HEIGHT
w = (h.toLong() * v.width / v.height).toInt()
}
return Rectangle(r.x + r.width - w, r.y + r.height - h, w, h)
}
private suspend fun onVideoSize(size: IntSize) {
if (size == video || size.width <= 0 || size.height <= 0) return
video = size
val x = x11 ?: return
if (applied != PlayerMode.Mini || busy) return
setHints(PlayerMode.Mini)
val g = x.geometry()
val fitted = fit(g)
if (abs(fitted.height - g.height) > 2 || abs(fitted.width - g.width) > 2) {
busyUntil = Long.MAX_VALUE
try {
place(x, fitted)
} finally {
busyUntil = monotonicMs() + SETTLE_MS
}
}
}
/** The mini window keeps the video's proportions while resized; the others are free. Returns true if they were rewritten. */
private fun setHints(mode: PlayerMode): Boolean {
val x = x11 ?: return false
val v = video.takeIf { mode == PlayerMode.Mini && it.width > 0 && it.height > 0 } ?: IntSize.Zero
return x.setSizeHints(v.width, v.height, MIN_WIDTH, MIN_HEIGHT)
}
private fun onMovedOrResized() {
val x = x11 ?: return
val mode = applied ?: return
// AWT rewrites the size hints wholesale on each of its reshapes, the proportions with them; its write may still
// be in its connection's buffer.
Toolkit.getDefaultToolkit().sync()
if (setHints(mode) && hintsRestored++ % 20 == 0) Log.d("Player window: size hints restored (%d)", hintsRestored)
if (busy) return
val fullscreen = x.isFullscreen
when {
// The window manager left full screen or went into it on its own (a desktop shortcut).
mode == PlayerMode.Fullscreen && !fullscreen -> player.switchTo(player.windowedMode)
mode != PlayerMode.Fullscreen && fullscreen -> player.switchTo(PlayerMode.Fullscreen)
else -> saveTimer.restart()
}
}
/** Keeps where the windowed mode is now: after a move or resize settles, before a mode change, on close. */
private fun savePlace() {
val x = x11 ?: return
val mode = applied ?: return
if (mode == PlayerMode.Fullscreen || busy || x.isFullscreen) return
player.placement.save(mode, x.geometry())
}
private fun startOp(e: MouseEvent, direction: Int, fromX: Int = e.xOnScreen, fromY: Int = e.yOnScreen) {
val x = x11 ?: return
x.startMoveResize(fromX, fromY, direction)
// The window manager has the pointer now: Compose never sees the button go up, so it gets a release.
SwingUtilities.invokeLater {
val src = e.component
src.dispatchEvent(
MouseEvent(src, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(), 0, e.x, e.y, e.xOnScreen, e.yOnScreen, 1, false, MouseEvent.BUTTON1),
)
}
}
/** Waits up to a second for the window manager to get the window into a state. */
private suspend fun waitFor(done: () -> Boolean) {
withTimeoutOrNull(1_000) { while (!done()) delay(16) }
}
private companion object {
/** After a mode change, the window manager's own moves and resizes. */
const val SETTLE_MS = 400L
const val PLACE_TRIES = 8
const val PLACE_RETRY_MS = 60L
const val SAVE_DELAY_MS = 800
val Icon by lazy {
runCatching { PlayerFrame::class.java.getResourceAsStream("/icons/sleepyowl-256.png")?.use(ImageIO::read) }.getOrNull()
}
}
}
/** The innermost component of [window]: Skiko's canvas, which Compose takes keys from. */
private fun canvasOf(window: java.awt.Component): java.awt.Component? {
if (window !is java.awt.Container || window.componentCount == 0) return window.takeIf { it.isFocusable }
return window.components.asSequence().mapNotNull(::canvasOf).lastOrNull()
}

View File

@@ -0,0 +1,505 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.Avatar
import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.components.ImageShade
import ru.stenfredd.sleepyowl.ui.components.ProgressLine
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
import ru.stenfredd.sleepyowl.ui.player.EpisodeModel
import ru.stenfredd.sleepyowl.ui.player.InfoRow
import ru.stenfredd.sleepyowl.ui.player.PanelChoice
import ru.stenfredd.sleepyowl.ui.player.PlayerInfo
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
import ru.stenfredd.sleepyowl.ui.player.PlayerLayout
import ru.stenfredd.sleepyowl.ui.player.PlayerTracks
import ru.stenfredd.sleepyowl.ui.player.PlayerViewModel
import ru.stenfredd.sleepyowl.ui.player.SeasonEpisodes
import ru.stenfredd.sleepyowl.ui.player.StreamMode
import ru.stenfredd.sleepyowl.ui.player.SubtitleChoices
import ru.stenfredd.sleepyowl.ui.player.toChoices
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
import java.util.UUID
/** Panels of the desktop player: the TV's four and its drawer ("Серии" / "О фильме"). */
internal enum class Panel { Audio, Subtitles, Quality, Info, Episodes }
/** Height of the drawer of the full layout; the controls move up by this much while it is open. */
internal const val DRAWER_HEIGHT = 210
/** Wheel and clicks inside stay inside: the wheel over the video sets the volume, a click on it pauses. */
private fun Modifier.keepsPointer(): Modifier = pointerInput(Unit) {
awaitEachGesture {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.type == PointerEventType.Scroll || event.type == PointerEventType.Press) event.changes.forEach { it.consume() }
if (event.changes.none { it.pressed }) break
}
}
}
/**
* A panel: on the right, full height, as on the TV; [sheet] (a small window) — over the whole window. The header has
* the title and ×; the content scrolls.
*/
@Composable
internal fun PanelFrame(
title: String,
dot: Color,
sheet: Boolean,
ui: PlayerUiState,
onClose: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier
.then(if (sheet) Modifier.fillMaxSize() else Modifier.fillMaxHeight().width(350.dp))
.holdsControls(ui, "panel")
.keepsPointer()
.background(PanelColors.Background)
.drawBehind { if (!sheet) drawRect(PanelColors.Edge, size = Size(0.5.dp.toPx(), size.height)) },
) {
Row(
Modifier
.fillMaxWidth()
.height(40.dp)
.background(PanelColors.Header)
.padding(start = 16.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
Box(Modifier.size(7.dp).background(dot, CircleShape))
BasicText(title, Modifier.weight(1f), style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em), maxLines = 1)
GlyphButton(Glyph.Close, 32.dp, 14.dp, onClick = onClose, bare = true)
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(15.dp),
content = content,
)
}
}
@Composable
internal fun PanelNote(text: String) {
BasicText(text, style = AppType.Body.copy(fontSize = 10.5.sp, lineHeight = 15.sp, color = AppColors.TextDim))
}
/** Audio, subtitles, quality, stream info: the TV's panels, picked with the mouse. */
@Composable
internal fun PanelContent(
panel: Panel,
state: PlaybackUiState,
info: PlayerInfo?,
media: MediaInfo?,
tracks: PlayerTracks,
style: SubtitleStyle,
session: PlaybackSession,
vm: PlayerViewModel,
sheet: Boolean,
ui: PlayerUiState,
close: () -> Unit,
modifier: Modifier,
) {
val selection = state.selection
when (panel) {
Panel.Audio -> PanelFrame("АУДИОДОРОЖКА", AppColors.Pink, sheet, ui, close, modifier) {
PanelSection("ДОРОЖКИ" + (info?.title?.let { " · " + it.uppercase() } ?: "")) {
PanelList(tracks.audio.toChoices(selection.audioStreamIndex)) { c ->
tracks.audio.firstOrNull { it.key == c.key }?.index?.let(session::selectAudio)
close()
}
}
PanelNote(if (info?.isEpisode == true) "Выбор запоминается для всего сериала." else "Выбор запоминается для этого фильма.")
}
Panel.Subtitles -> PanelFrame("СУБТИТРЫ", AppColors.Yellow, sheet, ui, close, modifier) {
val selected = media?.subtitles?.firstOrNull { it.index == selection.subtitleStreamIndex }
SubtitlePreview(style)
PanelSection("ДОРОЖКА") {
PanelList(tracks.subtitles.toChoices(selection.subtitleStreamIndex)) { c ->
session.selectSubtitle(tracks.subtitles.firstOrNull { it.key == c.key }?.index)
}
}
PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
val codec = selected?.codec?.lowercase()
when {
codec in GRAPHIC_SUBTITLES -> PanelNote("Это графические субтитры (картинки): они показываются как есть, размер и цвет к ним не применяются.")
codec == "ass" || codec == "ssa" -> PanelNote("У субтитров ASS своё оформление из файла: здесь меняется только их размер.")
else -> PanelNote("Вид применяется к текстовым субтитрам (SRT, VTT) и запоминается на этом компьютере.")
}
}
Panel.Quality -> PanelFrame("КАЧЕСТВО", AppColors.Blue, sheet, ui, close, modifier) {
val options = info?.qualities.orEmpty()
PanelSection("БИТРЕЙТ") {
PanelList(options.map { PanelChoice(it.key, it.label, it.detail, it.bitrate == state.maxBitrate) }) { c ->
vm.setQuality(options.firstOrNull { it.key == c.key }?.bitrate)
close()
}
}
PanelNote("Всё, что ниже оригинала, сервер перекодирует на лету. Это нагружает процессор сервера.")
}
Panel.Info -> PanelFrame("ИНФОРМАЦИЯ О ПОТОКЕ", AppColors.Green, sheet, ui, close, modifier) {
StreamModeCard(PlayerLabels.mode(state))
InfoRows(PlayerLabels.rows(state, media, session.activeEngine))
// The engine's own figures: information only, the desktop has a single engine (§1.4).
val extra = state.stats.extra.map { (k, v) -> InfoRow(k, v, AppColors.TextDim) } +
listOfNotNull(state.stats.videoFormat?.let { InfoRow("Поток", it, AppColors.Blue) })
if (extra.isNotEmpty()) PanelSection("ДОПОЛНИТЕЛЬНО") { InfoRows(extra) }
}
Panel.Episodes -> Unit
}
}
private val GRAPHIC_SUBTITLES = setOf("pgssub", "hdmv_pgs_subtitle", "dvdsub", "dvd_subtitle", "vobsub", "dvbsub")
/** "Прямое воспроизведение / Файл воспроизводится без изменений" in a green (or orange) frame. */
@Composable
private fun StreamModeCard(mode: StreamMode) {
val color = if (mode.direct) AppColors.Green else AppColors.Orange
Column(
Modifier
.fillMaxWidth()
.border(0.5.dp, color, RoundedCornerShape(7.dp))
.padding(horizontal = 11.dp, vertical = 10.dp),
) {
BasicText(mode.title, style = AppType.Section.copy(fontWeight = FontWeight.Bold, color = color))
BasicText(mode.reason, Modifier.padding(top = 4.dp), style = AppType.Body.copy(color = AppColors.TextMuted))
}
}
@Composable
private fun InfoRows(rows: List<InfoRow>) {
Column {
for (r in rows) {
Row(
Modifier
.fillMaxWidth()
.drawBehind {
val line = 0.5.dp.toPx()
drawRect(AppColors.BorderSubtle, Offset(0f, size.height - line), Size(size.width, line))
}
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(5.dp).background(r.color, CircleShape))
BasicText(r.key, Modifier.padding(start = 7.dp).width(95.dp), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = PanelColors.SectionTitle))
BasicText(r.value, Modifier.weight(1f), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = OsdColors.Text), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
private val DrawerBackground = Color(0xFF0C0D14)
private val EpisodeShade = ImageShade(0.6f, listOf(0f to Color(0xB306070C), 1f to Color.Transparent))
/**
* The full layout's drawer under the controls: the season's episodes (a click plays one without leaving the player)
* or "О фильме", and the way to the series or movie page in the library window.
*/
@Composable
internal fun PlayerDrawer(
info: PlayerInfo?,
season: SeasonEpisodes?,
currentId: UUID?,
playedFraction: Float,
ui: PlayerUiState,
onClose: () -> Unit,
onPage: () -> Unit,
onEpisode: (UUID) -> Unit,
modifier: Modifier = Modifier,
) {
val episodes = season?.takeIf { info?.isEpisode == true }
Column(
modifier
.fillMaxWidth()
.height(DRAWER_HEIGHT.dp)
.holdsControls(ui, "drawer")
.keepsPointer()
.background(DrawerBackground, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
.drawBehind { drawRect(Color(0xFF2A2E48), size = Size(size.width, 0.5.dp.toPx())) },
) {
Row(
Modifier.fillMaxWidth().padding(start = 48.dp, end = 36.dp, top = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(Modifier.size(6.dp).background(if (episodes != null) AppColors.Blue else AppColors.Accent, CircleShape))
BasicText(if (episodes != null) episodes.title else "О ФИЛЬМЕ", style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em))
BasicText(
(if (episodes != null) episodes.stat else info?.title).orEmpty(),
Modifier.weight(1f),
style = AppType.MonoCaption.copy(fontSize = 9.5.sp, color = PanelColors.SectionTitle),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
PagePill(if (episodes != null) "К сериалу" else "Страница фильма", onClick = onPage)
GlyphButton(Glyph.Close, 30.dp, 13.dp, onClick = onClose, bare = true)
}
when {
episodes != null -> {
val currentIndex = episodes.episodes.indexOfFirst { it.id == currentId }.coerceAtLeast(0)
ScrollRow(
Modifier.fillMaxWidth().padding(top = 12.dp),
state = rememberLazyListState(initialFirstVisibleItemIndex = (currentIndex - 1).coerceAtLeast(0)),
contentPadding = PaddingValues(horizontal = 48.dp),
spacing = 12.dp,
) {
items(episodes.episodes, key = { it.key }) { e ->
val current = e.id == currentId
EpisodeCard(e, current, if (current) playedFraction else e.progress, onClick = { onEpisode(e.id) })
}
}
}
info != null -> AboutMovie(info, Modifier.padding(start = 48.dp, end = 48.dp, top = 14.dp))
}
}
}
/**
* The drawer of a small window: over the whole window, episodes as a list (still, number and title, length) or
* "О фильме".
*/
@Composable
internal fun EpisodesSheet(
info: PlayerInfo?,
season: SeasonEpisodes?,
currentId: UUID?,
playedFraction: Float,
ui: PlayerUiState,
onClose: () -> Unit,
onPage: () -> Unit,
onEpisode: (UUID) -> Unit,
) {
val episodes = season?.takeIf { info?.isEpisode == true }
PanelFrame(if (episodes != null) episodes.title else "О ФИЛЬМЕ", if (episodes != null) AppColors.Blue else AppColors.Accent, sheet = true, ui, onClose) {
PagePill(if (episodes != null) "К сериалу" else "Страница фильма", onClick = onPage)
if (episodes != null) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
for (e in episodes.episodes) {
val current = e.id == currentId
EpisodeLine(e, current, if (current) playedFraction else e.progress, onClick = { onEpisode(e.id) })
}
}
} else if (info != null) {
AboutMovie(info, Modifier, narrow = true)
}
}
}
@Composable
private fun PagePill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
var hovered by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(15.dp)
Row(
modifier
.height(30.dp)
.onHover { hovered = it }
.background(if (hovered) AppColors.FocusFill else OsdColors.Fill, shape)
.border(0.5.dp, if (hovered) AppColors.FocusFill else OsdColors.Border, shape)
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(start = 12.dp, end = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val color = if (hovered) AppColors.OnFocusFill else OsdColors.Text
BasicText(label, style = AppType.Button.copy(fontSize = 11.sp, fontWeight = FontWeight.Medium, color = color))
Icon(AppIcon.ChevronRight, color, Modifier.size(10.dp), strokeWidth = 2.4f)
}
}
@Composable
private fun AboutMovie(info: PlayerInfo, modifier: Modifier, narrow: Boolean = false) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
if (!narrow) RemoteImage(info.poster, 7.dp, Modifier.offset(y = (-3).dp).size(PlayerLayout.POSTER_W.dp, PlayerLayout.POSTER_H.dp))
Column(Modifier.weight(1f)) {
val meta = AppType.BodyLarge.copy(fontSize = 11.5.sp, color = AppColors.TextSecondary)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
info.rating?.let { BasicText(it, style = meta.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
info.age?.let {
BasicText(
it,
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 1.dp),
style = meta.copy(fontSize = 9.5.sp),
)
}
listOfNotNull(info.year, info.runtime).forEach { BasicText(it, style = meta, maxLines = 1) }
}
info.genres?.let { BasicText(it, Modifier.padding(top = 5.dp), style = meta, maxLines = 1, overflow = TextOverflow.Ellipsis) }
info.description?.let {
BasicText(
it,
Modifier.padding(top = 8.dp),
style = AppType.Body.copy(fontSize = 12.sp, lineHeight = 18.sp, color = AppColors.TextSecondary),
maxLines = if (narrow) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
)
}
info.director?.let {
BasicText("Режиссёр: $it", Modifier.padding(top = 7.dp), style = AppType.Body.copy(fontSize = 10.5.sp, color = AppColors.TextDim), maxLines = 1)
}
}
if (info.cast.isNotEmpty() && !narrow) {
Column(Modifier.width(210.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
BasicText("В РОЛЯХ", style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle, letterSpacing = 0.1.em))
Spacer(Modifier.height(1.dp))
for (p in info.cast) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
if (p.image != null) {
RemoteImage(p.image, 12.dp, Modifier.size(PlayerLayout.AVATAR.dp))
} else {
Avatar(p.name, seed = p.key, size = PlayerLayout.AVATAR.dp, textStyle = AppType.MonoBadge.copy(fontSize = 9.sp, color = Color.White), letters = p.letters)
}
BasicText(p.name, style = AppType.CardTitle.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
}
@Composable
private fun EpisodeCard(e: EpisodeModel, current: Boolean, progress: Float?, onClick: () -> Unit) {
var hovered by remember { mutableStateOf(false) }
Column(Modifier.width(PlayerLayout.EPISODE_W.dp)) {
Box(
Modifier
.size(PlayerLayout.EPISODE_W.dp, PlayerLayout.EPISODE_H.dp)
.onHover { hovered = it }
.then(
when {
hovered -> Modifier.border(1.5.dp, AppColors.FocusFill, RoundedCornerShape(8.dp))
current -> Modifier.border(1.dp, Color(0xFF8B7DFF), RoundedCornerShape(8.dp))
else -> Modifier
},
)
.clickable(interactionSource = null, indication = null, onClick = onClick),
) {
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize(), shade = EpisodeShade)
BasicText(e.number, Modifier.padding(start = 8.dp, top = 6.dp), style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)))
EpisodeBadge(e, current, Modifier.align(Alignment.TopEnd).padding(7.dp))
e.duration?.let {
BasicText(it, Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 8.dp), style = AppType.MonoMeta.copy(fontSize = 9.sp, color = Color(0xFFD9DCEC)))
}
progress?.takeIf { it > 0f }?.let {
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 4.dp).fillMaxWidth().height(3.dp))
}
}
BasicText(
e.title,
Modifier.padding(top = 7.dp),
style = AppType.PosterTitle.copy(color = if (e.watched && !current) AppColors.TextMuted else AppColors.Text),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** One episode in the sheet: small still, title and length, the badge. */
@Composable
private fun EpisodeLine(e: EpisodeModel, current: Boolean, progress: Float?, onClick: () -> Unit) {
var hovered by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(7.dp)
Row(
Modifier
.fillMaxWidth()
.onHover { hovered = it }
.background(if (hovered) PanelColors.Focused else if (current) PanelColors.Selected else PanelColors.Option, shape)
.border(0.5.dp, if (hovered) AppColors.Accent else if (current) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape)
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(Modifier.size(96.dp, 54.dp)) {
RemoteImage(e.image, 5.dp, Modifier.fillMaxSize())
progress?.takeIf { it > 0f }?.let {
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 4.dp, vertical = 3.dp).fillMaxWidth().height(2.5.dp))
}
}
Column(Modifier.weight(1f)) {
BasicText(
e.title,
style = AppType.PosterTitle.copy(color = if (e.watched && !current) AppColors.TextMuted else AppColors.Text),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
e.duration?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption) }
}
EpisodeBadge(e, current, Modifier)
}
}
@Composable
private fun EpisodeBadge(e: EpisodeModel, current: Boolean, modifier: Modifier) {
val badge = when {
current -> Triple("СЕЙЧАС", AppColors.AccentStrong, Color.White)
e.watched -> Triple("✓", Color(0xBF0A0B10), AppColors.Green)
e.isNew -> Triple("НОВАЯ", Color(0xBF0A0B10), AppColors.Pink)
else -> return
}
val (text, bg, fg) = badge
BasicText(
text,
modifier
.background(bg, RoundedCornerShape(3.5.dp))
.then(if (current) Modifier else Modifier.border(0.5.dp, fg, RoundedCornerShape(3.5.dp)))
.padding(horizontal = 5.dp, vertical = 2.dp),
style = AppType.MonoBadge.copy(color = fg),
)
}

View File

@@ -0,0 +1,270 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
import ru.stenfredd.sleepyowl.ui.components.StatusDot
import ru.stenfredd.sleepyowl.ui.player.NextEpisode
import ru.stenfredd.sleepyowl.ui.player.PlayerLayout
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
import kotlin.math.roundToInt
/* What the player puts over the video besides the controls: skip, "next episode", loading, errors, the right-click menu. */
/** Without known credits the card shows this long before the end of an episode that has a next one. */
internal const val NEXT_PROMPT_MS = 30_000L
private val PromptColor = Color(0xFF10121C)
private val PromptBorder = Color(0x40FFFFFF)
/** "Пропустить заставку 1:12" (time left of the part) in the corner where the "next episode" card goes. */
@Composable
internal fun SkipButton(label: String, left: String, ui: PlayerUiState, onClick: () -> Unit, modifier: Modifier = Modifier) {
var hovered by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(20.dp)
Row(
modifier
.height(40.dp)
.holdsControls(ui, "skip")
.onHover { hovered = it }
.background(if (hovered) AppColors.FocusFill else PromptColor, shape)
.border(0.5.dp, if (hovered) AppColors.FocusFill else PromptBorder, shape)
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 17.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val color = if (hovered) AppColors.OnFocusFill else Color.White
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = color), maxLines = 1)
BasicText(left, style = AppType.MonoMeta.copy(fontSize = 10.sp, color = color.copy(alpha = 0.7f)))
}
}
/** "Заставка пропущена" for a few seconds after an automatic skip. */
@Composable
internal fun SkipNotice(text: String, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(16.dp)
BasicText(
text,
modifier.background(PromptColor, shape).border(0.5.dp, PromptBorder, shape).padding(horizontal = 14.dp, vertical = 8.dp),
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
)
}
private val CardColor = Color(0xFF0E1018)
private val CardBorder = Color(0xFF6F63FF)
/**
* "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and
* "Досмотреть титры"; [remainingMs] of [countdownMs] are left until it starts. [compact] (a window under 480 px): one
* line.
*/
@Composable
internal fun NextEpisodeCard(
next: NextEpisode,
remainingMs: Long,
countdownMs: Long,
compact: Boolean,
ui: PlayerUiState,
onPlay: () -> Unit,
onStay: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(10.dp)
val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0)
val fraction = (1f - remainingMs.toFloat() / countdownMs).coerceIn(0f, 1f)
Column(modifier.width(if (compact) 300.dp else 320.dp).holdsControls(ui, "next").background(CardColor, shape).border(0.5.dp, CardBorder, shape)) {
if (compact) {
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(Modifier.weight(1f)) {
BasicText("ЧЕРЕЗ $seconds С", style = AppType.MonoLabel.copy(fontSize = 9.sp, letterSpacing = 0.08.em))
BasicText(next.title, Modifier.padding(top = 3.dp), style = AppType.Section.copy(fontSize = 12.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
PromptButton("Смотреть", primary = true, onClick = onPlay)
GlyphButton(Glyph.Close, 28.dp, 12.dp, onClick = onStay, bare = true)
}
} else {
Row(Modifier.padding(11.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) {
RemoteImage(next.image, 6.dp, Modifier.size(PlayerLayout.NEXT_W.dp, PlayerLayout.NEXT_H.dp))
Column(Modifier.weight(1f)) {
BasicText("СЛЕДУЮЩАЯ ЧЕРЕЗ $seconds С", style = AppType.MonoLabel.copy(fontSize = 9.sp, letterSpacing = 0.08.em))
BasicText(next.title, Modifier.padding(top = 5.dp), style = AppType.Section.copy(fontSize = 13.sp, lineHeight = 16.sp), maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
Row(Modifier.padding(start = 11.dp, end = 11.dp, bottom = 11.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PromptButton("Смотреть сейчас", primary = true, onClick = onPlay, modifier = Modifier.weight(1f))
PromptButton("Досмотреть титры", primary = false, onClick = onStay)
}
}
Spacer(
Modifier
.fillMaxWidth()
.height(2.5.dp)
.padding(horizontal = 10.dp)
.drawBehind {
drawRect(AppColors.ProgressTrack)
drawRect(AppColors.ProgressGradient, Offset.Zero, Size(size.width * fraction, size.height))
},
)
}
}
@Composable
private fun PromptButton(label: String, primary: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
var hovered by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(17.dp)
Row(
modifier
.height(32.dp)
.onHover { hovered = it }
.then(
when {
primary -> Modifier.background(AppColors.PrimaryGradient, shape)
hovered -> Modifier.background(AppColors.FocusFill, shape)
else -> Modifier.background(OsdColors.Fill, shape).border(0.5.dp, OsdColors.Border, shape)
},
)
.then(if (primary && hovered) Modifier.border(1.5.dp, Color(0xFFD9D2FF), shape) else Modifier)
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
) {
val color = if (!primary && hovered) AppColors.OnFocusFill else Color.White
if (primary) Icon(AppIcon.Play, color, Modifier.size(11.dp))
BasicText(label, style = AppType.Button.copy(fontSize = 12.sp, fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal, color = color), maxLines = 1)
}
}
/** Until the first frame. */
@Composable
internal fun LoadingBadge(modifier: Modifier = Modifier) {
Row(
modifier.background(Color(0xCC0A0B10), RoundedCornerShape(16.dp)).padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StatusDot(AppColors.Accent, pulsing = true)
BasicText("ЗАГРУЗКА", style = AppType.MonoLabel.copy(color = AppColors.TextSecondary))
}
}
@Composable
internal fun ErrorCard(message: String, onClose: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier
.widthIn(max = 420.dp)
.padding(16.dp)
.background(AppColors.Surface, RoundedCornerShape(12.dp))
.border(0.5.dp, AppColors.Border, RoundedCornerShape(12.dp))
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
BasicText("Не удалось воспроизвести", style = AppType.Section.copy(fontWeight = FontWeight.Bold))
BasicText(message, style = AppType.Body, maxLines = 6, overflow = TextOverflow.Ellipsis)
Row(Modifier.padding(top = 6.dp)) { PromptButton("Закрыть", primary = false, onClick = onClose) }
}
}
/** One line of the right-click menu; [hint] is its key. */
@Immutable
internal data class MenuItem(val label: String, val hint: String? = null, val action: () -> Unit)
/**
* The right-click menu at [at] (moved in to stay whole inside the window). A click past it or Esc closes it
* ([onDismiss]); picking a line closes it and runs the line.
*/
@Composable
internal fun PlayerMenu(items: List<List<MenuItem>>, at: Offset, ui: PlayerUiState, onDismiss: () -> Unit) {
Box(Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures(onPress = { onDismiss() }) })
BoxWithConstraints(Modifier.fillMaxSize()) {
val bounds = constraints
val shape = RoundedCornerShape(10.dp)
Column(
Modifier
.layout { m, c ->
val p = m.measure(c.copy(minWidth = 0, minHeight = 0))
val x = at.x.roundToInt().coerceAtMost(bounds.maxWidth - p.width).coerceAtLeast(0)
val y = at.y.roundToInt().coerceAtMost(bounds.maxHeight - p.height).coerceAtLeast(0)
layout(c.maxWidth, c.maxHeight) { p.place(IntOffset(x, y)) }
}
.width(250.dp)
.holdsControls(ui, "menu")
.background(PanelColors.Background, shape)
.border(0.5.dp, PanelColors.Edge, shape)
.pointerInput(Unit) { detectTapGestures { } }
// A low window (the mini one) shows it whole by scrolling.
.verticalScroll(rememberScrollState())
.padding(vertical = 5.dp),
) {
items.forEachIndexed { i, group ->
if (i > 0) Box(Modifier.padding(vertical = 4.dp).fillMaxWidth().height(0.5.dp).background(PanelColors.Edge))
for (item in group) MenuLine(item) {
onDismiss()
item.action()
}
}
}
}
}
@Composable
private fun MenuLine(item: MenuItem, onClick: () -> Unit) {
var hovered by remember { mutableStateOf(false) }
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 5.dp)
.height(30.dp)
.onHover { hovered = it }
.background(if (hovered) PanelColors.Focused else Color.Transparent, RoundedCornerShape(6.dp))
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicText(item.label, Modifier.weight(1f), style = AppType.BodyLarge.copy(fontSize = 12.5.sp, color = if (hovered) Color.White else AppColors.TextSecondary), maxLines = 1)
item.hint?.let { BasicText(it, style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle)) }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,371 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.window.Window
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
import ru.stenfredd.sleepyowl.playback.api.VideoHost
import ru.stenfredd.sleepyowl.playback.mpv.MpvEngine
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuViewModel
import ru.stenfredd.sleepyowl.ui.player.PlayerViewModel
import java.awt.GraphicsEnvironment
import java.awt.Rectangle
import java.awt.Toolkit
/** The player window's modes (§1.1): they change on the fly, the video goes on. */
enum class PlayerMode { Mini, Window, Fullscreen }
/**
* "Смотреть" opens this: one player window at a time; a new item replaces the one playing. Lives in Koin, the window
* itself is composed by [main] next to the library window.
*/
class DesktopPlayer(stores: PreferenceStores, val settings: DesktopSettings) {
var screen by mutableStateOf<Screen.Player?>(null)
private set
val audio = PlayerAudio(stores)
val placement = PlayerPlacement(stores)
/** The window's mode; the next player opens in the last one (§1.1). */
var mode by mutableStateOf(placement.mode)
private set
/** The mode full screen goes back to: the mini window or the normal one. */
var windowedMode = placement.windowedMode
private set
val fullscreen: Boolean get() = mode == PlayerMode.Fullscreen
/** Opens a page in the library window and brings that window up; set by the library window. */
var openPage: (Screen) -> Unit = {}
/** Brings the library window up; set by it. */
var showLibrary: () -> Unit = {}
/** What the window plays, for the system ([SystemMedia]); null while nothing does. Set by the window. */
var nowPlaying by mutableStateOf<NowPlaying?>(null)
/** The window's controls for the media keys and the sound applet ([SystemMedia]); set by the window. */
var commands: PlayerCommands? = null
fun play(screen: Screen.Player) {
Log.i("Player: %s from %d ms", screen.itemId, screen.startPositionMs)
// "Открывать плеер" (L9): a new window opens in the chosen mode; a window already open keeps its own.
if (this.screen == null) settings.playerOpening.mode?.let(::switchTo)
this.screen = screen
}
fun close() {
screen = null
nowPlaying = null
commands = null
}
fun switchTo(new: PlayerMode) {
if (new == mode) return
if (new != PlayerMode.Fullscreen) windowedMode = new
mode = new
placement.saveMode(new, windowedMode)
}
/** `T`: on top of everything ↔ the normal window. */
fun toggleOnTop() = switchTo(if (mode == PlayerMode.Mini) PlayerMode.Window else PlayerMode.Mini)
fun toggleFullscreen() = switchTo(if (fullscreen) windowedMode else PlayerMode.Fullscreen)
fun leaveFullscreen() {
if (fullscreen) switchTo(windowedMode)
}
/** A double click on the video: mini → normal window → full screen → back. */
fun cycleMode() = switchTo(
when (mode) {
PlayerMode.Mini -> PlayerMode.Window
PlayerMode.Window -> PlayerMode.Fullscreen
PlayerMode.Fullscreen -> windowedMode
},
)
}
/**
* Where the player window goes (§1.1): the last mode and each windowed mode's own place and size (client area, screen
* pixels — the monitor is in them). A place no monitor shows any more gives way to the default one.
*/
class PlayerPlacement(stores: PreferenceStores) {
private val store = stores["desktop_player"]
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var lastWrite: Job? = null
// Read once, before the first window: the window opens where it was.
private val saved: Preferences? = try {
runBlocking(Dispatchers.IO) { withTimeoutOrNull(2_000) { store.data.first() } }
} catch (e: Exception) {
Log.w(e, "Player placement not read")
null
}
var mode = saved?.get(KEY_MODE)?.let(::modeOf) ?: PlayerMode.Mini
private set
var windowedMode = saved?.get(KEY_WINDOWED)?.let(::modeOf)?.takeIf { it != PlayerMode.Fullscreen } ?: PlayerMode.Mini
private set
/** The mini window is on every workspace (`_NET_WM_STATE_STICKY`). */
var sticky by mutableStateOf(saved?.get(KEY_STICKY) ?: false)
private set
private val bounds = mutableMapOf<PlayerMode, Rectangle>().apply {
saved?.get(KEY_MINI)?.let(::rectangleOf)?.let { put(PlayerMode.Mini, it) }
saved?.get(KEY_WINDOW)?.let(::rectangleOf)?.let { put(PlayerMode.Window, it) }
}
fun saveMode(mode: PlayerMode, windowed: PlayerMode) {
this.mode = mode
windowedMode = windowed
write {
it[KEY_MODE] = mode.name
it[KEY_WINDOWED] = windowed.name
}
}
fun saveSticky(on: Boolean) {
sticky = on
write { it[KEY_STICKY] = on }
}
/** [mode]'s place (a windowed one): kept when it changed. */
fun save(mode: PlayerMode, r: Rectangle) {
if (mode == PlayerMode.Fullscreen || r.width < MIN_WIDTH || r.height < MIN_HEIGHT || bounds[mode] == r) return
bounds[mode] = Rectangle(r)
write { it[if (mode == PlayerMode.Mini) KEY_MINI else KEY_WINDOW] = "${r.x},${r.y},${r.width},${r.height}" }
}
/** Where [mode] (a windowed one) opens: its last place while a monitor shows it, else the default place. */
fun bounds(mode: PlayerMode): Rectangle = bounds[mode]?.takeIf(::onScreen)?.let(::Rectangle) ?: defaultBounds(mode)
/** Waits for the last write: before the process ends. */
suspend fun flush() {
lastWrite?.join()
}
private fun write(change: (MutablePreferences) -> Unit) {
lastWrite = scope.launch {
try {
store.edit(change)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Player placement not saved")
}
}
}
private companion object {
val KEY_MODE = stringPreferencesKey("mode")
val KEY_WINDOWED = stringPreferencesKey("windowed_mode")
val KEY_STICKY = booleanPreferencesKey("mini_sticky")
val KEY_MINI = stringPreferencesKey("mini_bounds")
val KEY_WINDOW = stringPreferencesKey("window_bounds")
fun modeOf(name: String) = PlayerMode.entries.firstOrNull { it.name == name }
fun rectangleOf(text: String): Rectangle? =
text.split(',').mapNotNull { it.trim().toIntOrNull() }.takeIf { it.size == 4 }?.let { (x, y, w, h) -> Rectangle(x, y, w, h) }
/** Its middle and most of it on one monitor, and it fits there. */
fun onScreen(r: Rectangle): Boolean = screens().any { s ->
val shown = s.intersection(r)
s.contains(r.centerX, r.centerY) && r.width <= s.width && r.height <= s.height &&
shown.width.toLong() * shown.height >= r.width.toLong() * r.height / 2
}
fun screens(): List<Rectangle> = runCatching {
GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { it.defaultConfiguration.bounds }
}.getOrDefault(emptyList())
/** The mini window: 640×360 in the bottom right corner of the main monitor, 24 px in; the normal one in its middle. */
fun defaultBounds(mode: PlayerMode): Rectangle {
val area = runCatching {
val gc = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration
val insets = Toolkit.getDefaultToolkit().getScreenInsets(gc)
val s = gc.bounds
Rectangle(s.x + insets.left, s.y + insets.top, s.width - insets.left - insets.right, s.height - insets.top - insets.bottom)
}.getOrDefault(Rectangle(0, 0, 1920, 1080))
return if (mode == PlayerMode.Mini) {
Rectangle(area.x + area.width - MINI_WIDTH - MINI_MARGIN, area.y + area.height - MINI_HEIGHT - MINI_MARGIN, MINI_WIDTH, MINI_HEIGHT)
} else {
val w = minOf(WINDOW_WIDTH, area.width)
val h = minOf(WINDOW_HEIGHT, area.height)
Rectangle(area.x + (area.width - w) / 2, area.y + (area.height - h) / 2, w, h)
}
}
}
}
/**
* Volume and mute: kept across players and launches (§1.3). The engine starts with them ([MpvEngine.startVolume]), so a
* new episode is never loud for a moment.
*/
class PlayerAudio(stores: PreferenceStores) {
private val store = stores["desktop_player"]
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
var volume by mutableIntStateOf(100)
private set
var muted by mutableStateOf(false)
private set
init {
scope.launch {
try {
val prefs = store.data.first()
apply(prefs[KEY_VOLUME] ?: 100, prefs[KEY_MUTED] ?: false)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Player audio settings not read")
}
}
}
fun set(volume: Int, muted: Boolean) {
apply(volume, muted)
scope.launch {
store.edit {
it[KEY_VOLUME] = volume
it[KEY_MUTED] = muted
}
}
}
private fun apply(volume: Int, muted: Boolean) {
this.volume = volume.coerceIn(0, MpvEngine.MAX_VOLUME)
this.muted = muted
MpvEngine.startVolume = this.volume
MpvEngine.startMuted = muted
}
private companion object {
val KEY_VOLUME = intPreferencesKey("volume")
val KEY_MUTED = booleanPreferencesKey("muted")
}
}
/** The player's place for video: the session hands it each engine it starts. */
private class ComposeVideoHost : VideoHost {
var engine by mutableStateOf<PlaybackEngine?>(null)
private set
override fun show(engine: PlaybackEngine) {
this.engine = engine
}
override fun hide(engine: PlaybackEngine) {
if (this.engine === engine) this.engine = null
}
}
/**
* The player window: the mpv engine under the shared [PlayerViewModel] and [PlaybackSession] (progress, tracks,
* fallback to a transcode) with the desktop's controls ([PlayerContent]), in one of three modes ([PlayerFrame]).
*
* The window is made here ([PlayerFrame.create]) rather than by Compose's `Window(state = …)`: its place and size
* belong to the window manager and [PlayerPlacement], not to a `WindowState` kept in step with them.
*/
@Composable
fun PlayerWindow(player: DesktopPlayer, screen: Screen.Player) {
// The window's view models: cleared (and the playback released) when the window goes.
val owner = remember {
object : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
}
DisposableEffect(owner) { onDispose { owner.viewModelStore.clear() } }
CompositionLocalProvider(LocalViewModelStoreOwner provides owner) {
val vm = koinViewModel<PlayerViewModel>()
val watch = koinViewModel<WatchMenuViewModel>()
val scope = rememberCoroutineScope()
LaunchedEffect(screen) { vm.start(screen) }
val session by vm.session.collectAsState()
val info by vm.info.collectAsState()
val host = remember { ComposeVideoHost() }
DisposableEffect(session) {
val s = session
s?.attachVideo(host)
onDispose { s?.detachVideo() }
}
val keys = remember { PlayerKeys() }
val frame = remember { PlayerFrame(player) }
val title = info?.let { i -> listOfNotNull(i.title, i.subtitle.takeIf { i.isEpisode }).joinToString(" · ") } ?: "SleepyOwl"
Window(
visible = true,
onPreviewKeyEvent = { keys.handler(it) },
onKeyEvent = { false },
create = { frame.create(title) },
dispose = { frame.dispose() },
update = { it.title = title },
) {
frame.Modes((host.engine as? MpvEngine)?.video?.displaySize ?: IntSize.Zero)
val s = session
if (s == null) {
Box(Modifier.fillMaxSize().background(Color.Black))
} else {
ScaledContent(player.settings) {
PlayerContent(
s, vm, host.engine, player, frame, keys,
onClose = player::close,
onMarkWatched = { id, played ->
scope.launch {
runCatching { watch.setPlayed(id, played) }.onFailure { Log.w(it, "Player: marking watched failed") }
}
},
)
}
}
}
}
}
internal const val MIN_WIDTH = 320
internal const val MIN_HEIGHT = 180
private const val MINI_WIDTH = 640
private const val MINI_HEIGHT = 360
private const val MINI_MARGIN = 24
private const val WINDOW_WIDTH = 1280
private const val WINDOW_HEIGHT = 720

View File

@@ -1,43 +0,0 @@
package ru.stenfredd.sleepyowl.desktop
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.core.jellyfin.ServerRouting
import ru.stenfredd.sleepyowl.ui.Screen
import java.io.File
/**
* "Смотреть" until the desktop player (L6–L7): the L1 prototype (`desktop-proto`) in its own process, playing the file
* straight from the server in its mini window. It knows nothing of the app: no tracks chosen on the page, no progress
* reported, no next episode. Needs the repository (the prototype's classes and `tools/linux/l1/proto.sh`): the path
* comes in the system property `sleepyowl.repo`, set by `desktop.sh` and `./gradlew :desktop:run`.
*/
class ProtoPlayer(private val session: JellyfinSession, private val routing: ServerRouting) {
private var process: Process? = null
fun play(screen: Screen.Player) {
val script = System.getProperty("sleepyowl.repo")?.let { File(it, "tools/linux/l1/proto.sh") }
if (script == null || !script.canExecute()) {
Log.w("Player: the prototype is not found (sleepyowl.repo=%s)", System.getProperty("sleepyowl.repo"))
return
}
val base = routing.localUrl ?: session.session.value?.serverUrl?.trimEnd('/') ?: return
val id = screen.itemId.toString().replace("-", "")
// Static streams are served without a token.
val url = "$base/Videos/$id/stream?static=true&mediaSourceId=$id"
val start = screen.startFraction?.let { "%.1f%%".format(java.util.Locale.ROOT, it * 100) } ?: "%.3f".format(java.util.Locale.ROOT, screen.startPositionMs / 1000.0)
// One player at a time, as the desktop player will be.
process?.takeIf { it.isAlive }?.destroy()
Log.i("Player: prototype for %s from %s", id, start)
val dir = DesktopPaths.log.apply { mkdirs() }
process = runCatching {
ProcessBuilder(script.path, "--url", url, "--start", start)
.apply { environment()["PROTO_NOBUILD"] = "1" }
// Its crash reports (hs_err_pid*.log) go beside the logs, not into the current directory.
.directory(dir)
.redirectErrorStream(true)
.redirectOutput(ProcessBuilder.Redirect.appendTo(File(dir, "proto.log")))
.start()
}.onFailure { Log.w(it, "Player: prototype did not start") }.getOrNull()
}
}

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.data.LanguageSettings import ru.stenfredd.sleepyowl.core.data.LanguageSettings
import ru.stenfredd.sleepyowl.core.data.SubtitleMode import ru.stenfredd.sleepyowl.core.data.SubtitleMode
@@ -71,6 +72,8 @@ private enum class Section(val title: String, val dot: Color) {
Playback("Воспроизведение", AppColors.Blue), Playback("Воспроизведение", AppColors.Blue),
Languages("Языки", AppColors.Pink), Languages("Языки", AppColors.Pink),
Subtitles("Вид субтитров", AppColors.Yellow), Subtitles("Вид субтитров", AppColors.Yellow),
Desktop("Компьютер", AppColors.Orange),
Keys("Клавиши", AppColors.Accent),
Account("Аккаунт и сервер", AppColors.Green), Account("Аккаунт и сервер", AppColors.Green),
About("О приложении", AppColors.TextDim), About("О приложении", AppColors.TextDim),
} }
@@ -82,7 +85,7 @@ private data class Option<T>(val value: T, val label: String, val detail: String
/** /**
* Settings tab, as on the TV without its TV-only settings (video output, Dolby Vision, the debug tour): sections on the * Settings tab, as on the TV without its TV-only settings (video output, Dolby Vision, the debug tour): sections on the
* left, the chosen section's settings on the right as rows of chips. Every choice applies at once. The desktop's own * left, the chosen section's settings on the right as rows of chips. Every choice applies at once. The desktop's own
* settings (interface scale, the player window) come with L9. * settings are in "Компьютер" (interface scale, the player window), the keys and the mouse in "Клавиши" (L9).
*/ */
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -118,6 +121,8 @@ fun SettingsScreen(
Section.Playback -> PlaybackSection(vm, focus) Section.Playback -> PlaybackSection(vm, focus)
Section.Languages -> LanguagesSection(vm, focus) Section.Languages -> LanguagesSection(vm, focus)
Section.Subtitles -> SubtitlesSection(vm, focus) Section.Subtitles -> SubtitlesSection(vm, focus)
Section.Desktop -> DesktopSection(focus)
Section.Keys -> KeysSection()
Section.Account -> AccountSection(vm, focus, onOpen, onSignedOut, onSwitched) Section.Account -> AccountSection(vm, focus, onOpen, onSignedOut, onSwitched)
Section.About -> AboutSection(vm) Section.About -> AboutSection(vm)
} }
@@ -308,6 +313,106 @@ private fun SubtitlesSection(vm: SettingsViewModel, focus: ScreenFocus) {
) )
} }
// --- Компьютер ---
@Composable
private fun DesktopSection(focus: ScreenFocus) {
val settings = koinInject<DesktopSettings>()
val placement = koinInject<DesktopPlayer>().placement
Setting("Масштаб интерфейса", "Размер текста, карточек и кнопок в окнах приложения, поверх масштаба системы.") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Choices("scale", DesktopSettings.SCALES.map { Option(it, "$it %") }, settings.scalePercent, focus, settings::changeScale)
settings.scaleOverride?.let { Note("Сейчас действует SLEEPYOWL_SCALE=$it из окружения — он важнее этой настройки.", color = AppColors.Yellow) }
}
}
Setting(
"Открывать плеер",
"В каком виде открывается окно плеера. «Как в прошлый раз» — в том режиме, в котором его закрыли. " +
"Режим можно сменить в любой момент: T, F, двойной щелчок по видео.",
) {
val options = listOf(
Option(PlayerOpening.Last, "Как в прошлый раз"),
Option(PlayerOpening.Mini, "Мини поверх всех"),
Option(PlayerOpening.Window, "Обычное окно"),
Option(PlayerOpening.Fullscreen, "Во весь экран"),
)
Choices("player-opening", options, settings.playerOpening, focus, settings::changePlayerOpening)
}
Setting("Мини-окно на всех рабочих столах", "Мини-окно плеера остаётся на экране при переключении рабочих столов.") {
Choices("sticky", listOf(Option(false, "Выкл"), Option(true, "Вкл")), placement.sticky, focus, placement::saveSticky)
}
}
// --- Клавиши ---
private val PlayerKeysTable = listOf(
"Пробел, K" to "Пауза и продолжение",
"← / →" to "Назад и вперёд на 10 с",
"Shift + ← / →" to "Назад и вперёд на 60 с",
"1 … 9" to "К 10 … 90 % фильма или серии",
"↑ / ↓" to "Громкость",
"M" to "Без звука",
"S" to "Пропустить заставку или титры",
"N / Shift + N" to "Следующая и предыдущая серия",
"A" to "Следующая аудиодорожка",
"C" to "Следующие субтитры",
"F" to "Весь экран",
"T" to "Мини поверх всех или обычное окно",
"Esc" to "Закрыть меню или панель, выйти из полноэкранного",
"Ctrl + W" to "Закрыть плеер",
"Медиаклавиши" to "Пауза, следующая и предыдущая серия — даже когда плеер не в фокусе",
)
private val PlayerMouseTable = listOf(
"Щелчок" to "Пауза и продолжение",
"Двойной щелчок" to "Мини → окно → весь экран",
"Колесо" to "Громкость; над таймлайном — ±5 с",
"Правая кнопка" to "Меню плеера",
"Мини-окно" to "Тянуть за видео — переместить, за край или угол — размер",
)
private val LibraryKeysTable = listOf(
"Стрелки" to "Переход между карточками и кнопками",
"Enter" to "Открыть",
"Esc, Backspace, Alt + ←" to "Назад; ещё — кнопка мыши «назад»",
"Ctrl + F, /" to "Поиск",
"Правая кнопка" to "Отметить просмотренным или непросмотренным",
)
/** The keys and the mouse (§1.3): the player works by the physical keys, so in the Russian layout too. */
@Composable
private fun KeysSection() {
KeyTable("Плеер", PlayerKeysTable)
KeyTable("Мышь в плеере", PlayerMouseTable)
KeyTable("Медиатека", LibraryKeysTable)
Note("Клавиши плеера работают по физическим кнопкам — и в русской раскладке.")
}
@Composable
private fun KeyTable(title: String, rows: List<Pair<String, String>>) {
Setting(title, null) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
for ((keys, action) in rows) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(190.dp)) {
BasicText(
keys,
Modifier
.background(AppColors.SurfaceRaised, RoundedCornerShape(5.dp))
.border(0.5.dp, AppColors.Border, RoundedCornerShape(5.dp))
.padding(horizontal = 7.dp, vertical = 3.dp),
style = AppType.MonoMeta,
maxLines = 1,
)
}
BasicText(action, Modifier.widthIn(max = 420.dp), style = AppType.Body.copy(color = AppColors.TextSecondary))
}
}
}
}
}
// --- Аккаунт и сервер --- // --- Аккаунт и сервер ---
@Composable @Composable

View File

@@ -0,0 +1,397 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import org.freedesktop.dbus.DBusPath
import org.freedesktop.dbus.annotations.DBusInterfaceName
import org.freedesktop.dbus.connections.impl.DBusConnection
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
import org.freedesktop.dbus.interfaces.DBusInterface
import org.freedesktop.dbus.interfaces.Properties
import org.freedesktop.dbus.messages.DBusSignal
import org.freedesktop.dbus.types.UInt32
import org.freedesktop.dbus.types.Variant
import ru.stenfredd.sleepyowl.core.common.AppDirs
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.playback.mpv.MpvEngine
import java.io.File
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.swing.SwingUtilities
import kotlin.math.abs
import kotlin.math.roundToInt
/** What the player window plays, for the system: the sound applet's title and cover, the media keys' targets. */
@Immutable
data class NowPlaying(
val itemId: UUID,
val title: String,
/** The series of an episode, the year and genres of a movie. */
val artist: String?,
/** The server's poster (the series' one for an episode). */
val artUrl: String?,
val durationMs: Long,
/** Playing or about to (buffering); false on pause. */
val playing: Boolean,
val canNext: Boolean,
val canPrevious: Boolean,
)
/** The player window's controls from outside it (media keys, the sound applet): called on the UI thread. */
interface PlayerCommands {
/** Play (true), pause (false) or toggle (null); the controls show for a moment (§1.3). */
fun playPause(play: Boolean?)
fun next()
fun previous()
fun seekTo(positionMs: Long)
fun changeVolume(percent: Int)
/** Brings the player window up. */
fun bringUp()
/** Any thread. */
fun positionMs(): Long
}
/**
* The player in the system, over the session's D-Bus (§2.5):
*
* - **MPRIS** — `org.mpris.MediaPlayer2.sleepyowl` while a player window is open: the media keys (Cinnamon passes them
* to the MPRIS player even when another window has the focus) and the sound applet's title, cover, position and
* buttons. The cover is fetched through the app's own client (LAN routing) into `~/.cache/sleepyowl/mpris`.
* - **The screensaver** — `org.freedesktop.ScreenSaver.Inhibit` while the video plays, not on pause. The inhibition
* belongs to our D-Bus connection: the system drops it if the app dies.
*
* D-Bus calls run on one thread of their own; without a session bus (or with an error) the player simply goes without.
*/
class SystemMedia(private val player: DesktopPlayer, private val http: OkHttpClient, dirs: AppDirs) {
private val bus = Executors.newSingleThreadExecutor { Thread(it, "dbus").apply { isDaemon = true } }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val covers = File(dirs.files, "mpris")
private var connection: DBusConnection? = null
private var failed = false
private var busName: String? = null
private val mpris = MprisObject()
private var inhibitCookie: UInt32? = null
// What D-Bus clients read (on the bus's threads); set on the UI thread.
@Volatile private var now: NowPlaying? = null
@Volatile private var coverFile: File? = null
@Volatile private var volume = 100
@Volatile private var muted = false
/** The properties last announced (PropertiesChanged): only changes are sent. */
private var announced: Map<String, Variant<*>> = emptyMap()
fun start() {
scope.launch {
snapshotFlow { Triple(player.nowPlaying, player.audio.volume, player.audio.muted) }.collect { (n, v, m) ->
val old = now
now = n
volume = v
muted = m
if (n?.artUrl != old?.artUrl || n?.itemId != old?.itemId) fetchCover(n)
onBus { publish() }
}
}
// MPRIS has no position updates: a jump (a seek, here or from the applet) is announced by `Seeked`.
scope.launch {
var lastPos = -1L
var lastAt = 0L
var lastItem: UUID? = null
while (true) {
delay(1_000)
val n = now
val pos = player.commands?.positionMs()
if (n == null || pos == null) {
lastPos = -1
continue
}
val at = monotonicMs()
val expected = lastPos + if (n.playing) at - lastAt else 0
if (lastPos >= 0 && n.itemId == lastItem && abs(pos - expected) > SEEK_JUMP_MS) onBus { seeked(pos) }
lastPos = pos
lastAt = at
lastItem = n.itemId
}
}
}
/** Before the process ends: the screensaver back, the name released. */
fun close() {
onBus {
uninhibit()
connection?.let { c ->
busName?.let { runCatching { c.releaseBusName(it) } }
runCatching { c.close() }
}
connection = null
}
bus.shutdown()
bus.awaitTermination(1, TimeUnit.SECONDS)
}
private fun onBus(block: () -> Unit) {
if (bus.isShutdown) return
bus.execute {
try {
block()
} catch (e: Exception) {
Log.w(e, "D-Bus")
}
}
}
private fun connection(): DBusConnection? {
connection?.let { return it }
if (failed) return null
return try {
DBusConnectionBuilder.forSessionBus().withShared(false).build().also {
connection = it
it.exportObject(MPRIS_PATH, mpris)
}
} catch (e: Exception) {
failed = true
Log.w(e, "No session D-Bus: no MPRIS, no screensaver inhibition")
null
}
}
// --- On the bus thread ---
private fun publish() {
val n = now
val c = connection() ?: return
if (n?.playing == true) inhibit() else uninhibit()
if (n == null) {
busName?.let {
Log.i("MPRIS: %s released", it)
runCatching { c.releaseBusName(it) }
}
busName = null
announced = emptyMap()
return
}
if (busName == null) {
// A second copy of the app (the test stand next to the user's own) takes an instance name, as the spec allows.
busName = listOf(MPRIS_NAME, "$MPRIS_NAME.instance${ProcessHandle.current().pid()}").firstOrNull { name ->
runCatching { c.requestBusName(name) }.isSuccess
}
Log.i("MPRIS: %s", busName ?: "no name")
announced = playerProperties()
return
}
val props = playerProperties()
val changed = props.filter { (k, v) -> k != "Position" && announced[k]?.let(::contentOf) != contentOf(v) }
announced = props
if (changed.isNotEmpty()) c.sendMessage(Properties.PropertiesChanged(MPRIS_PATH, PLAYER_INTERFACE, changed, emptyList()))
}
private fun seeked(positionMs: Long) {
if (busName == null) return
connection()?.sendMessage(MprisPlayer.Seeked(MPRIS_PATH, positionMs * 1000))
}
private fun inhibit() {
if (inhibitCookie != null) return
val c = connection() ?: return
inhibitCookie = try {
c.getRemoteObject(SCREENSAVER_NAME, SCREENSAVER_PATH, ScreenSaver::class.java).Inhibit("SleepyOwl", "Воспроизведение видео")
.also { Log.i("Screensaver inhibited (%d)", it.toLong()) }
} catch (e: Exception) {
Log.w("Screensaver not inhibited: %s", e.message)
UInt32(0)
}
}
private fun uninhibit() {
val cookie = inhibitCookie ?: return
inhibitCookie = null
if (cookie.toLong() == 0L) return
try {
connection()?.getRemoteObject(SCREENSAVER_NAME, SCREENSAVER_PATH, ScreenSaver::class.java)?.UnInhibit(cookie)
Log.i("Screensaver allowed (%d)", cookie.toLong())
} catch (e: Exception) {
Log.w("Screensaver inhibition not lifted: %s", e.message)
}
}
// --- The cover ---
private fun fetchCover(n: NowPlaying?) {
coverFile = null
val url = n?.artUrl ?: return
scope.launch(Dispatchers.IO) {
val file = File(covers, "%08x.jpg".format(url.hashCode()))
try {
if (!file.isFile) {
covers.mkdirs()
// Only the current cover is kept.
covers.listFiles()?.forEach { it.delete() }
http.newCall(Request.Builder().url(url).build()).execute().use { r ->
check(r.isSuccessful) { "HTTP ${r.code}" }
val tmp = File(covers, file.name + ".part")
tmp.outputStream().use { out -> r.body.byteStream().copyTo(out) }
tmp.renameTo(file)
}
}
if (now?.artUrl == url) {
coverFile = file
onBus { publish() }
}
} catch (e: Exception) {
Log.w("MPRIS: cover not fetched: %s", e.message)
}
}
}
// --- MPRIS properties ---
private fun rootProperties(): Map<String, Variant<*>> = mapOf(
"CanQuit" to Variant(false),
"CanRaise" to Variant(true),
"HasTrackList" to Variant(false),
"Identity" to Variant("SleepyOwl"),
"DesktopEntry" to Variant("sleepyowl"),
"SupportedUriSchemes" to Variant(arrayOf<String>(), "as"),
"SupportedMimeTypes" to Variant(arrayOf<String>(), "as"),
)
private fun playerProperties(): Map<String, Variant<*>> {
val n = now
return mapOf(
"PlaybackStatus" to Variant(
when {
n == null -> "Stopped"
n.playing -> "Playing"
else -> "Paused"
},
),
"Rate" to Variant(1.0),
"MinimumRate" to Variant(1.0),
"MaximumRate" to Variant(1.0),
"Metadata" to Variant(metadata(n), "a{sv}"),
"Volume" to Variant(if (muted) 0.0 else volume / 100.0),
"Position" to Variant((player.commands?.positionMs() ?: 0L) * 1000),
"CanGoNext" to Variant(n?.canNext == true),
"CanGoPrevious" to Variant(n?.canPrevious == true),
"CanPlay" to Variant(n != null),
"CanPause" to Variant(n != null),
"CanSeek" to Variant(n != null && n.durationMs > 0),
"CanControl" to Variant(true),
)
}
private fun metadata(n: NowPlaying?): Map<String, Variant<*>> {
n ?: return mapOf("mpris:trackid" to Variant(DBusPath(NO_TRACK)))
return buildMap {
put("mpris:trackid", Variant(DBusPath(trackPath(n.itemId))))
if (n.durationMs > 0) put("mpris:length", Variant(n.durationMs * 1000))
put("xesam:title", Variant(n.title))
n.artist?.let { put("xesam:artist", Variant(arrayOf(it), "as")) }
coverFile?.let { put("mpris:artUrl", Variant(it.toPath().toUri().toString())) }
}
}
/** A variant's value comparable by content (Variant.equals compares arrays by reference). */
private fun contentOf(v: Variant<*>): String = when (val value = v.value) {
is Array<*> -> value.contentDeepToString()
is Map<*, *> -> value.entries.joinToString { (k, x) -> "$k=" + ((x as? Variant<*>)?.let(::contentOf) ?: x) }
else -> value.toString()
}
private fun trackPath(id: UUID) = "/ru/stenfredd/sleepyowl/track/" + id.toString().replace("-", "")
/** Commands from D-Bus go to the window on the UI thread. */
private fun command(block: PlayerCommands.() -> Unit) = SwingUtilities.invokeLater { player.commands?.block() }
private inner class MprisObject : MprisRoot, MprisPlayer, Properties {
override fun getObjectPath() = MPRIS_PATH
override fun Raise() = command { bringUp() }
override fun Quit() = Unit
override fun Next() = command { next() }
override fun Previous() = command { previous() }
override fun Pause() = command { playPause(false) }
override fun PlayPause() = command { playPause(null) }
// A player window has nothing between "paused" and "closed": Stop pauses (closing is the window's ×).
override fun Stop() = command { playPause(false) }
override fun Play() = command { playPause(true) }
override fun Seek(offset: Long) = command { seekTo(positionMs() + offset / 1000) }
override fun SetPosition(trackId: DBusPath, position: Long) {
val n = now ?: return
if (trackId.path == trackPath(n.itemId) && position >= 0 && position / 1000 <= n.durationMs) command { seekTo(position / 1000) }
}
override fun OpenUri(uri: String) = Unit
@Suppress("UNCHECKED_CAST")
override fun <A : Any?> Get(interfaceName: String, propertyName: String): A = GetAll(interfaceName)[propertyName] as A
override fun GetAll(interfaceName: String): Map<String, Variant<*>> = when (interfaceName) {
ROOT_INTERFACE -> rootProperties()
PLAYER_INTERFACE -> playerProperties()
else -> emptyMap()
}
override fun <A : Any?> Set(interfaceName: String, propertyName: String, value: A) {
if (interfaceName != PLAYER_INTERFACE || propertyName != "Volume") return
val v = ((value as? Variant<*>)?.value ?: value) as? Double ?: return
command { changeVolume((v * 100).roundToInt().coerceIn(0, MpvEngine.MAX_VOLUME)) }
}
}
private companion object {
const val MPRIS_NAME = "org.mpris.MediaPlayer2.sleepyowl"
const val MPRIS_PATH = "/org/mpris/MediaPlayer2"
const val ROOT_INTERFACE = "org.mpris.MediaPlayer2"
const val PLAYER_INTERFACE = "org.mpris.MediaPlayer2.Player"
const val NO_TRACK = "/org/mpris/MediaPlayer2/TrackList/NoTrack"
const val SCREENSAVER_NAME = "org.freedesktop.ScreenSaver"
const val SCREENSAVER_PATH = "/org/freedesktop/ScreenSaver"
/** A position this far from where it should be was a seek. */
const val SEEK_JUMP_MS = 2_000
}
}
// D-Bus interfaces: dbus-java takes method names as they are, hence the capitals.
@Suppress("FunctionName")
@DBusInterfaceName("org.mpris.MediaPlayer2")
interface MprisRoot : DBusInterface {
fun Raise()
fun Quit()
}
@Suppress("FunctionName")
@DBusInterfaceName("org.mpris.MediaPlayer2.Player")
interface MprisPlayer : DBusInterface {
fun Next()
fun Previous()
fun Pause()
fun PlayPause()
fun Stop()
fun Play()
fun Seek(offset: Long)
fun SetPosition(trackId: DBusPath, position: Long)
fun OpenUri(uri: String)
/** The position jumped, µs. */
class Seeked(path: String, position: Long) : DBusSignal(path, position)
}
@Suppress("FunctionName")
@DBusInterfaceName("org.freedesktop.ScreenSaver")
interface ScreenSaver : DBusInterface {
fun Inhibit(applicationName: String, reason: String): UInt32
fun UnInhibit(cookie: UInt32)
}

View File

@@ -0,0 +1,252 @@
package ru.stenfredd.sleepyowl.desktop
import com.sun.jna.Memory
import com.sun.jna.Native
import com.sun.jna.NativeLong
import com.sun.jna.Pointer
import com.sun.jna.ptr.IntByReference
import com.sun.jna.ptr.NativeLongByReference
import com.sun.jna.ptr.PointerByReference
import ru.stenfredd.sleepyowl.core.common.Log
import java.awt.Window
/** The bits of Xlib the player's window needs (the L1 prototype's `x11/X11.kt`). */
private object Xlib {
init {
Native.register("X11")
}
@JvmStatic external fun XOpenDisplay(name: String?): Pointer?
@JvmStatic external fun XDefaultRootWindow(dpy: Pointer): NativeLong
@JvmStatic external fun XInternAtom(dpy: Pointer, name: String, onlyIfExists: Int): NativeLong
@JvmStatic external fun XSendEvent(dpy: Pointer, w: NativeLong, propagate: Int, mask: NativeLong, event: Pointer): Int
@JvmStatic external fun XFlush(dpy: Pointer): Int
@JvmStatic external fun XUngrabPointer(dpy: Pointer, time: NativeLong): Int
@JvmStatic external fun XMoveResizeWindow(dpy: Pointer, w: NativeLong, x: Int, y: Int, width: Int, height: Int): Int
@JvmStatic external fun XGetGeometry(
dpy: Pointer, w: NativeLong, root: NativeLongByReference, x: IntByReference, y: IntByReference,
width: IntByReference, height: IntByReference, border: IntByReference, depth: IntByReference,
): Int
@JvmStatic external fun XTranslateCoordinates(
dpy: Pointer, src: NativeLong, dst: NativeLong, x: Int, y: Int, rx: IntByReference, ry: IntByReference, child: NativeLongByReference,
): Int
@JvmStatic external fun XGetWMNormalHints(dpy: Pointer, w: NativeLong, hints: Pointer, supplied: NativeLongByReference): Int
@JvmStatic external fun XSetWMNormalHints(dpy: Pointer, w: NativeLong, hints: Pointer)
@JvmStatic external fun XQueryTree(
dpy: Pointer, w: NativeLong, root: NativeLongByReference, parent: NativeLongByReference, children: PointerByReference, n: IntByReference,
): Int
@JvmStatic external fun XFree(data: Pointer): Int
@JvmStatic external fun XChangeProperty(dpy: Pointer, w: NativeLong, property: NativeLong, type: NativeLong, format: Int, mode: Int, data: Pointer, n: Int): Int
@JvmStatic external fun XGetWindowProperty(
dpy: Pointer, w: NativeLong, property: NativeLong, offset: NativeLong, length: NativeLong, delete: Int, reqType: NativeLong,
actualType: NativeLongByReference, actualFormat: IntByReference, nItems: NativeLongByReference, bytesAfter: NativeLongByReference,
prop: PointerByReference,
): Int
}
/**
* An AWT top-level window as X11 sees it, on a connection of its own (used from the AWT thread only).
*
* Full screen goes through the window manager (`_NET_WM_STATE_FULLSCREEN`, as mpv does it), on the monitor the window
* is on. Muffin takes a decorated AWT window into full screen but never out of it (Compose's
* `WindowPlacement.Fullscreen` and a plain `java.awt.Frame` alike); an undecorated one whose frame comes from
* `_MOTIF_WM_HINTS` ([setDecorated]) goes both ways — the L1 prototype's way.
*/
class X11Window private constructor(private val dpy: Pointer, val xid: Long) {
private val root = Xlib.XDefaultRootWindow(dpy)
private fun atom(name: String) = Xlib.XInternAtom(dpy, name, 0)
fun setFullscreen(on: Boolean) = setState("_NET_WM_STATE_FULLSCREEN", on)
/** On every workspace (`_NET_WM_STATE_STICKY`): the mini window's setting. */
fun setSticky(on: Boolean) = setState("_NET_WM_STATE_STICKY", on)
private fun setState(name: String, on: Boolean) = sendClientMessage("_NET_WM_STATE", if (on) 1 else 0, atom(name).toLong(), 0, 1)
/**
* The window manager moves or resizes the window (`_NET_WM_MOVERESIZE`, as mpv and GTK do): smoothly, across
* monitors, snapping to edges. AWT grabbed the pointer on the press; the window manager can't grab it while AWT
* holds it, so that grab goes first.
*/
fun startMoveResize(rootX: Int, rootY: Int, direction: Int) {
ungrabAwtPointer()
// x, y, direction, button 1, source: an application
sendClientMessage("_NET_WM_MOVERESIZE", rootX.toLong(), rootY.toLong(), direction.toLong(), 1, 1)
}
/** The client area on the screen, without the window manager's frame. AWT's own bounds lag behind the window manager. */
fun geometry(): java.awt.Rectangle {
val r = NativeLongByReference()
val x = IntByReference()
val y = IntByReference()
val w = IntByReference()
val h = IntByReference()
val b = IntByReference()
val depth = IntByReference()
Xlib.XGetGeometry(dpy, NativeLong(xid), r, x, y, w, h, b, depth)
val rx = IntByReference()
val ry = IntByReference()
val child = NativeLongByReference()
Xlib.XTranslateCoordinates(dpy, NativeLong(xid), root, 0, 0, rx, ry, child)
return java.awt.Rectangle(rx.value, ry.value, w.value, h.value)
}
/** Straight to X11: AWT's cached bounds may be stale after the window manager moved the window. */
fun moveResize(r: java.awt.Rectangle) {
Xlib.XMoveResizeWindow(dpy, NativeLong(xid), r.x, r.y, r.width, r.height)
Xlib.XFlush(dpy)
}
/**
* The window's proportions for the window manager (WM_NORMAL_HINTS, PAspect; none when [w] or [h] is 0) and its
* minimum size, over the hints AWT set. AWT rewrites the hints wholesale on each of its reshapes
* (`XBaseWindow.setSizeHints`), so this is called again after them; returns false when the hints were already so.
*/
fun setSizeHints(w: Int, h: Int, minW: Int, minH: Int): Boolean {
val hints = Memory(80).apply { clear() }
Xlib.XGetWMNormalHints(dpy, NativeLong(xid), hints, NativeLongByReference())
val before = hints.getByteArray(0, 80)
var flags = hints.getNativeLong(0).toLong()
if (w > 0 && h > 0) {
flags = flags or P_ASPECT
hints.setInt(48, w)
hints.setInt(52, h)
hints.setInt(56, w)
hints.setInt(60, h)
} else {
flags = flags and P_ASPECT.inv()
}
flags = flags or P_MIN_SIZE
hints.setInt(24, minW)
hints.setInt(28, minH)
hints.setNativeLong(0, NativeLong(flags))
if (hints.getByteArray(0, 80).contentEquals(before)) return false
Xlib.XSetWMNormalHints(dpy, NativeLong(xid), hints)
Xlib.XFlush(dpy)
return true
}
/**
* The window manager's frame on a live window, as mpv's `border`. AWT made the window undecorated and doesn't know:
* its insets stay zero and Compose fills the client area.
*/
fun setDecorated(decorated: Boolean) {
val hints = atom("_MOTIF_WM_HINTS")
val data = Memory(5L * 8).apply { clear() }
data.setNativeLong(0, NativeLong(MWM_HINTS_DECORATIONS))
data.setNativeLong(16, NativeLong(if (decorated) 1 else 0))
Xlib.XChangeProperty(dpy, NativeLong(xid), hints, hints, 32, PROP_MODE_REPLACE, data, 5)
Xlib.XFlush(dpy)
}
val isFullscreen: Boolean get() = hasState("_NET_WM_STATE_FULLSCREEN")
/** The window manager has taken the window (set WM_STATE): before that, state requests get lost. */
val isManaged: Boolean get() = hasProperty(dpy, xid, atom("WM_STATE"))
/** A `_NET_WM_STATE` the window manager keeps on the window. */
fun hasState(name: String): Boolean {
val type = NativeLongByReference()
val format = IntByReference()
val items = NativeLongByReference()
val after = NativeLongByReference()
val data = PointerByReference()
Xlib.XGetWindowProperty(dpy, NativeLong(xid), atom("_NET_WM_STATE"), NativeLong(0), NativeLong(64), 0, NativeLong(XA_ATOM), type, format, items, after, data)
val p = data.value ?: return false
val want = atom(name).toLong()
val found = (0 until items.value.toInt()).any { p.getNativeLong(8L * it).toLong() == want }
Xlib.XFree(p)
return found
}
/** A ClientMessage to the root window, as EWMH asks for state changes. */
private fun sendClientMessage(type: String, vararg l: Long) {
val ev = Memory(192).apply { clear() }
ev.setInt(0, CLIENT_MESSAGE)
ev.setInt(16, 1)
ev.setPointer(24, dpy)
ev.setNativeLong(32, NativeLong(xid))
ev.setNativeLong(40, atom(type))
ev.setInt(48, 32)
l.forEachIndexed { i, v -> ev.setNativeLong(56 + 8L * i, NativeLong(v)) }
Xlib.XSendEvent(dpy, root, 0, NativeLong(SUBSTRUCTURE_NOTIFY or SUBSTRUCTURE_REDIRECT), ev)
Xlib.XFlush(dpy)
}
companion object {
/** `_NET_WM_MOVERESIZE` directions: the window's sides and corners clockwise from the top left, then a move. */
const val SIZE_TOPLEFT = 0
const val SIZE_TOP = 1
const val SIZE_TOPRIGHT = 2
const val SIZE_RIGHT = 3
const val SIZE_BOTTOMRIGHT = 4
const val SIZE_BOTTOM = 5
const val SIZE_BOTTOMLEFT = 6
const val SIZE_LEFT = 7
const val MOVE = 8
private const val CLIENT_MESSAGE = 33
private const val P_MIN_SIZE = 16L
private const val P_ASPECT = 128L
private const val MWM_HINTS_DECORATIONS = 2L
private const val PROP_MODE_REPLACE = 0
private const val XA_ATOM = 4L
private const val SUBSTRUCTURE_NOTIFY = 1L shl 19
private const val SUBSTRUCTURE_REDIRECT = 1L shl 20
private val display: Pointer? by lazy { runCatching { Xlib.XOpenDisplay(null) }.onFailure { Log.w(it, "No X11 display") }.getOrNull() }
/** The window the window manager manages: up from AWT's own window to the one with WM_STATE (its frame's child). */
fun of(window: Window): X11Window? {
val d = display ?: return null
val own = runCatching { Native.getWindowID(window) }.getOrNull() ?: return null
val wmState = Xlib.XInternAtom(d, "WM_STATE", 0)
val rootId = Xlib.XDefaultRootWindow(d).toLong()
var w = own
repeat(8) {
if (hasProperty(d, w, wmState)) return X11Window(d, w)
val parent = parentOf(d, w) ?: return X11Window(d, own)
if (parent == rootId) return X11Window(d, w)
w = parent
}
return X11Window(d, own)
}
/** Releases the pointer grab on AWT's own X connection, under AWT's lock. */
private fun ungrabAwtPointer() {
runCatching {
val display = Class.forName("sun.awt.X11.XToolkit").getMethod("getDisplay").invoke(null) as Long
val sun = Class.forName("sun.awt.SunToolkit")
sun.getMethod("awtLock").invoke(null)
try {
Xlib.XUngrabPointer(Pointer(display), NativeLong(0))
Xlib.XFlush(Pointer(display))
} finally {
sun.getMethod("awtUnlock").invoke(null)
}
}.onFailure { Log.w(it, "X11: pointer not released") }
}
private fun parentOf(d: Pointer, w: Long): Long? {
val root = NativeLongByReference()
val parent = NativeLongByReference()
val children = PointerByReference()
val n = IntByReference()
if (Xlib.XQueryTree(d, NativeLong(w), root, parent, children, n) == 0) return null
children.value?.let { Xlib.XFree(it) }
return parent.value.toLong()
}
private fun hasProperty(d: Pointer, w: Long, prop: NativeLong): Boolean {
val type = NativeLongByReference()
val format = IntByReference()
val items = NativeLongByReference()
val after = NativeLongByReference()
val data = PointerByReference()
Xlib.XGetWindowProperty(d, NativeLong(w), prop, NativeLong(0), NativeLong(1), 0, NativeLong(0), type, format, items, after, data)
data.value?.let { Xlib.XFree(it) }
return type.value.toLong() != 0L
}
}
}

View File

@@ -9,6 +9,7 @@ import ru.stenfredd.sleepyowl.core.data.dataModule
import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule
import ru.stenfredd.sleepyowl.playback.api.EngineProvider import ru.stenfredd.sleepyowl.playback.api.EngineProvider
import ru.stenfredd.sleepyowl.playback.core.playbackModule import ru.stenfredd.sleepyowl.playback.core.playbackModule
import ru.stenfredd.sleepyowl.playback.mpv.mpvEngineModule
import ru.stenfredd.sleepyowl.ui.uiModule import ru.stenfredd.sleepyowl.ui.uiModule
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@@ -18,9 +19,9 @@ class DesktopGraphTest {
@OptIn(KoinExperimentalAPI::class) @OptIn(KoinExperimentalAPI::class)
@Test @Test
fun graphIsComplete() { fun graphIsComplete() {
module { includes(jellyfinModule, dataModule, playbackModule, uiModule, desktopModule(UiScale(1f))) }.verify( module { includes(jellyfinModule, dataModule, playbackModule, mpvEngineModule, uiModule, desktopModule()) }.verify(
// UUID is the id view models get through parametersOf; File and OkHttpClient.Builder are used inside lambdas; // UUID is the id view models get through parametersOf; File and OkHttpClient.Builder are used inside lambdas;
// EngineProvider: EngineSelector takes all of them (getAll); the desktop has none until mpv (L6). // EngineProvider: EngineSelector takes all of them (getAll), which verify() can't follow.
extraTypes = listOf(UUID::class, OkHttpClient.Builder::class, File::class, EngineProvider::class), extraTypes = listOf(UUID::class, OkHttpClient.Builder::class, File::class, EngineProvider::class),
) )
} }

View File

@@ -0,0 +1,100 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import org.jetbrains.skia.EncodedImageFormat
import org.junit.Test
import ru.stenfredd.sleepyowl.core.common.AppDirs
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
import ru.stenfredd.sleepyowl.core.data.model.SegmentKind
import ru.stenfredd.sleepyowl.playback.api.EngineState
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
import ru.stenfredd.sleepyowl.ui.player.NextEpisode
import ru.stenfredd.sleepyowl.ui.player.PlayerInfo
import ru.stenfredd.sleepyowl.ui.player.SeasonEpisodes
import java.io.File
import java.nio.file.Files
import java.util.UUID
/**
* The player's controls in the three layouts of §1.3 (full, compact, minimal) over a stand-in picture, with the skip
* button, the "next episode" card and the right-click menu, drawn without a window into
* `build/reports/desktop/player-<name>.png` to look at.
*/
class PlayerRenderTest {
private val dir = Files.createTempDirectory("player-render").toFile()
private val player = PreferenceStores(AppDirs(dir, dir)).let { DesktopPlayer(it, DesktopSettings(it)) }
private val id = UUID.randomUUID()
private val state = PlaybackUiState(
title = "Угрюмый аптекарь",
loading = false,
engineState = EngineState(EngineState.Phase.READY, playWhenReady = true),
positionMs = 754_000,
durationMs = 1_370_000,
bufferedMs = 900_000,
itemId = id,
)
private val info = PlayerInfo(
id = id, isEpisode = true, seriesId = UUID.randomUUID(), label = "СЕРИАЛ", title = "Монолог фармацевта",
subtitle = "Сезон 1 · Серия 2 · Угрюмый аптекарь", chaptersMs = listOf(88_000, 1_290_000), poster = null, rating = "★ 8.6",
age = "16+", year = "2023", runtime = "23 мин", genres = "Аниме, Драма", description = null, director = null, cast = emptyList(),
seasonNumber = 1, qualities = emptyList(),
)
private val season = SeasonEpisodes(
currentId = id, title = "СЕЗОН 1 · СЕРИИ", stat = "24 серии · 2 просмотрено", episodes = emptyList(),
previous = UUID.randomUUID(), next = UUID.randomUUID(), nextEpisode = NextEpisode(UUID.randomUUID(), "S1 · E3 · Смута из-за духа", null),
)
private val spans = listOf(MediaSegment(SegmentKind.Intro, 0, 88_000), MediaSegment(SegmentKind.Outro, 1_290_000, 1_370_000))
@Test
fun layouts() {
render("full", 1280, 720) {
FullOsd(
state, info, season, null, PlayerUiState(), state.positionMs, spans, playing = true, player, drawer = false,
onToggle = {}, onSeek = {}, onScrub = { _, _ -> }, onEpisode = {}, onPanel = {}, onVolume = {}, onMute = {}, onFullscreen = {}, onOnTop = {}, onLibrary = {}, onClose = {},
)
SkipButton("Пропустить заставку", "0:44", PlayerUiState(), onClick = {}, modifier = Modifier.align(Alignment.BottomEnd).padding(end = 48.dp, bottom = 150.dp))
}
render("compact", 720, 405) {
CompactOsd(
state, info, PlayerUiState(), state.positionMs, spans, playing = false, player,
onToggle = {}, onSeek = {}, onScrub = { _, _ -> }, onVolume = {}, onMute = {}, onMenu = {}, onFullscreen = {}, onOnTop = {}, onLibrary = {}, onClose = {},
)
val ui = PlayerUiState()
PlayerMenu(listOf(listOf(MenuItem("Во весь экран", "F") {}), listOf(MenuItem("Аудио", "A") {}, MenuItem("Субтитры", "C") {}), listOf(MenuItem("Закрыть плеер", "Ctrl+W") {})), Offset(380f, 60f), ui, onDismiss = {})
}
render("minimal", 400, 225) {
MinimalOsd(
state, info, season, PlayerUiState(), state.positionMs, spans, playing = true, PlayerMode.Mini,
onToggle = {}, onSeek = {}, onScrub = { _, _ -> }, onEpisode = {}, onMenu = {}, onOnTop = {}, onLibrary = {}, onClose = {},
)
}
render("next", 400, 225) {
NextEpisodeCard(season.nextEpisode!!, 9_000, 15_000, compact = true, PlayerUiState(), onPlay = {}, onStay = {}, modifier = Modifier.align(Alignment.BottomEnd).padding(12.dp))
}
}
private fun render(name: String, width: Int, height: Int, content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit) {
val scene = ImageComposeScene(width, height, Density(1f)) {
// A stand-in for the picture: the controls are drawn over video.
Box(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(Color(0xFF3A5A7A), Color(0xFF7A4A3A))))) { content() }
}
var frame = 0L
repeat(20) { scene.render(frame); frame += 16_000_000L }
val png = checkNotNull(scene.render(frame).encodeToData(EncodedImageFormat.PNG)).bytes
File("build/reports/desktop").apply { mkdirs() }.resolve("player-$name.png").writeBytes(png)
scene.close()
}
}

View File

@@ -26,6 +26,7 @@ zxing = "3.5.3"
compose-multiplatform = "1.12.2" compose-multiplatform = "1.12.2"
jna = "5.18.1" jna = "5.18.1"
koin = "4.2.2" koin = "4.2.2"
dbus-java = "5.2.2"
junit = "4.13.2" junit = "4.13.2"
[libraries] [libraries]
@@ -68,6 +69,9 @@ androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "andro
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
# D-Bus on the desktop (MPRIS, the screensaver): pure Java, the JDK's own unix sockets.
dbus-java-core = { module = "com.github.hypfvieh:dbus-java-core", version.ref = "dbus-java" }
dbus-java-unixsocket = { module = "com.github.hypfvieh:dbus-java-transport-native-unixsocket", version.ref = "dbus-java" }
# Compose Multiplatform for the shared UI (ui:shared): on android these resolve to the androidx artifacts. # Compose Multiplatform for the shared UI (ui:shared): on android these resolve to the androidx artifacts.
cmp-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } cmp-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
cmp-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } cmp-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }

View File

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

View File

@@ -25,6 +25,7 @@ include(":playback:api")
include(":playback:core") include(":playback:core")
include(":playback:engine-exo") include(":playback:engine-exo")
include(":playback:engine-vlc") include(":playback:engine-vlc")
include(":playback:engine-mpv")
include(":ui:shared") include(":ui:shared")
// SleepyOwl for the Linux desktop (LINUX_PLAN.md). // SleepyOwl for the Linux desktop (LINUX_PLAN.md).

View File

@@ -1,11 +1,11 @@
#!/bin/bash #!/bin/bash
# SleepyOwl for the desktop without Gradle around the process: ./tools/linux/desktop.sh # SleepyOwl for the desktop without Gradle around the process: ./tools/linux/desktop.sh
# DESKTOP_NOBUILD=1 — do not build first. SLEEPYOWL_SCALE=1.25 — interface scale (until the settings have it, L9). # DESKTOP_NOBUILD=1 — do not build first. SLEEPYOWL_SCALE=1.25 — interface scale over the setting «Масштаб интерфейса».
# Files go to the XDG places (~/.config/sleepyowl, ~/.cache/sleepyowl, ~/.local/state/sleepyowl/log); the test stand # Files go to the XDG places (~/.config/sleepyowl, ~/.cache/sleepyowl, ~/.local/state/sleepyowl/log); the test stand
# (tools/linux/stand.sh) points them at its own directory. # (tools/linux/stand.sh) points them at its own directory.
set -e set -e
R=$(cd "$(dirname "$0")/../.." && pwd) R=$(cd "$(dirname "$0")/../.." && pwd)
export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1} export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1}
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath) [ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath)
exec "$JAVA_HOME/bin/java" -Dsleepyowl.repo="$R" --add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED --add-opens=java.desktop/sun.awt=ALL-UNNAMED \ exec "$JAVA_HOME/bin/java" --add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED --add-opens=java.desktop/sun.awt=ALL-UNNAMED \
-cp "$(cat "$R/desktop/build/desktop.classpath")" ru.stenfredd.sleepyowl.desktop.MainKt "$@" -cp "$(cat "$R/desktop/build/desktop.classpath")" ru.stenfredd.sleepyowl.desktop.MainKt "$@"

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""A stand-in org.freedesktop.ScreenSaver for the test stand's D-Bus (tools/linux/stand.sh): prints every Inhibit and
UnInhibit, so the app's screensaver inhibition (L9) can be checked without the user's desktop.
stand.sh bus python3 tools/linux/fake_screensaver.py > log &
"""
from gi.repository import Gio, GLib
XML = """<node><interface name="org.freedesktop.ScreenSaver">
<method name="Inhibit"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="u" direction="out"/></method>
<method name="UnInhibit"><arg type="u" direction="in"/></method>
</interface></node>"""
cookies = iter(range(1, 1 << 31))
def call(conn, sender, path, iface, method, args, invocation):
if method == "Inhibit":
cookie = next(cookies)
print(f"Inhibit {args.unpack()} from {sender} -> {cookie}", flush=True)
invocation.return_value(GLib.Variant("(u)", (cookie,)))
else:
print(f"UnInhibit {args.unpack()[0]} from {sender}", flush=True)
invocation.return_value(None)
def acquired(conn, name):
conn.register_object("/org/freedesktop/ScreenSaver", Gio.DBusNodeInfo.new_for_xml(XML).interfaces[0], call, None, None)
# The real service drops an inhibition when its owner leaves the bus; show that moment.
conn.signal_subscribe("org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged", None, None, 0,
lambda c, s, p, i, sig, a: a.unpack()[2] == "" and a.unpack()[0].startswith(":") and print(f"gone {a.unpack()[0]}", flush=True))
print("ready", flush=True)
Gio.bus_own_name(Gio.BusType.SESSION, "org.freedesktop.ScreenSaver", 0, acquired, None, None)
GLib.MainLoop().run()

384
tools/linux/k2.py Executable file
View File

@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""Checkpoint К2 on the test stand (LINUX_PLAN.md §3): the player window and the system integration, end to end.
tools/linux/k2.py runs it; PASS/FAIL per check, screenshots in $STAND/shots/k2-*.png
Everything happens on the stand's display :87 and its own D-Bus (stand.sh): the user's screen, focus and desktop are
not touched; the video is muted, the app runs niced. The player's state is read back from MPRIS (status, position,
volume, title), the windows from X11 (_NET_WM_STATE, geometry, frame), the screensaver from fake_screensaver.py.
The player settings start from scratch (first launch) and are put back afterwards, as is the test account's watch data.
"""
import os, re, shutil, subprocess, sys, time
from Xlib import X, XK, display
from Xlib.ext import xtest
from PIL import Image
R = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
STAND = os.environ.get("STAND") or os.path.expanduser("~/.cache/sleepyowl-test")
STAND_SH = os.path.join(R, "tools/linux/stand.sh")
PREFS = os.path.join(STAND, "config/sleepyowl")
LOG = os.path.join(STAND, "state/sleepyowl/log/sleepyowl.log")
E2, E3, E4 = "00000000000000000000000000000000", "00000000000000000000000000000000", "00000000000000000000000000000000"
results = []
def sh(*args, env=None, check=True):
return subprocess.run(args, env=env, check=check, capture_output=True, text=True).stdout
def check(name, ok, detail=""):
results.append((name, bool(ok)))
print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else ""), flush=True)
def near(a, b, tol):
return abs(a - b) <= tol
def same_place(p, place):
# ±2 px: the window manager rounds the size to the video's proportions (874×491.6 → 491 or 492).
return all(near(v, w, 2) for v, w in zip((p["x"], p["y"], p["w"], p["h"]), place))
# --- Stand ---
sh(STAND_SH, "up")
d = display.Display(":87")
root = d.screen().root
BUS = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=open(os.path.join(STAND, "run/dbus.addr")).read().strip())
def atom(name):
return d.intern_atom(name)
def prop(w, name, kind=X.AnyPropertyType):
p = w.get_full_property(atom(name), kind)
return p.value if p else None
def windows():
"""The app's windows in mapping order (the library first): name, states, client geometry on the screen, frame."""
out = []
for wid in prop(root, "_NET_CLIENT_LIST") or []:
w = d.create_resource_object("window", wid)
try:
name = prop(w, "_NET_WM_NAME", atom("UTF8_STRING"))
name = name.decode() if isinstance(name, bytes) else (w.get_wm_name() or "")
states = [d.get_atom_name(a).replace("_NET_WM_STATE_", "") for a in (prop(w, "_NET_WM_STATE") or [])]
g = w.get_geometry()
t = w.translate_coords(root, 0, 0)
motif = prop(w, "_MOTIF_WM_HINTS")
out.append(dict(id=wid, name=name, states=states, x=-t.x, y=-t.y, w=g.width, h=g.height,
framed=bool(motif is None or (motif[0] & 2 == 0) or motif[2] != 0)))
except Exception:
pass
return out
def player():
ws = windows()
return ws[1] if len(ws) > 1 else None
def library():
ws = windows()
return ws[0] if ws else None
def press(code, down):
xtest.fake_input(d, X.KeyPress if down else X.KeyRelease, code)
d.sync()
def keycode(name):
return d.keysym_to_keycode(XK.string_to_keysym(name))
def key(chord, raw=None):
*mods, name = chord.split("+")
mc = [keycode({"ctrl": "Control_L", "shift": "Shift_L", "alt": "Alt_L"}[m]) for m in mods]
for c in mc: press(c, True)
c = raw or keycode(name)
press(c, True); time.sleep(0.03); press(c, False)
for c in reversed(mc): press(c, False)
time.sleep(0.15)
def move(x, y):
xtest.fake_input(d, X.MotionNotify, x=int(x), y=int(y)); d.sync(); time.sleep(0.05)
def click(x, y, button=1, times=1):
move(x, y)
for _ in range(times):
xtest.fake_input(d, X.ButtonPress, button); d.sync(); time.sleep(0.03)
xtest.fake_input(d, X.ButtonRelease, button); d.sync(); time.sleep(0.06)
def drag(x1, y1, x2, y2, steps=25):
move(x1, y1)
xtest.fake_input(d, X.ButtonPress, 1); d.sync(); time.sleep(0.1)
for i in range(1, steps + 1):
move(x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
time.sleep(0.15)
xtest.fake_input(d, X.ButtonRelease, 1); d.sync(); time.sleep(0.2)
def shot(name):
path = os.path.join(STAND, "shots", f"k2-{name}.png")
sh("import", "-display", ":87", "-window", "root", path)
return path
def top_line(name, p):
"""The window's top line (the compact layout's title and ×) as grey pixels: compared on a paused frame."""
return list(Image.open(shot(name)).convert("L").crop((p["x"], p["y"], p["x"] + p["w"], p["y"] + 36)).getdata())
def diff(a, b):
return sum(abs(x - y) for x, y in zip(a, b)) / max(1, len(a))
# --- MPRIS and the screensaver ---
MPRIS = ["gdbus", "call", "--session", "--dest", "org.mpris.MediaPlayer2.sleepyowl", "--object-path", "/org/mpris/MediaPlayer2"]
def mpris_raw(prop_name):
return subprocess.run(MPRIS + ["--method", "org.freedesktop.DBus.Properties.Get", "org.mpris.MediaPlayer2.Player", prop_name],
env=BUS, capture_output=True, text=True).stdout
def mpris(prop_name):
out = mpris_raw(prop_name)
m = re.search(r"<(?:int64 )?'?([^'>]*)'?>", out)
if prop_name == "Metadata":
t = re.search(r"xesam:title': <'([^']*)'", out)
return t.group(1) if t else None
if not m:
return None
v = m.group(1)
return float(v) if re.fullmatch(r"-?[\d.]+", v) else v
def mpris_call(method, *args):
subprocess.run(MPRIS + ["--method", f"org.mpris.MediaPlayer2.Player.{method}", *args], env=BUS, capture_output=True)
def mpris_present():
return "org.mpris.MediaPlayer2.sleepyowl" in sh("busctl", "--user", "list", "--no-pager", env=BUS, check=False)
def wait(cond, timeout, step=0.3):
end = time.time() + timeout
while time.time() < end:
v = cond()
if v:
return v
time.sleep(step)
return cond()
def pos():
return (mpris("Position") or 0) / 1e6
ss_log = os.path.join(STAND, "k2-screensaver.txt")
def ss_lines():
return open(ss_log).read().splitlines()
def new_log():
"""This launch's log: the app starts a new file each time (the previous one becomes sleepyowl.1.log)."""
return open(LOG, encoding="utf-8", errors="replace").read()
def run_logs():
"""Both launches of this run."""
return "".join(open(f, encoding="utf-8", errors="replace").read() for f in (LOG.replace(".log", ".1.log"), LOG) if os.path.exists(f))
def start_app(item, start):
env = dict(os.environ, DESKTOP_NOBUILD="1", SLEEPYOWL_MPV_OPTIONS="mute=yes")
subprocess.run(["nice", "-n", "15", STAND_SH, "app", "--play", item, "--start", str(start)], env=env, check=True, capture_output=True)
ok = wait(lambda: mpris_present() and player() and (mpris("Metadata") or ""), 60, 0.5)
time.sleep(2)
return ok
# --- The run ---
backup = os.path.join(STAND, "k2-prefs-backup")
os.makedirs(backup, exist_ok=True)
sh(STAND_SH, "stop")
for f in ("desktop_player.preferences_pb", "desktop.preferences_pb"):
if os.path.exists(os.path.join(PREFS, f)):
shutil.move(os.path.join(PREFS, f), os.path.join(backup, f))
subprocess.run(["python3", os.path.join(R, "tools/linux/watchdata.py"), "snapshot"], check=True, capture_output=True)
fake = subprocess.Popen(["python3", "-u", os.path.join(R, "tools/linux/fake_screensaver.py")], env=BUS,
stdout=open(ss_log, "w"), stderr=subprocess.STDOUT)
sh("setxkbmap", "-display", ":87", "-layout", "us")
time.sleep(1)
try:
print("== first open (§1.1, §1.2)", flush=True)
check("player opens, MPRIS up", start_app(E2, 60))
p = player()
check("mini 640×360 in the bottom right corner, 24 px in", p and near(p["x"], 1256, 2) and near(p["y"], 696, 2) and p["w"] == 640 and p["h"] == 360, p)
check("mini: on top, no frame, focused once", p and "ABOVE" in p["states"] and not p["framed"] and "FOCUSED" in p["states"], p and p["states"])
check("MPRIS: playing, title", mpris("PlaybackStatus") == "Playing" and "Серия 2" in (mpris("Metadata") or ""), mpris("Metadata"))
check("screensaver inhibited", wait(lambda: any(l.startswith("Inhibit") for l in ss_lines()), 5))
print("== keys (§1.3)", flush=True)
key("space"); time.sleep(1)
check("Space pauses", mpris("PlaybackStatus") == "Paused")
check("pause lifts the screensaver inhibition", wait(lambda: any(l.startswith("UnInhibit") for l in ss_lines()), 4))
key("space"); time.sleep(1)
check("Space plays again", mpris("PlaybackStatus") == "Playing")
p0 = pos()
key("Right"); key("Right"); key("Right"); time.sleep(2.5)
p1 = pos()
check("→ ×3: +30 s in one seek", 26 <= p1 - p0 <= 36, f"{p0:.1f} → {p1:.1f}")
key("shift+Left"); time.sleep(2.5)
p2 = pos()
check("Shift+←: −60 s", -64 <= p2 - p1 <= -54, f"{p1:.1f} → {p2:.1f}")
v0 = mpris("Volume")
key("Down"); time.sleep(0.5)
check("↓: volume −5 %", v0 is not None and near(mpris("Volume"), v0 - 0.05, 0.001), f"{v0} → {mpris('Volume')}")
key("Up"); time.sleep(0.5)
key("m"); time.sleep(0.5)
check("M: mute", mpris("Volume") == 0.0)
key("m"); time.sleep(0.5)
check("M again: sound back", near(mpris("Volume") or 0, v0, 0.001))
m_code = keycode("m")
sh("setxkbmap", "-display", ":87", "-layout", "ru")
time.sleep(0.5)
key("m", raw=m_code); time.sleep(0.5)
muted_ru = mpris("Volume") == 0.0
key("m", raw=m_code); time.sleep(0.5)
sh("setxkbmap", "-display", ":87", "-layout", "us")
check("Russian layout: physical M (Ь) mutes and unmutes", muted_ru and near(mpris("Volume") or 0, v0, 0.001))
before = len(re.findall(r"смена аудио|aid", new_log()))
key("a"); time.sleep(1.5); key("a"); time.sleep(1.5)
check("A ×2: audio track switched and back", len(re.findall(r"смена аудио|aid", new_log())) - before >= 2)
print("== autohide (§1.3), on a paused frame", flush=True)
p = player()
key("space"); time.sleep(1.5)
move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] // 2 + 5, p["y"] + p["h"] // 2 + 5)
time.sleep(0.4)
shown = top_line("osd-shown", p)
time.sleep(3.0)
rested = top_line("osd-rested", p)
check("pointer resting 2 s in mini: controls hidden", diff(shown, rested) > 4, f"diff {diff(shown, rested):.1f}")
move(p["x"] + 100, p["y"] + 100); time.sleep(0.4)
again = top_line("osd-again", p)
check("pointer moves: shown again", diff(shown, again) < 1.5, f"diff {diff(shown, again):.1f}")
move(150, 60); time.sleep(0.5)
left = top_line("osd-left", p)
check("pointer leaves: hidden at once", diff(rested, left) < 1.5 and diff(shown, left) > 4, f"diff {diff(rested, left):.1f} / {diff(shown, left):.1f}")
key("space"); time.sleep(1)
check("playing again", mpris("PlaybackStatus") == "Playing")
print("== drag and resize the mini window (§1.1)", flush=True)
p = player()
cx, cy = p["x"] + p["w"] // 2, p["y"] + p["h"] // 2
drag(cx, cy, cx - 600, cy - 300); time.sleep(1)
q = player()
check("drag by the video moves it", near(q["x"], p["x"] - 600, 12) and near(q["y"], p["y"] - 300, 12), f"{p['x']},{p['y']} → {q['x']},{q['y']}")
check("a drag is not a click: still playing", mpris("PlaybackStatus") == "Playing")
drag(q["x"] + q["w"] - 3, q["y"] + q["h"] // 2, q["x"] + q["w"] + 237, q["y"] + q["h"] // 2); time.sleep(1.2)
r = player()
check("right edge resizes, 16:9 kept", near(r["w"], q["w"] + 240, 20) and near(r["h"], r["w"] * 9 / 16, 3), f"{q['w']}×{q['h']} → {r['w']}×{r['h']}")
mini_place = (r["x"], r["y"], r["w"], r["h"])
print("== modes (§1.1)", flush=True)
key("t"); time.sleep(2.5)
p = player()
check("T: normal window 1280×720 in the middle, framed, not on top", p["w"] == 1280 and p["h"] == 720 and near(p["x"], 320, 2) and near(p["y"], 180, 40)
and p["framed"] and "ABOVE" not in p["states"], p)
key("f"); time.sleep(2.5)
p = player()
check("F: full screen", "FULLSCREEN" in p["states"] and p["w"] == 1920 and p["h"] == 1080, p["states"])
key("Escape"); time.sleep(2.5)
p = player()
check("Esc: back to the normal window", "FULLSCREEN" not in p["states"] and p["w"] == 1280 and p["framed"], p)
shot("window")
key("t"); time.sleep(2.5)
p = player()
check("T: mini again at its own place and size", "ABOVE" in p["states"] and same_place(p, mini_place), f"{mini_place} → {(p['x'], p['y'], p['w'], p['h'])}")
click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, times=2); time.sleep(2.5)
p = player()
check("double click in mini: normal window, still playing", p["framed"] and "ABOVE" not in p["states"] and mpris("PlaybackStatus") == "Playing", p["states"])
click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, times=2); time.sleep(2.5)
p = player()
check("double click in the window: full screen", "FULLSCREEN" in p["states"])
key("Escape"); time.sleep(2.5)
key("t"); time.sleep(2.5)
p = player()
check("Esc, T: mini", "ABOVE" in p["states"] and not p["framed"])
check("focus stayed with the player through the modes", "FOCUSED" in p["states"])
print("== menu (§1.3)", flush=True)
click(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2, button=3); time.sleep(0.8)
shot("menu")
key("Escape"); time.sleep(0.5)
print("== next episode, focus elsewhere (§1.2)", flush=True)
lib = library()
click(lib["x"] + 200, lib["y"] - 15); time.sleep(1) # the library's title bar
check("library has the focus", "FOCUSED" in library()["states"] and "FOCUSED" not in player()["states"])
pid = player()["id"]
mpris_call("Next")
check("MPRIS Next: S1E3", wait(lambda: "Серия 3" in (mpris("Metadata") or ""), 15), mpris("Metadata"))
time.sleep(2)
p = player()
check("same window, on top, focus not taken", p["id"] == pid and "ABOVE" in p["states"] and "FOCUSED" not in p["states"] and "FOCUSED" in library()["states"], p["states"])
m = re.search(r"mpris:length': <int64 (\d+)>", mpris_raw("Metadata"))
mpris_call("SetPosition", f"/ru/stenfredd/sleepyowl/track/{E3}", str(int(m.group(1)) - 8_000_000) if m else "0")
check("end of S1E3 plays S1E4 by itself", wait(lambda: "Серия 4" in (mpris("Metadata") or ""), 40), mpris("Metadata"))
time.sleep(2)
p = player()
check("…in the same window, focus still elsewhere", p["id"] == pid and "FOCUSED" not in p["states"], p["states"])
mpris_call("Previous")
check("MPRIS Previous: S1E3", wait(lambda: "Серия 3" in (mpris("Metadata") or ""), 15))
time.sleep(3)
p0 = pos(); mpris_call("Seek", "60000000"); time.sleep(2.5)
check("MPRIS Seek +60 s", 55 <= pos() - p0 <= 66, f"{p0:.1f} → {pos():.1f}")
mpris_call("Pause"); time.sleep(1)
check("MPRIS Pause", mpris("PlaybackStatus") == "Paused")
mpris_call("PlayPause"); time.sleep(1)
check("MPRIS PlayPause", mpris("PlaybackStatus") == "Playing")
print("== close and reopen (§1.2 п. 7, §1.1)", flush=True)
p = player()
move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(0.5)
shot("before-close")
click(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(2)
check("× closes the player, library stays", player() is None and library() is not None)
calls = [l for l in ss_lines() if l.startswith(("Inhibit", "UnInhibit"))]
check("MPRIS gone, screensaver allowed", not mpris_present() and calls and calls[-1].startswith("UnInhibit"), calls[-2:])
sh(STAND_SH, "stop")
check("reopens", start_app(E2, 90))
p = player()
check("…in mini at the last place and size", "ABOVE" in p["states"] and same_place(p, mini_place), f"{mini_place} → {(p['x'], p['y'], p['w'], p['h'])}")
move(p["x"] + p["w"] // 2, p["y"] + p["h"] // 2); time.sleep(0.2); move(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(0.5)
click(p["x"] + p["w"] - 21, p["y"] + 21); time.sleep(2)
check("closed again", player() is None)
errors = re.findall(r"^\S+ E .*|.*Exception.*", run_logs(), re.M)
check("no errors in the log", not errors, errors[:3])
finally:
sh(STAND_SH, "stop", check=False)
sh("setxkbmap", "-display", ":87", "-layout", "us", check=False)
fake.terminate()
for f in os.listdir(backup):
shutil.move(os.path.join(backup, f), os.path.join(PREFS, f))
time.sleep(5)
print(subprocess.run(["python3", os.path.join(R, "tools/linux/watchdata.py"), "restore"], capture_output=True, text=True).stdout.strip(), flush=True)
failed = [n for n, ok in results if not ok]
print(f"\n{len(results) - len(failed)}/{len(results)} passed" + (": failed " + "; ".join(failed) if failed else ""))
sys.exit(1 if failed else 0)

View File

@@ -1,14 +1,16 @@
#!/bin/bash #!/bin/bash
# Test stand of the desktop app (LINUX_PLAN.md §3 «Как проверяем»): a private virtual display :87 with the real # Test stand of the desktop app (LINUX_PLAN.md §3 «Как проверяем»): a private virtual display :87 with the real
# window manager (Muffin, in its own D-Bus session), so window, mouse and keyboard tests do not touch the user's screen. # window manager (Muffin), so window, mouse and keyboard tests do not touch the user's screen. Muffin and the app share
# the stand's own D-Bus session bus: the app's MPRIS player and screensaver calls never reach the user's desktop.
# #
# stand.sh up start Xvfb :87 (1920×1080) and Muffin, if not running # stand.sh up start Xvfb :87 (1920×1080), the stand's D-Bus and Muffin, if not running
# stand.sh down stop the app, Muffin and Xvfb # stand.sh down stop the app, Muffin and Xvfb
# stand.sh app [args] (re)start the app on :87 in the background; output in $STAND/app.out # stand.sh app [args] (re)start the app on :87 in the background; output in $STAND/app.out
# stand.sh stop stop the app # stand.sh stop stop the app
# stand.sh shot NAME screenshot of the display → $STAND/shots/NAME.png # stand.sh shot NAME screenshot of the display → $STAND/shots/NAME.png
# stand.sh in CMD… input on :87 (tools/linux/xinput.py): click X Y, move X Y, key NAME, type TEXT, … # stand.sh in CMD… input on :87 (tools/linux/xinput.py): click X Y, move X Y, key NAME, type TEXT, …
# stand.sh log the app's log (sleepyowl.log) # stand.sh log the app's log (sleepyowl.log)
# stand.sh bus CMD… run CMD on the stand's D-Bus (busctl --user, gdbus, dbus-monitor…)
# #
# The app's files live in $STAND (default ~/.cache/sleepyowl-test): its own config with the test account, # The app's files live in $STAND (default ~/.cache/sleepyowl-test): its own config with the test account,
# cache, log and lock — the user's own ~/.config/sleepyowl is never used. Gradle builds before `app` unless # cache, log and lock — the user's own ~/.config/sleepyowl is never used. Gradle builds before `app` unless
@@ -28,8 +30,11 @@ up() {
echo $! >"$STAND/run/xvfb.pid" echo $! >"$STAND/run/xvfb.pid"
for _ in $(seq 50); do xdpyinfo -display $D >/dev/null 2>&1 && break; sleep 0.1; done for _ in $(seq 50); do xdpyinfo -display $D >/dev/null 2>&1 && break; sleep 0.1; done
fi fi
if ! running dbus; then
dbus-daemon --session --fork --print-address=3 --print-pid=4 3>"$STAND/run/dbus.addr" 4>"$STAND/run/dbus.pid"
fi
if ! running muffin; then if ! running muffin; then
DISPLAY=$D setsid dbus-run-session muffin --replace >"$STAND/run/muffin.out" 2>&1 & DISPLAY=$D DBUS_SESSION_BUS_ADDRESS=$(cat "$STAND/run/dbus.addr") setsid muffin --replace >"$STAND/run/muffin.out" 2>&1 &
echo $! >"$STAND/run/muffin.pid" echo $! >"$STAND/run/muffin.pid"
sleep 2 sleep 2
fi fi
@@ -49,14 +54,14 @@ case "$1" in
up) up ;; up) up ;;
down) down)
stop_app stop_app
for p in muffin xvfb; do running $p && pkill -P "$(cat "$STAND/run/$p.pid")" 2>/dev/null; running $p && kill "$(cat "$STAND/run/$p.pid")"; rm -f "$STAND/run/$p.pid"; done for p in muffin dbus xvfb; do running $p && pkill -P "$(cat "$STAND/run/$p.pid")" 2>/dev/null; running $p && kill "$(cat "$STAND/run/$p.pid")"; rm -f "$STAND/run/$p.pid"; done
echo "stand down" ;; echo "stand down" ;;
app) app)
shift shift
up >/dev/null up >/dev/null
stop_app stop_app
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath) [ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath)
DISPLAY=$D XDG_CONFIG_HOME="$STAND/config" XDG_CACHE_HOME="$STAND/cache" XDG_STATE_HOME="$STAND/state" \ DISPLAY=$D DBUS_SESSION_BUS_ADDRESS=$(cat "$STAND/run/dbus.addr") XDG_CONFIG_HOME="$STAND/config" XDG_CACHE_HOME="$STAND/cache" XDG_STATE_HOME="$STAND/state" \
SLEEPYOWL_RUNTIME_DIR="$STAND/run" DESKTOP_NOBUILD=1 "$R/tools/linux/desktop.sh" "$@" >"$STAND/app.out" 2>&1 & SLEEPYOWL_RUNTIME_DIR="$STAND/run" DESKTOP_NOBUILD=1 "$R/tools/linux/desktop.sh" "$@" >"$STAND/app.out" 2>&1 &
echo $! >"$STAND/run/app.pid" echo $! >"$STAND/run/app.pid"
echo "app started, pid $(cat "$STAND/run/app.pid")" ;; echo "app started, pid $(cat "$STAND/run/app.pid")" ;;
@@ -66,5 +71,6 @@ case "$1" in
echo "$STAND/shots/$2.png" ;; echo "$STAND/shots/$2.png" ;;
in) shift; DISPLAY=$D python3 "$R/tools/linux/xinput.py" "$@" ;; in) shift; DISPLAY=$D python3 "$R/tools/linux/xinput.py" "$@" ;;
log) cat "$STAND/state/sleepyowl/log/sleepyowl.log" ;; log) cat "$STAND/state/sleepyowl/log/sleepyowl.log" ;;
*) sed -n '2,17p' "$0"; exit 1 ;; bus) shift; DBUS_SESSION_BUS_ADDRESS=$(cat "$STAND/run/dbus.addr") "$@" ;;
*) sed -n '2,19p' "$0"; exit 1 ;;
esac esac

View File

@@ -4,6 +4,7 @@
xinput.py click X Y [BUTTON] move there and click (1 left, 3 right, 8 back) xinput.py click X Y [BUTTON] move there and click (1 left, 3 right, 8 back)
xinput.py dclick X Y double click xinput.py dclick X Y double click
xinput.py move X Y xinput.py move X Y
xinput.py drag X1 Y1 X2 Y2 press the left button at X1 Y1, move in steps to X2 Y2, release
xinput.py key NAME [NAME…] keys by keysym name: Escape Return Tab BackSpace Left …; "alt+Left" for a chord xinput.py key NAME [NAME…] keys by keysym name: Escape Return Tab BackSpace Left …; "alt+Left" for a chord
xinput.py type TEXT types Latin text, digits and punctuation xinput.py type TEXT types Latin text, digits and punctuation
xinput.py wait SECONDS xinput.py wait SECONDS
@@ -52,6 +53,16 @@ def click(x, y, button=1, times=1):
xtest.fake_input(d, X.ButtonRelease, int(button)); d.sync(); time.sleep(0.06) xtest.fake_input(d, X.ButtonRelease, int(button)); d.sync(); time.sleep(0.06)
def drag(x1, y1, x2, y2, steps=20):
x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2)
move(x1, y1)
xtest.fake_input(d, X.ButtonPress, 1); d.sync(); time.sleep(0.1)
for i in range(1, steps + 1):
xtest.fake_input(d, X.MotionNotify, x=x1 + (x2 - x1) * i // steps, y=y1 + (y2 - y1) * i // steps); d.sync(); time.sleep(0.03)
time.sleep(0.1)
xtest.fake_input(d, X.ButtonRelease, 1); d.sync(); time.sleep(0.1)
SHIFTED = {c: True for c in '~!@#$%^&*()_+{}|:"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ'} SHIFTED = {c: True for c in '~!@#$%^&*()_+{}|:"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ'}
NAMES = {" ": "space", ".": "period", ":": "colon", "/": "slash", "-": "minus", "_": "underscore", "@": "at"} NAMES = {" ": "space", ".": "period", ":": "colon", "/": "slash", "-": "minus", "_": "underscore", "@": "at"}
@@ -72,6 +83,7 @@ def run(cmd):
if op == "click": click(args[0], args[1], args[2] if len(args) > 2 else 1) if op == "click": click(args[0], args[1], args[2] if len(args) > 2 else 1)
elif op == "dclick": click(args[0], args[1], times=2) elif op == "dclick": click(args[0], args[1], times=2)
elif op == "move": move(*args) elif op == "move": move(*args)
elif op == "drag": drag(*args)
elif op == "key": [key(a) for a in args] elif op == "key": [key(a) for a in args]
elif op == "type": type_text(" ".join(args)) elif op == "type": type_text(" ".join(args))
elif op == "wait": time.sleep(float(args[0])) elif op == "wait": time.sleep(float(args[0]))