Desktop player on mpv: floating window, system integration
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
|
||||
/** "Открывать плеер": in the last mode (§1.1) or always in one. */
|
||||
enum class PlayerOpening(val mode: PlayerMode?) { Last(null), Mini(PlayerMode.Mini), Window(PlayerMode.Window), Fullscreen(PlayerMode.Fullscreen) }
|
||||
|
||||
/**
|
||||
* The desktop's own settings (L9) in `desktop.preferences_pb`: read once before the first window, applied at once when
|
||||
* changed. "На всех рабочих столах" lives with the player's places ([PlayerPlacement.sticky]).
|
||||
*/
|
||||
class DesktopSettings(stores: PreferenceStores) {
|
||||
private val store = stores["desktop"]
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val saved: Preferences? = try {
|
||||
runBlocking(Dispatchers.IO) { withTimeoutOrNull(2_000) { store.data.first() } }
|
||||
} catch (e: Exception) {
|
||||
Log.w(e, "Desktop settings not read")
|
||||
null
|
||||
}
|
||||
|
||||
/** "Масштаб интерфейса", %: 100 = the TV's dp as pixels (§1.4), times the system's own scale. */
|
||||
var scalePercent by mutableIntStateOf(saved?.get(KEY_SCALE)?.takeIf { it in SCALES } ?: 100)
|
||||
private set
|
||||
|
||||
/** `SLEEPYOWL_SCALE=1.25` overrides the setting (test runs). */
|
||||
val scaleOverride: Float? = System.getenv("SLEEPYOWL_SCALE")?.toFloatOrNull()?.coerceIn(0.5f, 3f)
|
||||
|
||||
val scaleFactor: Float get() = scaleOverride ?: (scalePercent / 100f)
|
||||
|
||||
var playerOpening by mutableStateOf(saved?.get(KEY_PLAYER_OPENING)?.let { n -> PlayerOpening.entries.firstOrNull { it.name == n } } ?: PlayerOpening.Last)
|
||||
private set
|
||||
|
||||
fun changeScale(percent: Int) {
|
||||
scalePercent = percent
|
||||
write { it[KEY_SCALE] = percent }
|
||||
}
|
||||
|
||||
fun changePlayerOpening(opening: PlayerOpening) {
|
||||
playerOpening = opening
|
||||
write { it[KEY_PLAYER_OPENING] = opening.name }
|
||||
}
|
||||
|
||||
private fun write(change: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) {
|
||||
scope.launch {
|
||||
try {
|
||||
store.edit(change)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(e, "Desktop settings not saved")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SCALES = listOf(80, 90, 100, 110, 125, 150)
|
||||
private val KEY_SCALE = intPreferencesKey("ui_scale")
|
||||
private val KEY_PLAYER_OPENING = stringPreferencesKey("player_opening")
|
||||
}
|
||||
}
|
||||
|
||||
/** A window's content at "Масштаб интерфейса": changes apply at once. */
|
||||
@Composable
|
||||
fun ScaledContent(settings: DesktopSettings, content: @Composable () -> Unit) {
|
||||
val system = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(system.density * settings.scaleFactor, system.fontScale), content = content)
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
|
||||
import ru.stenfredd.sleepyowl.playback.mpv.MpvEngine
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/*
|
||||
* The desktop player's controls: the TV's OSD look (translucent circles, gradient ⏯, chips) lit by the pointer instead
|
||||
* of the remote's focus, and the timeline and volume for the mouse.
|
||||
*/
|
||||
|
||||
internal object OsdColors {
|
||||
val Fill = Color(0x14FFFFFF)
|
||||
val Border = Color(0x24FFFFFF)
|
||||
val Text = Color(0xFFE3E5F2)
|
||||
val TopShade = Brush.verticalGradient(listOf(Color(0xD905060A), Color(0x0005060A)))
|
||||
val BottomShade = Brush.verticalGradient(0f to Color(0x0005060A), 0.4f to Color(0x9905060A), 1f to Color(0xF705060A))
|
||||
}
|
||||
|
||||
/** Icons the TV doesn't have, in the same 24-unit grid and stroke as [AppIcon]. */
|
||||
internal enum class Glyph(path: String) {
|
||||
Volume("M4 9h4l5-4v14l-5-4H4zM16 9a4 4 0 0 1 0 6M18.5 6.5a7.5 7.5 0 0 1 0 11"),
|
||||
VolumeLow("M4 9h4l5-4v14l-5-4H4zM16 9a4 4 0 0 1 0 6"),
|
||||
Muted("M4 9h4l5-4v14l-5-4H4zM16.5 9.5l5 5M21.5 9.5l-5 5"),
|
||||
Fullscreen("M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"),
|
||||
ExitFullscreen("M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"),
|
||||
Close("M6 6l12 12M18 6L6 18"),
|
||||
More("M5.5 12h.01M12 12h.01M18.5 12h.01"),
|
||||
Episodes("M4 6h16M4 12h16M4 18h10"),
|
||||
/** The mini window on top of everything: a small picture in a large one. */
|
||||
OnTop("M3 5h18v14H3zM12.5 11.5h6v5h-6z"),
|
||||
/** The normal window: a frame with a title bar. */
|
||||
Windowed("M3 5h18v14H3zM3 9h18"),
|
||||
/** "К медиатеке". */
|
||||
Library("M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"),
|
||||
;
|
||||
|
||||
val path by lazy { PathParser().parsePathString(path).toPath() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GlyphIcon(glyph: Glyph, color: Color, modifier: Modifier = Modifier, strokeWidth: Float = 2f) {
|
||||
Spacer(
|
||||
modifier.drawWithCache {
|
||||
val k = size.minDimension / 24f
|
||||
// The dots of "⋯" are zero-length lines: their round caps draw them, thicker than a line.
|
||||
val width = if (glyph == Glyph.More) strokeWidth * 1.8f else strokeWidth
|
||||
val stroke = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round)
|
||||
onDrawBehind { scale(k, k, pivot = Offset.Zero) { drawPath(glyph.path, color, style = stroke) } }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Tracks the pointer over the element. */
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
internal fun Modifier.onHover(onChange: (Boolean) -> Unit): Modifier = this
|
||||
.onPointerEvent(PointerEventType.Enter) { onChange(true) }
|
||||
.onPointerEvent(PointerEventType.Exit) { onChange(false) }
|
||||
|
||||
/**
|
||||
* While the pointer is over this element the controls stay up (§1.3: buttons, the timeline, panels). [key] tells the
|
||||
* elements apart; one that leaves the composition under the pointer lets go too.
|
||||
*/
|
||||
internal fun Modifier.holdsControls(ui: PlayerUiState, key: Any): Modifier = composed {
|
||||
DisposableEffect(ui, key) { onDispose { ui.holders.remove(key) } }
|
||||
onHover { if (it) ui.holders[key] = Unit else ui.holders.remove(key) }
|
||||
}
|
||||
|
||||
/** ⟲ ⟳ ⏮ ⏭ and the other round buttons: translucent circle ([bare]: none), light fill under the pointer. */
|
||||
@Composable
|
||||
internal fun RoundButton(
|
||||
size: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bare: Boolean = false,
|
||||
content: @Composable (Color) -> Unit,
|
||||
) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier
|
||||
.size(size)
|
||||
.onHover { hovered = it }
|
||||
.background(if (hovered) AppColors.FocusFill else if (bare) Color.Transparent else OsdColors.Fill, CircleShape)
|
||||
.then(if (bare && !hovered) Modifier else Modifier.border(0.5.dp, if (hovered) AppColors.FocusFill else OsdColors.Border, CircleShape))
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content(if (hovered) AppColors.OnFocusFill else Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun IconButton(icon: AppIcon, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, bare: Boolean = false) {
|
||||
RoundButton(size, onClick, modifier, bare) { Icon(icon, it, Modifier.size(iconSize), strokeWidth = 2.2f) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GlyphButton(glyph: Glyph, size: Dp, iconSize: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, bare: Boolean = false) {
|
||||
RoundButton(size, onClick, modifier, bare) { GlyphIcon(glyph, it, Modifier.size(iconSize), strokeWidth = 2f) }
|
||||
}
|
||||
|
||||
/** The big gradient ⏯; a light ring under the pointer. */
|
||||
@Composable
|
||||
internal fun PlayButton(playing: Boolean, size: Dp, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier
|
||||
.size(size)
|
||||
.onHover { hovered = it }
|
||||
.background(AppColors.PrimaryGradient, CircleShape)
|
||||
.then(if (hovered) Modifier.border(2.dp, Color(0xFFD9D2FF), CircleShape) else Modifier)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (playing) Icon(AppIcon.Pause, Color.White, Modifier.size(size * 0.41f)) else Icon(AppIcon.Play, Color.White, Modifier.size(size * 0.43f))
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal data class OsdChipModel(val panel: Panel, val dot: Color, val label: String)
|
||||
|
||||
/** "● Русский 7.1": opens a panel. */
|
||||
@Composable
|
||||
internal fun OsdChip(chip: OsdChipModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(17.dp)
|
||||
Row(
|
||||
modifier
|
||||
.height(34.dp)
|
||||
.onHover { hovered = it }
|
||||
.background(if (hovered) AppColors.FocusFill else OsdColors.Fill, shape)
|
||||
.border(0.5.dp, if (hovered) AppColors.FocusFill else OsdColors.Border, shape)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(Modifier.size(6.dp).background(chip.dot, CircleShape))
|
||||
BasicText(
|
||||
chip.label,
|
||||
Modifier.widthIn(max = 170.dp),
|
||||
style = AppType.Button.copy(fontSize = 11.5.sp, fontWeight = FontWeight.Normal, color = if (hovered) AppColors.OnFocusFill else OsdColors.Text),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track with the buffered part, skippable parts (intro, credits), the played part and chapter marks. Under the pointer
|
||||
* it thickens, shows a knob and the time at the pointer; pressing or dragging scrubs ([onScrub] with the time under the
|
||||
* pointer, [onRelease] when let go), the wheel seeks ±5 s ([onWheel] with −1 / +1). [thin]: the minimal layout's line.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
internal fun Timeline(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
bufferedMs: Long,
|
||||
chaptersMs: List<Long>,
|
||||
spans: List<MediaSegment>,
|
||||
ui: PlayerUiState,
|
||||
onScrub: (Long) -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
onWheel: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
thin: Boolean = false,
|
||||
) {
|
||||
var hoverX by remember { mutableStateOf<Float?>(null) }
|
||||
var width by remember { mutableStateOf(1f) }
|
||||
val duration by rememberUpdatedState(durationMs)
|
||||
val scrub by rememberUpdatedState(onScrub)
|
||||
val release by rememberUpdatedState(onRelease)
|
||||
fun at(x: Float): Long = if (duration > 0) ((x / width).coerceIn(0f, 1f) * duration).toLong() else 0L
|
||||
val active = hoverX != null || ui.dragging
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (thin) 16.dp else 26.dp)
|
||||
.onSizeChanged { width = it.width.toFloat().coerceAtLeast(1f) }
|
||||
.holdsControls(ui, "timeline")
|
||||
.onPointerEvent(PointerEventType.Move) { hoverX = it.changes.first().position.x }
|
||||
.onPointerEvent(PointerEventType.Enter) { hoverX = it.changes.first().position.x }
|
||||
.onPointerEvent(PointerEventType.Exit) { hoverX = null }
|
||||
.onPointerEvent(PointerEventType.Scroll) { e ->
|
||||
val dy = e.changes.first().scrollDelta.y
|
||||
if (dy != 0f) onWheel(if (dy > 0) -1 else 1)
|
||||
e.changes.forEach { it.consume() }
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
if (duration <= 0) return@awaitEachGesture
|
||||
ui.dragging = true
|
||||
try {
|
||||
scrub(at(down.position.x))
|
||||
down.consume()
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
hoverX = change.position.x
|
||||
if (!change.pressed) break
|
||||
scrub(at(change.position.x))
|
||||
change.consume()
|
||||
}
|
||||
} finally {
|
||||
ui.dragging = false
|
||||
release()
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (thin) 16.dp else 26.dp)
|
||||
.drawBehind {
|
||||
val trackH = (if (active) (if (thin) 5.dp else 7.dp) else 3.dp).toPx()
|
||||
val top = (size.height - trackH) / 2
|
||||
val r = CornerRadius(trackH / 2)
|
||||
val w = size.width
|
||||
fun x(ms: Long) = if (durationMs > 0) (ms.toFloat() / durationMs).coerceIn(0f, 1f) * w else 0f
|
||||
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(w, trackH), r)
|
||||
if (bufferedMs > positionMs) drawRoundRect(Color(0x2EFFFFFF), Offset(0f, top), Size(x(bufferedMs), trackH), r)
|
||||
for (span in spans) {
|
||||
val from = x(span.startMs)
|
||||
drawRect(SpanColor, Offset(from, top), Size(x(span.endMs) - from, trackH))
|
||||
}
|
||||
val played = x(positionMs)
|
||||
if (played > 0f) {
|
||||
drawRoundRect(
|
||||
Brush.horizontalGradient(listOf(Color(0xFF4D8BFF), Color(0xFF9B6BFF)), endX = played.coerceAtLeast(1f)),
|
||||
Offset(0f, top), Size(played, trackH), r,
|
||||
)
|
||||
}
|
||||
val mark = 1.5.dp.toPx()
|
||||
for (c in chaptersMs) {
|
||||
if (c >= durationMs) continue
|
||||
drawRect(Color(0xD90A0B10), Offset(x(c) - mark / 2, top), Size(mark, trackH))
|
||||
}
|
||||
if (active) {
|
||||
val center = Offset(played, size.height / 2)
|
||||
drawCircle(Color(0x80A594FF), (if (thin) 7.dp else 9.5.dp).toPx(), center)
|
||||
drawCircle(Color.White, (if (thin) 5.5.dp else 7.5.dp).toPx(), center)
|
||||
}
|
||||
},
|
||||
)
|
||||
// The time under the pointer, above the track and kept inside it.
|
||||
val shownX = if (ui.dragging) width * (if (durationMs > 0) positionMs.toFloat() / durationMs else 0f) else hoverX
|
||||
if (shownX != null && durationMs > 0) {
|
||||
BasicText(
|
||||
PlayerLabels.time(if (ui.dragging) positionMs else at(shownX)),
|
||||
Modifier
|
||||
.layout { m, c ->
|
||||
val p = m.measure(c.copy(minWidth = 0, minHeight = 0))
|
||||
val x = (shownX - p.width / 2f).roundToInt().coerceIn(0, (c.maxWidth - p.width).coerceAtLeast(0))
|
||||
layout(0, 0) { p.place(IntOffset(x, -p.height - 2.dp.roundToPx())) }
|
||||
}
|
||||
.background(Color(0xE60E1018), RoundedCornerShape(5.dp))
|
||||
.border(0.5.dp, OsdColors.Border, RoundedCornerShape(5.dp))
|
||||
.padding(horizontal = 7.dp, vertical = 3.dp),
|
||||
style = AppType.MonoMeta.copy(fontSize = 11.sp, color = Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SpanColor = Color(0x598F6BFF)
|
||||
|
||||
/**
|
||||
* 🔊 and a short slider: the icon mutes, pressing or dragging along the slider sets the volume. The slider runs to
|
||||
* 100 %; louder (up to [MpvEngine.MAX_VOLUME]) only from the keys and the wheel.
|
||||
*/
|
||||
@Composable
|
||||
internal fun VolumeControl(audio: PlayerAudio, ui: PlayerUiState, onChange: (Int) -> Unit, onMute: () -> Unit, modifier: Modifier = Modifier, slider: Boolean = true) {
|
||||
val level = if (audio.muted) 0 else audio.volume
|
||||
Row(modifier.holdsControls(ui, "volume"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
GlyphButton(
|
||||
when {
|
||||
audio.muted || audio.volume == 0 -> Glyph.Muted
|
||||
audio.volume < 50 -> Glyph.VolumeLow
|
||||
else -> Glyph.Volume
|
||||
},
|
||||
size = 34.dp, iconSize = 17.dp, onClick = onMute, bare = true,
|
||||
)
|
||||
if (slider) {
|
||||
val change by rememberUpdatedState(onChange)
|
||||
var w by remember { mutableStateOf(1f) }
|
||||
Spacer(
|
||||
Modifier
|
||||
.width(72.dp)
|
||||
.height(24.dp)
|
||||
.onSizeChanged { w = it.width.toFloat().coerceAtLeast(1f) }
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
ui.dragging = true
|
||||
try {
|
||||
change(((down.position.x / w).coerceIn(0f, 1f) * 100).roundToInt())
|
||||
while (true) {
|
||||
val c = awaitPointerEvent().changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!c.pressed) break
|
||||
change(((c.position.x / w).coerceIn(0f, 1f) * 100).roundToInt())
|
||||
c.consume()
|
||||
}
|
||||
} finally {
|
||||
ui.dragging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.drawBehind {
|
||||
val h = 3.dp.toPx()
|
||||
val top = (size.height - h) / 2
|
||||
val filled = size.width * (level.coerceAtMost(100) / 100f)
|
||||
drawRoundRect(Color(0x29FFFFFF), Offset(0f, top), Size(size.width, h), CornerRadius(h / 2))
|
||||
drawRoundRect(Color.White, Offset(0f, top), Size(filled, h), CornerRadius(h / 2))
|
||||
drawCircle(Color.White, 5.dp.toPx(), Offset(filled, size.height / 2))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "1:02:11 / 1:48:30" */
|
||||
@Composable
|
||||
internal fun TimeLabel(positionMs: Long, durationMs: Long, modifier: Modifier = Modifier) {
|
||||
BasicText(
|
||||
PlayerLabels.time(positionMs) + if (durationMs > 0) " / " + PlayerLabels.time(durationMs) else "",
|
||||
modifier,
|
||||
style = AppType.MonoMeta.copy(fontSize = 11.sp, color = OsdColors.Text),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
/** Short message at the top of the video ("Громкость 70 %", "Аудио: Русский 5.1"). */
|
||||
@Composable
|
||||
internal fun Toast(text: String, modifier: Modifier = Modifier) {
|
||||
BasicText(
|
||||
text,
|
||||
modifier
|
||||
.background(Color(0xE60E1018), RoundedCornerShape(16.dp))
|
||||
.border(0.5.dp, OsdColors.Border, RoundedCornerShape(16.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.awt.ComposeWindow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import java.awt.Rectangle
|
||||
import java.awt.Toolkit
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.WindowConstants
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The player's window in its three modes (§1.1, §2.4), through the window manager ([X11Window]):
|
||||
*
|
||||
* - **mini** — no frame, on top of everything, moved by the video and resized by its edges (`_NET_WM_MOVERESIZE`),
|
||||
* keeping the video's proportions (size hints);
|
||||
* - **window** — the window manager's frame (`_MOTIF_WM_HINTS`; AWT keeps the window undecorated, L7);
|
||||
* - **full screen** — `_NET_WM_STATE_FULLSCREEN` on the window's monitor.
|
||||
*
|
||||
* Each windowed mode keeps its own place ([PlayerPlacement]). The window takes the focus once, when it opens; mode
|
||||
* changes and new episodes never take it back from another program (§1.2).
|
||||
*/
|
||||
internal class PlayerFrame(private val player: DesktopPlayer) {
|
||||
lateinit var window: ComposeWindow
|
||||
private set
|
||||
private var x11: X11Window? = null
|
||||
|
||||
/** The mode the window has been put in; null until the window manager has taken it. */
|
||||
private var applied: PlayerMode? = null
|
||||
|
||||
/** Until then the window's moves and resizes are a mode change settling, not the user's. */
|
||||
private var busyUntil = Long.MAX_VALUE
|
||||
private val busy get() = monotonicMs() < busyUntil
|
||||
|
||||
private var video = IntSize.Zero
|
||||
private var hintsRestored = 0
|
||||
private val saveTimer = javax.swing.Timer(SAVE_DELAY_MS) { savePlace() }.apply { isRepeats = false }
|
||||
|
||||
fun create(title: String): ComposeWindow = ComposeWindow().also { w ->
|
||||
window = w
|
||||
w.title = title
|
||||
// The frame comes from the window manager (setDecorated): full screen can be left again (L7).
|
||||
w.isUndecorated = true
|
||||
w.focusableWindowState = !TestMode.passive
|
||||
w.defaultCloseOperation = WindowConstants.DO_NOTHING_ON_CLOSE
|
||||
w.addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosing(e: WindowEvent) = player.close()
|
||||
})
|
||||
w.minimumSize = java.awt.Dimension(MIN_WIDTH, MIN_HEIGHT)
|
||||
Icon?.let { w.iconImage = it }
|
||||
val mode = player.mode
|
||||
w.isAlwaysOnTop = mode == PlayerMode.Mini
|
||||
w.bounds = TestMode.playerBounds?.let { (x, y, width, height) -> Rectangle(x, y, width, height) }
|
||||
?: player.placement.bounds(if (mode == PlayerMode.Fullscreen) player.windowedMode else mode)
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
saveTimer.stop()
|
||||
savePlace()
|
||||
window.dispose()
|
||||
}
|
||||
|
||||
/** Puts the window in the player's mode as it changes, and the mini window in the video's proportions. */
|
||||
@Composable
|
||||
fun Modes(videoSize: IntSize) {
|
||||
LaunchedEffect(Unit) { attach() }
|
||||
LaunchedEffect(player.mode) { if (applied != null) apply(player.mode) }
|
||||
LaunchedEffect(videoSize) { onVideoSize(videoSize) }
|
||||
LaunchedEffect(player.placement.sticky) {
|
||||
if (applied == PlayerMode.Mini) x11?.setSticky(player.placement.sticky)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val listener = object : ComponentAdapter() {
|
||||
override fun componentMoved(e: ComponentEvent) = onMovedOrResized()
|
||||
override fun componentResized(e: ComponentEvent) = onMovedOrResized()
|
||||
}
|
||||
window.addComponentListener(listener)
|
||||
onDispose { window.removeComponentListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The mini window is dragged by the video: the window manager moves it from here on. */
|
||||
fun startMove(e: MouseEvent, moved: Offset) {
|
||||
// The window manager moves the window by the pointer's moves from the given point: from the press, the window
|
||||
// keeps the spot under the pointer (from here it would lag by the way to the drag threshold).
|
||||
val scale = window.graphicsConfiguration?.defaultTransform?.scaleX ?: 1.0
|
||||
startOp(e, X11Window.MOVE, e.xOnScreen - (moved.x / scale).roundToInt(), e.yOnScreen - (moved.y / scale).roundToInt())
|
||||
}
|
||||
|
||||
/** One of `X11Window.SIZE_*`: the window manager resizes it from here on. */
|
||||
fun startResize(e: MouseEvent, direction: Int) = startOp(e, direction)
|
||||
|
||||
private suspend fun attach() {
|
||||
val x = X11Window.of(window) ?: return
|
||||
// Requests to a window the window manager hasn't taken yet get lost.
|
||||
withTimeoutOrNull(1_000) { while (!x.isManaged) delay(16) }
|
||||
x11 = x
|
||||
apply(player.mode, first = true)
|
||||
if (player.mode != applied) apply(player.mode)
|
||||
// The player takes the focus once, when it opens (§1.2); an undecorated AWT window doesn't get it on its own.
|
||||
if (!TestMode.passive) {
|
||||
window.toFront()
|
||||
window.requestFocus()
|
||||
// The frame itself gets the focus, not Compose's canvas in it (a click moves it there): keys would be lost.
|
||||
canvasOf(window)?.requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apply(new: PlayerMode, first: Boolean = false) {
|
||||
val x = x11 ?: return
|
||||
val old = applied
|
||||
if (old == new && !first) return
|
||||
if (old != null && old != PlayerMode.Fullscreen && !busy && !x.isFullscreen) player.placement.save(old, x.geometry())
|
||||
applied = new
|
||||
busyUntil = Long.MAX_VALUE
|
||||
Log.i("Player window: %s", new)
|
||||
try {
|
||||
if (new == PlayerMode.Fullscreen) {
|
||||
window.isAlwaysOnTop = false
|
||||
x.setSizeHints(0, 0, MIN_WIDTH, MIN_HEIGHT)
|
||||
if (!x.isFullscreen) {
|
||||
x.setFullscreen(true)
|
||||
waitFor { x.isFullscreen }
|
||||
}
|
||||
} else {
|
||||
if (x.isFullscreen) {
|
||||
// The window manager puts back the size from before full screen, and later than us: wait till it has.
|
||||
x.setFullscreen(false)
|
||||
waitFor { !x.isFullscreen }
|
||||
delay(30)
|
||||
}
|
||||
// The frame and the hints first, then the place: else the window manager fits the size to the old ones.
|
||||
x.setDecorated(new == PlayerMode.Window)
|
||||
window.isAlwaysOnTop = new == PlayerMode.Mini
|
||||
x.setSticky(new == PlayerMode.Mini && player.placement.sticky)
|
||||
setHints(new)
|
||||
val target = if (first && TestMode.playerBounds != null) x.geometry() else player.placement.bounds(new)
|
||||
place(x, if (new == PlayerMode.Mini) fit(target) else target)
|
||||
}
|
||||
} finally {
|
||||
busyUntil = monotonicMs() + SETTLE_MS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the client area at [target]. While the window manager adds or takes away the frame it may ignore the
|
||||
* request, so it is repeated until the window is there; Muffin places the frame's corner rather than the client
|
||||
* area's, so the request moves by the difference (L1).
|
||||
*/
|
||||
private suspend fun place(x: X11Window, target: Rectangle) {
|
||||
val request = Rectangle(target)
|
||||
x.moveResize(request)
|
||||
repeat(PLACE_TRIES) {
|
||||
delay(PLACE_RETRY_MS)
|
||||
val g = x.geometry()
|
||||
if (g.location == target.location && abs(g.width - target.width) <= 1 && abs(g.height - target.height) <= 1) return
|
||||
if (abs(g.width - target.width) <= 1 && abs(g.height - target.height) <= 1) request.translate(target.x - g.x, target.y - g.y)
|
||||
x.moveResize(request)
|
||||
}
|
||||
Log.w("Player window at %s, wanted %s", x.geometry(), target)
|
||||
}
|
||||
|
||||
/** The mini window takes the video's proportions: same width, the bottom right corner stays (§1.1). */
|
||||
private fun fit(r: Rectangle): Rectangle {
|
||||
val v = video
|
||||
if (v.width <= 0 || v.height <= 0) return r
|
||||
var w = r.width
|
||||
var h = (w.toLong() * v.height / v.width).toInt()
|
||||
if (h < MIN_HEIGHT) {
|
||||
h = MIN_HEIGHT
|
||||
w = (h.toLong() * v.width / v.height).toInt()
|
||||
}
|
||||
return Rectangle(r.x + r.width - w, r.y + r.height - h, w, h)
|
||||
}
|
||||
|
||||
private suspend fun onVideoSize(size: IntSize) {
|
||||
if (size == video || size.width <= 0 || size.height <= 0) return
|
||||
video = size
|
||||
val x = x11 ?: return
|
||||
if (applied != PlayerMode.Mini || busy) return
|
||||
setHints(PlayerMode.Mini)
|
||||
val g = x.geometry()
|
||||
val fitted = fit(g)
|
||||
if (abs(fitted.height - g.height) > 2 || abs(fitted.width - g.width) > 2) {
|
||||
busyUntil = Long.MAX_VALUE
|
||||
try {
|
||||
place(x, fitted)
|
||||
} finally {
|
||||
busyUntil = monotonicMs() + SETTLE_MS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The mini window keeps the video's proportions while resized; the others are free. Returns true if they were rewritten. */
|
||||
private fun setHints(mode: PlayerMode): Boolean {
|
||||
val x = x11 ?: return false
|
||||
val v = video.takeIf { mode == PlayerMode.Mini && it.width > 0 && it.height > 0 } ?: IntSize.Zero
|
||||
return x.setSizeHints(v.width, v.height, MIN_WIDTH, MIN_HEIGHT)
|
||||
}
|
||||
|
||||
private fun onMovedOrResized() {
|
||||
val x = x11 ?: return
|
||||
val mode = applied ?: return
|
||||
// AWT rewrites the size hints wholesale on each of its reshapes, the proportions with them; its write may still
|
||||
// be in its connection's buffer.
|
||||
Toolkit.getDefaultToolkit().sync()
|
||||
if (setHints(mode) && hintsRestored++ % 20 == 0) Log.d("Player window: size hints restored (%d)", hintsRestored)
|
||||
if (busy) return
|
||||
val fullscreen = x.isFullscreen
|
||||
when {
|
||||
// The window manager left full screen or went into it on its own (a desktop shortcut).
|
||||
mode == PlayerMode.Fullscreen && !fullscreen -> player.switchTo(player.windowedMode)
|
||||
mode != PlayerMode.Fullscreen && fullscreen -> player.switchTo(PlayerMode.Fullscreen)
|
||||
else -> saveTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps where the windowed mode is now: after a move or resize settles, before a mode change, on close. */
|
||||
private fun savePlace() {
|
||||
val x = x11 ?: return
|
||||
val mode = applied ?: return
|
||||
if (mode == PlayerMode.Fullscreen || busy || x.isFullscreen) return
|
||||
player.placement.save(mode, x.geometry())
|
||||
}
|
||||
|
||||
private fun startOp(e: MouseEvent, direction: Int, fromX: Int = e.xOnScreen, fromY: Int = e.yOnScreen) {
|
||||
val x = x11 ?: return
|
||||
x.startMoveResize(fromX, fromY, direction)
|
||||
// The window manager has the pointer now: Compose never sees the button go up, so it gets a release.
|
||||
SwingUtilities.invokeLater {
|
||||
val src = e.component
|
||||
src.dispatchEvent(
|
||||
MouseEvent(src, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(), 0, e.x, e.y, e.xOnScreen, e.yOnScreen, 1, false, MouseEvent.BUTTON1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits up to a second for the window manager to get the window into a state. */
|
||||
private suspend fun waitFor(done: () -> Boolean) {
|
||||
withTimeoutOrNull(1_000) { while (!done()) delay(16) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** After a mode change, the window manager's own moves and resizes. */
|
||||
const val SETTLE_MS = 400L
|
||||
const val PLACE_TRIES = 8
|
||||
const val PLACE_RETRY_MS = 60L
|
||||
const val SAVE_DELAY_MS = 800
|
||||
|
||||
val Icon by lazy {
|
||||
runCatching { PlayerFrame::class.java.getResourceAsStream("/icons/sleepyowl-256.png")?.use(ImageIO::read) }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The innermost component of [window]: Skiko's canvas, which Compose takes keys from. */
|
||||
private fun canvasOf(window: java.awt.Component): java.awt.Component? {
|
||||
if (window !is java.awt.Container || window.componentCount == 0) return window.takeIf { it.isFocusable }
|
||||
return window.components.asSequence().mapNotNull(::canvasOf).lastOrNull()
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
|
||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.ImageShade
|
||||
import ru.stenfredd.sleepyowl.ui.components.ProgressLine
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||
import ru.stenfredd.sleepyowl.ui.player.EpisodeModel
|
||||
import ru.stenfredd.sleepyowl.ui.player.InfoRow
|
||||
import ru.stenfredd.sleepyowl.ui.player.PanelChoice
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerInfo
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerLayout
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerTracks
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerViewModel
|
||||
import ru.stenfredd.sleepyowl.ui.player.SeasonEpisodes
|
||||
import ru.stenfredd.sleepyowl.ui.player.StreamMode
|
||||
import ru.stenfredd.sleepyowl.ui.player.SubtitleChoices
|
||||
import ru.stenfredd.sleepyowl.ui.player.toChoices
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import java.util.UUID
|
||||
|
||||
/** Panels of the desktop player: the TV's four and its drawer ("Серии" / "О фильме"). */
|
||||
internal enum class Panel { Audio, Subtitles, Quality, Info, Episodes }
|
||||
|
||||
/** Height of the drawer of the full layout; the controls move up by this much while it is open. */
|
||||
internal const val DRAWER_HEIGHT = 210
|
||||
|
||||
/** Wheel and clicks inside stay inside: the wheel over the video sets the volume, a click on it pauses. */
|
||||
private fun Modifier.keepsPointer(): Modifier = pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Main)
|
||||
if (event.type == PointerEventType.Scroll || event.type == PointerEventType.Press) event.changes.forEach { it.consume() }
|
||||
if (event.changes.none { it.pressed }) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A panel: on the right, full height, as on the TV; [sheet] (a small window) — over the whole window. The header has
|
||||
* the title and ×; the content scrolls.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PanelFrame(
|
||||
title: String,
|
||||
dot: Color,
|
||||
sheet: Boolean,
|
||||
ui: PlayerUiState,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.then(if (sheet) Modifier.fillMaxSize() else Modifier.fillMaxHeight().width(350.dp))
|
||||
.holdsControls(ui, "panel")
|
||||
.keepsPointer()
|
||||
.background(PanelColors.Background)
|
||||
.drawBehind { if (!sheet) drawRect(PanelColors.Edge, size = Size(0.5.dp.toPx(), size.height)) },
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.background(PanelColors.Header)
|
||||
.padding(start = 16.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
Box(Modifier.size(7.dp).background(dot, CircleShape))
|
||||
BasicText(title, Modifier.weight(1f), style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em), maxLines = 1)
|
||||
GlyphButton(Glyph.Close, 32.dp, 14.dp, onClick = onClose, bare = true)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(15.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PanelNote(text: String) {
|
||||
BasicText(text, style = AppType.Body.copy(fontSize = 10.5.sp, lineHeight = 15.sp, color = AppColors.TextDim))
|
||||
}
|
||||
|
||||
/** Audio, subtitles, quality, stream info: the TV's panels, picked with the mouse. */
|
||||
@Composable
|
||||
internal fun PanelContent(
|
||||
panel: Panel,
|
||||
state: PlaybackUiState,
|
||||
info: PlayerInfo?,
|
||||
media: MediaInfo?,
|
||||
tracks: PlayerTracks,
|
||||
style: SubtitleStyle,
|
||||
session: PlaybackSession,
|
||||
vm: PlayerViewModel,
|
||||
sheet: Boolean,
|
||||
ui: PlayerUiState,
|
||||
close: () -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val selection = state.selection
|
||||
when (panel) {
|
||||
Panel.Audio -> PanelFrame("АУДИОДОРОЖКА", AppColors.Pink, sheet, ui, close, modifier) {
|
||||
PanelSection("ДОРОЖКИ" + (info?.title?.let { " · " + it.uppercase() } ?: "")) {
|
||||
PanelList(tracks.audio.toChoices(selection.audioStreamIndex)) { c ->
|
||||
tracks.audio.firstOrNull { it.key == c.key }?.index?.let(session::selectAudio)
|
||||
close()
|
||||
}
|
||||
}
|
||||
PanelNote(if (info?.isEpisode == true) "Выбор запоминается для всего сериала." else "Выбор запоминается для этого фильма.")
|
||||
}
|
||||
Panel.Subtitles -> PanelFrame("СУБТИТРЫ", AppColors.Yellow, sheet, ui, close, modifier) {
|
||||
val selected = media?.subtitles?.firstOrNull { it.index == selection.subtitleStreamIndex }
|
||||
SubtitlePreview(style)
|
||||
PanelSection("ДОРОЖКА") {
|
||||
PanelList(tracks.subtitles.toChoices(selection.subtitleStreamIndex)) { c ->
|
||||
session.selectSubtitle(tracks.subtitles.firstOrNull { it.key == c.key }?.index)
|
||||
}
|
||||
}
|
||||
PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
|
||||
val codec = selected?.codec?.lowercase()
|
||||
when {
|
||||
codec in GRAPHIC_SUBTITLES -> PanelNote("Это графические субтитры (картинки): они показываются как есть, размер и цвет к ним не применяются.")
|
||||
codec == "ass" || codec == "ssa" -> PanelNote("У субтитров ASS своё оформление из файла: здесь меняется только их размер.")
|
||||
else -> PanelNote("Вид применяется к текстовым субтитрам (SRT, VTT) и запоминается на этом компьютере.")
|
||||
}
|
||||
}
|
||||
Panel.Quality -> PanelFrame("КАЧЕСТВО", AppColors.Blue, sheet, ui, close, modifier) {
|
||||
val options = info?.qualities.orEmpty()
|
||||
PanelSection("БИТРЕЙТ") {
|
||||
PanelList(options.map { PanelChoice(it.key, it.label, it.detail, it.bitrate == state.maxBitrate) }) { c ->
|
||||
vm.setQuality(options.firstOrNull { it.key == c.key }?.bitrate)
|
||||
close()
|
||||
}
|
||||
}
|
||||
PanelNote("Всё, что ниже оригинала, сервер перекодирует на лету. Это нагружает процессор сервера.")
|
||||
}
|
||||
Panel.Info -> PanelFrame("ИНФОРМАЦИЯ О ПОТОКЕ", AppColors.Green, sheet, ui, close, modifier) {
|
||||
StreamModeCard(PlayerLabels.mode(state))
|
||||
InfoRows(PlayerLabels.rows(state, media, session.activeEngine))
|
||||
// The engine's own figures: information only, the desktop has a single engine (§1.4).
|
||||
val extra = state.stats.extra.map { (k, v) -> InfoRow(k, v, AppColors.TextDim) } +
|
||||
listOfNotNull(state.stats.videoFormat?.let { InfoRow("Поток", it, AppColors.Blue) })
|
||||
if (extra.isNotEmpty()) PanelSection("ДОПОЛНИТЕЛЬНО") { InfoRows(extra) }
|
||||
}
|
||||
Panel.Episodes -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private val GRAPHIC_SUBTITLES = setOf("pgssub", "hdmv_pgs_subtitle", "dvdsub", "dvd_subtitle", "vobsub", "dvbsub")
|
||||
|
||||
/** "Прямое воспроизведение / Файл воспроизводится без изменений" in a green (or orange) frame. */
|
||||
@Composable
|
||||
private fun StreamModeCard(mode: StreamMode) {
|
||||
val color = if (mode.direct) AppColors.Green else AppColors.Orange
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.border(0.5.dp, color, RoundedCornerShape(7.dp))
|
||||
.padding(horizontal = 11.dp, vertical = 10.dp),
|
||||
) {
|
||||
BasicText(mode.title, style = AppType.Section.copy(fontWeight = FontWeight.Bold, color = color))
|
||||
BasicText(mode.reason, Modifier.padding(top = 4.dp), style = AppType.Body.copy(color = AppColors.TextMuted))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRows(rows: List<InfoRow>) {
|
||||
Column {
|
||||
for (r in rows) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind {
|
||||
val line = 0.5.dp.toPx()
|
||||
drawRect(AppColors.BorderSubtle, Offset(0f, size.height - line), Size(size.width, line))
|
||||
}
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(5.dp).background(r.color, CircleShape))
|
||||
BasicText(r.key, Modifier.padding(start = 7.dp).width(95.dp), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = PanelColors.SectionTitle))
|
||||
BasicText(r.value, Modifier.weight(1f), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = OsdColors.Text), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DrawerBackground = Color(0xFF0C0D14)
|
||||
private val EpisodeShade = ImageShade(0.6f, listOf(0f to Color(0xB306070C), 1f to Color.Transparent))
|
||||
|
||||
/**
|
||||
* The full layout's drawer under the controls: the season's episodes (a click plays one without leaving the player)
|
||||
* or "О фильме", and the way to the series or movie page in the library window.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PlayerDrawer(
|
||||
info: PlayerInfo?,
|
||||
season: SeasonEpisodes?,
|
||||
currentId: UUID?,
|
||||
playedFraction: Float,
|
||||
ui: PlayerUiState,
|
||||
onClose: () -> Unit,
|
||||
onPage: () -> Unit,
|
||||
onEpisode: (UUID) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val episodes = season?.takeIf { info?.isEpisode == true }
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(DRAWER_HEIGHT.dp)
|
||||
.holdsControls(ui, "drawer")
|
||||
.keepsPointer()
|
||||
.background(DrawerBackground, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
|
||||
.drawBehind { drawRect(Color(0xFF2A2E48), size = Size(size.width, 0.5.dp.toPx())) },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = 48.dp, end = 36.dp, top = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(Modifier.size(6.dp).background(if (episodes != null) AppColors.Blue else AppColors.Accent, CircleShape))
|
||||
BasicText(if (episodes != null) episodes.title else "О ФИЛЬМЕ", style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em))
|
||||
BasicText(
|
||||
(if (episodes != null) episodes.stat else info?.title).orEmpty(),
|
||||
Modifier.weight(1f),
|
||||
style = AppType.MonoCaption.copy(fontSize = 9.5.sp, color = PanelColors.SectionTitle),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
PagePill(if (episodes != null) "К сериалу" else "Страница фильма", onClick = onPage)
|
||||
GlyphButton(Glyph.Close, 30.dp, 13.dp, onClick = onClose, bare = true)
|
||||
}
|
||||
when {
|
||||
episodes != null -> {
|
||||
val currentIndex = episodes.episodes.indexOfFirst { it.id == currentId }.coerceAtLeast(0)
|
||||
ScrollRow(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
state = rememberLazyListState(initialFirstVisibleItemIndex = (currentIndex - 1).coerceAtLeast(0)),
|
||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||
spacing = 12.dp,
|
||||
) {
|
||||
items(episodes.episodes, key = { it.key }) { e ->
|
||||
val current = e.id == currentId
|
||||
EpisodeCard(e, current, if (current) playedFraction else e.progress, onClick = { onEpisode(e.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
info != null -> AboutMovie(info, Modifier.padding(start = 48.dp, end = 48.dp, top = 14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer of a small window: over the whole window, episodes as a list (still, number and title, length) or
|
||||
* "О фильме".
|
||||
*/
|
||||
@Composable
|
||||
internal fun EpisodesSheet(
|
||||
info: PlayerInfo?,
|
||||
season: SeasonEpisodes?,
|
||||
currentId: UUID?,
|
||||
playedFraction: Float,
|
||||
ui: PlayerUiState,
|
||||
onClose: () -> Unit,
|
||||
onPage: () -> Unit,
|
||||
onEpisode: (UUID) -> Unit,
|
||||
) {
|
||||
val episodes = season?.takeIf { info?.isEpisode == true }
|
||||
PanelFrame(if (episodes != null) episodes.title else "О ФИЛЬМЕ", if (episodes != null) AppColors.Blue else AppColors.Accent, sheet = true, ui, onClose) {
|
||||
PagePill(if (episodes != null) "К сериалу" else "Страница фильма", onClick = onPage)
|
||||
if (episodes != null) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
for (e in episodes.episodes) {
|
||||
val current = e.id == currentId
|
||||
EpisodeLine(e, current, if (current) playedFraction else e.progress, onClick = { onEpisode(e.id) })
|
||||
}
|
||||
}
|
||||
} else if (info != null) {
|
||||
AboutMovie(info, Modifier, narrow = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PagePill(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(15.dp)
|
||||
Row(
|
||||
modifier
|
||||
.height(30.dp)
|
||||
.onHover { hovered = it }
|
||||
.background(if (hovered) AppColors.FocusFill else OsdColors.Fill, shape)
|
||||
.border(0.5.dp, if (hovered) AppColors.FocusFill else OsdColors.Border, shape)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(start = 12.dp, end = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val color = if (hovered) AppColors.OnFocusFill else OsdColors.Text
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 11.sp, fontWeight = FontWeight.Medium, color = color))
|
||||
Icon(AppIcon.ChevronRight, color, Modifier.size(10.dp), strokeWidth = 2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutMovie(info: PlayerInfo, modifier: Modifier, narrow: Boolean = false) {
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
if (!narrow) RemoteImage(info.poster, 7.dp, Modifier.offset(y = (-3).dp).size(PlayerLayout.POSTER_W.dp, PlayerLayout.POSTER_H.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
val meta = AppType.BodyLarge.copy(fontSize = 11.5.sp, color = AppColors.TextSecondary)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
info.rating?.let { BasicText(it, style = meta.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
|
||||
info.age?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
style = meta.copy(fontSize = 9.5.sp),
|
||||
)
|
||||
}
|
||||
listOfNotNull(info.year, info.runtime).forEach { BasicText(it, style = meta, maxLines = 1) }
|
||||
}
|
||||
info.genres?.let { BasicText(it, Modifier.padding(top = 5.dp), style = meta, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
info.description?.let {
|
||||
BasicText(
|
||||
it,
|
||||
Modifier.padding(top = 8.dp),
|
||||
style = AppType.Body.copy(fontSize = 12.sp, lineHeight = 18.sp, color = AppColors.TextSecondary),
|
||||
maxLines = if (narrow) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
info.director?.let {
|
||||
BasicText("Режиссёр: $it", Modifier.padding(top = 7.dp), style = AppType.Body.copy(fontSize = 10.5.sp, color = AppColors.TextDim), maxLines = 1)
|
||||
}
|
||||
}
|
||||
if (info.cast.isNotEmpty() && !narrow) {
|
||||
Column(Modifier.width(210.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
BasicText("В РОЛЯХ", style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle, letterSpacing = 0.1.em))
|
||||
Spacer(Modifier.height(1.dp))
|
||||
for (p in info.cast) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
if (p.image != null) {
|
||||
RemoteImage(p.image, 12.dp, Modifier.size(PlayerLayout.AVATAR.dp))
|
||||
} else {
|
||||
Avatar(p.name, seed = p.key, size = PlayerLayout.AVATAR.dp, textStyle = AppType.MonoBadge.copy(fontSize = 9.sp, color = Color.White), letters = p.letters)
|
||||
}
|
||||
BasicText(p.name, style = AppType.CardTitle.copy(fontSize = 11.sp, fontWeight = FontWeight.SemiBold), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCard(e: EpisodeModel, current: Boolean, progress: Float?, onClick: () -> Unit) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
Column(Modifier.width(PlayerLayout.EPISODE_W.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(PlayerLayout.EPISODE_W.dp, PlayerLayout.EPISODE_H.dp)
|
||||
.onHover { hovered = it }
|
||||
.then(
|
||||
when {
|
||||
hovered -> Modifier.border(1.5.dp, AppColors.FocusFill, RoundedCornerShape(8.dp))
|
||||
current -> Modifier.border(1.dp, Color(0xFF8B7DFF), RoundedCornerShape(8.dp))
|
||||
else -> Modifier
|
||||
},
|
||||
)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||
) {
|
||||
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize(), shade = EpisodeShade)
|
||||
BasicText(e.number, Modifier.padding(start = 8.dp, top = 6.dp), style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)))
|
||||
EpisodeBadge(e, current, Modifier.align(Alignment.TopEnd).padding(7.dp))
|
||||
e.duration?.let {
|
||||
BasicText(it, Modifier.align(Alignment.BottomStart).padding(start = 8.dp, bottom = 8.dp), style = AppType.MonoMeta.copy(fontSize = 9.sp, color = Color(0xFFD9DCEC)))
|
||||
}
|
||||
progress?.takeIf { it > 0f }?.let {
|
||||
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 4.dp).fillMaxWidth().height(3.dp))
|
||||
}
|
||||
}
|
||||
BasicText(
|
||||
e.title,
|
||||
Modifier.padding(top = 7.dp),
|
||||
style = AppType.PosterTitle.copy(color = if (e.watched && !current) AppColors.TextMuted else AppColors.Text),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One episode in the sheet: small still, title and length, the badge. */
|
||||
@Composable
|
||||
private fun EpisodeLine(e: EpisodeModel, current: Boolean, progress: Float?, onClick: () -> Unit) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(7.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.onHover { hovered = it }
|
||||
.background(if (hovered) PanelColors.Focused else if (current) PanelColors.Selected else PanelColors.Option, shape)
|
||||
.border(0.5.dp, if (hovered) AppColors.Accent else if (current) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(Modifier.size(96.dp, 54.dp)) {
|
||||
RemoteImage(e.image, 5.dp, Modifier.fillMaxSize())
|
||||
progress?.takeIf { it > 0f }?.let {
|
||||
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 4.dp, vertical = 3.dp).fillMaxWidth().height(2.5.dp))
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText(
|
||||
e.title,
|
||||
style = AppType.PosterTitle.copy(color = if (e.watched && !current) AppColors.TextMuted else AppColors.Text),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
e.duration?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption) }
|
||||
}
|
||||
EpisodeBadge(e, current, Modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeBadge(e: EpisodeModel, current: Boolean, modifier: Modifier) {
|
||||
val badge = when {
|
||||
current -> Triple("СЕЙЧАС", AppColors.AccentStrong, Color.White)
|
||||
e.watched -> Triple("✓", Color(0xBF0A0B10), AppColors.Green)
|
||||
e.isNew -> Triple("НОВАЯ", Color(0xBF0A0B10), AppColors.Pink)
|
||||
else -> return
|
||||
}
|
||||
val (text, bg, fg) = badge
|
||||
BasicText(
|
||||
text,
|
||||
modifier
|
||||
.background(bg, RoundedCornerShape(3.5.dp))
|
||||
.then(if (current) Modifier else Modifier.border(0.5.dp, fg, RoundedCornerShape(3.5.dp)))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
style = AppType.MonoBadge.copy(color = fg),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||
import ru.stenfredd.sleepyowl.ui.components.StatusDot
|
||||
import ru.stenfredd.sleepyowl.ui.player.NextEpisode
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerLayout
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/* What the player puts over the video besides the controls: skip, "next episode", loading, errors, the right-click menu. */
|
||||
|
||||
/** Without known credits the card shows this long before the end of an episode that has a next one. */
|
||||
internal const val NEXT_PROMPT_MS = 30_000L
|
||||
|
||||
private val PromptColor = Color(0xFF10121C)
|
||||
private val PromptBorder = Color(0x40FFFFFF)
|
||||
|
||||
/** "Пропустить заставку 1:12" (time left of the part) in the corner where the "next episode" card goes. */
|
||||
@Composable
|
||||
internal fun SkipButton(label: String, left: String, ui: PlayerUiState, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
Row(
|
||||
modifier
|
||||
.height(40.dp)
|
||||
.holdsControls(ui, "skip")
|
||||
.onHover { hovered = it }
|
||||
.background(if (hovered) AppColors.FocusFill else PromptColor, shape)
|
||||
.border(0.5.dp, if (hovered) AppColors.FocusFill else PromptBorder, shape)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 17.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val color = if (hovered) AppColors.OnFocusFill else Color.White
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = color), maxLines = 1)
|
||||
BasicText(left, style = AppType.MonoMeta.copy(fontSize = 10.sp, color = color.copy(alpha = 0.7f)))
|
||||
}
|
||||
}
|
||||
|
||||
/** "Заставка пропущена" for a few seconds after an automatic skip. */
|
||||
@Composable
|
||||
internal fun SkipNotice(text: String, modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
BasicText(
|
||||
text,
|
||||
modifier.background(PromptColor, shape).border(0.5.dp, PromptBorder, shape).padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
|
||||
)
|
||||
}
|
||||
|
||||
private val CardColor = Color(0xFF0E1018)
|
||||
private val CardBorder = Color(0xFF6F63FF)
|
||||
|
||||
/**
|
||||
* "Следующая через N с" at the end of an episode: still and title of the next one, "Смотреть сейчас" and
|
||||
* "Досмотреть титры"; [remainingMs] of [countdownMs] are left until it starts. [compact] (a window under 480 px): one
|
||||
* line.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NextEpisodeCard(
|
||||
next: NextEpisode,
|
||||
remainingMs: Long,
|
||||
countdownMs: Long,
|
||||
compact: Boolean,
|
||||
ui: PlayerUiState,
|
||||
onPlay: () -> Unit,
|
||||
onStay: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
val seconds = ((remainingMs + 999) / 1000).coerceAtLeast(0)
|
||||
val fraction = (1f - remainingMs.toFloat() / countdownMs).coerceIn(0f, 1f)
|
||||
Column(modifier.width(if (compact) 300.dp else 320.dp).holdsControls(ui, "next").background(CardColor, shape).border(0.5.dp, CardBorder, shape)) {
|
||||
if (compact) {
|
||||
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText("ЧЕРЕЗ $seconds С", style = AppType.MonoLabel.copy(fontSize = 9.sp, letterSpacing = 0.08.em))
|
||||
BasicText(next.title, Modifier.padding(top = 3.dp), style = AppType.Section.copy(fontSize = 12.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
PromptButton("Смотреть", primary = true, onClick = onPlay)
|
||||
GlyphButton(Glyph.Close, 28.dp, 12.dp, onClick = onStay, bare = true)
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.padding(11.dp), horizontalArrangement = Arrangement.spacedBy(11.dp)) {
|
||||
RemoteImage(next.image, 6.dp, Modifier.size(PlayerLayout.NEXT_W.dp, PlayerLayout.NEXT_H.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
BasicText("СЛЕДУЮЩАЯ ЧЕРЕЗ $seconds С", style = AppType.MonoLabel.copy(fontSize = 9.sp, letterSpacing = 0.08.em))
|
||||
BasicText(next.title, Modifier.padding(top = 5.dp), style = AppType.Section.copy(fontSize = 13.sp, lineHeight = 16.sp), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(start = 11.dp, end = 11.dp, bottom = 11.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
PromptButton("Смотреть сейчас", primary = true, onClick = onPlay, modifier = Modifier.weight(1f))
|
||||
PromptButton("Досмотреть титры", primary = false, onClick = onStay)
|
||||
}
|
||||
}
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.5.dp)
|
||||
.padding(horizontal = 10.dp)
|
||||
.drawBehind {
|
||||
drawRect(AppColors.ProgressTrack)
|
||||
drawRect(AppColors.ProgressGradient, Offset.Zero, Size(size.width * fraction, size.height))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PromptButton(label: String, primary: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(17.dp)
|
||||
Row(
|
||||
modifier
|
||||
.height(32.dp)
|
||||
.onHover { hovered = it }
|
||||
.then(
|
||||
when {
|
||||
primary -> Modifier.background(AppColors.PrimaryGradient, shape)
|
||||
hovered -> Modifier.background(AppColors.FocusFill, shape)
|
||||
else -> Modifier.background(OsdColors.Fill, shape).border(0.5.dp, OsdColors.Border, shape)
|
||||
},
|
||||
)
|
||||
.then(if (primary && hovered) Modifier.border(1.5.dp, Color(0xFFD9D2FF), shape) else Modifier)
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
val color = if (!primary && hovered) AppColors.OnFocusFill else Color.White
|
||||
if (primary) Icon(AppIcon.Play, color, Modifier.size(11.dp))
|
||||
BasicText(label, style = AppType.Button.copy(fontSize = 12.sp, fontWeight = if (primary) FontWeight.SemiBold else FontWeight.Normal, color = color), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Until the first frame. */
|
||||
@Composable
|
||||
internal fun LoadingBadge(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier.background(Color(0xCC0A0B10), RoundedCornerShape(16.dp)).padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatusDot(AppColors.Accent, pulsing = true)
|
||||
BasicText("ЗАГРУЗКА", style = AppType.MonoLabel.copy(color = AppColors.TextSecondary))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ErrorCard(message: String, onClose: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.widthIn(max = 420.dp)
|
||||
.padding(16.dp)
|
||||
.background(AppColors.Surface, RoundedCornerShape(12.dp))
|
||||
.border(0.5.dp, AppColors.Border, RoundedCornerShape(12.dp))
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
BasicText("Не удалось воспроизвести", style = AppType.Section.copy(fontWeight = FontWeight.Bold))
|
||||
BasicText(message, style = AppType.Body, maxLines = 6, overflow = TextOverflow.Ellipsis)
|
||||
Row(Modifier.padding(top = 6.dp)) { PromptButton("Закрыть", primary = false, onClick = onClose) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of the right-click menu; [hint] is its key. */
|
||||
@Immutable
|
||||
internal data class MenuItem(val label: String, val hint: String? = null, val action: () -> Unit)
|
||||
|
||||
/**
|
||||
* The right-click menu at [at] (moved in to stay whole inside the window). A click past it or Esc closes it
|
||||
* ([onDismiss]); picking a line closes it and runs the line.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PlayerMenu(items: List<List<MenuItem>>, at: Offset, ui: PlayerUiState, onDismiss: () -> Unit) {
|
||||
Box(Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures(onPress = { onDismiss() }) })
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val bounds = constraints
|
||||
val shape = RoundedCornerShape(10.dp)
|
||||
Column(
|
||||
Modifier
|
||||
.layout { m, c ->
|
||||
val p = m.measure(c.copy(minWidth = 0, minHeight = 0))
|
||||
val x = at.x.roundToInt().coerceAtMost(bounds.maxWidth - p.width).coerceAtLeast(0)
|
||||
val y = at.y.roundToInt().coerceAtMost(bounds.maxHeight - p.height).coerceAtLeast(0)
|
||||
layout(c.maxWidth, c.maxHeight) { p.place(IntOffset(x, y)) }
|
||||
}
|
||||
.width(250.dp)
|
||||
.holdsControls(ui, "menu")
|
||||
.background(PanelColors.Background, shape)
|
||||
.border(0.5.dp, PanelColors.Edge, shape)
|
||||
.pointerInput(Unit) { detectTapGestures { } }
|
||||
// A low window (the mini one) shows it whole by scrolling.
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 5.dp),
|
||||
) {
|
||||
items.forEachIndexed { i, group ->
|
||||
if (i > 0) Box(Modifier.padding(vertical = 4.dp).fillMaxWidth().height(0.5.dp).background(PanelColors.Edge))
|
||||
for (item in group) MenuLine(item) {
|
||||
onDismiss()
|
||||
item.action()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuLine(item: MenuItem, onClick: () -> Unit) {
|
||||
var hovered by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp)
|
||||
.height(30.dp)
|
||||
.onHover { hovered = it }
|
||||
.background(if (hovered) PanelColors.Focused else Color.Transparent, RoundedCornerShape(6.dp))
|
||||
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
BasicText(item.label, Modifier.weight(1f), style = AppType.BodyLarge.copy(fontSize = 12.5.sp, color = if (hovered) Color.White else AppColors.TextSecondary), maxLines = 1)
|
||||
item.hint?.let { BasicText(it, style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle)) }
|
||||
}
|
||||
}
|
||||
1014
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerUi.kt
Normal file
1014
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/PlayerUi.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoHost
|
||||
import ru.stenfredd.sleepyowl.playback.mpv.MpvEngine
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuViewModel
|
||||
import ru.stenfredd.sleepyowl.ui.player.PlayerViewModel
|
||||
import java.awt.GraphicsEnvironment
|
||||
import java.awt.Rectangle
|
||||
import java.awt.Toolkit
|
||||
|
||||
/** The player window's modes (§1.1): they change on the fly, the video goes on. */
|
||||
enum class PlayerMode { Mini, Window, Fullscreen }
|
||||
|
||||
/**
|
||||
* "Смотреть" opens this: one player window at a time; a new item replaces the one playing. Lives in Koin, the window
|
||||
* itself is composed by [main] next to the library window.
|
||||
*/
|
||||
class DesktopPlayer(stores: PreferenceStores, val settings: DesktopSettings) {
|
||||
var screen by mutableStateOf<Screen.Player?>(null)
|
||||
private set
|
||||
|
||||
val audio = PlayerAudio(stores)
|
||||
val placement = PlayerPlacement(stores)
|
||||
|
||||
/** The window's mode; the next player opens in the last one (§1.1). */
|
||||
var mode by mutableStateOf(placement.mode)
|
||||
private set
|
||||
|
||||
/** The mode full screen goes back to: the mini window or the normal one. */
|
||||
var windowedMode = placement.windowedMode
|
||||
private set
|
||||
|
||||
val fullscreen: Boolean get() = mode == PlayerMode.Fullscreen
|
||||
|
||||
/** Opens a page in the library window and brings that window up; set by the library window. */
|
||||
var openPage: (Screen) -> Unit = {}
|
||||
|
||||
/** Brings the library window up; set by it. */
|
||||
var showLibrary: () -> Unit = {}
|
||||
|
||||
/** What the window plays, for the system ([SystemMedia]); null while nothing does. Set by the window. */
|
||||
var nowPlaying by mutableStateOf<NowPlaying?>(null)
|
||||
|
||||
/** The window's controls for the media keys and the sound applet ([SystemMedia]); set by the window. */
|
||||
var commands: PlayerCommands? = null
|
||||
|
||||
fun play(screen: Screen.Player) {
|
||||
Log.i("Player: %s from %d ms", screen.itemId, screen.startPositionMs)
|
||||
// "Открывать плеер" (L9): a new window opens in the chosen mode; a window already open keeps its own.
|
||||
if (this.screen == null) settings.playerOpening.mode?.let(::switchTo)
|
||||
this.screen = screen
|
||||
}
|
||||
|
||||
fun close() {
|
||||
screen = null
|
||||
nowPlaying = null
|
||||
commands = null
|
||||
}
|
||||
|
||||
fun switchTo(new: PlayerMode) {
|
||||
if (new == mode) return
|
||||
if (new != PlayerMode.Fullscreen) windowedMode = new
|
||||
mode = new
|
||||
placement.saveMode(new, windowedMode)
|
||||
}
|
||||
|
||||
/** `T`: on top of everything ↔ the normal window. */
|
||||
fun toggleOnTop() = switchTo(if (mode == PlayerMode.Mini) PlayerMode.Window else PlayerMode.Mini)
|
||||
|
||||
fun toggleFullscreen() = switchTo(if (fullscreen) windowedMode else PlayerMode.Fullscreen)
|
||||
|
||||
fun leaveFullscreen() {
|
||||
if (fullscreen) switchTo(windowedMode)
|
||||
}
|
||||
|
||||
/** A double click on the video: mini → normal window → full screen → back. */
|
||||
fun cycleMode() = switchTo(
|
||||
when (mode) {
|
||||
PlayerMode.Mini -> PlayerMode.Window
|
||||
PlayerMode.Window -> PlayerMode.Fullscreen
|
||||
PlayerMode.Fullscreen -> windowedMode
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the player window goes (§1.1): the last mode and each windowed mode's own place and size (client area, screen
|
||||
* pixels — the monitor is in them). A place no monitor shows any more gives way to the default one.
|
||||
*/
|
||||
class PlayerPlacement(stores: PreferenceStores) {
|
||||
private val store = stores["desktop_player"]
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var lastWrite: Job? = null
|
||||
|
||||
// Read once, before the first window: the window opens where it was.
|
||||
private val saved: Preferences? = try {
|
||||
runBlocking(Dispatchers.IO) { withTimeoutOrNull(2_000) { store.data.first() } }
|
||||
} catch (e: Exception) {
|
||||
Log.w(e, "Player placement not read")
|
||||
null
|
||||
}
|
||||
|
||||
var mode = saved?.get(KEY_MODE)?.let(::modeOf) ?: PlayerMode.Mini
|
||||
private set
|
||||
var windowedMode = saved?.get(KEY_WINDOWED)?.let(::modeOf)?.takeIf { it != PlayerMode.Fullscreen } ?: PlayerMode.Mini
|
||||
private set
|
||||
|
||||
/** The mini window is on every workspace (`_NET_WM_STATE_STICKY`). */
|
||||
var sticky by mutableStateOf(saved?.get(KEY_STICKY) ?: false)
|
||||
private set
|
||||
|
||||
private val bounds = mutableMapOf<PlayerMode, Rectangle>().apply {
|
||||
saved?.get(KEY_MINI)?.let(::rectangleOf)?.let { put(PlayerMode.Mini, it) }
|
||||
saved?.get(KEY_WINDOW)?.let(::rectangleOf)?.let { put(PlayerMode.Window, it) }
|
||||
}
|
||||
|
||||
fun saveMode(mode: PlayerMode, windowed: PlayerMode) {
|
||||
this.mode = mode
|
||||
windowedMode = windowed
|
||||
write {
|
||||
it[KEY_MODE] = mode.name
|
||||
it[KEY_WINDOWED] = windowed.name
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSticky(on: Boolean) {
|
||||
sticky = on
|
||||
write { it[KEY_STICKY] = on }
|
||||
}
|
||||
|
||||
/** [mode]'s place (a windowed one): kept when it changed. */
|
||||
fun save(mode: PlayerMode, r: Rectangle) {
|
||||
if (mode == PlayerMode.Fullscreen || r.width < MIN_WIDTH || r.height < MIN_HEIGHT || bounds[mode] == r) return
|
||||
bounds[mode] = Rectangle(r)
|
||||
write { it[if (mode == PlayerMode.Mini) KEY_MINI else KEY_WINDOW] = "${r.x},${r.y},${r.width},${r.height}" }
|
||||
}
|
||||
|
||||
/** Where [mode] (a windowed one) opens: its last place while a monitor shows it, else the default place. */
|
||||
fun bounds(mode: PlayerMode): Rectangle = bounds[mode]?.takeIf(::onScreen)?.let(::Rectangle) ?: defaultBounds(mode)
|
||||
|
||||
/** Waits for the last write: before the process ends. */
|
||||
suspend fun flush() {
|
||||
lastWrite?.join()
|
||||
}
|
||||
|
||||
private fun write(change: (MutablePreferences) -> Unit) {
|
||||
lastWrite = scope.launch {
|
||||
try {
|
||||
store.edit(change)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(e, "Player placement not saved")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_MODE = stringPreferencesKey("mode")
|
||||
val KEY_WINDOWED = stringPreferencesKey("windowed_mode")
|
||||
val KEY_STICKY = booleanPreferencesKey("mini_sticky")
|
||||
val KEY_MINI = stringPreferencesKey("mini_bounds")
|
||||
val KEY_WINDOW = stringPreferencesKey("window_bounds")
|
||||
|
||||
fun modeOf(name: String) = PlayerMode.entries.firstOrNull { it.name == name }
|
||||
|
||||
fun rectangleOf(text: String): Rectangle? =
|
||||
text.split(',').mapNotNull { it.trim().toIntOrNull() }.takeIf { it.size == 4 }?.let { (x, y, w, h) -> Rectangle(x, y, w, h) }
|
||||
|
||||
/** Its middle and most of it on one monitor, and it fits there. */
|
||||
fun onScreen(r: Rectangle): Boolean = screens().any { s ->
|
||||
val shown = s.intersection(r)
|
||||
s.contains(r.centerX, r.centerY) && r.width <= s.width && r.height <= s.height &&
|
||||
shown.width.toLong() * shown.height >= r.width.toLong() * r.height / 2
|
||||
}
|
||||
|
||||
fun screens(): List<Rectangle> = runCatching {
|
||||
GraphicsEnvironment.getLocalGraphicsEnvironment().screenDevices.map { it.defaultConfiguration.bounds }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** The mini window: 640×360 in the bottom right corner of the main monitor, 24 px in; the normal one in its middle. */
|
||||
fun defaultBounds(mode: PlayerMode): Rectangle {
|
||||
val area = runCatching {
|
||||
val gc = GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration
|
||||
val insets = Toolkit.getDefaultToolkit().getScreenInsets(gc)
|
||||
val s = gc.bounds
|
||||
Rectangle(s.x + insets.left, s.y + insets.top, s.width - insets.left - insets.right, s.height - insets.top - insets.bottom)
|
||||
}.getOrDefault(Rectangle(0, 0, 1920, 1080))
|
||||
return if (mode == PlayerMode.Mini) {
|
||||
Rectangle(area.x + area.width - MINI_WIDTH - MINI_MARGIN, area.y + area.height - MINI_HEIGHT - MINI_MARGIN, MINI_WIDTH, MINI_HEIGHT)
|
||||
} else {
|
||||
val w = minOf(WINDOW_WIDTH, area.width)
|
||||
val h = minOf(WINDOW_HEIGHT, area.height)
|
||||
Rectangle(area.x + (area.width - w) / 2, area.y + (area.height - h) / 2, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume and mute: kept across players and launches (§1.3). The engine starts with them ([MpvEngine.startVolume]), so a
|
||||
* new episode is never loud for a moment.
|
||||
*/
|
||||
class PlayerAudio(stores: PreferenceStores) {
|
||||
private val store = stores["desktop_player"]
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
var volume by mutableIntStateOf(100)
|
||||
private set
|
||||
var muted by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
try {
|
||||
val prefs = store.data.first()
|
||||
apply(prefs[KEY_VOLUME] ?: 100, prefs[KEY_MUTED] ?: false)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(e, "Player audio settings not read")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun set(volume: Int, muted: Boolean) {
|
||||
apply(volume, muted)
|
||||
scope.launch {
|
||||
store.edit {
|
||||
it[KEY_VOLUME] = volume
|
||||
it[KEY_MUTED] = muted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun apply(volume: Int, muted: Boolean) {
|
||||
this.volume = volume.coerceIn(0, MpvEngine.MAX_VOLUME)
|
||||
this.muted = muted
|
||||
MpvEngine.startVolume = this.volume
|
||||
MpvEngine.startMuted = muted
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_VOLUME = intPreferencesKey("volume")
|
||||
val KEY_MUTED = booleanPreferencesKey("muted")
|
||||
}
|
||||
}
|
||||
|
||||
/** The player's place for video: the session hands it each engine it starts. */
|
||||
private class ComposeVideoHost : VideoHost {
|
||||
var engine by mutableStateOf<PlaybackEngine?>(null)
|
||||
private set
|
||||
|
||||
override fun show(engine: PlaybackEngine) {
|
||||
this.engine = engine
|
||||
}
|
||||
|
||||
override fun hide(engine: PlaybackEngine) {
|
||||
if (this.engine === engine) this.engine = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The player window: the mpv engine under the shared [PlayerViewModel] and [PlaybackSession] (progress, tracks,
|
||||
* fallback to a transcode) with the desktop's controls ([PlayerContent]), in one of three modes ([PlayerFrame]).
|
||||
*
|
||||
* The window is made here ([PlayerFrame.create]) rather than by Compose's `Window(state = …)`: its place and size
|
||||
* belong to the window manager and [PlayerPlacement], not to a `WindowState` kept in step with them.
|
||||
*/
|
||||
@Composable
|
||||
fun PlayerWindow(player: DesktopPlayer, screen: Screen.Player) {
|
||||
// The window's view models: cleared (and the playback released) when the window goes.
|
||||
val owner = remember {
|
||||
object : ViewModelStoreOwner {
|
||||
override val viewModelStore = ViewModelStore()
|
||||
}
|
||||
}
|
||||
DisposableEffect(owner) { onDispose { owner.viewModelStore.clear() } }
|
||||
CompositionLocalProvider(LocalViewModelStoreOwner provides owner) {
|
||||
val vm = koinViewModel<PlayerViewModel>()
|
||||
val watch = koinViewModel<WatchMenuViewModel>()
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(screen) { vm.start(screen) }
|
||||
val session by vm.session.collectAsState()
|
||||
val info by vm.info.collectAsState()
|
||||
val host = remember { ComposeVideoHost() }
|
||||
DisposableEffect(session) {
|
||||
val s = session
|
||||
s?.attachVideo(host)
|
||||
onDispose { s?.detachVideo() }
|
||||
}
|
||||
val keys = remember { PlayerKeys() }
|
||||
val frame = remember { PlayerFrame(player) }
|
||||
val title = info?.let { i -> listOfNotNull(i.title, i.subtitle.takeIf { i.isEpisode }).joinToString(" · ") } ?: "SleepyOwl"
|
||||
Window(
|
||||
visible = true,
|
||||
onPreviewKeyEvent = { keys.handler(it) },
|
||||
onKeyEvent = { false },
|
||||
create = { frame.create(title) },
|
||||
dispose = { frame.dispose() },
|
||||
update = { it.title = title },
|
||||
) {
|
||||
frame.Modes((host.engine as? MpvEngine)?.video?.displaySize ?: IntSize.Zero)
|
||||
val s = session
|
||||
if (s == null) {
|
||||
Box(Modifier.fillMaxSize().background(Color.Black))
|
||||
} else {
|
||||
ScaledContent(player.settings) {
|
||||
PlayerContent(
|
||||
s, vm, host.engine, player, frame, keys,
|
||||
onClose = player::close,
|
||||
onMarkWatched = { id, played ->
|
||||
scope.launch {
|
||||
runCatching { watch.setPlayed(id, played) }.onFailure { Log.w(it, "Player: marking watched failed") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal const val MIN_WIDTH = 320
|
||||
internal const val MIN_HEIGHT = 180
|
||||
private const val MINI_WIDTH = 640
|
||||
private const val MINI_HEIGHT = 360
|
||||
private const val MINI_MARGIN = 24
|
||||
private const val WINDOW_WIDTH = 1280
|
||||
private const val WINDOW_HEIGHT = 720
|
||||
@@ -1,43 +0,0 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.ServerRouting
|
||||
import ru.stenfredd.sleepyowl.ui.Screen
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* "Смотреть" until the desktop player (L6–L7): the L1 prototype (`desktop-proto`) in its own process, playing the file
|
||||
* straight from the server in its mini window. It knows nothing of the app: no tracks chosen on the page, no progress
|
||||
* reported, no next episode. Needs the repository (the prototype's classes and `tools/linux/l1/proto.sh`): the path
|
||||
* comes in the system property `sleepyowl.repo`, set by `desktop.sh` and `./gradlew :desktop:run`.
|
||||
*/
|
||||
class ProtoPlayer(private val session: JellyfinSession, private val routing: ServerRouting) {
|
||||
private var process: Process? = null
|
||||
|
||||
fun play(screen: Screen.Player) {
|
||||
val script = System.getProperty("sleepyowl.repo")?.let { File(it, "tools/linux/l1/proto.sh") }
|
||||
if (script == null || !script.canExecute()) {
|
||||
Log.w("Player: the prototype is not found (sleepyowl.repo=%s)", System.getProperty("sleepyowl.repo"))
|
||||
return
|
||||
}
|
||||
val base = routing.localUrl ?: session.session.value?.serverUrl?.trimEnd('/') ?: return
|
||||
val id = screen.itemId.toString().replace("-", "")
|
||||
// Static streams are served without a token.
|
||||
val url = "$base/Videos/$id/stream?static=true&mediaSourceId=$id"
|
||||
val start = screen.startFraction?.let { "%.1f%%".format(java.util.Locale.ROOT, it * 100) } ?: "%.3f".format(java.util.Locale.ROOT, screen.startPositionMs / 1000.0)
|
||||
// One player at a time, as the desktop player will be.
|
||||
process?.takeIf { it.isAlive }?.destroy()
|
||||
Log.i("Player: prototype for %s from %s", id, start)
|
||||
val dir = DesktopPaths.log.apply { mkdirs() }
|
||||
process = runCatching {
|
||||
ProcessBuilder(script.path, "--url", url, "--start", start)
|
||||
.apply { environment()["PROTO_NOBUILD"] = "1" }
|
||||
// Its crash reports (hs_err_pid*.log) go beside the logs, not into the current directory.
|
||||
.directory(dir)
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(ProcessBuilder.Redirect.appendTo(File(dir, "proto.log")))
|
||||
.start()
|
||||
}.onFailure { Log.w(it, "Player: prototype did not start") }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.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
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.freedesktop.dbus.DBusPath
|
||||
import org.freedesktop.dbus.annotations.DBusInterfaceName
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnection
|
||||
import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder
|
||||
import org.freedesktop.dbus.interfaces.DBusInterface
|
||||
import org.freedesktop.dbus.interfaces.Properties
|
||||
import org.freedesktop.dbus.messages.DBusSignal
|
||||
import org.freedesktop.dbus.types.UInt32
|
||||
import org.freedesktop.dbus.types.Variant
|
||||
import ru.stenfredd.sleepyowl.core.common.AppDirs
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.playback.mpv.MpvEngine
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** What the player window plays, for the system: the sound applet's title and cover, the media keys' targets. */
|
||||
@Immutable
|
||||
data class NowPlaying(
|
||||
val itemId: UUID,
|
||||
val title: String,
|
||||
/** The series of an episode, the year and genres of a movie. */
|
||||
val artist: String?,
|
||||
/** The server's poster (the series' one for an episode). */
|
||||
val artUrl: String?,
|
||||
val durationMs: Long,
|
||||
/** Playing or about to (buffering); false on pause. */
|
||||
val playing: Boolean,
|
||||
val canNext: Boolean,
|
||||
val canPrevious: Boolean,
|
||||
)
|
||||
|
||||
/** The player window's controls from outside it (media keys, the sound applet): called on the UI thread. */
|
||||
interface PlayerCommands {
|
||||
/** Play (true), pause (false) or toggle (null); the controls show for a moment (§1.3). */
|
||||
fun playPause(play: Boolean?)
|
||||
fun next()
|
||||
fun previous()
|
||||
fun seekTo(positionMs: Long)
|
||||
fun changeVolume(percent: Int)
|
||||
/** Brings the player window up. */
|
||||
fun bringUp()
|
||||
/** Any thread. */
|
||||
fun positionMs(): Long
|
||||
}
|
||||
|
||||
/**
|
||||
* The player in the system, over the session's D-Bus (§2.5):
|
||||
*
|
||||
* - **MPRIS** — `org.mpris.MediaPlayer2.sleepyowl` while a player window is open: the media keys (Cinnamon passes them
|
||||
* to the MPRIS player even when another window has the focus) and the sound applet's title, cover, position and
|
||||
* buttons. The cover is fetched through the app's own client (LAN routing) into `~/.cache/sleepyowl/mpris`.
|
||||
* - **The screensaver** — `org.freedesktop.ScreenSaver.Inhibit` while the video plays, not on pause. The inhibition
|
||||
* belongs to our D-Bus connection: the system drops it if the app dies.
|
||||
*
|
||||
* D-Bus calls run on one thread of their own; without a session bus (or with an error) the player simply goes without.
|
||||
*/
|
||||
class SystemMedia(private val player: DesktopPlayer, private val http: OkHttpClient, dirs: AppDirs) {
|
||||
private val bus = Executors.newSingleThreadExecutor { Thread(it, "dbus").apply { isDaemon = true } }
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val covers = File(dirs.files, "mpris")
|
||||
|
||||
private var connection: DBusConnection? = null
|
||||
private var failed = false
|
||||
private var busName: String? = null
|
||||
private val mpris = MprisObject()
|
||||
private var inhibitCookie: UInt32? = null
|
||||
|
||||
// What D-Bus clients read (on the bus's threads); set on the UI thread.
|
||||
@Volatile private var now: NowPlaying? = null
|
||||
@Volatile private var coverFile: File? = null
|
||||
@Volatile private var volume = 100
|
||||
@Volatile private var muted = false
|
||||
|
||||
/** The properties last announced (PropertiesChanged): only changes are sent. */
|
||||
private var announced: Map<String, Variant<*>> = emptyMap()
|
||||
|
||||
fun start() {
|
||||
scope.launch {
|
||||
snapshotFlow { Triple(player.nowPlaying, player.audio.volume, player.audio.muted) }.collect { (n, v, m) ->
|
||||
val old = now
|
||||
now = n
|
||||
volume = v
|
||||
muted = m
|
||||
if (n?.artUrl != old?.artUrl || n?.itemId != old?.itemId) fetchCover(n)
|
||||
onBus { publish() }
|
||||
}
|
||||
}
|
||||
// MPRIS has no position updates: a jump (a seek, here or from the applet) is announced by `Seeked`.
|
||||
scope.launch {
|
||||
var lastPos = -1L
|
||||
var lastAt = 0L
|
||||
var lastItem: UUID? = null
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
val n = now
|
||||
val pos = player.commands?.positionMs()
|
||||
if (n == null || pos == null) {
|
||||
lastPos = -1
|
||||
continue
|
||||
}
|
||||
val at = monotonicMs()
|
||||
val expected = lastPos + if (n.playing) at - lastAt else 0
|
||||
if (lastPos >= 0 && n.itemId == lastItem && abs(pos - expected) > SEEK_JUMP_MS) onBus { seeked(pos) }
|
||||
lastPos = pos
|
||||
lastAt = at
|
||||
lastItem = n.itemId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Before the process ends: the screensaver back, the name released. */
|
||||
fun close() {
|
||||
onBus {
|
||||
uninhibit()
|
||||
connection?.let { c ->
|
||||
busName?.let { runCatching { c.releaseBusName(it) } }
|
||||
runCatching { c.close() }
|
||||
}
|
||||
connection = null
|
||||
}
|
||||
bus.shutdown()
|
||||
bus.awaitTermination(1, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
private fun onBus(block: () -> Unit) {
|
||||
if (bus.isShutdown) return
|
||||
bus.execute {
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
Log.w(e, "D-Bus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connection(): DBusConnection? {
|
||||
connection?.let { return it }
|
||||
if (failed) return null
|
||||
return try {
|
||||
DBusConnectionBuilder.forSessionBus().withShared(false).build().also {
|
||||
connection = it
|
||||
it.exportObject(MPRIS_PATH, mpris)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failed = true
|
||||
Log.w(e, "No session D-Bus: no MPRIS, no screensaver inhibition")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// --- On the bus thread ---
|
||||
|
||||
private fun publish() {
|
||||
val n = now
|
||||
val c = connection() ?: return
|
||||
if (n?.playing == true) inhibit() else uninhibit()
|
||||
if (n == null) {
|
||||
busName?.let {
|
||||
Log.i("MPRIS: %s released", it)
|
||||
runCatching { c.releaseBusName(it) }
|
||||
}
|
||||
busName = null
|
||||
announced = emptyMap()
|
||||
return
|
||||
}
|
||||
if (busName == null) {
|
||||
// A second copy of the app (the test stand next to the user's own) takes an instance name, as the spec allows.
|
||||
busName = listOf(MPRIS_NAME, "$MPRIS_NAME.instance${ProcessHandle.current().pid()}").firstOrNull { name ->
|
||||
runCatching { c.requestBusName(name) }.isSuccess
|
||||
}
|
||||
Log.i("MPRIS: %s", busName ?: "no name")
|
||||
announced = playerProperties()
|
||||
return
|
||||
}
|
||||
val props = playerProperties()
|
||||
val changed = props.filter { (k, v) -> k != "Position" && announced[k]?.let(::contentOf) != contentOf(v) }
|
||||
announced = props
|
||||
if (changed.isNotEmpty()) c.sendMessage(Properties.PropertiesChanged(MPRIS_PATH, PLAYER_INTERFACE, changed, emptyList()))
|
||||
}
|
||||
|
||||
private fun seeked(positionMs: Long) {
|
||||
if (busName == null) return
|
||||
connection()?.sendMessage(MprisPlayer.Seeked(MPRIS_PATH, positionMs * 1000))
|
||||
}
|
||||
|
||||
private fun inhibit() {
|
||||
if (inhibitCookie != null) return
|
||||
val c = connection() ?: return
|
||||
inhibitCookie = try {
|
||||
c.getRemoteObject(SCREENSAVER_NAME, SCREENSAVER_PATH, ScreenSaver::class.java).Inhibit("SleepyOwl", "Воспроизведение видео")
|
||||
.also { Log.i("Screensaver inhibited (%d)", it.toLong()) }
|
||||
} catch (e: Exception) {
|
||||
Log.w("Screensaver not inhibited: %s", e.message)
|
||||
UInt32(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun uninhibit() {
|
||||
val cookie = inhibitCookie ?: return
|
||||
inhibitCookie = null
|
||||
if (cookie.toLong() == 0L) return
|
||||
try {
|
||||
connection()?.getRemoteObject(SCREENSAVER_NAME, SCREENSAVER_PATH, ScreenSaver::class.java)?.UnInhibit(cookie)
|
||||
Log.i("Screensaver allowed (%d)", cookie.toLong())
|
||||
} catch (e: Exception) {
|
||||
Log.w("Screensaver inhibition not lifted: %s", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// --- The cover ---
|
||||
|
||||
private fun fetchCover(n: NowPlaying?) {
|
||||
coverFile = null
|
||||
val url = n?.artUrl ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val file = File(covers, "%08x.jpg".format(url.hashCode()))
|
||||
try {
|
||||
if (!file.isFile) {
|
||||
covers.mkdirs()
|
||||
// Only the current cover is kept.
|
||||
covers.listFiles()?.forEach { it.delete() }
|
||||
http.newCall(Request.Builder().url(url).build()).execute().use { r ->
|
||||
check(r.isSuccessful) { "HTTP ${r.code}" }
|
||||
val tmp = File(covers, file.name + ".part")
|
||||
tmp.outputStream().use { out -> r.body.byteStream().copyTo(out) }
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
if (now?.artUrl == url) {
|
||||
coverFile = file
|
||||
onBus { publish() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("MPRIS: cover not fetched: %s", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MPRIS properties ---
|
||||
|
||||
private fun rootProperties(): Map<String, Variant<*>> = mapOf(
|
||||
"CanQuit" to Variant(false),
|
||||
"CanRaise" to Variant(true),
|
||||
"HasTrackList" to Variant(false),
|
||||
"Identity" to Variant("SleepyOwl"),
|
||||
"DesktopEntry" to Variant("sleepyowl"),
|
||||
"SupportedUriSchemes" to Variant(arrayOf<String>(), "as"),
|
||||
"SupportedMimeTypes" to Variant(arrayOf<String>(), "as"),
|
||||
)
|
||||
|
||||
private fun playerProperties(): Map<String, Variant<*>> {
|
||||
val n = now
|
||||
return mapOf(
|
||||
"PlaybackStatus" to Variant(
|
||||
when {
|
||||
n == null -> "Stopped"
|
||||
n.playing -> "Playing"
|
||||
else -> "Paused"
|
||||
},
|
||||
),
|
||||
"Rate" to Variant(1.0),
|
||||
"MinimumRate" to Variant(1.0),
|
||||
"MaximumRate" to Variant(1.0),
|
||||
"Metadata" to Variant(metadata(n), "a{sv}"),
|
||||
"Volume" to Variant(if (muted) 0.0 else volume / 100.0),
|
||||
"Position" to Variant((player.commands?.positionMs() ?: 0L) * 1000),
|
||||
"CanGoNext" to Variant(n?.canNext == true),
|
||||
"CanGoPrevious" to Variant(n?.canPrevious == true),
|
||||
"CanPlay" to Variant(n != null),
|
||||
"CanPause" to Variant(n != null),
|
||||
"CanSeek" to Variant(n != null && n.durationMs > 0),
|
||||
"CanControl" to Variant(true),
|
||||
)
|
||||
}
|
||||
|
||||
private fun metadata(n: NowPlaying?): Map<String, Variant<*>> {
|
||||
n ?: return mapOf("mpris:trackid" to Variant(DBusPath(NO_TRACK)))
|
||||
return buildMap {
|
||||
put("mpris:trackid", Variant(DBusPath(trackPath(n.itemId))))
|
||||
if (n.durationMs > 0) put("mpris:length", Variant(n.durationMs * 1000))
|
||||
put("xesam:title", Variant(n.title))
|
||||
n.artist?.let { put("xesam:artist", Variant(arrayOf(it), "as")) }
|
||||
coverFile?.let { put("mpris:artUrl", Variant(it.toPath().toUri().toString())) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A variant's value comparable by content (Variant.equals compares arrays by reference). */
|
||||
private fun contentOf(v: Variant<*>): String = when (val value = v.value) {
|
||||
is Array<*> -> value.contentDeepToString()
|
||||
is Map<*, *> -> value.entries.joinToString { (k, x) -> "$k=" + ((x as? Variant<*>)?.let(::contentOf) ?: x) }
|
||||
else -> value.toString()
|
||||
}
|
||||
|
||||
private fun trackPath(id: UUID) = "/ru/stenfredd/sleepyowl/track/" + id.toString().replace("-", "")
|
||||
|
||||
/** Commands from D-Bus go to the window on the UI thread. */
|
||||
private fun command(block: PlayerCommands.() -> Unit) = SwingUtilities.invokeLater { player.commands?.block() }
|
||||
|
||||
private inner class MprisObject : MprisRoot, MprisPlayer, Properties {
|
||||
override fun getObjectPath() = MPRIS_PATH
|
||||
|
||||
override fun Raise() = command { bringUp() }
|
||||
override fun Quit() = Unit
|
||||
|
||||
override fun Next() = command { next() }
|
||||
override fun Previous() = command { previous() }
|
||||
override fun Pause() = command { playPause(false) }
|
||||
override fun PlayPause() = command { playPause(null) }
|
||||
// A player window has nothing between "paused" and "closed": Stop pauses (closing is the window's ×).
|
||||
override fun Stop() = command { playPause(false) }
|
||||
override fun Play() = command { playPause(true) }
|
||||
override fun Seek(offset: Long) = command { seekTo(positionMs() + offset / 1000) }
|
||||
override fun SetPosition(trackId: DBusPath, position: Long) {
|
||||
val n = now ?: return
|
||||
if (trackId.path == trackPath(n.itemId) && position >= 0 && position / 1000 <= n.durationMs) command { seekTo(position / 1000) }
|
||||
}
|
||||
override fun OpenUri(uri: String) = Unit
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <A : Any?> Get(interfaceName: String, propertyName: String): A = GetAll(interfaceName)[propertyName] as A
|
||||
|
||||
override fun GetAll(interfaceName: String): Map<String, Variant<*>> = when (interfaceName) {
|
||||
ROOT_INTERFACE -> rootProperties()
|
||||
PLAYER_INTERFACE -> playerProperties()
|
||||
else -> emptyMap()
|
||||
}
|
||||
|
||||
override fun <A : Any?> Set(interfaceName: String, propertyName: String, value: A) {
|
||||
if (interfaceName != PLAYER_INTERFACE || propertyName != "Volume") return
|
||||
val v = ((value as? Variant<*>)?.value ?: value) as? Double ?: return
|
||||
command { changeVolume((v * 100).roundToInt().coerceIn(0, MpvEngine.MAX_VOLUME)) }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MPRIS_NAME = "org.mpris.MediaPlayer2.sleepyowl"
|
||||
const val MPRIS_PATH = "/org/mpris/MediaPlayer2"
|
||||
const val ROOT_INTERFACE = "org.mpris.MediaPlayer2"
|
||||
const val PLAYER_INTERFACE = "org.mpris.MediaPlayer2.Player"
|
||||
const val NO_TRACK = "/org/mpris/MediaPlayer2/TrackList/NoTrack"
|
||||
const val SCREENSAVER_NAME = "org.freedesktop.ScreenSaver"
|
||||
const val SCREENSAVER_PATH = "/org/freedesktop/ScreenSaver"
|
||||
|
||||
/** A position this far from where it should be was a seek. */
|
||||
const val SEEK_JUMP_MS = 2_000
|
||||
}
|
||||
}
|
||||
|
||||
// D-Bus interfaces: dbus-java takes method names as they are, hence the capitals.
|
||||
|
||||
@Suppress("FunctionName")
|
||||
@DBusInterfaceName("org.mpris.MediaPlayer2")
|
||||
interface MprisRoot : DBusInterface {
|
||||
fun Raise()
|
||||
fun Quit()
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
@DBusInterfaceName("org.mpris.MediaPlayer2.Player")
|
||||
interface MprisPlayer : DBusInterface {
|
||||
fun Next()
|
||||
fun Previous()
|
||||
fun Pause()
|
||||
fun PlayPause()
|
||||
fun Stop()
|
||||
fun Play()
|
||||
fun Seek(offset: Long)
|
||||
fun SetPosition(trackId: DBusPath, position: Long)
|
||||
fun OpenUri(uri: String)
|
||||
|
||||
/** The position jumped, µs. */
|
||||
class Seeked(path: String, position: Long) : DBusSignal(path, position)
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
@DBusInterfaceName("org.freedesktop.ScreenSaver")
|
||||
interface ScreenSaver : DBusInterface {
|
||||
fun Inhibit(applicationName: String, reason: String): UInt32
|
||||
fun UnInhibit(cookie: UInt32)
|
||||
}
|
||||
252
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/X11.kt
Normal file
252
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/X11.kt
Normal file
@@ -0,0 +1,252 @@
|
||||
package ru.stenfredd.sleepyowl.desktop
|
||||
|
||||
import com.sun.jna.Memory
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.NativeLong
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import com.sun.jna.ptr.NativeLongByReference
|
||||
import com.sun.jna.ptr.PointerByReference
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.awt.Window
|
||||
|
||||
/** The bits of Xlib the player's window needs (the L1 prototype's `x11/X11.kt`). */
|
||||
private object Xlib {
|
||||
init {
|
||||
Native.register("X11")
|
||||
}
|
||||
|
||||
@JvmStatic external fun XOpenDisplay(name: String?): Pointer?
|
||||
@JvmStatic external fun XDefaultRootWindow(dpy: Pointer): NativeLong
|
||||
@JvmStatic external fun XInternAtom(dpy: Pointer, name: String, onlyIfExists: Int): NativeLong
|
||||
@JvmStatic external fun XSendEvent(dpy: Pointer, w: NativeLong, propagate: Int, mask: NativeLong, event: Pointer): Int
|
||||
@JvmStatic external fun XFlush(dpy: Pointer): Int
|
||||
@JvmStatic external fun XUngrabPointer(dpy: Pointer, time: NativeLong): Int
|
||||
@JvmStatic external fun XMoveResizeWindow(dpy: Pointer, w: NativeLong, x: Int, y: Int, width: Int, height: Int): Int
|
||||
@JvmStatic external fun XGetGeometry(
|
||||
dpy: Pointer, w: NativeLong, root: NativeLongByReference, x: IntByReference, y: IntByReference,
|
||||
width: IntByReference, height: IntByReference, border: IntByReference, depth: IntByReference,
|
||||
): Int
|
||||
@JvmStatic external fun XTranslateCoordinates(
|
||||
dpy: Pointer, src: NativeLong, dst: NativeLong, x: Int, y: Int, rx: IntByReference, ry: IntByReference, child: NativeLongByReference,
|
||||
): Int
|
||||
@JvmStatic external fun XGetWMNormalHints(dpy: Pointer, w: NativeLong, hints: Pointer, supplied: NativeLongByReference): Int
|
||||
@JvmStatic external fun XSetWMNormalHints(dpy: Pointer, w: NativeLong, hints: Pointer)
|
||||
@JvmStatic external fun XQueryTree(
|
||||
dpy: Pointer, w: NativeLong, root: NativeLongByReference, parent: NativeLongByReference, children: PointerByReference, n: IntByReference,
|
||||
): Int
|
||||
@JvmStatic external fun XFree(data: Pointer): Int
|
||||
@JvmStatic external fun XChangeProperty(dpy: Pointer, w: NativeLong, property: NativeLong, type: NativeLong, format: Int, mode: Int, data: Pointer, n: Int): Int
|
||||
@JvmStatic external fun XGetWindowProperty(
|
||||
dpy: Pointer, w: NativeLong, property: NativeLong, offset: NativeLong, length: NativeLong, delete: Int, reqType: NativeLong,
|
||||
actualType: NativeLongByReference, actualFormat: IntByReference, nItems: NativeLongByReference, bytesAfter: NativeLongByReference,
|
||||
prop: PointerByReference,
|
||||
): Int
|
||||
}
|
||||
|
||||
/**
|
||||
* An AWT top-level window as X11 sees it, on a connection of its own (used from the AWT thread only).
|
||||
*
|
||||
* Full screen goes through the window manager (`_NET_WM_STATE_FULLSCREEN`, as mpv does it), on the monitor the window
|
||||
* is on. Muffin takes a decorated AWT window into full screen but never out of it (Compose's
|
||||
* `WindowPlacement.Fullscreen` and a plain `java.awt.Frame` alike); an undecorated one whose frame comes from
|
||||
* `_MOTIF_WM_HINTS` ([setDecorated]) goes both ways — the L1 prototype's way.
|
||||
*/
|
||||
class X11Window private constructor(private val dpy: Pointer, val xid: Long) {
|
||||
private val root = Xlib.XDefaultRootWindow(dpy)
|
||||
|
||||
private fun atom(name: String) = Xlib.XInternAtom(dpy, name, 0)
|
||||
|
||||
fun setFullscreen(on: Boolean) = setState("_NET_WM_STATE_FULLSCREEN", on)
|
||||
|
||||
/** On every workspace (`_NET_WM_STATE_STICKY`): the mini window's setting. */
|
||||
fun setSticky(on: Boolean) = setState("_NET_WM_STATE_STICKY", on)
|
||||
|
||||
private fun setState(name: String, on: Boolean) = sendClientMessage("_NET_WM_STATE", if (on) 1 else 0, atom(name).toLong(), 0, 1)
|
||||
|
||||
/**
|
||||
* The window manager moves or resizes the window (`_NET_WM_MOVERESIZE`, as mpv and GTK do): smoothly, across
|
||||
* monitors, snapping to edges. AWT grabbed the pointer on the press; the window manager can't grab it while AWT
|
||||
* holds it, so that grab goes first.
|
||||
*/
|
||||
fun startMoveResize(rootX: Int, rootY: Int, direction: Int) {
|
||||
ungrabAwtPointer()
|
||||
// x, y, direction, button 1, source: an application
|
||||
sendClientMessage("_NET_WM_MOVERESIZE", rootX.toLong(), rootY.toLong(), direction.toLong(), 1, 1)
|
||||
}
|
||||
|
||||
/** The client area on the screen, without the window manager's frame. AWT's own bounds lag behind the window manager. */
|
||||
fun geometry(): java.awt.Rectangle {
|
||||
val r = NativeLongByReference()
|
||||
val x = IntByReference()
|
||||
val y = IntByReference()
|
||||
val w = IntByReference()
|
||||
val h = IntByReference()
|
||||
val b = IntByReference()
|
||||
val depth = IntByReference()
|
||||
Xlib.XGetGeometry(dpy, NativeLong(xid), r, x, y, w, h, b, depth)
|
||||
val rx = IntByReference()
|
||||
val ry = IntByReference()
|
||||
val child = NativeLongByReference()
|
||||
Xlib.XTranslateCoordinates(dpy, NativeLong(xid), root, 0, 0, rx, ry, child)
|
||||
return java.awt.Rectangle(rx.value, ry.value, w.value, h.value)
|
||||
}
|
||||
|
||||
/** Straight to X11: AWT's cached bounds may be stale after the window manager moved the window. */
|
||||
fun moveResize(r: java.awt.Rectangle) {
|
||||
Xlib.XMoveResizeWindow(dpy, NativeLong(xid), r.x, r.y, r.width, r.height)
|
||||
Xlib.XFlush(dpy)
|
||||
}
|
||||
|
||||
/**
|
||||
* The window's proportions for the window manager (WM_NORMAL_HINTS, PAspect; none when [w] or [h] is 0) and its
|
||||
* minimum size, over the hints AWT set. AWT rewrites the hints wholesale on each of its reshapes
|
||||
* (`XBaseWindow.setSizeHints`), so this is called again after them; returns false when the hints were already so.
|
||||
*/
|
||||
fun setSizeHints(w: Int, h: Int, minW: Int, minH: Int): Boolean {
|
||||
val hints = Memory(80).apply { clear() }
|
||||
Xlib.XGetWMNormalHints(dpy, NativeLong(xid), hints, NativeLongByReference())
|
||||
val before = hints.getByteArray(0, 80)
|
||||
var flags = hints.getNativeLong(0).toLong()
|
||||
if (w > 0 && h > 0) {
|
||||
flags = flags or P_ASPECT
|
||||
hints.setInt(48, w)
|
||||
hints.setInt(52, h)
|
||||
hints.setInt(56, w)
|
||||
hints.setInt(60, h)
|
||||
} else {
|
||||
flags = flags and P_ASPECT.inv()
|
||||
}
|
||||
flags = flags or P_MIN_SIZE
|
||||
hints.setInt(24, minW)
|
||||
hints.setInt(28, minH)
|
||||
hints.setNativeLong(0, NativeLong(flags))
|
||||
if (hints.getByteArray(0, 80).contentEquals(before)) return false
|
||||
Xlib.XSetWMNormalHints(dpy, NativeLong(xid), hints)
|
||||
Xlib.XFlush(dpy)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The window manager's frame on a live window, as mpv's `border`. AWT made the window undecorated and doesn't know:
|
||||
* its insets stay zero and Compose fills the client area.
|
||||
*/
|
||||
fun setDecorated(decorated: Boolean) {
|
||||
val hints = atom("_MOTIF_WM_HINTS")
|
||||
val data = Memory(5L * 8).apply { clear() }
|
||||
data.setNativeLong(0, NativeLong(MWM_HINTS_DECORATIONS))
|
||||
data.setNativeLong(16, NativeLong(if (decorated) 1 else 0))
|
||||
Xlib.XChangeProperty(dpy, NativeLong(xid), hints, hints, 32, PROP_MODE_REPLACE, data, 5)
|
||||
Xlib.XFlush(dpy)
|
||||
}
|
||||
|
||||
val isFullscreen: Boolean get() = hasState("_NET_WM_STATE_FULLSCREEN")
|
||||
|
||||
/** The window manager has taken the window (set WM_STATE): before that, state requests get lost. */
|
||||
val isManaged: Boolean get() = hasProperty(dpy, xid, atom("WM_STATE"))
|
||||
|
||||
/** A `_NET_WM_STATE` the window manager keeps on the window. */
|
||||
fun hasState(name: String): Boolean {
|
||||
val type = NativeLongByReference()
|
||||
val format = IntByReference()
|
||||
val items = NativeLongByReference()
|
||||
val after = NativeLongByReference()
|
||||
val data = PointerByReference()
|
||||
Xlib.XGetWindowProperty(dpy, NativeLong(xid), atom("_NET_WM_STATE"), NativeLong(0), NativeLong(64), 0, NativeLong(XA_ATOM), type, format, items, after, data)
|
||||
val p = data.value ?: return false
|
||||
val want = atom(name).toLong()
|
||||
val found = (0 until items.value.toInt()).any { p.getNativeLong(8L * it).toLong() == want }
|
||||
Xlib.XFree(p)
|
||||
return found
|
||||
}
|
||||
|
||||
/** A ClientMessage to the root window, as EWMH asks for state changes. */
|
||||
private fun sendClientMessage(type: String, vararg l: Long) {
|
||||
val ev = Memory(192).apply { clear() }
|
||||
ev.setInt(0, CLIENT_MESSAGE)
|
||||
ev.setInt(16, 1)
|
||||
ev.setPointer(24, dpy)
|
||||
ev.setNativeLong(32, NativeLong(xid))
|
||||
ev.setNativeLong(40, atom(type))
|
||||
ev.setInt(48, 32)
|
||||
l.forEachIndexed { i, v -> ev.setNativeLong(56 + 8L * i, NativeLong(v)) }
|
||||
Xlib.XSendEvent(dpy, root, 0, NativeLong(SUBSTRUCTURE_NOTIFY or SUBSTRUCTURE_REDIRECT), ev)
|
||||
Xlib.XFlush(dpy)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** `_NET_WM_MOVERESIZE` directions: the window's sides and corners clockwise from the top left, then a move. */
|
||||
const val SIZE_TOPLEFT = 0
|
||||
const val SIZE_TOP = 1
|
||||
const val SIZE_TOPRIGHT = 2
|
||||
const val SIZE_RIGHT = 3
|
||||
const val SIZE_BOTTOMRIGHT = 4
|
||||
const val SIZE_BOTTOM = 5
|
||||
const val SIZE_BOTTOMLEFT = 6
|
||||
const val SIZE_LEFT = 7
|
||||
const val MOVE = 8
|
||||
|
||||
private const val CLIENT_MESSAGE = 33
|
||||
private const val P_MIN_SIZE = 16L
|
||||
private const val P_ASPECT = 128L
|
||||
private const val MWM_HINTS_DECORATIONS = 2L
|
||||
private const val PROP_MODE_REPLACE = 0
|
||||
private const val XA_ATOM = 4L
|
||||
private const val SUBSTRUCTURE_NOTIFY = 1L shl 19
|
||||
private const val SUBSTRUCTURE_REDIRECT = 1L shl 20
|
||||
|
||||
private val display: Pointer? by lazy { runCatching { Xlib.XOpenDisplay(null) }.onFailure { Log.w(it, "No X11 display") }.getOrNull() }
|
||||
|
||||
/** The window the window manager manages: up from AWT's own window to the one with WM_STATE (its frame's child). */
|
||||
fun of(window: Window): X11Window? {
|
||||
val d = display ?: return null
|
||||
val own = runCatching { Native.getWindowID(window) }.getOrNull() ?: return null
|
||||
val wmState = Xlib.XInternAtom(d, "WM_STATE", 0)
|
||||
val rootId = Xlib.XDefaultRootWindow(d).toLong()
|
||||
var w = own
|
||||
repeat(8) {
|
||||
if (hasProperty(d, w, wmState)) return X11Window(d, w)
|
||||
val parent = parentOf(d, w) ?: return X11Window(d, own)
|
||||
if (parent == rootId) return X11Window(d, w)
|
||||
w = parent
|
||||
}
|
||||
return X11Window(d, own)
|
||||
}
|
||||
|
||||
/** Releases the pointer grab on AWT's own X connection, under AWT's lock. */
|
||||
private fun ungrabAwtPointer() {
|
||||
runCatching {
|
||||
val display = Class.forName("sun.awt.X11.XToolkit").getMethod("getDisplay").invoke(null) as Long
|
||||
val sun = Class.forName("sun.awt.SunToolkit")
|
||||
sun.getMethod("awtLock").invoke(null)
|
||||
try {
|
||||
Xlib.XUngrabPointer(Pointer(display), NativeLong(0))
|
||||
Xlib.XFlush(Pointer(display))
|
||||
} finally {
|
||||
sun.getMethod("awtUnlock").invoke(null)
|
||||
}
|
||||
}.onFailure { Log.w(it, "X11: pointer not released") }
|
||||
}
|
||||
|
||||
private fun parentOf(d: Pointer, w: Long): Long? {
|
||||
val root = NativeLongByReference()
|
||||
val parent = NativeLongByReference()
|
||||
val children = PointerByReference()
|
||||
val n = IntByReference()
|
||||
if (Xlib.XQueryTree(d, NativeLong(w), root, parent, children, n) == 0) return null
|
||||
children.value?.let { Xlib.XFree(it) }
|
||||
return parent.value.toLong()
|
||||
}
|
||||
|
||||
private fun hasProperty(d: Pointer, w: Long, prop: NativeLong): Boolean {
|
||||
val type = NativeLongByReference()
|
||||
val format = IntByReference()
|
||||
val items = NativeLongByReference()
|
||||
val after = NativeLongByReference()
|
||||
val data = PointerByReference()
|
||||
Xlib.XGetWindowProperty(d, NativeLong(w), prop, NativeLong(0), NativeLong(1), 0, NativeLong(0), type, format, items, after, data)
|
||||
data.value?.let { Xlib.XFree(it) }
|
||||
return type.value.toLong() != 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import ru.stenfredd.sleepyowl.core.data.dataModule
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule
|
||||
import ru.stenfredd.sleepyowl.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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user