Desktop player on mpv: floating window, system integration
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
1014
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerUi.kt
Normal file
1014
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerUi.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
252
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/X11.kt
Normal file
252
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/X11.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
playback/engine-mpv/build.gradle.kts
Normal file
21
playback/engine-mpv/build.gradle.kts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// The desktop's playback engine (LINUX_PLAN.md §2.3): libmpv through JNA, video drawn offscreen and shown in Compose.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin { jvmToolchain(21) }
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":playback:api"))
|
||||||
|
implementation(project(":playback:core"))
|
||||||
|
implementation(project(":core:common"))
|
||||||
|
implementation(compose.runtime)
|
||||||
|
implementation(compose.foundation)
|
||||||
|
implementation(compose.ui)
|
||||||
|
implementation(libs.jna)
|
||||||
|
implementation(libs.koin.core)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.ptr.IntByReference
|
||||||
|
import com.sun.jna.ptr.PointerByReference
|
||||||
|
|
||||||
|
/** EGL 1.5 of libEGL (glvnd): just enough for an invisible OpenGL context. */
|
||||||
|
internal object Egl {
|
||||||
|
const val NONE = 0x3038
|
||||||
|
const val SURFACE_TYPE = 0x3033
|
||||||
|
const val PBUFFER_BIT = 0x0001
|
||||||
|
const val RENDERABLE_TYPE = 0x3040
|
||||||
|
const val OPENGL_BIT = 0x0008
|
||||||
|
const val RED_SIZE = 0x3024
|
||||||
|
const val GREEN_SIZE = 0x3023
|
||||||
|
const val BLUE_SIZE = 0x3022
|
||||||
|
const val WIDTH = 0x3057
|
||||||
|
const val HEIGHT = 0x3056
|
||||||
|
const val OPENGL_API = 0x30A2
|
||||||
|
const val VENDOR = 0x3053
|
||||||
|
const val VERSION = 0x3054
|
||||||
|
|
||||||
|
init { Native.register("EGL") }
|
||||||
|
|
||||||
|
@JvmStatic external fun eglGetDisplay(nativeDisplay: Pointer?): Pointer?
|
||||||
|
@JvmStatic external fun eglInitialize(dpy: Pointer, major: IntByReference?, minor: IntByReference?): Int
|
||||||
|
@JvmStatic external fun eglBindAPI(api: Int): Int
|
||||||
|
@JvmStatic external fun eglChooseConfig(dpy: Pointer, attribs: IntArray, configs: PointerByReference, size: Int, num: IntByReference): Int
|
||||||
|
@JvmStatic external fun eglCreateContext(dpy: Pointer, config: Pointer, share: Pointer?, attribs: IntArray): Pointer?
|
||||||
|
@JvmStatic external fun eglCreatePbufferSurface(dpy: Pointer, config: Pointer, attribs: IntArray): Pointer?
|
||||||
|
@JvmStatic external fun eglMakeCurrent(dpy: Pointer, draw: Pointer?, read: Pointer?, ctx: Pointer?): Int
|
||||||
|
@JvmStatic external fun eglDestroyContext(dpy: Pointer, ctx: Pointer): Int
|
||||||
|
@JvmStatic external fun eglDestroySurface(dpy: Pointer, surface: Pointer): Int
|
||||||
|
@JvmStatic external fun eglGetProcAddress(name: String): Pointer?
|
||||||
|
@JvmStatic external fun eglGetError(): Int
|
||||||
|
@JvmStatic external fun eglQueryString(dpy: Pointer, name: Int): String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OpenGL of libOpenGL (glvnd), dispatched to the driver of the current context. */
|
||||||
|
internal object Gl {
|
||||||
|
const val FRAMEBUFFER = 0x8D40
|
||||||
|
const val FRAMEBUFFER_COMPLETE = 0x8CD5
|
||||||
|
const val COLOR_ATTACHMENT0 = 0x8CE0
|
||||||
|
const val TEXTURE_2D = 0x0DE1
|
||||||
|
const val TEXTURE_MIN_FILTER = 0x2801
|
||||||
|
const val TEXTURE_MAG_FILTER = 0x2800
|
||||||
|
const val LINEAR = 0x2601
|
||||||
|
const val RGBA8 = 0x8058
|
||||||
|
const val BGRA = 0x80E1
|
||||||
|
const val UNSIGNED_INT_8_8_8_8_REV = 0x8367
|
||||||
|
const val PIXEL_PACK_BUFFER = 0x88EB
|
||||||
|
const val MAP_READ_BIT = 0x0001
|
||||||
|
const val MAP_PERSISTENT_BIT = 0x0040
|
||||||
|
const val MAP_COHERENT_BIT = 0x0080
|
||||||
|
const val CLIENT_STORAGE_BIT = 0x0200
|
||||||
|
const val PACK_ALIGNMENT = 0x0D05
|
||||||
|
const val SYNC_GPU_COMMANDS_COMPLETE = 0x9117
|
||||||
|
const val SYNC_FLUSH_COMMANDS_BIT = 0x1
|
||||||
|
const val RENDERER = 0x1F01
|
||||||
|
const val VERSION = 0x1F02
|
||||||
|
|
||||||
|
init { Native.register("OpenGL") }
|
||||||
|
|
||||||
|
@JvmStatic external fun glGetString(name: Int): String?
|
||||||
|
@JvmStatic external fun glGenFramebuffers(n: Int, ids: IntArray)
|
||||||
|
@JvmStatic external fun glDeleteFramebuffers(n: Int, ids: IntArray)
|
||||||
|
@JvmStatic external fun glBindFramebuffer(target: Int, fb: Int)
|
||||||
|
@JvmStatic external fun glFramebufferTexture2D(target: Int, attachment: Int, texTarget: Int, tex: Int, level: Int)
|
||||||
|
@JvmStatic external fun glCheckFramebufferStatus(target: Int): Int
|
||||||
|
@JvmStatic external fun glGenTextures(n: Int, ids: IntArray)
|
||||||
|
@JvmStatic external fun glDeleteTextures(n: Int, ids: IntArray)
|
||||||
|
@JvmStatic external fun glBindTexture(target: Int, tex: Int)
|
||||||
|
@JvmStatic external fun glTexParameteri(target: Int, name: Int, value: Int)
|
||||||
|
@JvmStatic external fun glTexImage2D(target: Int, level: Int, internalFormat: Int, w: Int, h: Int, border: Int, format: Int, type: Int, data: Pointer?)
|
||||||
|
@JvmStatic external fun glGenBuffers(n: Int, ids: IntArray)
|
||||||
|
@JvmStatic external fun glDeleteBuffers(n: Int, ids: IntArray)
|
||||||
|
@JvmStatic external fun glBindBuffer(target: Int, buf: Int)
|
||||||
|
/** GL 4.4 / ARB_buffer_storage: immutable storage that can stay mapped. */
|
||||||
|
@JvmStatic external fun glBufferStorage(target: Int, size: Long, data: Pointer?, flags: Int)
|
||||||
|
@JvmStatic external fun glMapBufferRange(target: Int, offset: Long, length: Long, access: Int): Pointer?
|
||||||
|
@JvmStatic external fun glUnmapBuffer(target: Int): Byte
|
||||||
|
@JvmStatic external fun glPixelStorei(name: Int, value: Int)
|
||||||
|
/** [offset] is into the bound PIXEL_PACK_BUFFER. */
|
||||||
|
@JvmStatic external fun glReadPixels(x: Int, y: Int, w: Int, h: Int, format: Int, type: Int, offset: Long)
|
||||||
|
@JvmStatic external fun glFenceSync(condition: Int, flags: Int): Pointer?
|
||||||
|
@JvmStatic external fun glClientWaitSync(sync: Pointer, flags: Int, timeoutNs: Long): Int
|
||||||
|
@JvmStatic external fun glDeleteSync(sync: Pointer)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
import com.sun.jna.Callback
|
||||||
|
import com.sun.jna.Library
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.ptr.PointerByReference
|
||||||
|
|
||||||
|
/** The part of libmpv the engine uses (client.h, render.h, render_gl.h; API 2.x, mpv 0.37 of Mint's repository). */
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
internal interface LibMpv : Library {
|
||||||
|
fun mpv_client_api_version(): Long
|
||||||
|
fun mpv_error_string(error: Int): String
|
||||||
|
fun mpv_free(data: Pointer?)
|
||||||
|
|
||||||
|
fun mpv_create(): Pointer?
|
||||||
|
fun mpv_initialize(ctx: Pointer): Int
|
||||||
|
fun mpv_terminate_destroy(ctx: Pointer)
|
||||||
|
fun mpv_set_option_string(ctx: Pointer, name: String, data: String): Int
|
||||||
|
fun mpv_set_property_string(ctx: Pointer, name: String, data: String): Int
|
||||||
|
fun mpv_get_property_string(ctx: Pointer, name: String): Pointer?
|
||||||
|
/** [args] ends with null. */
|
||||||
|
fun mpv_command_async(ctx: Pointer, userdata: Long, args: Array<String?>): Int
|
||||||
|
fun mpv_observe_property(ctx: Pointer, userdata: Long, name: String, format: Int): Int
|
||||||
|
fun mpv_request_log_messages(ctx: Pointer, minLevel: String): Int
|
||||||
|
fun mpv_wait_event(ctx: Pointer, timeout: Double): Pointer
|
||||||
|
fun mpv_wakeup(ctx: Pointer)
|
||||||
|
|
||||||
|
fun mpv_render_context_create(res: PointerByReference, mpv: Pointer, params: Pointer): Int
|
||||||
|
fun mpv_render_context_set_update_callback(ctx: Pointer, callback: UpdateCallback?, callbackCtx: Pointer?)
|
||||||
|
fun mpv_render_context_update(ctx: Pointer): Long
|
||||||
|
fun mpv_render_context_render(ctx: Pointer, params: Pointer): Int
|
||||||
|
fun mpv_render_context_report_swap(ctx: Pointer)
|
||||||
|
fun mpv_render_context_free(ctx: Pointer)
|
||||||
|
|
||||||
|
fun interface UpdateCallback : Callback { fun invoke(ctx: Pointer?) }
|
||||||
|
fun interface GetProcAddress : Callback { fun invoke(ctx: Pointer?, name: String): Pointer? }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val INSTANCE: LibMpv by lazy {
|
||||||
|
// libmpv needs LC_NUMERIC=C; the JVM sets the user's locale (ru_RU writes decimals with a comma).
|
||||||
|
CLib.setlocale(CLib.LC_NUMERIC, "C")
|
||||||
|
Native.load("mpv", LibMpv::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
const val FORMAT_NONE = 0
|
||||||
|
const val FORMAT_STRING = 1
|
||||||
|
const val FORMAT_FLAG = 3
|
||||||
|
const val FORMAT_INT64 = 4
|
||||||
|
const val FORMAT_DOUBLE = 5
|
||||||
|
|
||||||
|
const val EVENT_NONE = 0
|
||||||
|
const val EVENT_SHUTDOWN = 1
|
||||||
|
const val EVENT_LOG_MESSAGE = 2
|
||||||
|
const val EVENT_START_FILE = 6
|
||||||
|
const val EVENT_END_FILE = 7
|
||||||
|
const val EVENT_FILE_LOADED = 8
|
||||||
|
const val EVENT_SEEK = 20
|
||||||
|
const val EVENT_PLAYBACK_RESTART = 21
|
||||||
|
const val EVENT_PROPERTY_CHANGE = 22
|
||||||
|
|
||||||
|
const val END_FILE_REASON_ERROR = 4
|
||||||
|
|
||||||
|
const val ERROR_LOADING_FAILED = -13
|
||||||
|
const val ERROR_AO_INIT_FAILED = -15
|
||||||
|
const val ERROR_VO_INIT_FAILED = -14
|
||||||
|
const val ERROR_NOTHING_TO_PLAY = -16
|
||||||
|
const val ERROR_UNKNOWN_FORMAT = -17
|
||||||
|
|
||||||
|
const val RENDER_PARAM_API_TYPE = 1
|
||||||
|
const val RENDER_PARAM_OPENGL_INIT_PARAMS = 2
|
||||||
|
const val RENDER_PARAM_OPENGL_FBO = 3
|
||||||
|
const val RENDER_PARAM_ADVANCED_CONTROL = 10
|
||||||
|
|
||||||
|
const val RENDER_UPDATE_FRAME = 1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The libc functions the engine needs. */
|
||||||
|
internal object CLib {
|
||||||
|
const val LC_NUMERIC = 1
|
||||||
|
|
||||||
|
init { Native.register("c") }
|
||||||
|
|
||||||
|
@JvmStatic external fun setlocale(category: Int, locale: String?): String?
|
||||||
|
@JvmStatic external fun memcpy(dst: Pointer, src: Pointer, n: Long): Pointer
|
||||||
|
@JvmStatic external fun setenv(name: String, value: String, overwrite: Int): Int
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.AudioDelayCapable
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.EngineEvent
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.EngineState
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.EngineStats
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.LoadRequest
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleDelayCapable
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* libmpv engine of the desktop (LINUX_PLAN.md §2.3). One mpv core per playback, without a window of its own
|
||||||
|
* (`vo=libmpv`): the picture comes out of [video] and is shown by [MpvVideoSurface].
|
||||||
|
*
|
||||||
|
* Commands go to mpv asynchronously, so the UI thread never waits on the core; mpv's events and observed properties are
|
||||||
|
* read on the engine's own thread and land in volatile fields and state flows.
|
||||||
|
*/
|
||||||
|
class MpvEngine internal constructor() : PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
|
||||||
|
override val id = MpvEngineProvider.ID
|
||||||
|
|
||||||
|
private val lib = LibMpv.INSTANCE
|
||||||
|
private val handle: Pointer = lib.mpv_create() ?: error("mpv_create returned NULL")
|
||||||
|
|
||||||
|
val video = MpvVideo(handle, onReady = ::onRenderReady, onFirstFrame = ::onFirstFrame, onFailure = { t ->
|
||||||
|
reportError(EngineError(EngineError.Kind.NO_VIDEO_OUTPUT, "mpv: нет вывода видео (${t.message})", t))
|
||||||
|
})
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(EngineState())
|
||||||
|
override val state = _state.asStateFlow()
|
||||||
|
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
override val events = _events.asSharedFlow()
|
||||||
|
private val _stats = MutableStateFlow(EngineStats(videoDecoder = "mpv"))
|
||||||
|
override val stats = _stats.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile private var position = 0L
|
||||||
|
@Volatile private var duration = 0L
|
||||||
|
@Volatile private var cacheEnd = -1L
|
||||||
|
@Volatile private var seekable = true
|
||||||
|
override val positionMs get() = position
|
||||||
|
override val durationMs get() = duration
|
||||||
|
override val isSeekable get() = seekable
|
||||||
|
override val bufferedPositionMs get() = cacheEnd
|
||||||
|
|
||||||
|
private val lock = Object()
|
||||||
|
private var renderReady = false
|
||||||
|
/** loadfile and the options before it, waiting for the render context. */
|
||||||
|
private var pendingLoad: List<Array<String>>? = null
|
||||||
|
@Volatile private var request: LoadRequest? = null
|
||||||
|
@Volatile private var fileLoaded = false
|
||||||
|
/** A seek asked for before the file was open: done once it is. */
|
||||||
|
@Volatile private var pendingSeekMs = -1L
|
||||||
|
@Volatile private var tracks: List<Track> = emptyList()
|
||||||
|
private var audioIndex: Int? = null
|
||||||
|
private var subtitleIndex: Int? = null
|
||||||
|
|
||||||
|
private var paused = true
|
||||||
|
private var pausedForCache = false
|
||||||
|
private var seeking = false
|
||||||
|
private var eof = false
|
||||||
|
private val props = HashMap<String, String>()
|
||||||
|
private var lastStatsAt = 0L
|
||||||
|
|
||||||
|
@Volatile private var running = true
|
||||||
|
@Volatile private var released = false
|
||||||
|
private var errorReported = false
|
||||||
|
private val eventThread: Thread
|
||||||
|
|
||||||
|
init {
|
||||||
|
// SLEEPYOWL_MPV_OPTIONS="mute=yes,hwdec=no": extra options for checks and measurements.
|
||||||
|
val extra = System.getenv("SLEEPYOWL_MPV_OPTIONS").orEmpty().split(',').mapNotNull { o ->
|
||||||
|
o.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0].trim() to it[1].trim() }
|
||||||
|
}
|
||||||
|
val audio = listOf("volume" to "$startVolume", "mute" to if (startMuted) "yes" else "no")
|
||||||
|
for ((name, value) in OPTIONS + audio + extra) {
|
||||||
|
val rc = lib.mpv_set_option_string(handle, name, value)
|
||||||
|
if (rc < 0) Log.w("mpv option %s=%s: %s", name, value, lib.mpv_error_string(rc))
|
||||||
|
}
|
||||||
|
lib.mpv_request_log_messages(handle, System.getenv("SLEEPYOWL_MPV_LOG") ?: "warn")
|
||||||
|
val rc = lib.mpv_initialize(handle)
|
||||||
|
check(rc >= 0) { "mpv_initialize: ${lib.mpv_error_string(rc)}" }
|
||||||
|
for ((name, format) in OBSERVED) lib.mpv_observe_property(handle, 0, name, format)
|
||||||
|
eventThread = thread(name = "mpv-events", isDaemon = true) { eventLoop() }
|
||||||
|
video.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(request: LoadRequest) {
|
||||||
|
this.request = request
|
||||||
|
audioIndex = request.audioStreamIndex
|
||||||
|
subtitleIndex = request.subtitleStreamIndex
|
||||||
|
val direct = request.playMethod == PlayMethod.DIRECT_PLAY
|
||||||
|
val commands = mutableListOf<Array<String>>()
|
||||||
|
commands += arrayOf("set", "start", if (request.startPositionMs > 0) seconds(request.startPositionMs) else "none")
|
||||||
|
commands += arrayOf("set", "deinterlace", if (request.mediaSource.videoStream?.isInterlaced == true) "yes" else "no")
|
||||||
|
// Until the file's tracks are known, embedded tracks by their place among the same type; checked on file-loaded.
|
||||||
|
val aid = request.audioStreamIndex?.takeIf { direct }?.let { request.mediaSource.embeddedOrdinal(it, MediaStreamType.AUDIO) }
|
||||||
|
commands += arrayOf("set", "aid", aid?.let { "${it + 1}" } ?: "auto")
|
||||||
|
val sid = request.subtitleStreamIndex?.takeIf { direct }?.let { request.mediaSource.embeddedOrdinal(it, MediaStreamType.SUBTITLE) }
|
||||||
|
commands += arrayOf("set", "sid", sid?.let { "${it + 1}" } ?: "no")
|
||||||
|
commands += arrayOf("change-list", "sub-files", "clr", "")
|
||||||
|
// One at a time: a list value would be split at ':' (the path list separator), and URLs are full of them.
|
||||||
|
for (sub in request.externalSubtitles) commands += arrayOf("change-list", "sub-files", "append", sub.url)
|
||||||
|
commands += arrayOf("loadfile", request.url)
|
||||||
|
_state.value = EngineState(EngineState.Phase.BUFFERING, false)
|
||||||
|
val now = synchronized(lock) {
|
||||||
|
if (!renderReady) pendingLoad = commands
|
||||||
|
renderReady
|
||||||
|
}
|
||||||
|
if (now) commands.forEach(::command)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On the render thread: mpv can open its video output now. */
|
||||||
|
private fun onRenderReady() {
|
||||||
|
val commands = synchronized(lock) {
|
||||||
|
renderReady = true
|
||||||
|
pendingLoad.also { pendingLoad = null }
|
||||||
|
}
|
||||||
|
if (!released) commands?.forEach(::command)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onFirstFrame() {
|
||||||
|
if (released) return
|
||||||
|
Log.i("mpv: first frame (%s, %s)", video.renderSize, video.glInfo)
|
||||||
|
_events.tryEmit(EngineEvent.FirstFrameRendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun play() {
|
||||||
|
command("set", "pause", "no")
|
||||||
|
_state.update { it.copy(playWhenReady = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
command("set", "pause", "yes")
|
||||||
|
_state.update { it.copy(playWhenReady = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekTo(positionMs: Long) {
|
||||||
|
position = positionMs
|
||||||
|
if (!fileLoaded) {
|
||||||
|
pendingSeekMs = positionMs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// hr-seek=yes: absolute seeks land on the exact frame.
|
||||||
|
command("seek", seconds(positionMs), "absolute")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun selectAudio(streamIndex: Int) {
|
||||||
|
audioIndex = streamIndex
|
||||||
|
if (fileLoaded) applyTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun selectSubtitle(streamIndex: Int?) {
|
||||||
|
subtitleIndex = streamIndex
|
||||||
|
if (fileLoaded) applyTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jellyfin's stream index → mpv's track id. Embedded tracks carry ffmpeg's stream index (`ff-index`), which is
|
||||||
|
* Jellyfin's index too (both come from ffprobe) after the external files; external subtitles are found by their URL. In a transcode the
|
||||||
|
* server has already picked the audio, so only subtitles are chosen.
|
||||||
|
*/
|
||||||
|
private fun applyTracks() {
|
||||||
|
val req = request ?: return
|
||||||
|
val list = tracks
|
||||||
|
val direct = req.playMethod == PlayMethod.DIRECT_PLAY
|
||||||
|
val aid = if (direct) audioIndex?.let { index ->
|
||||||
|
trackId(req, list, index, "audio").also { if (it == null) Log.w("mpv: audio stream %d not found among %s", index, list) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
aid?.let { command("set", "aid", "$it") }
|
||||||
|
val sub = subtitleIndex
|
||||||
|
val sid = when {
|
||||||
|
sub == null -> "no"
|
||||||
|
else -> trackId(req, list, sub, "sub")?.toString().also { if (it == null) Log.w("mpv: subtitle stream %d not found among %s", sub, list) }
|
||||||
|
}
|
||||||
|
sid?.let { command("set", "sid", it) }
|
||||||
|
Log.i("mpv: audio %s → aid %s, subtitles %s → sid %s", audioIndex, aid, sub, sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackId(req: LoadRequest, list: List<Track>, streamIndex: Int, type: String): Int? {
|
||||||
|
val external = req.externalSubtitles.indexOfFirst { it.streamIndex == streamIndex }
|
||||||
|
if (external >= 0) {
|
||||||
|
val url = req.externalSubtitles[external].url
|
||||||
|
return list.firstOrNull { it.external && it.filename == url }?.id
|
||||||
|
?: list.filter { it.type == type && it.external }.getOrNull(external)?.id
|
||||||
|
}
|
||||||
|
if (req.playMethod != PlayMethod.DIRECT_PLAY) return null
|
||||||
|
// Jellyfin numbers external files first, then the container's streams by ffmpeg's index.
|
||||||
|
val ffIndex = streamIndex - req.mediaSource.mediaStreams.orEmpty().count { it.isExternal && it.index < streamIndex }
|
||||||
|
list.firstOrNull { it.type == type && !it.external && it.ffIndex == ffIndex }?.let { return it.id }
|
||||||
|
val mediaType = if (type == "audio") MediaStreamType.AUDIO else MediaStreamType.SUBTITLE
|
||||||
|
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, mediaType) ?: return null
|
||||||
|
return list.filter { it.type == type && !it.external }.getOrNull(ordinal)?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0…[MAX_VOLUME] %; the player keeps it in its settings and sets [startVolume] for the next engine. */
|
||||||
|
fun setVolume(percent: Int) = command("set", "volume", "${percent.coerceIn(0, MAX_VOLUME)}")
|
||||||
|
|
||||||
|
fun setMuted(muted: Boolean) = command("set", "mute", if (muted) "yes" else "no")
|
||||||
|
|
||||||
|
override fun setAudioDelay(delayMs: Long) = command("set", "audio-delay", seconds(delayMs))
|
||||||
|
|
||||||
|
override fun setSubtitleDelay(delayMs: Long) = command("set", "sub-delay", seconds(delayMs))
|
||||||
|
|
||||||
|
/** Text subtitles only: ASS keeps its own styles except for the size (`sub-ass-override=scale`). */
|
||||||
|
override fun setSubtitleStyle(style: SubtitleStyle): Boolean {
|
||||||
|
command("set", "sub-scale", "%.2f".format(Locale.ROOT, style.size.scale))
|
||||||
|
command("set", "sub-color", color(style.color))
|
||||||
|
when (style.background) {
|
||||||
|
SubtitleStyle.Background.NONE -> {
|
||||||
|
command("set", "sub-border-style", "outline-and-shadow")
|
||||||
|
command("set", "sub-outline-size", "0")
|
||||||
|
command("set", "sub-shadow-offset", "0")
|
||||||
|
}
|
||||||
|
SubtitleStyle.Background.SHADOW -> {
|
||||||
|
command("set", "sub-border-style", "outline-and-shadow")
|
||||||
|
command("set", "sub-outline-size", "1.65")
|
||||||
|
command("set", "sub-outline-color", "#FF000000")
|
||||||
|
command("set", "sub-shadow-offset", "1.5")
|
||||||
|
command("set", "sub-back-color", "#A0000000")
|
||||||
|
}
|
||||||
|
SubtitleStyle.Background.BOX -> {
|
||||||
|
// The box of opaque-box is drawn in the outline colour.
|
||||||
|
command("set", "sub-border-style", "opaque-box")
|
||||||
|
command("set", "sub-outline-size", "4")
|
||||||
|
command("set", "sub-outline-color", "#B8000000")
|
||||||
|
command("set", "sub-shadow-offset", "0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `sub-pos` (100 = the bottom, % of the picture's height): unlike `sub-margin-y`, it moves ASS subtitles too — libass
|
||||||
|
* leaves the signs placed by the script where they are.
|
||||||
|
*/
|
||||||
|
override fun setSubtitleLift(fraction: Float) {
|
||||||
|
command("set", "sub-pos", "${(100 - fraction * 100).roundToInt().coerceIn(0, 100)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun release() {
|
||||||
|
if (released) return
|
||||||
|
released = true
|
||||||
|
// Teardown waits for the render thread and for mpv's own threads (a stalled network read can hold them):
|
||||||
|
// never on the UI thread.
|
||||||
|
thread(name = "mpv-release") {
|
||||||
|
val started = monotonicMs()
|
||||||
|
running = false
|
||||||
|
lib.mpv_wakeup(handle)
|
||||||
|
eventThread.join(2000)
|
||||||
|
video.stop()
|
||||||
|
lib.mpv_terminate_destroy(handle)
|
||||||
|
val frames = video.rendered.get()
|
||||||
|
Log.i(
|
||||||
|
"mpv released in %d ms; %d frames drawn, %.1f ms render, %.1f ms copy on average, %d replaced before shown",
|
||||||
|
monotonicMs() - started, frames, video.renderNs.get() / 1e6 / frames.coerceAtLeast(1),
|
||||||
|
video.readbackNs.get() / 1e6 / frames.coerceAtLeast(1), video.frames.lost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun command(vararg args: String) {
|
||||||
|
if (released) return
|
||||||
|
lib.mpv_command_async(handle, 0, arrayOf(*args, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eventLoop() {
|
||||||
|
while (running) {
|
||||||
|
val ev = lib.mpv_wait_event(handle, 0.5)
|
||||||
|
if (!running) return
|
||||||
|
when (ev.getInt(0)) {
|
||||||
|
LibMpv.EVENT_NONE -> Unit
|
||||||
|
LibMpv.EVENT_SHUTDOWN -> return
|
||||||
|
LibMpv.EVENT_LOG_MESSAGE -> {
|
||||||
|
val msg = ev.getPointer(16)
|
||||||
|
val prefix = msg.getPointer(0).getString(0)
|
||||||
|
val level = msg.getPointer(8).getString(0)
|
||||||
|
val text = msg.getPointer(16).getString(0, "UTF-8").trimEnd()
|
||||||
|
if (level == "error" || level == "fatal") lastError = "$prefix: $text"
|
||||||
|
Log.i("mpv [%s] %s: %s", prefix, level, text)
|
||||||
|
}
|
||||||
|
LibMpv.EVENT_PROPERTY_CHANGE -> onProperty(ev.getPointer(16))
|
||||||
|
LibMpv.EVENT_FILE_LOADED -> onFileLoaded()
|
||||||
|
LibMpv.EVENT_END_FILE -> {
|
||||||
|
val data = ev.getPointer(16)
|
||||||
|
if (data.getInt(0) == LibMpv.END_FILE_REASON_ERROR) onLoadError(data.getInt(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val now = monotonicMs()
|
||||||
|
if (now - lastStatsAt >= 1000) {
|
||||||
|
lastStatsAt = now
|
||||||
|
publishStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastError: String? = null
|
||||||
|
|
||||||
|
private fun onFileLoaded() {
|
||||||
|
tracks = readTracks()
|
||||||
|
Log.i("mpv: file loaded, tracks %s", tracks)
|
||||||
|
fileLoaded = true
|
||||||
|
applyTracks()
|
||||||
|
val seek = pendingSeekMs
|
||||||
|
if (seek >= 0) {
|
||||||
|
pendingSeekMs = -1
|
||||||
|
command("seek", seconds(seek), "absolute")
|
||||||
|
}
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLoadError(code: Int) {
|
||||||
|
val kind = when (code) {
|
||||||
|
LibMpv.ERROR_LOADING_FAILED -> EngineError.Kind.NETWORK
|
||||||
|
LibMpv.ERROR_UNKNOWN_FORMAT, LibMpv.ERROR_NOTHING_TO_PLAY -> EngineError.Kind.FORMAT
|
||||||
|
LibMpv.ERROR_AO_INIT_FAILED -> EngineError.Kind.AUDIO_OUTPUT
|
||||||
|
LibMpv.ERROR_VO_INIT_FAILED -> EngineError.Kind.NO_VIDEO_OUTPUT
|
||||||
|
else -> EngineError.Kind.UNKNOWN
|
||||||
|
}
|
||||||
|
reportError(EngineError(kind, "mpv: ${lib.mpv_error_string(code)}" + (lastError?.let { " ($it)" } ?: "")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous reads, but on the event thread: the core never waits for it. */
|
||||||
|
private fun readTracks(): List<Track> {
|
||||||
|
val count = getString("track-list/count")?.toIntOrNull() ?: return emptyList()
|
||||||
|
return (0 until count).mapNotNull { i ->
|
||||||
|
val id = getString("track-list/$i/id")?.toIntOrNull() ?: return@mapNotNull null
|
||||||
|
Track(
|
||||||
|
id = id,
|
||||||
|
type = getString("track-list/$i/type").orEmpty(),
|
||||||
|
ffIndex = getString("track-list/$i/ff-index")?.toIntOrNull(),
|
||||||
|
external = getString("track-list/$i/external") == "yes",
|
||||||
|
filename = getString("track-list/$i/external-filename"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getString(name: String): String? {
|
||||||
|
val p = lib.mpv_get_property_string(handle, name) ?: return null
|
||||||
|
return try {
|
||||||
|
p.getString(0, "UTF-8")
|
||||||
|
} finally {
|
||||||
|
lib.mpv_free(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onProperty(p: Pointer) {
|
||||||
|
val name = p.getPointer(0).getString(0)
|
||||||
|
val format = p.getInt(8)
|
||||||
|
val data = p.getPointer(16)
|
||||||
|
val value: Any? = when {
|
||||||
|
data == null || format == LibMpv.FORMAT_NONE -> null
|
||||||
|
format == LibMpv.FORMAT_DOUBLE -> data.getDouble(0)
|
||||||
|
format == LibMpv.FORMAT_FLAG -> data.getInt(0) != 0
|
||||||
|
format == LibMpv.FORMAT_INT64 -> data.getLong(0)
|
||||||
|
format == LibMpv.FORMAT_STRING -> data.getPointer(0)?.getString(0, "UTF-8")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
when (name) {
|
||||||
|
"pause" -> { paused = value == true; publishState() }
|
||||||
|
"paused-for-cache" -> { pausedForCache = value == true; publishState() }
|
||||||
|
"seeking" -> { seeking = value == true; publishState() }
|
||||||
|
"eof-reached" -> { eof = value == true; publishState() }
|
||||||
|
"time-pos" -> (value as? Double)?.let { position = (it * 1000).toLong() }
|
||||||
|
"duration" -> duration = ((value as? Double) ?: 0.0).times(1000).toLong()
|
||||||
|
"demuxer-cache-time" -> cacheEnd = (value as? Double)?.times(1000)?.toLong() ?: -1
|
||||||
|
"seekable" -> seekable = value != false
|
||||||
|
"dwidth", "dheight" -> {
|
||||||
|
props[name] = value?.toString().orEmpty()
|
||||||
|
video.setVideoSize(props["dwidth"]?.toIntOrNull() ?: 0, props["dheight"]?.toIntOrNull() ?: 0)
|
||||||
|
}
|
||||||
|
else -> props[name] = when (value) {
|
||||||
|
null -> ""
|
||||||
|
is Double -> "%.3f".format(Locale.ROOT, value)
|
||||||
|
else -> value.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishState() {
|
||||||
|
val phase = when {
|
||||||
|
eof -> EngineState.Phase.ENDED
|
||||||
|
!fileLoaded || pausedForCache || seeking -> EngineState.Phase.BUFFERING
|
||||||
|
else -> EngineState.Phase.READY
|
||||||
|
}
|
||||||
|
_state.update { it.copy(phase = phase, playWhenReady = !paused) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishStats() {
|
||||||
|
fun p(name: String) = props[name]?.takeIf { it.isNotEmpty() }
|
||||||
|
val frames = video.rendered.get().coerceAtLeast(1)
|
||||||
|
_stats.value = EngineStats(
|
||||||
|
videoDecoder = "mpv · " + (p("hwdec-current")?.takeIf { it != "no" }?.let { "аппаратно ($it)" } ?: "программно"),
|
||||||
|
videoFormat = listOfNotNull(
|
||||||
|
p("video-codec"),
|
||||||
|
p("video-params/w")?.let { w -> "${w}x${p("video-params/h")}" },
|
||||||
|
p("video-params/pixelformat"),
|
||||||
|
p("container-fps")?.let { "${it.trimEnd('0').trimEnd('.')} fps" },
|
||||||
|
).joinToString(" ").ifEmpty { null },
|
||||||
|
audioDecoder = p("audio-codec-name"),
|
||||||
|
audioFormat = listOfNotNull(
|
||||||
|
p("audio-params/channel-count")?.let { "$it кан." },
|
||||||
|
p("audio-params/samplerate")?.let { "$it Гц" },
|
||||||
|
).joinToString(", ").ifEmpty { null },
|
||||||
|
audioOutput = p("current-ao"),
|
||||||
|
droppedFrames = (p("frame-drop-count")?.toIntOrNull() ?: 0) + (p("decoder-frame-drop-count")?.toIntOrNull() ?: 0),
|
||||||
|
extra = listOf(
|
||||||
|
"GPU" to video.glInfo,
|
||||||
|
"кадр в памяти" to video.renderSize,
|
||||||
|
"отрисовка / копия" to "%.1f / %.1f мс".format(Locale.ROOT, video.renderNs.get() / 1e6 / frames, video.readbackNs.get() / 1e6 / frames),
|
||||||
|
"потеряно в окне" to "${video.frames.lost}",
|
||||||
|
).toMap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reportError(error: EngineError) {
|
||||||
|
if (errorReported || released) return
|
||||||
|
errorReported = true
|
||||||
|
Log.w("mpv error: %s", error.message)
|
||||||
|
_events.tryEmit(EngineEvent.Error(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class Track(val id: Int, val type: String, val ffIndex: Int?, val external: Boolean, val filename: String?) {
|
||||||
|
override fun toString() = "$type#$id" + (ffIndex?.let { "/ff$it" } ?: "") + if (external) "/ext" else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** mpv's own default `volume-max`. */
|
||||||
|
const val MAX_VOLUME = 130
|
||||||
|
|
||||||
|
/** Volume and mute an engine starts with (from the first sample on: no loud blip before the player sets them). */
|
||||||
|
@Volatile var startVolume = 100
|
||||||
|
@Volatile var startMuted = false
|
||||||
|
|
||||||
|
|
||||||
|
private fun seconds(ms: Long) = "%.3f".format(Locale.ROOT, ms / 1000.0)
|
||||||
|
|
||||||
|
/** ARGB → mpv's `#AARRGGBB`. */
|
||||||
|
private fun color(argb: Int) = "#%08X".format(argb)
|
||||||
|
|
||||||
|
private val OPTIONS = listOf(
|
||||||
|
"vo" to "libmpv",
|
||||||
|
"hwdec" to "auto-safe",
|
||||||
|
"config" to "no",
|
||||||
|
"terminal" to "no",
|
||||||
|
"input-default-bindings" to "no",
|
||||||
|
"input-vo-keyboard" to "no",
|
||||||
|
"osc" to "no",
|
||||||
|
"ytdl" to "no",
|
||||||
|
"idle" to "yes",
|
||||||
|
"keep-open" to "yes",
|
||||||
|
"hr-seek" to "yes",
|
||||||
|
"cache" to "yes",
|
||||||
|
"demuxer-max-bytes" to "150MiB",
|
||||||
|
"demuxer-max-back-bytes" to "50MiB",
|
||||||
|
"audio-channels" to "auto-safe",
|
||||||
|
"ao" to "pipewire,pulse,alsa",
|
||||||
|
"audio-client-name" to "SleepyOwl",
|
||||||
|
"tone-mapping" to "auto",
|
||||||
|
"sub-auto" to "no",
|
||||||
|
"sub-ass-override" to "scale",
|
||||||
|
"user-agent" to "SleepyOwl",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val OBSERVED = listOf(
|
||||||
|
"pause" to LibMpv.FORMAT_FLAG,
|
||||||
|
"paused-for-cache" to LibMpv.FORMAT_FLAG,
|
||||||
|
"seeking" to LibMpv.FORMAT_FLAG,
|
||||||
|
"eof-reached" to LibMpv.FORMAT_FLAG,
|
||||||
|
"seekable" to LibMpv.FORMAT_FLAG,
|
||||||
|
"time-pos" to LibMpv.FORMAT_DOUBLE,
|
||||||
|
"duration" to LibMpv.FORMAT_DOUBLE,
|
||||||
|
"demuxer-cache-time" to LibMpv.FORMAT_DOUBLE,
|
||||||
|
"dwidth" to LibMpv.FORMAT_INT64,
|
||||||
|
"dheight" to LibMpv.FORMAT_INT64,
|
||||||
|
"hwdec-current" to LibMpv.FORMAT_STRING,
|
||||||
|
"video-codec" to LibMpv.FORMAT_STRING,
|
||||||
|
"video-params/w" to LibMpv.FORMAT_INT64,
|
||||||
|
"video-params/h" to LibMpv.FORMAT_INT64,
|
||||||
|
"video-params/pixelformat" to LibMpv.FORMAT_STRING,
|
||||||
|
"container-fps" to LibMpv.FORMAT_DOUBLE,
|
||||||
|
"audio-codec-name" to LibMpv.FORMAT_STRING,
|
||||||
|
"audio-params/channel-count" to LibMpv.FORMAT_INT64,
|
||||||
|
"audio-params/samplerate" to LibMpv.FORMAT_INT64,
|
||||||
|
"current-ao" to LibMpv.FORMAT_STRING,
|
||||||
|
"frame-drop-count" to LibMpv.FORMAT_INT64,
|
||||||
|
"decoder-frame-drop-count" to LibMpv.FORMAT_INT64,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
|
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||||
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||||
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
|
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||||
|
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
|
||||||
|
|
||||||
|
/** Koin: the desktop's only engine and the desktop's device probe. */
|
||||||
|
val mpvEngineModule = module {
|
||||||
|
single { MpvEngineProvider() } bind EngineProvider::class
|
||||||
|
single<DeviceCapabilitiesProbe> { PcCapabilitiesProbe() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mpv plays everything ffmpeg demuxes and decodes, in hardware where the GPU can (NVDEC, VAAPI), otherwise on the CPU,
|
||||||
|
* which a PC can afford. HDR and Dolby Vision are tone-mapped to SDR (the desktop has no HDR output, LINUX_PLAN.md §0).
|
||||||
|
*/
|
||||||
|
class MpvEngineProvider : EngineProvider {
|
||||||
|
override val id = ID
|
||||||
|
override val displayName = "mpv"
|
||||||
|
override val priority = 0
|
||||||
|
|
||||||
|
override fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict {
|
||||||
|
val range = source.videoStream?.videoRangeType?.serialName
|
||||||
|
val notes = listOfNotNull(range?.takeIf { it != "SDR" && it != "Unknown" }?.let { "$it → SDR" })
|
||||||
|
return Verdict.Direct(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile = buildDeviceProfile {
|
||||||
|
name = "SleepyOwl / mpv"
|
||||||
|
maxStreamingBitrate = MAX_BITRATE
|
||||||
|
maxStaticBitrate = MAX_BITRATE
|
||||||
|
directPlayProfile { type = DlnaProfileType.VIDEO }
|
||||||
|
directPlayProfile { type = DlnaProfileType.AUDIO }
|
||||||
|
transcodingProfile {
|
||||||
|
type = DlnaProfileType.VIDEO
|
||||||
|
context = EncodingContext.STREAMING
|
||||||
|
protocol = MediaStreamProtocol.HLS
|
||||||
|
container = "ts"
|
||||||
|
videoCodec("h264", "hevc")
|
||||||
|
audioCodec("aac", "ac3", "eac3", "mp3", "opus", "flac")
|
||||||
|
minSegments = 1
|
||||||
|
}
|
||||||
|
for (format in listOf("srt", "subrip", "ass", "ssa", "pgssub", "dvdsub", "dvbsub", "mov_text", "vtt", "webvtt", "sub", "sup", "idx")) {
|
||||||
|
subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
|
||||||
|
}
|
||||||
|
for (format in listOf("srt", "subrip", "ass", "ssa", "vtt", "webvtt", "sub", "sup", "pgssub")) {
|
||||||
|
subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun create(device: DeviceCapabilities): PlaybackEngine = MpvEngine()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val ID = EngineId("mpv")
|
||||||
|
private const val MAX_BITRATE = 400_000_000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import com.sun.jna.CallbackReference
|
||||||
|
import com.sun.jna.CallbackThreadInitializer
|
||||||
|
import com.sun.jna.Memory
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import com.sun.jna.Pointer
|
||||||
|
import com.sun.jna.ptr.IntByReference
|
||||||
|
import com.sun.jna.ptr.PointerByReference
|
||||||
|
import org.jetbrains.skia.Data
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mpv's picture, variant B of LINUX_PLAN.md §2.3: mpv draws in its own invisible OpenGL context (EGL pbuffer) on its
|
||||||
|
* own thread, into an FBO; the frame is read back through a persistently mapped PBO into memory that Compose shows
|
||||||
|
* ([MpvVideoSurface]). mpv knows nothing about the window, so the window can be rebuilt freely.
|
||||||
|
*
|
||||||
|
* The FBO is the size of the video area, but never larger than the video itself (L1: Skia uploads every frame, a
|
||||||
|
* 1080p film drawn 3440×1440 costs 2.6 times the bytes for nothing): above the video's own size Skia scales it up.
|
||||||
|
*/
|
||||||
|
class MpvVideo internal constructor(
|
||||||
|
private val mpv: Pointer,
|
||||||
|
private val onReady: () -> Unit,
|
||||||
|
private val onFirstFrame: () -> Unit,
|
||||||
|
private val onFailure: (Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
internal val frames = FramePool()
|
||||||
|
|
||||||
|
/** Grows with every new frame; read while drawing, it makes Compose redraw. */
|
||||||
|
internal val serial = mutableIntStateOf(0)
|
||||||
|
|
||||||
|
/** The video's display size, [IntSize.Zero] until known: the mini window keeps its proportions. */
|
||||||
|
var displaySize by mutableStateOf(IntSize.Zero)
|
||||||
|
private set
|
||||||
|
|
||||||
|
// Written by the render thread, read for the stream info.
|
||||||
|
internal val rendered = AtomicLong()
|
||||||
|
internal val renderNs = AtomicLong()
|
||||||
|
internal val readbackNs = AtomicLong()
|
||||||
|
@Volatile internal var glInfo = ""
|
||||||
|
@Volatile internal var renderSize = ""
|
||||||
|
|
||||||
|
private val lock = Object()
|
||||||
|
private var areaW = 0
|
||||||
|
private var areaH = 0
|
||||||
|
private var videoW = 0
|
||||||
|
private var videoH = 0
|
||||||
|
private var pendingUpdate = false
|
||||||
|
private var pendingResize = false
|
||||||
|
private var stopping = false
|
||||||
|
private var firstFrameSent = false
|
||||||
|
private var thread: Thread? = null
|
||||||
|
|
||||||
|
private val updateCallback = LibMpv.UpdateCallback {
|
||||||
|
synchronized(lock) { pendingUpdate = true; lock.notifyAll() }
|
||||||
|
}
|
||||||
|
private val getProcAddress = LibMpv.GetProcAddress { _, name -> Egl.eglGetProcAddress(name) }
|
||||||
|
|
||||||
|
/** Starts the render thread; [onReady] is called on it once mpv has its render context (before that loadfile fails). */
|
||||||
|
internal fun start() {
|
||||||
|
// mpv calls back from its own threads: keep them attached to the JVM instead of attaching on every call.
|
||||||
|
Native.setCallbackThreadInitializer(updateCallback, CallbackThreadInitializer(true, false, "mpv-update"))
|
||||||
|
thread = thread(name = "mpv-render", isDaemon = true) {
|
||||||
|
try {
|
||||||
|
run()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(t, "mpv render thread failed")
|
||||||
|
onFailure(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Size of the video area in window pixels. */
|
||||||
|
fun resize(w: Int, h: Int) = synchronized(lock) {
|
||||||
|
if (w == areaW && h == areaH) return
|
||||||
|
areaW = w; areaH = h; pendingResize = true; lock.notifyAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The video's display size (mpv's `dwidth`/`dheight`); 0 until known. A known size also arms the first-frame event. */
|
||||||
|
internal fun setVideoSize(w: Int, h: Int) = synchronized(lock) {
|
||||||
|
if (w == videoW && h == videoH) return
|
||||||
|
videoW = w; videoH = h; pendingResize = true; lock.notifyAll()
|
||||||
|
displaySize = IntSize(w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frees the render context and the GL objects; must run before `mpv_terminate_destroy`. Blocks up to 3 s. */
|
||||||
|
internal fun stop() {
|
||||||
|
synchronized(lock) { stopping = true; lock.notifyAll() }
|
||||||
|
thread?.join(3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run() {
|
||||||
|
val egl = EglContext.create()
|
||||||
|
glInfo = egl.info
|
||||||
|
val lib = LibMpv.INSTANCE
|
||||||
|
val initParams = Memory(16).apply {
|
||||||
|
setPointer(0, CallbackReference.getFunctionPointer(getProcAddress))
|
||||||
|
setPointer(8, null)
|
||||||
|
}
|
||||||
|
val one = Memory(4).apply { setInt(0, 1) }
|
||||||
|
val apiType = Memory(8).apply { setString(0, "opengl") }
|
||||||
|
val createParams = params(
|
||||||
|
LibMpv.RENDER_PARAM_API_TYPE to apiType,
|
||||||
|
LibMpv.RENDER_PARAM_OPENGL_INIT_PARAMS to initParams,
|
||||||
|
// Allowed: this thread only calls mpv_render_* and never waits on the core.
|
||||||
|
LibMpv.RENDER_PARAM_ADVANCED_CONTROL to one,
|
||||||
|
)
|
||||||
|
val ref = PointerByReference()
|
||||||
|
val rc = lib.mpv_render_context_create(ref, mpv, createParams)
|
||||||
|
if (rc < 0) {
|
||||||
|
egl.destroy()
|
||||||
|
error("mpv_render_context_create: ${lib.mpv_error_string(rc)}")
|
||||||
|
}
|
||||||
|
val ctx = ref.value
|
||||||
|
lib.mpv_render_context_set_update_callback(ctx, updateCallback, null)
|
||||||
|
onReady()
|
||||||
|
|
||||||
|
val fbo = Memory(16)
|
||||||
|
// No FLIP_Y: glReadPixels reads bottom-up, so the frame lands in memory top-down, as Skia wants it.
|
||||||
|
val renderParams = params(LibMpv.RENDER_PARAM_OPENGL_FBO to fbo)
|
||||||
|
val target = RenderTarget()
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
var update: Boolean
|
||||||
|
var resized: Boolean
|
||||||
|
var w: Int
|
||||||
|
var h: Int
|
||||||
|
var hasVideo: Boolean
|
||||||
|
synchronized(lock) {
|
||||||
|
while (!pendingUpdate && !pendingResize && !stopping) lock.wait()
|
||||||
|
if (stopping) return
|
||||||
|
update = pendingUpdate; resized = pendingResize
|
||||||
|
pendingUpdate = false; pendingResize = false
|
||||||
|
val size = renderSize(areaW, areaH, videoW, videoH)
|
||||||
|
w = size.first; h = size.second
|
||||||
|
hasVideo = videoW > 0 && videoH > 0
|
||||||
|
}
|
||||||
|
var newFrame = false
|
||||||
|
if (update) newFrame = lib.mpv_render_context_update(ctx) and LibMpv.RENDER_UPDATE_FRAME != 0L
|
||||||
|
if (!(newFrame || resized) || w <= 0 || h <= 0) continue
|
||||||
|
|
||||||
|
target.ensure(w, h)
|
||||||
|
val sizeText = "${w}x$h"
|
||||||
|
if (sizeText != renderSize && hasVideo) Log.d("mpv: frame %s for the area %dx%d", sizeText, areaW, areaH)
|
||||||
|
renderSize = sizeText
|
||||||
|
fbo.setInt(0, target.fbo); fbo.setInt(4, w); fbo.setInt(8, h); fbo.setInt(12, Gl.RGBA8)
|
||||||
|
val t0 = System.nanoTime()
|
||||||
|
lib.mpv_render_context_render(ctx, renderParams)
|
||||||
|
val t1 = System.nanoTime()
|
||||||
|
val frame = frames.acquire(w, h)
|
||||||
|
target.readInto(frame.pointer!!, w, h)
|
||||||
|
val t2 = System.nanoTime()
|
||||||
|
frames.publish(frame)
|
||||||
|
lib.mpv_render_context_report_swap(ctx)
|
||||||
|
renderNs.addAndGet(t1 - t0); readbackNs.addAndGet(t2 - t1); rendered.incrementAndGet()
|
||||||
|
serial.intValue++
|
||||||
|
if (newFrame && hasVideo && !firstFrameSent) {
|
||||||
|
firstFrameSent = true
|
||||||
|
onFirstFrame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lib.mpv_render_context_set_update_callback(ctx, null, null)
|
||||||
|
lib.mpv_render_context_free(ctx)
|
||||||
|
target.release()
|
||||||
|
egl.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun params(vararg p: Pair<Int, Pointer>): Memory {
|
||||||
|
val m = Memory(16L * (p.size + 1)).apply { clear() }
|
||||||
|
p.forEachIndexed { i, (type, data) -> m.setInt(16L * i, type); m.setPointer(16L * i + 8, data) }
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FBO with its texture and the readback buffer. Lives on the render thread. */
|
||||||
|
private class RenderTarget {
|
||||||
|
var fbo = 0
|
||||||
|
private var tex = 0
|
||||||
|
private var pbo = 0
|
||||||
|
/** The PBO stays mapped for its whole life (GL 4.4 buffer storage); null when the driver refused that. */
|
||||||
|
private var mapped: Pointer? = null
|
||||||
|
private var w = 0
|
||||||
|
private var h = 0
|
||||||
|
|
||||||
|
fun ensure(nw: Int, nh: Int) {
|
||||||
|
if (nw == w && nh == h && fbo != 0) return
|
||||||
|
release()
|
||||||
|
w = nw; h = nh
|
||||||
|
val id = IntArray(1)
|
||||||
|
Gl.glGenTextures(1, id); tex = id[0]
|
||||||
|
Gl.glBindTexture(Gl.TEXTURE_2D, tex)
|
||||||
|
Gl.glTexParameteri(Gl.TEXTURE_2D, Gl.TEXTURE_MIN_FILTER, Gl.LINEAR)
|
||||||
|
Gl.glTexParameteri(Gl.TEXTURE_2D, Gl.TEXTURE_MAG_FILTER, Gl.LINEAR)
|
||||||
|
Gl.glTexImage2D(Gl.TEXTURE_2D, 0, Gl.RGBA8, w, h, 0, Gl.BGRA, Gl.UNSIGNED_INT_8_8_8_8_REV, null)
|
||||||
|
Gl.glGenFramebuffers(1, id); fbo = id[0]
|
||||||
|
Gl.glBindFramebuffer(Gl.FRAMEBUFFER, fbo)
|
||||||
|
Gl.glFramebufferTexture2D(Gl.FRAMEBUFFER, Gl.COLOR_ATTACHMENT0, Gl.TEXTURE_2D, tex, 0)
|
||||||
|
val status = Gl.glCheckFramebufferStatus(Gl.FRAMEBUFFER)
|
||||||
|
check(status == Gl.FRAMEBUFFER_COMPLETE) { "FBO incomplete: 0x${status.toString(16)}" }
|
||||||
|
val size = 4L * w * h
|
||||||
|
val flags = Gl.MAP_READ_BIT or Gl.MAP_PERSISTENT_BIT or Gl.MAP_COHERENT_BIT
|
||||||
|
Gl.glGenBuffers(1, id); pbo = id[0]
|
||||||
|
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, pbo)
|
||||||
|
// Client storage: the driver keeps it in cached system memory, which the CPU reads fast.
|
||||||
|
Gl.glBufferStorage(Gl.PIXEL_PACK_BUFFER, size, null, flags or Gl.CLIENT_STORAGE_BIT)
|
||||||
|
mapped = Gl.glMapBufferRange(Gl.PIXEL_PACK_BUFFER, 0, size, flags)
|
||||||
|
if (mapped == null) Log.w("mpv: the readback buffer can't stay mapped, mapping it per frame")
|
||||||
|
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous: the frame is needed now, or the picture runs a frame behind the sound. */
|
||||||
|
fun readInto(dst: Pointer, w: Int, h: Int) {
|
||||||
|
val size = 4L * w * h
|
||||||
|
Gl.glBindFramebuffer(Gl.FRAMEBUFFER, fbo)
|
||||||
|
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, pbo)
|
||||||
|
Gl.glPixelStorei(Gl.PACK_ALIGNMENT, 4)
|
||||||
|
Gl.glReadPixels(0, 0, w, h, Gl.BGRA, Gl.UNSIGNED_INT_8_8_8_8_REV, 0L)
|
||||||
|
val sync = Gl.glFenceSync(Gl.SYNC_GPU_COMMANDS_COMPLETE, 0)
|
||||||
|
if (sync != null) {
|
||||||
|
Gl.glClientWaitSync(sync, Gl.SYNC_FLUSH_COMMANDS_BIT, 200_000_000L)
|
||||||
|
Gl.glDeleteSync(sync)
|
||||||
|
}
|
||||||
|
val src = mapped
|
||||||
|
if (src != null) {
|
||||||
|
CLib.memcpy(dst, src, size)
|
||||||
|
} else {
|
||||||
|
Gl.glMapBufferRange(Gl.PIXEL_PACK_BUFFER, 0, size, Gl.MAP_READ_BIT)?.let {
|
||||||
|
CLib.memcpy(dst, it, size)
|
||||||
|
Gl.glUnmapBuffer(Gl.PIXEL_PACK_BUFFER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
if (fbo != 0) Gl.glDeleteFramebuffers(1, intArrayOf(fbo))
|
||||||
|
if (tex != 0) Gl.glDeleteTextures(1, intArrayOf(tex))
|
||||||
|
if (pbo != 0) {
|
||||||
|
if (mapped != null) {
|
||||||
|
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, pbo)
|
||||||
|
Gl.glUnmapBuffer(Gl.PIXEL_PACK_BUFFER)
|
||||||
|
Gl.glBindBuffer(Gl.PIXEL_PACK_BUFFER, 0)
|
||||||
|
}
|
||||||
|
Gl.glDeleteBuffers(1, intArrayOf(pbo))
|
||||||
|
}
|
||||||
|
fbo = 0; tex = 0; pbo = 0; mapped = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An invisible OpenGL context through EGL: all drawing goes to our own FBOs, the 16×16 pbuffer is only there to bind. */
|
||||||
|
internal class EglContext(val dpy: Pointer, val surface: Pointer?, val ctx: Pointer, val info: String) {
|
||||||
|
fun destroy() {
|
||||||
|
Egl.eglMakeCurrent(dpy, null, null, null)
|
||||||
|
surface?.let { Egl.eglDestroySurface(dpy, it) }
|
||||||
|
Egl.eglDestroyContext(dpy, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun create(): EglContext {
|
||||||
|
val dpy = Egl.eglGetDisplay(null) ?: error("eglGetDisplay")
|
||||||
|
check(Egl.eglInitialize(dpy, IntByReference(), IntByReference()) != 0) { "eglInitialize 0x${Egl.eglGetError().toString(16)}" }
|
||||||
|
check(Egl.eglBindAPI(Egl.OPENGL_API) != 0) { "eglBindAPI" }
|
||||||
|
val egl = "EGL ${Egl.eglQueryString(dpy, Egl.VERSION)} ${Egl.eglQueryString(dpy, Egl.VENDOR)}"
|
||||||
|
val attribs = intArrayOf(
|
||||||
|
Egl.SURFACE_TYPE, Egl.PBUFFER_BIT, Egl.RENDERABLE_TYPE, Egl.OPENGL_BIT,
|
||||||
|
Egl.RED_SIZE, 8, Egl.GREEN_SIZE, 8, Egl.BLUE_SIZE, 8, Egl.NONE,
|
||||||
|
)
|
||||||
|
val configs = PointerByReference()
|
||||||
|
val num = IntByReference()
|
||||||
|
check(Egl.eglChooseConfig(dpy, attribs, configs, 1, num) != 0 && num.value > 0) { "eglChooseConfig ($egl)" }
|
||||||
|
val config = configs.value
|
||||||
|
val ctx = Egl.eglCreateContext(dpy, config, null, intArrayOf(Egl.NONE)) ?: error("eglCreateContext 0x${Egl.eglGetError().toString(16)} ($egl)")
|
||||||
|
var surface = Egl.eglCreatePbufferSurface(dpy, config, intArrayOf(Egl.WIDTH, 16, Egl.HEIGHT, 16, Egl.NONE))
|
||||||
|
if (surface == null || Egl.eglMakeCurrent(dpy, surface, surface, ctx) == 0) {
|
||||||
|
Log.w("mpv: EGL pbuffer refused (0x%s, %s), trying without a surface", Egl.eglGetError().toString(16), egl)
|
||||||
|
surface?.let { Egl.eglDestroySurface(dpy, it) }
|
||||||
|
surface = null
|
||||||
|
check(Egl.eglMakeCurrent(dpy, null, null, ctx) != 0) { "eglMakeCurrent 0x${Egl.eglGetError().toString(16)} ($egl)" }
|
||||||
|
}
|
||||||
|
val info = "${Gl.glGetString(Gl.RENDERER)} · GL ${Gl.glGetString(Gl.VERSION)?.substringBefore(' ')}"
|
||||||
|
return EglContext(dpy, surface, ctx, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Call first thing in `main`, before any window. libglvnd rewrites the GL entry points for the first vendor that
|
||||||
|
* makes a context current — Skiko's GLX context of the first Compose window — and then refuses to make current a
|
||||||
|
* context of another API: on NVIDIA mpv's EGL context fails in eglMakeCurrent with no error code (Mesa doesn't
|
||||||
|
* mind, the L1 prototype made its EGL context before its window). Disallowing the rewrite costs nothing
|
||||||
|
* measurable; libGLdispatch reads the variable when it loads, which is when Skiko first draws.
|
||||||
|
*/
|
||||||
|
fun prepareProcess() {
|
||||||
|
CLib.setenv("__GLVND_DISALLOW_PATCHING", "1", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FBO for a video area of [areaW]×[areaH]: the area itself, scaled down when the video would be drawn larger
|
||||||
|
* than its own size, so mpv never upscales into extra bytes. The area's proportions are kept: mpv still draws
|
||||||
|
* the black bars, and Skia stretches the frame evenly.
|
||||||
|
*/
|
||||||
|
internal fun renderSize(areaW: Int, areaH: Int, videoW: Int, videoH: Int): Pair<Int, Int> {
|
||||||
|
if (areaW <= 0 || areaH <= 0 || videoW <= 0 || videoH <= 0) return areaW to areaH
|
||||||
|
val scale = min(areaW.toDouble() / videoW, areaH.toDouble() / videoH)
|
||||||
|
if (scale <= 1.0) return areaW to areaH
|
||||||
|
return max(1, (areaW / scale).roundToInt()) to max(1, (areaH / scale).roundToInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three frame buffers: the one shown (held by the UI), the latest ready one and the one being written. A frame replaced
|
||||||
|
* before the UI got to show it counts as [lost].
|
||||||
|
*
|
||||||
|
* The memory is Skia's own [Data], not the mapped PBO: an Image over the PBO would outlive the buffer whenever Compose
|
||||||
|
* replays a recorded picture after the engine is gone; Data is reference-counted by the images using it.
|
||||||
|
*/
|
||||||
|
internal class FramePool {
|
||||||
|
class Frame {
|
||||||
|
var data: Data? = null
|
||||||
|
var pointer: Pointer? = null
|
||||||
|
var width = 0
|
||||||
|
var height = 0
|
||||||
|
var serial = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val all = List(3) { Frame() }
|
||||||
|
private var latest: Frame? = null
|
||||||
|
private var shown: Frame? = null
|
||||||
|
private var nextSerial = 1L
|
||||||
|
var lost = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Synchronized fun acquire(w: Int, h: Int): Frame {
|
||||||
|
val f = all.first { it !== latest && it !== shown }
|
||||||
|
val size = 4 * w * h
|
||||||
|
if (f.data == null || f.data!!.size < size) {
|
||||||
|
f.data?.close() // an Image in the UI keeps its own reference to the old Data
|
||||||
|
val d = Data.makeUninitialized(size)
|
||||||
|
f.data = d
|
||||||
|
f.pointer = Pointer(d.writableData())
|
||||||
|
}
|
||||||
|
f.width = w
|
||||||
|
f.height = h
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized fun publish(f: Frame) {
|
||||||
|
if (latest != null && latest !== shown) lost++
|
||||||
|
f.serial = nextSerial++
|
||||||
|
latest = f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the UI: the latest frame. The one shown before goes back to the writer. */
|
||||||
|
@Synchronized fun take(): Frame? {
|
||||||
|
latest?.let { shown = it }
|
||||||
|
return shown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import org.jetbrains.skia.ColorAlphaType
|
||||||
|
import org.jetbrains.skia.ColorType
|
||||||
|
import org.jetbrains.skia.Image
|
||||||
|
import org.jetbrains.skia.ImageInfo
|
||||||
|
import org.jetbrains.skia.Paint
|
||||||
|
import org.jetbrains.skia.Rect
|
||||||
|
import org.jetbrains.skia.SamplingMode
|
||||||
|
|
||||||
|
/** The latest frame of [video], filling the composable; the composable's pixel size is what mpv draws for. */
|
||||||
|
@Composable
|
||||||
|
fun MpvVideoSurface(video: MpvVideo, modifier: Modifier = Modifier) {
|
||||||
|
val holder = remember(video) { ImageHolder() }
|
||||||
|
DisposableEffect(holder) { onDispose { holder.close() } }
|
||||||
|
Canvas(modifier.onSizeChanged { video.resize(it.width, it.height) }) {
|
||||||
|
video.serial.intValue
|
||||||
|
drawRect(Color.Black)
|
||||||
|
val frame = video.frames.take() ?: return@Canvas
|
||||||
|
val image = holder.imageFor(frame)
|
||||||
|
drawIntoCanvas { canvas ->
|
||||||
|
val exact = frame.width == size.width.toInt() && frame.height == size.height.toInt()
|
||||||
|
// The frame matches the area: no resampling. Smaller (a small video in a big window, or a frame of the old
|
||||||
|
// size while the window is resized): Skia scales it up.
|
||||||
|
canvas.nativeCanvas.drawImageRect(
|
||||||
|
image, Rect.makeWH(frame.width.toFloat(), frame.height.toFloat()), Rect.makeWH(size.width, size.height),
|
||||||
|
if (exact) SamplingMode.DEFAULT else SamplingMode.CATMULL_ROM, holder.paint, true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Skia Image over the frame's memory, without a copy; made anew for every new frame. */
|
||||||
|
private class ImageHolder {
|
||||||
|
val paint = Paint()
|
||||||
|
private var image: Image? = null
|
||||||
|
private var serial = -1L
|
||||||
|
|
||||||
|
fun imageFor(frame: FramePool.Frame): Image {
|
||||||
|
image?.let { if (serial == frame.serial) return it }
|
||||||
|
image?.close()
|
||||||
|
val info = ImageInfo(frame.width, frame.height, ColorType.BGRA_8888, ColorAlphaType.OPAQUE)
|
||||||
|
val data = frame.data!!.makeSubset(0, 4 * frame.width * frame.height)
|
||||||
|
return Image.makeRaster(info, data, 4 * frame.width).also { image = it; serial = frame.serial; data.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
image?.close()
|
||||||
|
image = null
|
||||||
|
paint.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||||
|
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PC as the stream info shows it. Decoders are mpv's business (it picks NVDEC/VAAPI or the CPU per file), so
|
||||||
|
* there is nothing to probe for the engine choice: only names for the diagnostics.
|
||||||
|
*/
|
||||||
|
class PcCapabilitiesProbe : DeviceCapabilitiesProbe {
|
||||||
|
override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
|
||||||
|
|
||||||
|
private fun probe() = DeviceCapabilities(
|
||||||
|
manufacturer = "PC",
|
||||||
|
model = read("/etc/hostname") ?: "Linux",
|
||||||
|
soc = read("/proc/cpuinfo")?.lineSequence()?.firstOrNull { it.startsWith("model name") }?.substringAfter(':')?.trim()
|
||||||
|
?: System.getProperty("os.arch"),
|
||||||
|
sdkInt = 0,
|
||||||
|
abis = listOf(System.getProperty("os.arch")),
|
||||||
|
videoDecoders = emptyList(),
|
||||||
|
audioDecoders = emptyList(),
|
||||||
|
displayHdrTypes = emptySet(),
|
||||||
|
displayModes = emptyList(),
|
||||||
|
passthroughEncodings = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun read(path: String) = runCatching { File(path).readText().trim() }.getOrNull()?.ifEmpty { null }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual check: `java -cp … ru.stenfredd.sleepyowl.playback.mpv.EglProbeKt [mpv] [awt]` — creates mpv's EGL context
|
||||||
|
* alone, after an mpv core (`mpv`), after an AWT window (`awt`) or a Compose one (`compose`); `prepare` calls
|
||||||
|
* [MpvVideo.prepareProcess] first.
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
if ("prepare" in args) MpvVideo.prepareProcess()
|
||||||
|
if ("awt" in args) {
|
||||||
|
javax.swing.SwingUtilities.invokeAndWait {
|
||||||
|
javax.swing.JFrame("probe").apply { setSize(200, 100); isVisible = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("compose" in args) {
|
||||||
|
javax.swing.SwingUtilities.invokeAndWait {
|
||||||
|
androidx.compose.ui.awt.ComposeWindow().apply {
|
||||||
|
setSize(300, 200)
|
||||||
|
focusableWindowState = false
|
||||||
|
setLocation(3700, 1500)
|
||||||
|
setContent { androidx.compose.foundation.text.BasicText("probe") }
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread.sleep(1500)
|
||||||
|
}
|
||||||
|
if ("mpv" in args) {
|
||||||
|
val lib = LibMpv.INSTANCE
|
||||||
|
val h = lib.mpv_create()!!
|
||||||
|
lib.mpv_set_option_string(h, "vo", "libmpv")
|
||||||
|
println("mpv init ${lib.mpv_initialize(h)}")
|
||||||
|
}
|
||||||
|
val t = Thread {
|
||||||
|
val c = MpvVideo.EglContext.create()
|
||||||
|
println("OK ${c.info} surface=${c.surface}")
|
||||||
|
c.destroy()
|
||||||
|
}
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
System.exit(0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.playback.mpv
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RenderSizeTest {
|
||||||
|
@Test
|
||||||
|
fun smallVideoInBigWindowIsDrawnAtItsOwnSize() {
|
||||||
|
// A 2.4:1 film on 3440×1440 (2.39:1): the FBO keeps the window's shape, the film fills its width.
|
||||||
|
assertEquals(1728 to 723, MpvVideo.renderSize(3440, 1440, 1728, 720))
|
||||||
|
// 16:9 1080p: the film fills the height, the bars are left and right.
|
||||||
|
assertEquals(2580 to 1080, MpvVideo.renderSize(3440, 1440, 1920, 1080))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun windowSmallerThanVideoIsDrawnAtWindowSize() {
|
||||||
|
assertEquals(640 to 360, MpvVideo.renderSize(640, 360, 3840, 2160))
|
||||||
|
assertEquals(3440 to 1440, MpvVideo.renderSize(3440, 1440, 3840, 1600))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownVideoSizeTakesTheWindow() {
|
||||||
|
assertEquals(1024 to 576, MpvVideo.renderSize(1024, 576, 0, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
|
|||||||
35
tools/linux/fake_screensaver.py
Normal file
35
tools/linux/fake_screensaver.py
Normal 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
384
tools/linux/k2.py
Executable 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]))
|
||||||
|
|||||||
Reference in New Issue
Block a user