diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index a2d8dc2..931728d 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -10,6 +10,7 @@ kotlin { jvmToolchain(21) } dependencies { implementation(project(":ui:shared")) + implementation(project(":playback:engine-mpv")) implementation(compose.desktop.currentOs) implementation(libs.koin.core) implementation(libs.koin.compose.viewmodel) @@ -17,6 +18,8 @@ dependencies { implementation(libs.coil.network.okhttp) implementation(libs.kotlinx.coroutines.swing) implementation(libs.jna) + implementation(libs.dbus.java.core) + implementation(libs.dbus.java.unixsocket) testImplementation(libs.junit) testImplementation(libs.koin.test) @@ -28,7 +31,7 @@ val awtOpens = listOf("--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED", "--add compose.desktop { application { mainClass = "ru.stenfredd.sleepyowl.desktop.MainKt" - jvmArgs += awtOpens + "-Dsleepyowl.repo=${rootDir.path}" + jvmArgs += awtOpens } } diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt index fce35c2..9e650bf 100644 --- a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt @@ -60,6 +60,19 @@ fun App(window: java.awt.Window, keys: BackKeys) { Log.i("Session restored: %s", if (loggedIn) "logged in" else "login screen") 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() + 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 entry = remember { KeyboardEntry() } val onBack = { stack?.let { back(dispatcher, it) } } @@ -150,7 +163,7 @@ private fun BackButton(window: java.awt.Window, onBack: () -> Unit) { @Composable private fun Navigation(stack: NavStack, session: JellyfinSession, onBack: () -> Unit) { - val player = koinInject() + val player = koinInject() fun open(screen: Screen) { if (screen is Screen.Player) player.play(screen) else stack.push(screen) } diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopModule.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopModule.kt index 4a3418e..9b3fbf9 100644 --- a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopModule.kt +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopModule.kt @@ -16,22 +16,21 @@ import ru.stenfredd.sleepyowl.core.common.PreferenceStores import ru.stenfredd.sleepyowl.core.common.desktopAppDirs import ru.stenfredd.sleepyowl.core.jellyfin.DesktopJellyfinPlatform 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 java.io.File /** - * Koin: the desktop parts of the shared core and UI. The shared modules are listed in [main]; the mpv engine comes - * with L6. + * Koin: the desktop parts of the shared core and UI. The shared modules and the mpv engine (with the PC's device probe) + * are listed in [main]. */ -fun desktopModule(scale: UiScale) = module { +fun desktopModule() = module { single { desktopAppDirs() } singleOf(::PreferenceStores) + singleOf(::DesktopSettings) single { DesktopJellyfinPlatform() } - single { DesktopCapabilitiesProbe() } - single { DesktopUiPlatform(scale) } - singleOf(::ProtoPlayer) + single { DesktopUiPlatform(get()) } + singleOf(::DesktopPlayer) + singleOf(::SystemMedia) } /** 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") } -/** - * 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 { +private class DesktopUiPlatform(private val settings: DesktopSettings) : UiPlatform { override val density: Float - get() = scale.factor * systemScale() + get() = settings.scaleFactor * systemScale() override val version = "${BuildInfo.VERSION} · desktop" override val device = "${System.getProperty("os.name")} ${System.getProperty("os.version")} · ${hostname()}" 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 { const val VERSION = "0.1.0" } diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopSettings.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopSettings.kt new file mode 100644 index 0000000..b1db34f --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopSettings.kt @@ -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) +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Main.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Main.kt index 3a50d06..0d374ce 100644 --- a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Main.kt +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Main.kt @@ -1,13 +1,10 @@ package ru.stenfredd.sleepyowl.desktop -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.painter.BitmapPainter 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.dp 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.logToFile 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.playback.core.PlaybackSession 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.Screen import java.awt.Dimension +import java.util.UUID import java.awt.Frame import java.awt.Toolkit 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 [--start ]` 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) { val instance = SingleInstance.acquire(DesktopPaths.runtime) ?: run { System.err.println("SleepyOwl is already running: its window has been asked to show") return } + // Before any window: mpv's own GL context must be able to coexist with Skiko's. + MpvVideo.prepareProcess() logToFile(DesktopPaths.log) Log.i("SleepyOwl %s, Java %s, %s", BuildInfo.VERSION, System.getProperty("java.version"), System.getenv("DISPLAY")) Toolkit.getDefaultToolkit() X11App.setClassName("sleepyowl") - val scale = UiScale.fromEnvironment() - val koin = startKoin { modules(jellyfinModule, dataModule, playbackModule, uiModule, desktopModule(scale)) }.koin + val koin = startKoin { modules(jellyfinModule, dataModule, playbackModule, mpvEngineModule, uiModule, desktopModule()) }.koin + val settings = 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 player = koin.get() + val media = koin.get().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().session.first { it != null } + player.play(screen) + } + } application(exitProcessOnExit = false) { + player.screen?.let { PlayerWindow(player, it) } val keys = remember { BackKeys() } - val state = rememberWindowState(size = DpSize(1280.dp, 800.dp), position = WindowPosition(Alignment.Center)) - Window(onCloseRequest = ::exitApplication, state = state, title = "SleepyOwl", icon = icon, onPreviewKeyEvent = keys::onPreview, onKeyEvent = keys::onKey) { + 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, focusable = !TestMode.passive, + onPreviewKeyEvent = keys::onPreview, onKeyEvent = keys::onKey, + ) { LaunchedEffect(window) { window.minimumSize = Dimension(800, 500) - instance.listen { - 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) + instance.listen { SwingUtilities.invokeLater { raise(window) } } } + ScaledContent(settings) { App(window, keys) } } } 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() KoinPlatform.stopKoin() // 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) } +/** 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): 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? = System.getenv("SLEEPYOWL_PLAYER_BOUNDS")?.split(',')?.mapNotNull { it.trim().toIntOrNull() }?.takeIf { it.size == 4 } +} + /** The class of this file, for its resources. */ private object Main diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerControls.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerControls.kt new file mode 100644 index 0000000..809315b --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerControls.kt @@ -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, + spans: List, + ui: PlayerUiState, + onScrub: (Long) -> Unit, + onRelease: () -> Unit, + onWheel: (Int) -> Unit, + modifier: Modifier = Modifier, + thin: Boolean = false, +) { + var hoverX by remember { mutableStateOf(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, + ) +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerFrame.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerFrame.kt new file mode 100644 index 0000000..353ca1c --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerFrame.kt @@ -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() +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerPanels.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerPanels.kt new file mode 100644 index 0000000..3e49b74 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerPanels.kt @@ -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) { + 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), + ) +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerPrompts.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerPrompts.kt new file mode 100644 index 0000000..91d2ccf --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerPrompts.kt @@ -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>, 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)) } + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerUi.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerUi.kt new file mode 100644 index 0000000..184b5c6 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerUi.kt @@ -0,0 +1,1014 @@ +package ru.stenfredd.sleepyowl.desktop + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +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.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.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.awt.awtEventOrNull +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +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.em +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.MediaStreamType +import ru.stenfredd.sleepyowl.core.common.monotonicMs +import ru.stenfredd.sleepyowl.core.data.model.MediaInfo +import ru.stenfredd.sleepyowl.core.data.model.MediaSegment +import ru.stenfredd.sleepyowl.core.data.toMediaInfo +import ru.stenfredd.sleepyowl.playback.api.EngineState +import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine +import ru.stenfredd.sleepyowl.playback.api.streams +import ru.stenfredd.sleepyowl.playback.core.PlaybackSession +import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState +import ru.stenfredd.sleepyowl.playback.mpv.MpvEngine +import ru.stenfredd.sleepyowl.playback.mpv.MpvVideoSurface +import ru.stenfredd.sleepyowl.ui.Screen +import ru.stenfredd.sleepyowl.ui.components.AppIcon +import ru.stenfredd.sleepyowl.ui.player.CREDITS_COUNTDOWN_MS +import ru.stenfredd.sleepyowl.ui.player.PlayerInfo +import ru.stenfredd.sleepyowl.ui.player.PlayerLabels +import ru.stenfredd.sleepyowl.ui.player.PlayerTracks +import ru.stenfredd.sleepyowl.ui.player.PlayerViewModel +import ru.stenfredd.sleepyowl.ui.player.SKIP_NOTICE_MS +import ru.stenfredd.sleepyowl.ui.player.SKIP_PROMPT_MS +import ru.stenfredd.sleepyowl.ui.player.SeasonEpisodes +import ru.stenfredd.sleepyowl.ui.player.SegmentSet +import ru.stenfredd.sleepyowl.ui.player.SkipLabels +import ru.stenfredd.sleepyowl.ui.player.SkipPlan +import ru.stenfredd.sleepyowl.ui.player.Skippable +import ru.stenfredd.sleepyowl.ui.theme.AppColors +import ru.stenfredd.sleepyowl.ui.theme.AppType +import androidx.compose.ui.unit.IntSize +import java.awt.Cursor +import java.awt.Point +import java.awt.event.MouseEvent +import java.awt.Toolkit +import java.awt.image.BufferedImage +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** The controls by the window's width (§1.3): the TV's full OSD, one compact row, or the bare minimum. */ +internal enum class OsdLayout { Full, Compact, Minimal } + +@Stable +internal class PlayerUiState { + var osd by mutableStateOf(true) + /** Bumped by the pointer and keys: restarts the hide timer. */ + var activity by mutableIntStateOf(0) + var pointerInside by mutableStateOf(false) + /** Where the pointer last was in the window: the right-click menu opens there. */ + var pointer = Offset.Unspecified + var cursorHidden by mutableStateOf(false) + /** Controls under the pointer ([holdsControls]): they stay up meanwhile. */ + val holders = mutableStateMapOf() + /** The timeline or the volume slider is being dragged. */ + var dragging by mutableStateOf(false) + var panel by mutableStateOf(null) + var menuAt by mutableStateOf(null) + /** Where the user is seeking to; sent to the engine [scrubDelayMs] after the last key, or when the drag ends. */ + var scrubTarget by mutableStateOf(null) + var scrubVersion by mutableIntStateOf(0) + var scrubDelayMs = SCRUB_COMMIT_MS + var lastClickAt = 0L + /** Height of the bottom controls: text subtitles move above them. */ + var bottomPx by mutableIntStateOf(0) + + /** "Досмотреть титры" was chosen for this item: no "next episode" card for it any more. */ + var nextDismissedFor by mutableStateOf(null) + /** Where the "next episode" card appeared over known credits: its countdown runs from there. */ + var nextFrom by mutableStateOf(null) + /** Segments skipped in this playback (by the button or on their own): not offered again. */ + var skipped by mutableStateOf(SegmentSet()) + /** Segments the user seeked into: they went there on purpose, so these are not skipped on their own. */ + var seekedInto by mutableStateOf(SegmentSet()) + /** Segments whose button timed out over the bare video: it shows only with the controls. */ + var skipHidden by mutableStateOf(SegmentSet()) + /** "Заставка пропущена" after an automatic skip. */ + var notice by mutableStateOf(null) + var noticeVersion by mutableIntStateOf(0) + /** "Громкость 70 %" and the like, at the top. */ + var toast by mutableStateOf(null) + var toastVersion by mutableIntStateOf(0) + + fun poke() { + osd = true + cursorHidden = false + activity++ + } + + fun say(text: String) { + toast = text + toastVersion++ + } +} + +/** The window's key handler is set once; it calls the player's current one. */ +internal class PlayerKeys { + var handler: (KeyEvent) -> Boolean = { false } +} + +/** + * The player over [session]: video and the controls of §1.3 — shown by the pointer and keys, hidden when the pointer + * leaves or rests; panels, the drawer, skip and "next episode" as on the TV, the right-click menu, the keys' table. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun PlayerContent( + session: PlaybackSession, + vm: PlayerViewModel, + engine: PlaybackEngine?, + player: DesktopPlayer, + frame: PlayerFrame?, + keys: PlayerKeys, + onClose: () -> Unit, + onMarkWatched: (UUID, Boolean) -> Unit, +) { + val state by session.state.collectAsState() + val info by vm.info.collectAsState() + val season by vm.season.collectAsState() + val style by vm.subtitleStyle.collectAsState() + val segments by vm.segments.collectAsState() + val defaults by vm.defaults.collectAsState() + val ui = remember { PlayerUiState() } + val audio = player.audio + val media = remember(state.source) { state.source?.toMediaInfo() } + val tracks = remember(media) { PlayerTracks.of(media) } + val ended = state.engineState.phase == EngineState.Phase.ENDED + val shownPosition = ui.scrubTarget ?: state.positionMs + val remainingMs = state.durationMs - state.positionMs + val itemId = state.itemId + val episodes = season?.takeIf { it.currentId == itemId && info?.isEpisode == true } + val nextEpisode = episodes?.nextEpisode + val plan = remember(segments, itemId, state.durationMs, defaults, nextEpisode != null) { + segments?.takeIf { it.itemId == itemId }?.let { SkipPlan.of(it.segments, state.durationMs, defaults, hasNext = nextEpisode != null) } + } + val credits = plan?.credits + val overVideo = !ended && state.error == null && ui.panel == null + val nextDismissed = ui.nextDismissedFor == itemId + // Known credits with "Сразу": the next episode starts with them, unless the user seeked into them. + val creditsAuto = credits != null && plan?.creditsAuto == true && credits !in ui.seekedInto.of(itemId) && !nextDismissed + // "Следующая через N с" when the credits start (or near the end), unless dismissed or a panel is open. + val nextWindow = if (credits != null) shownPosition >= credits.startMs else remainingMs in 0..NEXT_PROMPT_MS + val next = nextEpisode?.takeIf { state.durationMs > 0 && remainingMs >= 0 && nextWindow && overVideo && !nextDismissed && !creditsAuto } + val nextCountdownMs = if (credits != null) CREDITS_COUNTDOWN_MS else NEXT_PROMPT_MS + val nextLeftMs = if (credits != null) minOf((ui.nextFrom ?: state.positionMs) + CREDITS_COUNTDOWN_MS - state.positionMs, remainingMs) else remainingMs + // "Пропустить заставку" while in a skippable part; over the bare video it goes after a while, the controls bring it back. + val skip = plan?.skipAt(shownPosition)?.takeIf { s -> + overVideo && !state.loading && next == null && s.segment !in ui.skipped.of(itemId) && + (!s.auto || s.segment in ui.seekedInto.of(itemId)) && (ui.osd || s.segment !in ui.skipHidden.of(itemId)) + } + val autoSkip = plan?.skipAt(state.positionMs)?.takeIf { s -> + s.auto && ui.scrubTarget == null && !state.loading && state.error == null && !ended && + s.segment !in ui.skipped.of(itemId) && s.segment !in ui.seekedInto.of(itemId) + } + val mpv = engine as? MpvEngine + + fun skipPart(part: Skippable, auto: Boolean) { + ui.skipped = ui.skipped.plus(itemId, part.segment) + session.seekTo(part.segment.endMs) + if (auto) { + ui.notice = SkipLabels.skipped(part.segment.kind) + ui.noticeVersion++ + } + } + + /** Keys add up: the engine gets one seek once they stop. */ + fun scrubBy(deltaMs: Long) { + val end = (state.durationMs - 1_000).coerceAtLeast(0) + ui.scrubTarget = ((ui.scrubTarget ?: state.positionMs) + deltaMs).coerceIn(0, end) + ui.scrubDelayMs = SCRUB_COMMIT_MS + ui.scrubVersion++ + } + + /** The timeline: [target] follows the pointer; with [now] it goes to the engine at once. */ + fun scrubTo(target: Long, now: Boolean) { + ui.scrubTarget = target.coerceIn(0, (state.durationMs - 1_000).coerceAtLeast(0)) + ui.scrubDelayMs = 0 + if (now) ui.scrubVersion++ + } + + fun setVolume(volume: Int) { + val v = volume.coerceIn(0, MpvEngine.MAX_VOLUME) + audio.set(v, muted = false) + mpv?.setVolume(v) + mpv?.setMuted(false) + ui.say("Громкость $v %") + } + + fun toggleMute() { + audio.set(audio.volume, !audio.muted) + mpv?.setMuted(audio.muted) + ui.say(if (audio.muted) "Без звука" else "Громкость ${audio.volume} %") + } + + fun cycleAudio() { + val options = state.source?.streams(MediaStreamType.AUDIO).orEmpty().map { it.index } + if (options.size < 2) return + val next = options[(options.indexOf(state.selection.audioStreamIndex) + 1) % options.size] + session.selectAudio(next) + ui.say("Аудио: " + PlayerLabels.audioChip(media, next)) + } + + fun cycleSubtitles() { + val options: List = state.source?.streams(MediaStreamType.SUBTITLE).orEmpty().map { it.index } + null + if (options.size < 2) return + val next = options[(options.indexOf(state.selection.subtitleStreamIndex) + 1) % options.size] + session.selectSubtitle(next) + ui.say(PlayerLabels.subtitleChip(media, next).let { if (next == null) it else "Субтитры: $it" }) + } + + fun toggleFullscreen() = player.toggleFullscreen() + + fun toggleOnTop() = player.toggleOnTop() + + fun showLibrary() { + player.leaveFullscreen() + player.showLibrary() + } + + fun openPanel(panel: Panel) { + ui.menuAt = null + ui.panel = panel + ui.poke() + } + + fun openPage() { + val id = state.itemId ?: return + val seriesId = info?.seriesId + player.leaveFullscreen() + player.openPage(if (seriesId != null) Screen.Series(seriesId, id) else Screen.Detail(id)) + } + + fun playEpisode(id: UUID) { + if (id == state.itemId) return + ui.panel = null + vm.playEpisode(id) + } + + fun onVideoClick() { + if (ui.panel != null) { + ui.panel = null + return + } + val now = monotonicMs() + session.togglePlayPause() + // The second click of a double click undoes the first one's pause and changes the window instead (§1.1). + if (now - ui.lastClickAt < DoubleClickMs) { + ui.lastClickAt = 0 + player.cycleMode() + } else { + ui.lastClickAt = now + } + } + + fun escape(): Boolean { + when { + ui.menuAt != null -> ui.menuAt = null + ui.panel != null -> ui.panel = null + player.fullscreen -> player.leaveFullscreen() + else -> return false + } + return true + } + + fun menuItems(): List> { + val isEpisode = info?.isEpisode == true + val sticky = player.placement.sticky + return listOf( + listOfNotNull( + MenuItem(if (player.mode == PlayerMode.Mini) "Обычное окно" else "Поверх всех окон", "T") { toggleOnTop() }, + MenuItem(if (player.fullscreen) "Выйти из полноэкранного" else "Во весь экран", "F") { toggleFullscreen() }, + MenuItem("На всех рабочих столах", if (sticky) "✓" else null) { player.placement.saveSticky(!sticky) } + .takeIf { player.mode == PlayerMode.Mini }, + ), + listOfNotNull( + MenuItem("Аудио", "A") { openPanel(Panel.Audio) }.takeIf { (media?.audio?.size ?: 0) > 0 }, + MenuItem("Субтитры", "C") { openPanel(Panel.Subtitles) }.takeIf { (media?.subtitles?.size ?: 0) > 0 }, + MenuItem("Качество") { openPanel(Panel.Quality) }.takeIf { (info?.qualities?.size ?: 0) > 1 }, + MenuItem("Информация о потоке") { openPanel(Panel.Info) }, + ), + listOfNotNull( + info?.let { MenuItem(if (isEpisode) "Серии" else "О фильме") { openPanel(Panel.Episodes) } }, + MenuItem(if (isEpisode) "К сериалу" else "Страница фильма") { openPage() }, + itemId?.let { id -> + MenuItem(if (isEpisode) "Отметить просмотренной" else "Отметить просмотренным") { + onMarkWatched(id, true) + ui.say("Отмечено как просмотренное") + } + }, + ), + listOf(MenuItem("К медиатеке") { showLibrary() }, MenuItem("Закрыть плеер", "Ctrl+W") { onClose() }), + ) + } + + fun onKey(e: KeyEvent): Boolean { + if (e.type != KeyEventType.KeyDown) return false + val key = PlayerKey.of(e) + if (key == Key.Escape) return escape() + ui.poke() + val shift = e.isShiftPressed + when { + e.isCtrlPressed && key == Key.W -> onClose() + e.isCtrlPressed -> return false + key == Key.Spacebar || key == Key.K -> session.togglePlayPause() + key == Key.DirectionLeft -> scrubBy(if (shift) -60_000 else -10_000) + key == Key.DirectionRight -> scrubBy(if (shift) 60_000 else 10_000) + key == Key.DirectionUp -> setVolume((if (audio.muted) 0 else audio.volume) + VOLUME_STEP) + key == Key.DirectionDown -> setVolume((if (audio.muted) 0 else audio.volume) - VOLUME_STEP) + key == Key.M -> toggleMute() + key == Key.S -> skip?.let { skipPart(it, auto = false) } ?: next?.let { playEpisode(it.id) } ?: return true + key == Key.N && shift -> episodes?.previous?.let(::playEpisode) + key == Key.N -> episodes?.next?.let(::playEpisode) + key == Key.A -> cycleAudio() + key == Key.C -> cycleSubtitles() + key == Key.F -> toggleFullscreen() + key == Key.T -> toggleOnTop() + key in DigitKeys && state.durationMs > 0 -> scrubTo(state.durationMs * (DigitKeys.indexOf(key) + 1) / 10, now = true) + else -> return false + } + return true + } + keys.handler = ::onKey + + // The system's view of the player (§2.5): MPRIS for the media keys and the sound applet, the screensaver. + val nowPlaying = info?.takeIf { it.id == itemId }?.let { i -> + NowPlaying( + itemId = i.id, + title = if (i.isEpisode) i.subtitle ?: i.title else i.title, + artist = if (i.isEpisode) i.title else i.subtitle, + artUrl = i.poster?.url, + durationMs = state.durationMs, + playing = state.engineState.playWhenReady && !ended && state.error == null, + canNext = episodes?.next != null, + canPrevious = episodes?.previous != null, + ) + } + // While the next episode's info loads the system keeps the last one: the player doesn't vanish from the applet. + if (nowPlaying != null) SideEffect { player.nowPlaying = nowPlaying } + player.commands = object : PlayerCommands { + override fun playPause(play: Boolean?) { + if (play == null || play != session.state.value.engineState.playWhenReady) session.togglePlayPause() + ui.poke() + } + + override fun next() { + episodes?.next?.let(::playEpisode) + } + + override fun previous() { + episodes?.previous?.let(::playEpisode) + } + + override fun seekTo(positionMs: Long) = scrubTo(positionMs, now = true) + + override fun changeVolume(percent: Int) = setVolume(percent) + + override fun bringUp() { + frame?.let { raise(it.window) } + } + + override fun positionMs() = session.state.value.positionMs + } + DisposableEffect(player) { + onDispose { + player.nowPlaying = null + player.commands = null + } + } + + // Hide after a while of no pointer moves or keys, unless something holds the controls (§1.3). + val hold = ui.holders.isNotEmpty() || ui.dragging || ui.panel != null || ui.menuAt != null || state.error != null + LaunchedEffect(ui.activity, ui.osd, hold) { + if (!ui.osd || hold) return@LaunchedEffect + delay(if (player.mode == PlayerMode.Mini) MINI_IDLE_HIDE_MS else IDLE_HIDE_MS) + ui.osd = false + ui.cursorHidden = ui.pointerInside + } + // A panel or menu left open by the pointer leaving the window closes after a moment, unless it comes back. + LaunchedEffect(ui.pointerInside, ui.panel, ui.menuAt) { + if (ui.pointerInside || (ui.panel == null && ui.menuAt == null)) return@LaunchedEffect + delay(PANEL_LEAVE_MS) + ui.panel = null + ui.menuAt = null + ui.osd = false + } + LaunchedEffect(ui.scrubVersion) { + val target = ui.scrubTarget ?: return@LaunchedEffect + delay(ui.scrubDelayMs) + plan?.spanAt(target)?.let { ui.seekedInto = ui.seekedInto.plus(state.itemId, it) } + session.seekTo(target) + // Keep showing the target until the engine reports the new position. + delay(SCRUB_SETTLE_MS) + if (!ui.dragging) ui.scrubTarget = null + } + LaunchedEffect(ended) { + if (!ended) return@LaunchedEffect + // The end of an episode plays the next one (the card counted down to it, or was dismissed); else the player closes. + season?.next?.let(vm::playEpisode) ?: onClose() + } + LaunchedEffect(state.itemId) { + if (ui.panel == Panel.Episodes) ui.panel = null + } + LaunchedEffect(autoSkip) { + autoSkip?.let { skipPart(it, auto = true) } + } + val creditsGo = creditsAuto && nextEpisode != null && credits != null && state.positionMs >= credits.startMs && + ui.scrubTarget == null && overVideo && !state.loading + val nextCountedDown = next != null && credits != null && nextLeftMs <= 0 + LaunchedEffect(creditsGo, nextCountedDown) { + if (creditsGo || nextCountedDown) nextEpisode?.let { vm.playEpisode(it.id) } + } + LaunchedEffect(ui.noticeVersion) { + if (ui.notice == null) return@LaunchedEffect + delay(SKIP_NOTICE_MS) + ui.notice = null + } + LaunchedEffect(ui.toastVersion) { + if (ui.toast == null) return@LaunchedEffect + delay(TOAST_MS) + ui.toast = null + } + LaunchedEffect(skip?.segment, ui.osd) { + val part = skip?.segment ?: return@LaunchedEffect + if (ui.osd) return@LaunchedEffect + delay(SKIP_PROMPT_MS) + ui.skipHidden = ui.skipHidden.plus(itemId, part) + } + val nextShown = next != null + LaunchedEffect(nextShown) { + ui.nextFrom = if (nextShown) state.positionMs else null + } + + // The mini window is resized by its edges (§1.1): the window manager does it from the press on. + val mini = player.mode == PlayerMode.Mini + var edge by remember { mutableStateOf(null) } + BoxWithConstraints( + Modifier + .fillMaxSize() + .background(Color.Black) + .pointerHoverIcon(edge?.let(::edgeIcon) ?: if (ui.cursorHidden) BlankCursor else PointerIcon.Default, overrideDescendants = edge != null) + // Initial pass: every move counts, over the controls too. Compose also sends a move without one when the + // layout under a resting pointer changes (the controls fading out): those must not bring them back. + .onPointerEvent(PointerEventType.Move, PointerEventPass.Initial) { + val at = it.changes.first().position + if (at != ui.pointer) ui.poke() + ui.pointer = at + ui.pointerInside = true + edge = if (mini) edgeAt(at, size) else null + } + .onPointerEvent(PointerEventType.Enter, PointerEventPass.Initial) { + ui.pointer = it.changes.first().position + ui.pointerInside = true + ui.poke() + } + .onPointerEvent(PointerEventType.Exit, PointerEventPass.Initial) { + ui.pointerInside = false + edge = null + ui.holders.clear() + if (!ui.dragging && ui.panel == null && ui.menuAt == null) ui.osd = false + } + .onPointerEvent(PointerEventType.Press, PointerEventPass.Initial) { + val at = it.changes.first().position + ui.pointer = at + ui.poke() + val direction = if (mini && it.buttons.isPrimaryPressed) edgeAt(at, size) else null + val awt = it.awtEventOrNull + if (direction != null && awt != null && frame != null) { + it.changes.forEach { change -> change.consume() } + frame.startResize(awt, direction) + } + } + // The wheel nobody else took (the timeline seeks, panels scroll): volume. Not past the end of the menu. + .onPointerEvent(PointerEventType.Scroll) { e -> + val change = e.changes.first() + if (change.isConsumed || change.scrollDelta.y == 0f || ui.menuAt != null) return@onPointerEvent + setVolume((if (audio.muted) 0 else audio.volume) + if (change.scrollDelta.y < 0) VOLUME_STEP else -VOLUME_STEP) + }, + ) { + val heightPx = constraints.maxHeight + val layout = when { + maxWidth >= FULL_MIN_WIDTH.dp -> OsdLayout.Full + maxWidth >= COMPACT_MIN_WIDTH.dp -> OsdLayout.Compact + else -> OsdLayout.Minimal + } + // Small windows: panels over the whole window; the drawer of the full layout is a sheet there too. + val sheets = layout != OsdLayout.Full || maxHeight < SHEET_MAX_HEIGHT.dp + val drawer = ui.panel == Panel.Episodes && !sheets + + VideoLayer( + mpv, onClick = ::onVideoClick, onSecondary = { at -> ui.menuAt = at }, + onDrag = frame?.takeIf { mini }?.let { f -> f::startMove }, + ) + + // Text subtitles move above the controls while they are shown. + val liftPx = if (ui.osd) ui.bottomPx else 0 + LaunchedEffect(session, liftPx, heightPx) { + session.setSubtitleLift(if (heightPx > 0) liftPx.toFloat() / heightPx else 0f) + } + + AnimatedVisibility(ui.osd || drawer, Modifier.fillMaxSize(), enter = fadeIn(tween(120)), exit = fadeOut(tween(FADE_MS))) { + val playing = state.engineState.playWhenReady + when (layout) { + OsdLayout.Full -> FullOsd( + state, info, episodes, media, ui, shownPosition, plan?.spans.orEmpty(), playing, player, drawer, + onToggle = session::togglePlayPause, onSeek = ::scrubBy, onScrub = ::scrubTo, onEpisode = ::playEpisode, + onPanel = ::openPanel, onVolume = ::setVolume, onMute = ::toggleMute, onFullscreen = ::toggleFullscreen, + onOnTop = ::toggleOnTop, onLibrary = ::showLibrary, onClose = onClose, + ) + OsdLayout.Compact -> CompactOsd( + state, info, ui, shownPosition, plan?.spans.orEmpty(), playing, player, + onToggle = session::togglePlayPause, onSeek = ::scrubBy, onScrub = ::scrubTo, + onVolume = ::setVolume, onMute = ::toggleMute, onMenu = { ui.menuAt = it }, onFullscreen = ::toggleFullscreen, + onOnTop = ::toggleOnTop, onLibrary = ::showLibrary, onClose = onClose, + ) + OsdLayout.Minimal -> MinimalOsd( + state, info, episodes, ui, shownPosition, plan?.spans.orEmpty(), playing, player.mode, + onToggle = session::togglePlayPause, onSeek = ::scrubBy, onScrub = ::scrubTo, onEpisode = ::playEpisode, + onMenu = { ui.menuAt = it }, onOnTop = ::toggleOnTop, onLibrary = ::showLibrary, onClose = onClose, + ) + } + } + if (drawer) { + PlayerDrawer( + info, season, state.itemId, + playedFraction = if (state.durationMs > 0) shownPosition.toFloat() / state.durationMs else 0f, + ui = ui, onClose = { ui.panel = null }, onPage = ::openPage, onEpisode = ::playEpisode, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + + // Prompts sit in the bottom right corner, above the controls while they are shown. + val density = androidx.compose.ui.platform.LocalDensity.current + val promptBottom = if (ui.osd) with(density) { ui.bottomPx.toDp() } + 12.dp else 24.dp + val promptEnd = if (layout == OsdLayout.Full) 48.dp else 12.dp + val promptModifier = Modifier.align(Alignment.BottomEnd).padding(end = promptEnd, bottom = promptBottom) + skip?.let { part -> + SkipButton(SkipLabels.button(part.segment.kind), PlayerLabels.time(part.segment.endMs - shownPosition), ui, onClick = { skipPart(part, auto = false) }, modifier = promptModifier) + } + ui.notice?.takeIf { skip == null && next == null }?.let { SkipNotice(it, promptModifier) } + next?.let { n -> + NextEpisodeCard( + n, remainingMs = nextLeftMs, countdownMs = nextCountdownMs, compact = layout == OsdLayout.Minimal, ui = ui, + onPlay = { playEpisode(n.id) }, + onStay = { ui.nextDismissedFor = state.itemId }, + modifier = promptModifier, + ) + } + if (state.loading && state.error == null) LoadingBadge(Modifier.align(Alignment.Center)) + state.error?.let { ErrorCard(it.lineSequence().first(), onClose, Modifier.align(Alignment.Center)) } + ui.toast?.let { Toast(it, Modifier.align(Alignment.TopCenter).padding(top = if (layout == OsdLayout.Full) 96.dp else 44.dp)) } + + ui.panel?.takeIf { !drawer }?.let { panel -> + if (panel == Panel.Episodes) { + EpisodesSheet( + info, season, state.itemId, + playedFraction = if (state.durationMs > 0) shownPosition.toFloat() / state.durationMs else 0f, + ui = ui, onClose = { ui.panel = null }, onPage = ::openPage, onEpisode = ::playEpisode, + ) + } else { + PanelContent( + panel, state, info, media, tracks, style, session, vm, sheets, ui, close = { ui.panel = null }, + modifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + ui.menuAt?.let { at -> PlayerMenu(menuItems(), at, ui, onDismiss = { ui.menuAt = null }) } + } +} + +/** + * The picture, and the mouse on it: a click pauses, a double click changes the window, the right button opens the + * menu. With [onDrag] (the mini window) a press that moves drags the window instead. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun VideoLayer(engine: MpvEngine?, onClick: () -> Unit, onSecondary: (Offset) -> Unit, onDrag: ((MouseEvent, Offset) -> Unit)?) { + Box( + Modifier + .fillMaxSize() + .onPointerEvent(PointerEventType.Press) { if (it.buttons.isSecondaryPressed) onSecondary(it.changes.first().position) } + .pointerInput(onDrag) { + val slop = CLICK_SLOP.dp.toPx() + awaitEachGesture { + val down = awaitFirstDown() + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + var moved = false + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if ((change.position - down.position).getDistance() > slop) { + moved = true + val awt = event.awtEventOrNull + if (onDrag != null && awt != null) { + onDrag(awt, change.position - down.position) + break + } + } + if (!change.pressed) break + } + if (!moved) onClick() + } + }, + ) { + engine?.let { MpvVideoSurface(it.video, Modifier.fillMaxSize()) } + } +} + +/** ≥ 900 px: the TV's OSD — title and clock on top; timeline, ⏮ ⟲ ⏯ ⟳ ⏭, chips, volume and the window at the bottom. */ +@Composable +internal fun FullOsd( + state: PlaybackUiState, + info: PlayerInfo?, + episodes: SeasonEpisodes?, + media: MediaInfo?, + ui: PlayerUiState, + positionMs: Long, + spans: List, + playing: Boolean, + player: DesktopPlayer, + drawer: Boolean, + onToggle: () -> Unit, + onSeek: (Long) -> Unit, + onScrub: (Long, Boolean) -> Unit, + onEpisode: (UUID) -> Unit, + onPanel: (Panel) -> Unit, + onVolume: (Int) -> Unit, + onMute: () -> Unit, + onFullscreen: () -> Unit, + onOnTop: () -> Unit, + onLibrary: () -> Unit, + onClose: () -> Unit, +) { + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxWidth().height(110.dp).background(OsdColors.TopShade)) + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height((if (drawer) DRAWER_HEIGHT + 180 else 180).dp).background(OsdColors.BottomShade)) + val remaining = (state.durationMs - positionMs).coerceAtLeast(0) + Row(Modifier.fillMaxWidth().padding(start = 48.dp, end = 24.dp, top = 28.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + BasicText(info?.label ?: "", style = AppType.MonoLabel) + BasicText( + info?.title ?: state.title, + Modifier.padding(top = 5.dp), + style = AppType.Hero.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + info?.subtitle?.let { + BasicText(it, Modifier.padding(top = 4.dp), style = AppType.BodyLarge.copy(color = AppColors.TextSecondary), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Column(Modifier.padding(start = 24.dp), horizontalAlignment = Alignment.End) { + Clock(AppType.Clock.copy(fontSize = 17.sp, color = AppColors.Text)) + if (state.durationMs > 0) { + BasicText("закончится в " + LocalTime.now().plusSeconds(remaining / 1000).format(HourMinute), Modifier.padding(top = 3.dp), style = AppType.MonoCaption.copy(fontSize = 10.sp)) + } + } + GlyphButton(Glyph.Library, 34.dp, 15.dp, onClick = onLibrary, modifier = Modifier.padding(start = 14.dp).holdsControls(ui, "library"), bare = true) + GlyphButton(Glyph.Close, 34.dp, 15.dp, onClick = onClose, modifier = Modifier.padding(start = 4.dp).holdsControls(ui, "close"), bare = true) + } + Column( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .onSizeChanged { ui.bottomPx = it.height } + .holdsControls(ui, "bottom") + .padding(start = 48.dp, end = 48.dp, top = 8.dp, bottom = if (drawer) (DRAWER_HEIGHT + 14).dp else 26.dp), + ) { + Row(Modifier.fillMaxWidth()) { + BasicText(PlayerLabels.time(positionMs), style = AppType.MonoMeta.copy(fontSize = 12.sp)) + Spacer(Modifier.weight(1f)) + if (state.durationMs > 0) BasicText("−" + PlayerLabels.time(remaining), style = AppType.MonoMeta.copy(fontSize = 12.sp)) + } + Timeline( + positionMs, state.durationMs, state.bufferedMs, info?.chaptersMs.orEmpty(), spans, ui, + onScrub = { onScrub(it, false) }, onRelease = { ui.scrubTarget?.let { onScrub(it, true) } }, onWheel = { onSeek(it * WHEEL_SEEK_MS) }, + modifier = Modifier.padding(top = 3.dp), + ) + Row( + Modifier.padding(top = 10.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + episodes?.previous?.let { id -> IconButton(AppIcon.Previous, 34.dp, 14.dp, onClick = { onEpisode(id) }) } + IconButton(AppIcon.Replay, 38.dp, 17.dp, onClick = { onSeek(-10_000) }) + PlayButton(playing, 46.dp, onClick = onToggle) + IconButton(AppIcon.Forward, 38.dp, 17.dp, onClick = { onSeek(10_000) }) + episodes?.next?.let { id -> IconButton(AppIcon.Next, 34.dp, 14.dp, onClick = { onEpisode(id) }) } + Spacer(Modifier.weight(1f)) + for (chip in chips(state, info, media, episodes)) OsdChip(chip, onClick = { onPanel(chip.panel) }) + VolumeControl(player.audio, ui, onChange = onVolume, onMute = onMute, modifier = Modifier.padding(start = 8.dp)) + GlyphButton(modeGlyph(player.mode), 34.dp, 16.dp, onClick = onOnTop, bare = true) + GlyphButton(if (player.fullscreen) Glyph.ExitFullscreen else Glyph.Fullscreen, 34.dp, 16.dp, onClick = onFullscreen, bare = true) + } + } + } +} + +/** 480–900 px: a thin title line with × on top, one row at the bottom; panels from ⋯. */ +@Composable +internal fun CompactOsd( + state: PlaybackUiState, + info: PlayerInfo?, + ui: PlayerUiState, + positionMs: Long, + spans: List, + playing: Boolean, + player: DesktopPlayer, + onToggle: () -> Unit, + onSeek: (Long) -> Unit, + onScrub: (Long, Boolean) -> Unit, + onVolume: (Int) -> Unit, + onMute: () -> Unit, + onMenu: (Offset) -> Unit, + onFullscreen: () -> Unit, + onOnTop: () -> Unit, + onLibrary: () -> Unit, + onClose: () -> Unit, +) { + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxWidth().height(64.dp).background(OsdColors.TopShade)) + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(90.dp).background(OsdColors.BottomShade)) + TopLine(info, state, ui, onLibrary, onClose) + BoxWithConstraints(Modifier.align(Alignment.BottomStart).fillMaxWidth()) { + val roomy = maxWidth >= 640.dp + Row( + Modifier + .fillMaxWidth() + .onSizeChanged { ui.bottomPx = it.height } + .holdsControls(ui, "bottom") + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + PlayButton(playing, 36.dp, onClick = onToggle) + IconButton(AppIcon.Replay, 32.dp, 15.dp, onClick = { onSeek(-10_000) }, bare = true) + IconButton(AppIcon.Forward, 32.dp, 15.dp, onClick = { onSeek(10_000) }, bare = true) + TimeLabel(positionMs, state.durationMs, Modifier.padding(horizontal = 4.dp)) + Timeline( + positionMs, state.durationMs, state.bufferedMs, info?.chaptersMs.orEmpty(), spans, ui, + onScrub = { onScrub(it, false) }, onRelease = { ui.scrubTarget?.let { onScrub(it, true) } }, onWheel = { onSeek(it * WHEEL_SEEK_MS) }, + modifier = Modifier.weight(1f), + ) + VolumeControl(player.audio, ui, onChange = onVolume, onMute = onMute, slider = roomy) + GlyphButton(Glyph.More, 32.dp, 17.dp, onClick = { onMenu(ui.pointer) }, bare = true) + GlyphButton(modeGlyph(player.mode), 32.dp, 15.dp, onClick = onOnTop, bare = true) + GlyphButton(if (player.fullscreen) Glyph.ExitFullscreen else Glyph.Fullscreen, 32.dp, 15.dp, onClick = onFullscreen, bare = true) + } + } + } +} + +/** < 480 px: ⏯ in the middle with ⏮/⏭ (⟲/⟳ for a movie), a thin timeline at the bottom, ⋯ and × in the top corners. */ +@Composable +internal fun MinimalOsd( + state: PlaybackUiState, + info: PlayerInfo?, + episodes: SeasonEpisodes?, + ui: PlayerUiState, + positionMs: Long, + spans: List, + playing: Boolean, + mode: PlayerMode, + onToggle: () -> Unit, + onSeek: (Long) -> Unit, + onScrub: (Long, Boolean) -> Unit, + onEpisode: (UUID) -> Unit, + onMenu: (Offset) -> Unit, + onOnTop: () -> Unit, + onLibrary: () -> Unit, + onClose: () -> Unit, +) { + Box(Modifier.fillMaxSize().background(Color(0x59000000))) { + Row(Modifier.align(Alignment.TopStart).padding(6.dp).holdsControls(ui, "top-start")) { + GlyphButton(Glyph.More, 30.dp, 16.dp, onClick = { onMenu(ui.pointer) }, bare = true) + GlyphButton(modeGlyph(mode), 30.dp, 14.dp, onClick = onOnTop, bare = true) + } + Row(Modifier.align(Alignment.TopEnd).padding(6.dp).holdsControls(ui, "top-end")) { + GlyphButton(Glyph.Library, 30.dp, 13.dp, onClick = onLibrary, bare = true) + GlyphButton(Glyph.Close, 30.dp, 13.dp, onClick = onClose, bare = true) + } + Row( + Modifier.align(Alignment.Center).holdsControls(ui, "center"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + val previous = episodes?.previous + val following = episodes?.next + if (episodes != null) { + IconButton(AppIcon.Previous, 32.dp, 13.dp, onClick = { previous?.let(onEpisode) }) + } else { + IconButton(AppIcon.Replay, 32.dp, 14.dp, onClick = { onSeek(-10_000) }) + } + PlayButton(playing, 46.dp, onClick = onToggle) + if (episodes != null) { + IconButton(AppIcon.Next, 32.dp, 13.dp, onClick = { following?.let(onEpisode) }) + } else { + IconButton(AppIcon.Forward, 32.dp, 14.dp, onClick = { onSeek(10_000) }) + } + } + Column( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .onSizeChanged { ui.bottomPx = it.height } + .holdsControls(ui, "bottom") + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + TimeLabel(positionMs, state.durationMs) + Timeline( + positionMs, state.durationMs, state.bufferedMs, info?.chaptersMs.orEmpty(), spans, ui, + onScrub = { onScrub(it, false) }, onRelease = { ui.scrubTarget?.let { onScrub(it, true) } }, onWheel = { onSeek(it * WHEEL_SEEK_MS) }, + thin = true, + ) + } + } +} + +/** "Название · Сезон 1 · Серия 4", "К медиатеке" and ×. */ +@Composable +private fun TopLine(info: PlayerInfo?, state: PlaybackUiState, ui: PlayerUiState, onLibrary: () -> Unit, onClose: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(start = 14.dp, end = 6.dp, top = 6.dp), verticalAlignment = Alignment.CenterVertically) { + BasicText( + listOfNotNull(info?.title ?: state.title, info?.subtitle).joinToString(" · "), + Modifier.weight(1f), + style = AppType.Section.copy(fontSize = 13.sp, color = AppColors.Text), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + GlyphButton(Glyph.Library, 30.dp, 13.dp, onClick = onLibrary, modifier = Modifier.holdsControls(ui, "library"), bare = true) + GlyphButton(Glyph.Close, 30.dp, 13.dp, onClick = onClose, modifier = Modifier.holdsControls(ui, "close"), bare = true) + } +} + +/** The window mode button: to the mini window on top, or out of it into the normal one. */ +private fun modeGlyph(mode: PlayerMode) = if (mode == PlayerMode.Mini) Glyph.Windowed else Glyph.OnTop + +/** The mini window's edge under [p] (8 px, corners 20 px), as an `X11Window.SIZE_*` direction; null inside. */ +private fun edgeAt(p: Offset, size: IntSize): Int? { + val l = p.x < EDGE + val r = p.x > size.width - EDGE + val t = p.y < EDGE + val b = p.y > size.height - EDGE + val lc = p.x < CORNER + val rc = p.x > size.width - CORNER + val tc = p.y < CORNER + val bc = p.y > size.height - CORNER + return when { + (t && lc) || (l && tc) -> X11Window.SIZE_TOPLEFT + (t && rc) || (r && tc) -> X11Window.SIZE_TOPRIGHT + (b && lc) || (l && bc) -> X11Window.SIZE_BOTTOMLEFT + (b && rc) || (r && bc) -> X11Window.SIZE_BOTTOMRIGHT + t -> X11Window.SIZE_TOP + b -> X11Window.SIZE_BOTTOM + l -> X11Window.SIZE_LEFT + r -> X11Window.SIZE_RIGHT + else -> null + } +} + +private fun edgeIcon(direction: Int) = PointerIcon( + Cursor( + when (direction) { + X11Window.SIZE_TOPLEFT -> Cursor.NW_RESIZE_CURSOR + X11Window.SIZE_TOP -> Cursor.N_RESIZE_CURSOR + X11Window.SIZE_TOPRIGHT -> Cursor.NE_RESIZE_CURSOR + X11Window.SIZE_RIGHT -> Cursor.E_RESIZE_CURSOR + X11Window.SIZE_BOTTOMRIGHT -> Cursor.SE_RESIZE_CURSOR + X11Window.SIZE_BOTTOM -> Cursor.S_RESIZE_CURSOR + X11Window.SIZE_BOTTOMLEFT -> Cursor.SW_RESIZE_CURSOR + else -> Cursor.W_RESIZE_CURSOR + }, + ), +) + +/** "Серии", audio, subtitles, quality, Direct/Транскод: only the ones that have something to show. */ +private fun chips(state: PlaybackUiState, info: PlayerInfo?, media: MediaInfo?, episodes: SeasonEpisodes?): List = buildList { + val selection = state.selection + if (info != null) { + add(OsdChipModel(Panel.Episodes, if (episodes != null) AppColors.Blue else AppColors.Accent, if (episodes != null) "Серии" else "О фильме")) + } + if ((media?.audio?.size ?: 0) > 0) add(OsdChipModel(Panel.Audio, AppColors.Pink, PlayerLabels.audioChip(media, selection.audioStreamIndex))) + if ((media?.subtitles?.size ?: 0) > 0) add(OsdChipModel(Panel.Subtitles, AppColors.Yellow, PlayerLabels.subtitleChip(media, selection.subtitleStreamIndex))) + info?.qualities?.takeIf { it.size > 1 }?.let { options -> + add(OsdChipModel(Panel.Quality, AppColors.Blue, options.firstOrNull { it.bitrate == state.maxBitrate }?.label ?: "Оригинал")) + } + val direct = PlayerLabels.isDirect(state) + add(OsdChipModel(Panel.Info, if (direct) AppColors.Green else AppColors.Orange, if (direct) "Direct" else "Транскод")) +} + +/** HH:mm, recomposes once a minute and only itself. */ +@Composable +private fun Clock(style: androidx.compose.ui.text.TextStyle) { + var now by remember { mutableStateOf(LocalTime.now()) } + LaunchedEffect(Unit) { + while (true) { + delay(60_000L - (System.currentTimeMillis() % 60_000L)) + now = LocalTime.now() + } + } + BasicText(now.format(HourMinute), style = style) +} + +/** + * The key the player acts on. The table of §1.3 is by physical keys: in the Russian layout "Ь" is still M. AWT names + * a key by the character of its layout, and for Cyrillic it has no names; the X key code (the place on the keyboard) + * gives the Latin one. + */ +internal object PlayerKey { + fun of(e: KeyEvent): Key { + val awt = e.awtEventOrNull ?: return e.key + if (awt.keyCode != java.awt.event.KeyEvent.VK_UNDEFINED && awt.keyCode < 0x01000000) return e.key + return byX11Code[rawCode(awt)] ?: e.key + } + + /** X11 key codes (evdev + 8) of the US layout's letters and digits the player uses. */ + private val byX11Code = mapOf( + 38 to Key.A, 54 to Key.C, 45 to Key.K, 58 to Key.M, 57 to Key.N, 39 to Key.S, 41 to Key.F, 28 to Key.T, 25 to Key.W, + 10 to Key.One, 11 to Key.Two, 12 to Key.Three, 13 to Key.Four, 14 to Key.Five, 15 to Key.Six, 16 to Key.Seven, 17 to Key.Eight, 18 to Key.Nine, + ) + + /** AWT keeps the X key code in a private field, closed by the module system; its public description shows it. */ + private fun rawCode(e: java.awt.event.KeyEvent): Int = RawCode.find(e.paramString())?.groupValues?.get(1)?.toIntOrNull() ?: 0 + + private val RawCode = Regex("""rawCode=(\d+)""") +} + +private val DigitKeys = listOf(Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine) + +private val HourMinute = DateTimeFormatter.ofPattern("HH:mm") + +private val BlankCursor by lazy { + PointerIcon(Toolkit.getDefaultToolkit().createCustomCursor(BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB), Point(0, 0), "none")) +} + +/** The system's double click, at most 0.5 s. */ +private val DoubleClickMs: Long by lazy { + ((Toolkit.getDefaultToolkit().getDesktopProperty("awt.multiClickInterval") as? Int) ?: 400).coerceIn(200, 500).toLong() +} + +private const val FULL_MIN_WIDTH = 900 +private const val COMPACT_MIN_WIDTH = 480 +/** Lower windows get their panels as sheets: a 350 px column next to the video would be too cramped. */ +private const val SHEET_MAX_HEIGHT = 420 +private const val IDLE_HIDE_MS = 3_000L +private const val MINI_IDLE_HIDE_MS = 2_000L +private const val EDGE = 8f +private const val CORNER = 20f +private const val PANEL_LEAVE_MS = 1_500L +private const val FADE_MS = 150 +private const val TOAST_MS = 1_200L +private const val SCRUB_COMMIT_MS = 400L +private const val SCRUB_SETTLE_MS = 1_200L +private const val WHEEL_SEEK_MS = 5_000L +private const val VOLUME_STEP = 5 +/** A press that moves further is a drag, not a click. */ +private const val CLICK_SLOP = 4 diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerWindow.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerWindow.kt new file mode 100644 index 0000000..0cc9ce9 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerWindow.kt @@ -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(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(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().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 = 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(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() + val watch = koinViewModel() + 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 diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/ProtoPlayer.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/ProtoPlayer.kt deleted file mode 100644 index 441aebb..0000000 --- a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/ProtoPlayer.kt +++ /dev/null @@ -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() - } -} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SettingsScreen.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SettingsScreen.kt index 82716c7..f89398f 100644 --- a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SettingsScreen.kt +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SettingsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import ru.stenfredd.sleepyowl.core.data.LanguageSettings import ru.stenfredd.sleepyowl.core.data.SubtitleMode @@ -71,6 +72,8 @@ private enum class Section(val title: String, val dot: Color) { Playback("Воспроизведение", AppColors.Blue), Languages("Языки", AppColors.Pink), Subtitles("Вид субтитров", AppColors.Yellow), + Desktop("Компьютер", AppColors.Orange), + Keys("Клавиши", AppColors.Accent), Account("Аккаунт и сервер", AppColors.Green), About("О приложении", AppColors.TextDim), } @@ -82,7 +85,7 @@ private data class Option(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 * 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 fun SettingsScreen( @@ -118,6 +121,8 @@ fun SettingsScreen( Section.Playback -> PlaybackSection(vm, focus) Section.Languages -> LanguagesSection(vm, focus) Section.Subtitles -> SubtitlesSection(vm, focus) + Section.Desktop -> DesktopSection(focus) + Section.Keys -> KeysSection() Section.Account -> AccountSection(vm, focus, onOpen, onSignedOut, onSwitched) Section.About -> AboutSection(vm) } @@ -308,6 +313,106 @@ private fun SubtitlesSection(vm: SettingsViewModel, focus: ScreenFocus) { ) } +// --- Компьютер --- + +@Composable +private fun DesktopSection(focus: ScreenFocus) { + val settings = koinInject() + val placement = koinInject().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>) { + 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 diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SystemMedia.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SystemMedia.kt new file mode 100644 index 0000000..7ebc1bf --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SystemMedia.kt @@ -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> = 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> = 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(), "as"), + "SupportedMimeTypes" to Variant(arrayOf(), "as"), + ) + + private fun playerProperties(): Map> { + 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> { + 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 Get(interfaceName: String, propertyName: String): A = GetAll(interfaceName)[propertyName] as A + + override fun GetAll(interfaceName: String): Map> = when (interfaceName) { + ROOT_INTERFACE -> rootProperties() + PLAYER_INTERFACE -> playerProperties() + else -> emptyMap() + } + + override fun 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) +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/X11.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/X11.kt new file mode 100644 index 0000000..c15f99c --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/X11.kt @@ -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 + } + } +} diff --git a/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopGraphTest.kt b/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopGraphTest.kt index 71ce3d5..711a8db 100644 --- a/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopGraphTest.kt +++ b/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopGraphTest.kt @@ -9,6 +9,7 @@ import ru.stenfredd.sleepyowl.core.data.dataModule import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule import ru.stenfredd.sleepyowl.playback.api.EngineProvider import ru.stenfredd.sleepyowl.playback.core.playbackModule +import ru.stenfredd.sleepyowl.playback.mpv.mpvEngineModule import ru.stenfredd.sleepyowl.ui.uiModule import java.io.File import java.util.UUID @@ -18,9 +19,9 @@ class DesktopGraphTest { @OptIn(KoinExperimentalAPI::class) @Test 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; - // 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), ) } diff --git a/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerRenderTest.kt b/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerRenderTest.kt new file mode 100644 index 0000000..1369931 --- /dev/null +++ b/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerRenderTest.kt @@ -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-.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() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 191df07..cc8f93c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ zxing = "3.5.3" compose-multiplatform = "1.12.2" jna = "5.18.1" koin = "4.2.2" +dbus-java = "5.2.2" junit = "4.13.2" [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" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } 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. cmp-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } cmp-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } diff --git a/playback/core/src/commonMain/kotlin/ru/stenfredd/sleepyowl/playback/core/PlaybackSession.kt b/playback/core/src/commonMain/kotlin/ru/stenfredd/sleepyowl/playback/core/PlaybackSession.kt index 15c76b2..fe180f6 100644 --- a/playback/core/src/commonMain/kotlin/ru/stenfredd/sleepyowl/playback/core/PlaybackSession.kt +++ b/playback/core/src/commonMain/kotlin/ru/stenfredd/sleepyowl/playback/core/PlaybackSession.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -677,7 +678,9 @@ class PlaybackSession( private fun reportStopAsync(positionMs: Long, failed: Boolean) { // The owner's scope is being cancelled at this point; reporting must still reach the server. @Suppress("OPT_IN_USAGE") - kotlinx.coroutines.GlobalScope.launch { reportStop(positionMs, failed) } + val job = kotlinx.coroutines.GlobalScope.launch { reportStop(positionMs, failed) } + pendingStops += job + job.invokeOnCompletion { pendingStops -= job } } private fun log(message: String) { @@ -691,17 +694,22 @@ class PlaybackSession( _state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") } } - private companion object { - const val TICKS_PER_MS = 10_000L - const val MAX_BITRATE = 400_000_000 - const val TICK_MS = 500L - const val PROGRESS_INTERVAL_MS = 10_000L - const val FIRST_FRAME_TIMEOUT_MS = 60_000L - const val STALL_TICK_MS = 1000L - const val STALL_TIMEOUT_MS = 8_000L + companion object { + private val pendingStops: MutableSet = java.util.Collections.synchronizedSet(mutableSetOf()) + + /** For an app about to exit (the desktop): waits until the stop reports already sent off are done. */ + suspend fun awaitStopReports() = synchronized(pendingStops) { pendingStops.toList() }.joinAll() + + private const val TICKS_PER_MS = 10_000L + private const val MAX_BITRATE = 400_000_000 + private const val TICK_MS = 500L + private const val PROGRESS_INTERVAL_MS = 10_000L + private const val FIRST_FRAME_TIMEOUT_MS = 60_000L + private const val STALL_TICK_MS = 1000L + private const val STALL_TIMEOUT_MS = 8_000L /** Seeking rebuffers and drops a few frames: give the engine time to settle before judging it. */ - const val SEEK_GRACE_MS = 5_000L - const val DROP_WINDOW_TICKS = 10 - const val MAX_DROPPED_SHARE = 0.25 + private const val SEEK_GRACE_MS = 5_000L + private const val DROP_WINDOW_TICKS = 10 + private const val MAX_DROPPED_SHARE = 0.25 } } diff --git a/playback/engine-mpv/build.gradle.kts b/playback/engine-mpv/build.gradle.kts new file mode 100644 index 0000000..6464ad0 --- /dev/null +++ b/playback/engine-mpv/build.gradle.kts @@ -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) +} diff --git a/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/Gl.kt b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/Gl.kt new file mode 100644 index 0000000..77cb0cd --- /dev/null +++ b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/Gl.kt @@ -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) +} diff --git a/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/LibMpv.kt b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/LibMpv.kt new file mode 100644 index 0000000..4d35472 --- /dev/null +++ b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/LibMpv.kt @@ -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): 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 +} diff --git a/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvEngine.kt b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvEngine.kt new file mode 100644 index 0000000..5f25250 --- /dev/null +++ b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvEngine.kt @@ -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(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>? = 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 = 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() + 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>() + 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, 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 { + 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, + ) + } +} diff --git a/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvEngineProvider.kt b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvEngineProvider.kt new file mode 100644 index 0000000..2fcd9c5 --- /dev/null +++ b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvEngineProvider.kt @@ -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 { 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 + } +} diff --git a/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvVideo.kt b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvVideo.kt new file mode 100644 index 0000000..3c8627c --- /dev/null +++ b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvVideo.kt @@ -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): 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 { + 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 + } +} diff --git a/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvVideoSurface.kt b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvVideoSurface.kt new file mode 100644 index 0000000..1dcd9ba --- /dev/null +++ b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/MpvVideoSurface.kt @@ -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() + } +} diff --git a/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/PcCapabilitiesProbe.kt b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/PcCapabilitiesProbe.kt new file mode 100644 index 0000000..6387c64 --- /dev/null +++ b/playback/engine-mpv/src/main/kotlin/ru/stenfredd/sleepyowl/playback/mpv/PcCapabilitiesProbe.kt @@ -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 } +} diff --git a/playback/engine-mpv/src/test/kotlin/ru/stenfredd/sleepyowl/playback/mpv/EglProbe.kt b/playback/engine-mpv/src/test/kotlin/ru/stenfredd/sleepyowl/playback/mpv/EglProbe.kt new file mode 100644 index 0000000..39dd5de --- /dev/null +++ b/playback/engine-mpv/src/test/kotlin/ru/stenfredd/sleepyowl/playback/mpv/EglProbe.kt @@ -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) { + 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) +} diff --git a/playback/engine-mpv/src/test/kotlin/ru/stenfredd/sleepyowl/playback/mpv/RenderSizeTest.kt b/playback/engine-mpv/src/test/kotlin/ru/stenfredd/sleepyowl/playback/mpv/RenderSizeTest.kt new file mode 100644 index 0000000..98666e2 --- /dev/null +++ b/playback/engine-mpv/src/test/kotlin/ru/stenfredd/sleepyowl/playback/mpv/RenderSizeTest.kt @@ -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)) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9874bd7..fa11627 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ include(":playback:api") include(":playback:core") include(":playback:engine-exo") include(":playback:engine-vlc") +include(":playback:engine-mpv") include(":ui:shared") // SleepyOwl for the Linux desktop (LINUX_PLAN.md). diff --git a/tools/linux/desktop.sh b/tools/linux/desktop.sh index 0fd2215..809ab12 100755 --- a/tools/linux/desktop.sh +++ b/tools/linux/desktop.sh @@ -1,11 +1,11 @@ #!/bin/bash # 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 # (tools/linux/stand.sh) points them at its own directory. set -e R=$(cd "$(dirname "$0")/../.." && pwd) 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) -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 \ +[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath) +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 "$@" diff --git a/tools/linux/fake_screensaver.py b/tools/linux/fake_screensaver.py new file mode 100644 index 0000000..5a27812 --- /dev/null +++ b/tools/linux/fake_screensaver.py @@ -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 = """ + + +""" +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() diff --git a/tools/linux/k2.py b/tools/linux/k2.py new file mode 100755 index 0000000..e3165fe --- /dev/null +++ b/tools/linux/k2.py @@ -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': ", 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) diff --git a/tools/linux/stand.sh b/tools/linux/stand.sh index a3b9bff..b2fa7b8 100755 --- a/tools/linux/stand.sh +++ b/tools/linux/stand.sh @@ -1,14 +1,16 @@ #!/bin/bash # 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 app [args] (re)start the app on :87 in the background; output in $STAND/app.out # stand.sh stop stop the app # 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 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, # 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" for _ in $(seq 50); do xdpyinfo -display $D >/dev/null 2>&1 && break; sleep 0.1; done 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 - 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" sleep 2 fi @@ -49,14 +54,14 @@ case "$1" in up) up ;; down) 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" ;; app) shift up >/dev/null stop_app - [ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath) - DISPLAY=$D XDG_CONFIG_HOME="$STAND/config" XDG_CACHE_HOME="$STAND/cache" XDG_STATE_HOME="$STAND/state" \ + [ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath) + 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 & echo $! >"$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" ;; in) shift; DISPLAY=$D python3 "$R/tools/linux/xinput.py" "$@" ;; 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 diff --git a/tools/linux/xinput.py b/tools/linux/xinput.py index 4fccdd2..da3ae23 100755 --- a/tools/linux/xinput.py +++ b/tools/linux/xinput.py @@ -4,6 +4,7 @@ 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 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 type TEXT types Latin text, digits and punctuation 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) +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'} 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) elif op == "dclick": click(args[0], args[1], times=2) elif op == "move": move(*args) + elif op == "drag": drag(*args) elif op == "key": [key(a) for a in args] elif op == "type": type_text(" ".join(args)) elif op == "wait": time.sleep(float(args[0]))