Desktop player on mpv: floating window, system integration

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

View File

@@ -10,6 +10,7 @@ kotlin { jvmToolchain(21) }
dependencies {
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
}
}

View File

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

View File

@@ -16,22 +16,21 @@ import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.common.desktopAppDirs
import ru.stenfredd.sleepyowl.core.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<JellyfinPlatform> { DesktopJellyfinPlatform() }
single<DeviceCapabilitiesProbe> { DesktopCapabilitiesProbe() }
single<UiPlatform> { DesktopUiPlatform(scale) }
singleOf(::ProtoPlayer)
single<UiPlatform> { 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"
}

View File

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

View File

@@ -1,13 +1,10 @@
package ru.stenfredd.sleepyowl.desktop
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 <item id> [--start <seconds>]` opens the player on that item right away (tests and checks of the engine; the
* account must already be signed in).
*/
fun main(args: Array<String>) {
val instance = SingleInstance.acquire(DesktopPaths.runtime) ?: run {
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<DesktopSettings>()
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<DesktopPlayer>()
val media = koin.get<SystemMedia>().apply { start() }
playArgument(args)?.let { screen ->
// After the main window has restored the session.
@Suppress("OPT_IN_USAGE")
kotlinx.coroutines.GlobalScope.launch(Dispatchers.Main) {
koin.get<JellyfinSession>().session.first { it != null }
player.play(screen)
}
}
application(exitProcessOnExit = false) {
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<String>): Screen.Player? {
val id = args.indexOf("--play").takeIf { it >= 0 }?.let { args.getOrNull(it + 1) } ?: return null
val start = args.indexOf("--start").takeIf { it >= 0 }?.let { args.getOrNull(it + 1)?.toDoubleOrNull() } ?: 0.0
val uuid = runCatching { UUID.fromString(id.replace(Regex("^(.{8})(.{4})(.{4})(.{4})(.{12})$"), "$1-$2-$3-$4-$5")) }.getOrNull() ?: return null
return Screen.Player(uuid, engine = null, startPositionMs = (start * 1000).toLong())
}
/**
* Automated runs on the user's own screen (the engine's measurements): `SLEEPYOWL_TEST_PASSIVE=1` keeps every window
* from taking the focus (a test window once took the user's typing, L1) and starts the main window minimized;
* `SLEEPYOWL_PLAYER_BOUNDS=x,y,w,h` places the player window (pixels).
*/
object TestMode {
val passive = System.getenv("SLEEPYOWL_TEST_PASSIVE") == "1"
val playerBounds: List<Int>? = System.getenv("SLEEPYOWL_PLAYER_BOUNDS")?.split(',')?.mapNotNull { it.trim().toIntOrNull() }?.takeIf { it.size == 4 }
}
/** The class of this file, for its resources. */
private object Main

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.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<T>(val value: T, val label: String, val detail: String
/**
* Settings tab, as on the TV without its TV-only settings (video output, Dolby Vision, the debug tour): sections on the
* 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<DesktopSettings>()
val placement = koinInject<DesktopPlayer>().placement
Setting("Масштаб интерфейса", "Размер текста, карточек и кнопок в окнах приложения, поверх масштаба системы.") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Choices("scale", DesktopSettings.SCALES.map { Option(it, "$it %") }, settings.scalePercent, focus, settings::changeScale)
settings.scaleOverride?.let { Note("Сейчас действует SLEEPYOWL_SCALE=$it из окружения — он важнее этой настройки.", color = AppColors.Yellow) }
}
}
Setting(
"Открывать плеер",
"В каком виде открывается окно плеера. «Как в прошлый раз» — в том режиме, в котором его закрыли. " +
"Режим можно сменить в любой момент: T, F, двойной щелчок по видео.",
) {
val options = listOf(
Option(PlayerOpening.Last, "Как в прошлый раз"),
Option(PlayerOpening.Mini, "Мини поверх всех"),
Option(PlayerOpening.Window, "Обычное окно"),
Option(PlayerOpening.Fullscreen, "Во весь экран"),
)
Choices("player-opening", options, settings.playerOpening, focus, settings::changePlayerOpening)
}
Setting("Мини-окно на всех рабочих столах", "Мини-окно плеера остаётся на экране при переключении рабочих столов.") {
Choices("sticky", listOf(Option(false, "Выкл"), Option(true, "Вкл")), placement.sticky, focus, placement::saveSticky)
}
}
// --- Клавиши ---
private val PlayerKeysTable = listOf(
"Пробел, K" to "Пауза и продолжение",
"← / →" to "Назад и вперёд на 10 с",
"Shift + ← / →" to "Назад и вперёд на 60 с",
"1 … 9" to "К 10 … 90 % фильма или серии",
"↑ / ↓" to "Громкость",
"M" to "Без звука",
"S" to "Пропустить заставку или титры",
"N / Shift + N" to "Следующая и предыдущая серия",
"A" to "Следующая аудиодорожка",
"C" to "Следующие субтитры",
"F" to "Весь экран",
"T" to "Мини поверх всех или обычное окно",
"Esc" to "Закрыть меню или панель, выйти из полноэкранного",
"Ctrl + W" to "Закрыть плеер",
"Медиаклавиши" to "Пауза, следующая и предыдущая серия — даже когда плеер не в фокусе",
)
private val PlayerMouseTable = listOf(
"Щелчок" to "Пауза и продолжение",
"Двойной щелчок" to "Мини → окно → весь экран",
"Колесо" to "Громкость; над таймлайном — ±5 с",
"Правая кнопка" to "Меню плеера",
"Мини-окно" to "Тянуть за видео — переместить, за край или угол — размер",
)
private val LibraryKeysTable = listOf(
"Стрелки" to "Переход между карточками и кнопками",
"Enter" to "Открыть",
"Esc, Backspace, Alt + ←" to "Назад; ещё — кнопка мыши «назад»",
"Ctrl + F, /" to "Поиск",
"Правая кнопка" to "Отметить просмотренным или непросмотренным",
)
/** The keys and the mouse (§1.3): the player works by the physical keys, so in the Russian layout too. */
@Composable
private fun KeysSection() {
KeyTable("Плеер", PlayerKeysTable)
KeyTable("Мышь в плеере", PlayerMouseTable)
KeyTable("Медиатека", LibraryKeysTable)
Note("Клавиши плеера работают по физическим кнопкам — и в русской раскладке.")
}
@Composable
private fun KeyTable(title: String, rows: List<Pair<String, String>>) {
Setting(title, null) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
for ((keys, action) in rows) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(190.dp)) {
BasicText(
keys,
Modifier
.background(AppColors.SurfaceRaised, RoundedCornerShape(5.dp))
.border(0.5.dp, AppColors.Border, RoundedCornerShape(5.dp))
.padding(horizontal = 7.dp, vertical = 3.dp),
style = AppType.MonoMeta,
maxLines = 1,
)
}
BasicText(action, Modifier.widthIn(max = 420.dp), style = AppType.Body.copy(color = AppColors.TextSecondary))
}
}
}
}
}
// --- Аккаунт и сервер ---
@Composable

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import ru.stenfredd.sleepyowl.core.data.dataModule
import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule
import ru.stenfredd.sleepyowl.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),
)
}

View File

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