Desktop app: shell and library screens
@@ -1,11 +1,33 @@
|
|||||||
package ru.stenfredd.sleepyowl.core.common
|
package ru.stenfredd.sleepyowl.core.common
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.PrintStream
|
||||||
|
import java.time.LocalDateTime
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
private val TIME = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
|
private val TIME = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
|
||||||
private val LEVELS = mapOf(Log.DEBUG to 'D', Log.INFO to 'I', Log.WARN to 'W', Log.ERROR to 'E')
|
private val LEVELS = mapOf(Log.DEBUG to 'D', Log.INFO to 'I', Log.WARN to 'W', Log.ERROR to 'E')
|
||||||
|
|
||||||
|
/** The log file of this run, see [logToFile]; written next to stderr. */
|
||||||
|
@Volatile
|
||||||
|
private var file: PrintStream? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Also writes the log into `dir/sleepyowl.log`; the previous run's file is kept as `sleepyowl.1.log`.
|
||||||
|
* Called once by the desktop app at start.
|
||||||
|
*/
|
||||||
|
fun logToFile(dir: File) {
|
||||||
|
runCatching {
|
||||||
|
dir.mkdirs()
|
||||||
|
val current = File(dir, "sleepyowl.log")
|
||||||
|
if (current.exists()) current.renameTo(File(dir, "sleepyowl.1.log"))
|
||||||
|
file = PrintStream(current.outputStream().buffered(), true, Charsets.UTF_8).also {
|
||||||
|
it.println("---- ${LocalDateTime.now()}")
|
||||||
|
}
|
||||||
|
}.onFailure { System.err.println("Log file not opened in $dir: $it") }
|
||||||
|
}
|
||||||
|
|
||||||
internal actual fun logEnabled() = true
|
internal actual fun logEnabled() = true
|
||||||
|
|
||||||
internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>) {
|
internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>) {
|
||||||
@@ -14,5 +36,9 @@ internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, messa
|
|||||||
synchronized(System.err) {
|
synchronized(System.err) {
|
||||||
System.err.println(line)
|
System.err.println(line)
|
||||||
t?.printStackTrace()
|
t?.printStackTrace()
|
||||||
|
file?.let { out ->
|
||||||
|
out.println(line)
|
||||||
|
t?.printStackTrace(out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.JellyfinOptions
|
||||||
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
class DesktopJellyfinPlatform : JellyfinPlatform {
|
class DesktopJellyfinPlatform : JellyfinPlatform {
|
||||||
// /etc/hostname rather than InetAddress: that one may wait for DNS.
|
// /etc/hostname rather than InetAddress: that one may wait for DNS.
|
||||||
override val deviceName = "SleepyOwl · " + (runCatching { File("/etc/hostname").readText().trim() }.getOrNull()?.ifEmpty { null } ?: "Linux")
|
override val deviceName = "SleepyOwl · " + (readLine("/etc/hostname") ?: "Linux")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SDK's default device: on Android it comes from the system, on the desktop it has to be given — server
|
||||||
|
* discovery and probing (`ServerLogin.find`) create their API clients with it. Signed-in clients use the
|
||||||
|
* account's saved device id instead. The id is this machine's (`/etc/machine-id`), hashed.
|
||||||
|
*/
|
||||||
|
override fun configure(options: JellyfinOptions.Builder) {
|
||||||
|
val machine = readLine("/etc/machine-id") ?: deviceName
|
||||||
|
options.deviceInfo = DeviceInfo(id = UUID.nameUUIDFromBytes(machine.toByteArray()).toString(), name = deviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLine(path: String) = runCatching { File(path).readText().trim() }.getOrNull()?.ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|||||||
44
desktop/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// SleepyOwl for the Linux desktop (LINUX_PLAN.md): windows, desktop screens, later the mpv player and system integration.
|
||||||
|
// Run: ./gradlew :desktop:run, or tools/linux/desktop.sh without Gradle around the process.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.compose.multiplatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin { jvmToolchain(21) }
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":ui:shared"))
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
implementation(libs.koin.core)
|
||||||
|
implementation(libs.koin.compose.viewmodel)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network.okhttp)
|
||||||
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
|
implementation(libs.jna)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.koin.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
// XToolkit: the windows' WM_CLASS (sun.awt.X11.XToolkit.awtAppClassName), set before the first window.
|
||||||
|
val awtOpens = listOf("--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED")
|
||||||
|
|
||||||
|
compose.desktop {
|
||||||
|
application {
|
||||||
|
mainClass = "ru.stenfredd.sleepyowl.desktop.MainKt"
|
||||||
|
jvmArgs += awtOpens + "-Dsleepyowl.repo=${rootDir.path}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach { jvmArgs(awtOpens) }
|
||||||
|
|
||||||
|
// Classpath into a file: tools/linux/desktop.sh starts the app without Gradle.
|
||||||
|
tasks.register("desktopClasspath") {
|
||||||
|
val cp = sourceSets.main.get().runtimeClasspath
|
||||||
|
val out = layout.buildDirectory.file("desktop.classpath")
|
||||||
|
inputs.files(cp)
|
||||||
|
outputs.file(out)
|
||||||
|
doLast { out.get().asFile.writeText(cp.joinToString(":")) }
|
||||||
|
}
|
||||||
14
desktop/packaging/sleepyowl.desktop
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SleepyOwl
|
||||||
|
GenericName=Jellyfin client
|
||||||
|
GenericName[ru]=Клиент Jellyfin
|
||||||
|
Comment=Movies and series from your Jellyfin server
|
||||||
|
Comment[ru]=Фильмы и сериалы с вашего сервера Jellyfin
|
||||||
|
Exec=sleepyowl
|
||||||
|
Icon=sleepyowl
|
||||||
|
Terminal=false
|
||||||
|
Categories=AudioVideo;Video;Player;
|
||||||
|
Keywords=jellyfin;movies;series;video;кино;сериалы;
|
||||||
|
StartupWMClass=sleepyowl
|
||||||
|
StartupNotify=true
|
||||||
241
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
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.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isAltPressed
|
||||||
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||||
|
import ru.stenfredd.sleepyowl.core.data.model.LibraryType
|
||||||
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.library.LibraryMemory
|
||||||
|
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
|
||||||
|
import ru.stenfredd.sleepyowl.ui.shell.Tab
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
import java.awt.AWTEvent
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.event.AWTEventListener
|
||||||
|
import java.awt.event.MouseEvent
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
|
||||||
|
/** The main window's content: restores the session, then the login screen or the tabs, with "Назад" everywhere. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun App(window: java.awt.Window, keys: BackKeys) {
|
||||||
|
val session = koinInject<JellyfinSession>()
|
||||||
|
var stack by remember { mutableStateOf<NavStack?>(null) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val loggedIn = session.restore()
|
||||||
|
Log.i("Session restored: %s", if (loggedIn) "logged in" else "login screen")
|
||||||
|
stack = NavStack(if (loggedIn) root(session, Tab.Home) else Screen.Login)
|
||||||
|
}
|
||||||
|
val dispatcher = remember { BackDispatcher() }
|
||||||
|
val entry = remember { KeyboardEntry() }
|
||||||
|
val onBack = { stack?.let { back(dispatcher, it) } }
|
||||||
|
BackButton(window) { onBack() }
|
||||||
|
keys.onBack = { onBack() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
keys.onArrow = { direction -> focusManager.moveFocus(direction) || entry.enter() }
|
||||||
|
keys.onSearch = { stack?.takeIf { it.top != Screen.Login }?.resetTo(root(session, Tab.Search)) }
|
||||||
|
Box(Modifier.fillMaxSize().background(AppColors.Background).trackPointer()) {
|
||||||
|
val nav = stack ?: return@Box
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalBackDispatcher provides dispatcher,
|
||||||
|
LocalKeyboardEntry provides entry,
|
||||||
|
LocalBringIntoViewSpec provides KeyboardBringIntoView,
|
||||||
|
) {
|
||||||
|
WatchMenuHost { Navigation(nav, session) { onBack() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A handler of the screen first, then the stack. The last screen stays: closing the window is the window's job. */
|
||||||
|
private fun back(dispatcher: BackDispatcher, stack: NavStack) {
|
||||||
|
if (!dispatcher.dispatch()) stack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys for the window's `onKeyEvent`: it gets every key the focused element did not take, and keys with nothing
|
||||||
|
* focused at all (a modifier on the content would miss those). Esc, Alt+← and Backspace go back (Backspace is a text
|
||||||
|
* field's own while one is focused, even an empty one); Ctrl+F and / open the search. Arrows move focus as the TV's
|
||||||
|
* remote does (Compose on the desktop moves it only with Tab); with nothing focused they enter the screen
|
||||||
|
* ([KeyboardEntry]).
|
||||||
|
*/
|
||||||
|
class BackKeys {
|
||||||
|
var onBack: () -> Unit = {}
|
||||||
|
var onArrow: (FocusDirection) -> Boolean = { false }
|
||||||
|
var onSearch: () -> Unit = {}
|
||||||
|
|
||||||
|
/** Every key first: from now on focus moved by keys scrolls into view ([Input]). */
|
||||||
|
fun onPreview(event: KeyEvent): Boolean {
|
||||||
|
Input.keyboard = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onKey(event: KeyEvent): Boolean {
|
||||||
|
val down = event.type == KeyEventType.KeyDown
|
||||||
|
val back = event.key == Key.Escape || (event.key == Key.Backspace && !typing) || (event.isAltPressed && event.key == Key.DirectionLeft)
|
||||||
|
if (back) {
|
||||||
|
if (down) onBack()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val search = (event.isCtrlPressed && event.key == Key.F) || (event.key == Key.Slash && !typing)
|
||||||
|
if (search) {
|
||||||
|
if (down) onSearch()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val direction = Arrows[event.key]
|
||||||
|
if (direction != null && !event.isAltPressed) return !down || onArrow(direction)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val Arrows = mapOf(
|
||||||
|
Key.DirectionUp to FocusDirection.Up,
|
||||||
|
Key.DirectionDown to FocusDirection.Down,
|
||||||
|
Key.DirectionLeft to FocusDirection.Left,
|
||||||
|
Key.DirectionRight to FocusDirection.Right,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mouse's back button over [window]. X11 sends it as button 8, AWT passes it on as its button 6, and Compose
|
||||||
|
* drops buttons past 5 — so it is taken from AWT directly.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BackButton(window: java.awt.Window, onBack: () -> Unit) {
|
||||||
|
val current = rememberUpdatedState(onBack)
|
||||||
|
DisposableEffect(window) {
|
||||||
|
val listener = AWTEventListener { e ->
|
||||||
|
if (e is MouseEvent && e.id == MouseEvent.MOUSE_PRESSED && e.button == 6 && SwingUtilities.getWindowAncestor(e.component) == window) {
|
||||||
|
current.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK)
|
||||||
|
onDispose { Toolkit.getDefaultToolkit().removeAWTEventListener(listener) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Navigation(stack: NavStack, session: JellyfinSession, onBack: () -> Unit) {
|
||||||
|
val player = koinInject<ProtoPlayer>()
|
||||||
|
fun open(screen: Screen) {
|
||||||
|
if (screen is Screen.Player) player.play(screen) else stack.push(screen)
|
||||||
|
}
|
||||||
|
stack.Content { screen ->
|
||||||
|
when (screen) {
|
||||||
|
Screen.Login -> LoginScreen(onDone = { stack.resetTo(root(session, Tab.Home)) })
|
||||||
|
is Screen.Root -> Root(
|
||||||
|
screen.tab,
|
||||||
|
session,
|
||||||
|
onTab = { stack.resetTo(root(session, it)) },
|
||||||
|
onLibrary = { id ->
|
||||||
|
val account = session.session.value?.accountId.orEmpty()
|
||||||
|
stack.resetTo(Screen.Root(Tab.Library, account, library = id))
|
||||||
|
},
|
||||||
|
onOpen = ::open,
|
||||||
|
onSignedOut = { stack.resetTo(Screen.Login) },
|
||||||
|
onSwitched = { stack.resetTo(root(session, Tab.Home)) },
|
||||||
|
)
|
||||||
|
is Screen.Detail -> Page(onBack) { DetailsScreen(screen, onOpen = ::open) }
|
||||||
|
is Screen.Series -> Page(onBack) { SeriesScreen(screen, onOpen = ::open) }
|
||||||
|
is Screen.Person -> Page(onBack) { PersonScreen(screen, onOpen = ::open) }
|
||||||
|
is Screen.Items -> Page(onBack) { ItemsScreen(screen, onOpen = ::open) }
|
||||||
|
// The player opens in its own window; licences and the debug tour are the TV's.
|
||||||
|
is Screen.Player, is Screen.License, is Screen.Tour -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A full-screen page (no rail) with "‹" at the top left. */
|
||||||
|
@Composable
|
||||||
|
private fun Page(onBack: () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
content()
|
||||||
|
BackArrow(onBack, Modifier.padding(start = 14.dp, top = 10.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun root(session: JellyfinSession, tab: Tab) = Screen.Root(tab, session.session.value?.accountId.orEmpty())
|
||||||
|
|
||||||
|
/** Account → its libraries on the rail: the next tab's rail starts with them instead of appearing a frame later. */
|
||||||
|
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Root(
|
||||||
|
tab: Tab,
|
||||||
|
session: JellyfinSession,
|
||||||
|
onTab: (Tab) -> Unit,
|
||||||
|
onLibrary: (java.util.UUID) -> Unit,
|
||||||
|
onOpen: (Screen) -> Unit,
|
||||||
|
onSignedOut: () -> Unit,
|
||||||
|
onSwitched: () -> Unit,
|
||||||
|
) {
|
||||||
|
val repository = koinInject<LibraryRepository>()
|
||||||
|
val memory = koinInject<LibraryMemory>()
|
||||||
|
val user by session.session.collectAsState()
|
||||||
|
val account = user?.accountId.orEmpty()
|
||||||
|
val libraries by produceState(railLibraries.takeIf { it.first == account }?.second.orEmpty(), account) {
|
||||||
|
value = runCatching { repository.views() }.onFailure { Log.w(it, "Libraries not loaded") }.getOrNull().orEmpty().map { view ->
|
||||||
|
val icon = when (view.type) {
|
||||||
|
LibraryType.Movies -> AppIcon.Film
|
||||||
|
LibraryType.Shows -> AppIcon.Tv
|
||||||
|
else -> AppIcon.Library
|
||||||
|
}
|
||||||
|
RailLibrary(view.id, view.name, icon)
|
||||||
|
}
|
||||||
|
railLibraries = account to value
|
||||||
|
}
|
||||||
|
Shell(
|
||||||
|
current = tab,
|
||||||
|
userName = user?.userName.orEmpty(),
|
||||||
|
userSeed = user?.userId?.toString().orEmpty(),
|
||||||
|
libraries = libraries,
|
||||||
|
onTab = onTab,
|
||||||
|
onLibrary = { id ->
|
||||||
|
memory.setLibrary(account, id)
|
||||||
|
onLibrary(id)
|
||||||
|
},
|
||||||
|
onProfile = { onOpen(Screen.Login) },
|
||||||
|
) {
|
||||||
|
when (tab) {
|
||||||
|
Tab.Home -> HomeScreen(onOpen = onOpen)
|
||||||
|
Tab.Library -> LibraryScreen(onOpen = onOpen)
|
||||||
|
Tab.Search -> SearchScreen(onOpen = onOpen)
|
||||||
|
Tab.Favorites -> FavoritesScreen(onOpen = onOpen)
|
||||||
|
Tab.Settings -> SettingsScreen(onOpen = onOpen, onSignedOut = onSignedOut, onSwitched = onSwitched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.PlatformContext
|
||||||
|
import coil3.disk.DiskCache
|
||||||
|
import coil3.disk.directory
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
|
import org.koin.core.module.dsl.singleOf
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.AppDirs
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
fun desktopModule(scale: UiScale) = module {
|
||||||
|
single { desktopAppDirs() }
|
||||||
|
singleOf(::PreferenceStores)
|
||||||
|
single<JellyfinPlatform> { DesktopJellyfinPlatform() }
|
||||||
|
single<DeviceCapabilitiesProbe> { DesktopCapabilitiesProbe() }
|
||||||
|
single<UiPlatform> { DesktopUiPlatform(scale) }
|
||||||
|
singleOf(::ProtoPlayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The places of the app's files besides [AppDirs]: the log and the single-instance lock. */
|
||||||
|
object DesktopPaths {
|
||||||
|
/** `~/.local/state/sleepyowl/log`. */
|
||||||
|
val log: File = xdg("XDG_STATE_HOME", ".local/state").resolve("log")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `$XDG_RUNTIME_DIR/sleepyowl`, or the cache when there is no runtime dir. `SLEEPYOWL_RUNTIME_DIR` replaces it
|
||||||
|
* for the test stand: its app runs beside the user's own, and the runtime dir also leads to PipeWire.
|
||||||
|
*/
|
||||||
|
val runtime: File = System.getenv("SLEEPYOWL_RUNTIME_DIR")?.takeIf { it.isNotBlank() }?.let(::File)
|
||||||
|
?: System.getenv("XDG_RUNTIME_DIR")?.takeIf { it.isNotBlank() }?.let { File(it, "sleepyowl") }
|
||||||
|
?: xdg("XDG_CACHE_HOME", ".cache").resolve("run")
|
||||||
|
|
||||||
|
private fun xdg(variable: String, default: String) =
|
||||||
|
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 {
|
||||||
|
override val density: Float
|
||||||
|
get() = scale.factor * 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Images: the API's client (`ServerRouting` sends them to the LAN address at home), a disk cache in
|
||||||
|
* `~/.cache/sleepyowl/images` that keeps posters across launches, a memory cache of a quarter of the heap.
|
||||||
|
*/
|
||||||
|
fun imageLoader(context: PlatformContext, http: OkHttpClient, dirs: AppDirs): ImageLoader = ImageLoader.Builder(context)
|
||||||
|
.memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.25).build() }
|
||||||
|
.diskCache { DiskCache.Builder().directory(File(dirs.files, "images").toOkioPath()).maxSizeBytes(500L * 1024 * 1024).build() }
|
||||||
|
.components { add(OkHttpNetworkFetcherFactory(callFactory = { http })) }
|
||||||
|
.crossfade(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
internal fun hostname(): String = runCatching { File("/etc/hostname").readText().trim() }.getOrNull()?.ifEmpty { null } ?: "Linux"
|
||||||
|
|
||||||
|
/** Pixels per dp of the main screen as Java sees it (GDK_SCALE / Xft.dpi). */
|
||||||
|
internal fun systemScale(): Float = runCatching {
|
||||||
|
java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat()
|
||||||
|
}.getOrDefault(1f)
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
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.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
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.heightIn
|
||||||
|
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.layout.widthIn
|
||||||
|
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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
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.dp
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.CircleButton
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FormatBadge
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.PrimaryButton
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.details.CastModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.details.DetailsModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.details.DetailsState
|
||||||
|
import ru.stenfredd.sleepyowl.ui.details.DetailsViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.details.TrackOption
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
import ru.stenfredd.sleepyowl.ui.details.DetailsLayout as L
|
||||||
|
|
||||||
|
/** Movie (or episode) page, as on the TV: backdrop, poster, title, actions, track choice, cast, similar. */
|
||||||
|
@Composable
|
||||||
|
fun DetailsScreen(
|
||||||
|
screen: Screen.Detail,
|
||||||
|
onOpen: (Screen) -> Unit,
|
||||||
|
vm: DetailsViewModel = koinViewModel { parametersOf(screen.itemId) },
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
val item = state.item
|
||||||
|
|
||||||
|
when {
|
||||||
|
item != null -> Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 80.dp)) {
|
||||||
|
Hero(item, state, focus, vm, onOpen)
|
||||||
|
if (item.cast.isNotEmpty()) Cast(item, focus, onOpen)
|
||||||
|
if (state.similar.isNotEmpty()) {
|
||||||
|
ContentRow("Похожие", null, Modifier.padding(top = 6.dp)) {
|
||||||
|
items(state.similar, key = { it.key }, contentType = { "poster" }) { p ->
|
||||||
|
PosterCard(p, { vm.similarTarget(p.key)?.let(onOpen) }, Modifier.hoverFocus().remembered(focus, p.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.error != null -> Message("Не удалось загрузить: ${state.error}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// "С начала" disappears once the movie is watched to the end: focus "Смотреть" instead.
|
||||||
|
val restartShown = (item?.resumeMs ?: 0) > 0
|
||||||
|
LaunchedEffect(restartShown) {
|
||||||
|
if (!restartShown && focus.lastKey == KEY_RESTART) focus.lastKey = KEY_PLAY
|
||||||
|
}
|
||||||
|
focus.Entry(ready = item != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val KEY_PLAY = "play"
|
||||||
|
private const val KEY_RESTART = "restart"
|
||||||
|
|
||||||
|
/** First screen of the page: backdrop, poster with formats, title, actions and the track choice. */
|
||||||
|
@Composable
|
||||||
|
private fun Hero(item: DetailsModel, state: DetailsState, focus: ScreenFocus, vm: DetailsViewModel, onOpen: (Screen) -> Unit) {
|
||||||
|
Box(Modifier.fillMaxWidth().heightIn(min = L.PAGE_H.dp)) {
|
||||||
|
item.backdropUrl?.let { Backdrop(it) }
|
||||||
|
Column(Modifier.offset(L.POSTER_X.dp, L.POSTER_Y.dp).width(L.POSTER_W.dp)) {
|
||||||
|
val shape = RoundedCornerShape(L.POSTER_CORNER.dp)
|
||||||
|
RemoteImage(
|
||||||
|
item.poster,
|
||||||
|
L.POSTER_CORNER.dp,
|
||||||
|
Modifier.size(L.POSTER_W.dp, L.POSTER_H.dp).border(0.5.dp, Color(0x1AFFFFFF), shape),
|
||||||
|
)
|
||||||
|
if (item.badges.isNotEmpty()) {
|
||||||
|
FlowRow(Modifier.padding(top = 11.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
item.badges.forEach { FormatBadge(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item.fileLine?.let { BasicText(it, Modifier.padding(top = 7.dp), style = AppType.MonoMeta.copy(color = AppColors.TextDim)) }
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(start = 320.dp, end = 60.dp, top = L.POSTER_Y.dp, bottom = 24.dp)) {
|
||||||
|
BasicText(item.label, style = AppType.MonoLabel)
|
||||||
|
BasicText(item.title, Modifier.padding(top = 12.dp), style = TitleStyle, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
item.subtitle?.let { BasicText(it, Modifier.padding(top = 8.dp), style = AppType.BodyLarge.copy(fontSize = 15.sp), maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
Meta(item.rating, item.age, listOfNotNull(item.year, item.runtime, item.genres), Modifier.padding(top = 11.dp))
|
||||||
|
item.description?.let {
|
||||||
|
BasicText(
|
||||||
|
it,
|
||||||
|
Modifier.padding(top = 11.dp).widthIn(max = 640.dp),
|
||||||
|
style = AppType.Body.copy(fontSize = 13.sp, lineHeight = 19.5.sp, color = AppColors.TextSecondary),
|
||||||
|
maxLines = 6,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Actions(item, focus, vm, onOpen, Modifier.padding(top = 18.dp))
|
||||||
|
if (item.audio.isNotEmpty() || item.subtitles.isNotEmpty()) PlaybackOptions(item, state, focus, vm, Modifier.padding(top = 20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TitleStyle = AppType.Display.copy(fontSize = 46.sp, lineHeight = 48.sp, letterSpacing = (-0.03).em)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backdrop at the top right, 70 % of the window wide, dimmed and fading into the background at its left and
|
||||||
|
* bottom edges, as the TV's baked one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Backdrop(url: String, height: Dp = L.PAGE_H.dp) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxWidth().height(height)) {
|
||||||
|
val w = maxWidth * 0.7f
|
||||||
|
val h = minOf(w * 9f / 16f, maxHeight)
|
||||||
|
val bg = AppColors.Background
|
||||||
|
Box(Modifier.align(Alignment.TopEnd).size(w, h)) {
|
||||||
|
AsyncImage(url, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.7f)
|
||||||
|
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to bg.copy(alpha = 0.55f), 1f to bg.copy(alpha = 0.8f))))
|
||||||
|
Spacer(Modifier.fillMaxSize().background(Brush.horizontalGradient(0f to bg, 0.14f to bg.copy(alpha = 0.6f), 0.4f to Color.Transparent)))
|
||||||
|
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0.65f to Color.Transparent, 1f to bg)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "★ 7.9 16+ 2024 2 ч 14 мин Драма, Приключения" */
|
||||||
|
@Composable
|
||||||
|
fun Meta(rating: String?, age: String?, rest: List<String>, modifier: Modifier, badges: List<String> = emptyList()) {
|
||||||
|
val text = AppType.Body.copy(fontSize = 12.sp, lineHeight = 12.sp, color = AppColors.TextSecondary)
|
||||||
|
FlowRow(modifier, horizontalArrangement = Arrangement.spacedBy(9.dp), verticalArrangement = Arrangement.spacedBy(6.dp), itemVerticalAlignment = Alignment.CenterVertically) {
|
||||||
|
rating?.let { BasicText(it, style = text.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
|
||||||
|
age?.let {
|
||||||
|
BasicText(
|
||||||
|
it,
|
||||||
|
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 2.dp),
|
||||||
|
style = text.copy(fontSize = 10.sp, lineHeight = 10.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rest.forEach { BasicText(it, style = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
if (badges.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { badges.forEach { FormatBadge(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Actions(item: DetailsModel, focus: ScreenFocus, vm: DetailsViewModel, onOpen: (Screen) -> Unit, modifier: Modifier) {
|
||||||
|
FlowRow(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
val resume = item.resumeMs > 0
|
||||||
|
PrimaryButton(
|
||||||
|
when {
|
||||||
|
!resume -> "Смотреть"
|
||||||
|
item.remainingMinutes != null -> "Продолжить · ещё ${item.remainingMinutes} мин"
|
||||||
|
else -> "Продолжить"
|
||||||
|
},
|
||||||
|
{ vm.player(fromStart = false)?.let(onOpen) },
|
||||||
|
Modifier.hoverFocus().remembered(focus, KEY_PLAY, isDefault = true),
|
||||||
|
)
|
||||||
|
if (resume) SecondaryButton("С начала", { vm.player(fromStart = true)?.let(onOpen) }, Modifier.hoverFocus().remembered(focus, KEY_RESTART))
|
||||||
|
CircleButton(
|
||||||
|
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
|
||||||
|
vm::toggleFavorite,
|
||||||
|
Modifier.hoverFocus().remembered(focus, "favorite"),
|
||||||
|
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
||||||
|
)
|
||||||
|
CircleButton(AppIcon.Check, vm::togglePlayed, Modifier.hoverFocus().remembered(focus, "watched"), tint = if (item.played) AppColors.Green else Color(0xFFE3E5F2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Параметры воспроизведения": audio and subtitle chips, passed to the player when it starts. */
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackOptions(item: DetailsModel, state: DetailsState, focus: ScreenFocus, vm: DetailsViewModel, modifier: Modifier) {
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(PanelColor, shape)
|
||||||
|
.border(0.5.dp, Color(0xFF262A3A), shape),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(26.dp)
|
||||||
|
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp))
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||||
|
BasicText(
|
||||||
|
"ПАРАМЕТРЫ ВОСПРОИЗВЕДЕНИЯ",
|
||||||
|
Modifier.padding(start = 6.dp).weight(1f),
|
||||||
|
style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted),
|
||||||
|
)
|
||||||
|
BasicText("применятся при запуске", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
||||||
|
Column(Modifier.padding(vertical = 5.dp)) {
|
||||||
|
if (item.audio.isNotEmpty()) OptionRow("Аудио", AppColors.Pink, item.audio, state.audio, focus, vm::selectAudio)
|
||||||
|
if (item.subtitles.isNotEmpty()) OptionRow("Субтитры", AppColors.Yellow, item.subtitles, state.subtitle, focus, vm::selectSubtitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PanelColor = Color(0xFF111319)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OptionRow(title: String, color: Color, options: List<TrackOption>, selected: Int?, focus: ScreenFocus, onPick: (Int?) -> Unit) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Row(Modifier.padding(start = 12.dp).width(78.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.size(5.dp).background(color, CircleShape))
|
||||||
|
BasicText(title, Modifier.padding(start = 5.dp), style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 11.sp, color = AppColors.TextDim))
|
||||||
|
}
|
||||||
|
// Files can have a dozen subtitle tracks: the chips scroll instead of wrapping, opened on the chosen one.
|
||||||
|
val selectedAt = options.indexOfFirst { it.index == selected }
|
||||||
|
ScrollRow(
|
||||||
|
state = rememberLazyListState(initialFirstVisibleItemIndex = if (selectedAt > 1) selectedAt - 1 else 0),
|
||||||
|
contentPadding = PaddingValues(start = 2.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
spacing = 5.dp,
|
||||||
|
) {
|
||||||
|
items(options, key = { it.key }) { o ->
|
||||||
|
Chip(o.label, o.index == selected, { onPick(o.index) }, Modifier.hoverFocus().remembered(focus, o.key), detail = o.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Cast(item: DetailsModel, focus: ScreenFocus, onOpen: (Screen) -> Unit) {
|
||||||
|
Column(Modifier.padding(top = 10.dp)) {
|
||||||
|
SectionTitle(item.castTitle, null)
|
||||||
|
ScrollRow(contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 15.dp), spacing = 12.dp) {
|
||||||
|
items(item.cast, key = { it.key }) { p ->
|
||||||
|
CastChip(p, { p.personId?.let { onOpen(Screen.Person(it)) } }, Modifier.hoverFocus().remembered(focus, p.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val CastFocus = FocusStyle(scale = 1.03f, corner = 27.5.dp, ring = 1.dp, glows = listOf(FocusStyle.Glow(17.dp, 0.dp, Color(0x737C5CFF))))
|
||||||
|
|
||||||
|
/** Actor pill: photo (or initials), name, role; opens the person's page. */
|
||||||
|
@Composable
|
||||||
|
private fun CastChip(person: CastModel, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(27.5.dp)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(CastFocus)
|
||||||
|
.height(55.dp)
|
||||||
|
.background(if (focused) Color(0xFF1F2238) else AppColors.Surface, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF262A3A), shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(start = 8.dp, end = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||||
|
) {
|
||||||
|
if (person.image != null) {
|
||||||
|
RemoteImage(person.image, (L.AVATAR / 2).dp, Modifier.size(L.AVATAR.dp))
|
||||||
|
} else {
|
||||||
|
Avatar(person.name, person.key, L.AVATAR.dp, AppType.CardTitle.copy(fontSize = 14.sp), letters = person.letters)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
BasicText(person.name, style = AppType.CardTitle.copy(fontSize = 12.sp, fontWeight = FontWeight.SemiBold), maxLines = 1)
|
||||||
|
person.role?.let {
|
||||||
|
BasicText(it, Modifier.padding(top = 2.dp).widthIn(max = 160.dp), style = AppType.Body.copy(fontSize = 9.5.sp, lineHeight = 11.sp, color = AppColors.TextDim), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import ru.stenfredd.sleepyowl.ui.BrowseViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.fullItem
|
||||||
|
import ru.stenfredd.sleepyowl.ui.favorites.FavoritesViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.ForgetMissing
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||||
|
import ru.stenfredd.sleepyowl.ui.isPlayable
|
||||||
|
import ru.stenfredd.sleepyowl.ui.label
|
||||||
|
import ru.stenfredd.sleepyowl.ui.person.PersonLayout
|
||||||
|
import ru.stenfredd.sleepyowl.ui.person.PersonViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Screens that are one grid of posters under a header: favourites, a person, a collection or folder. The grid has as
|
||||||
|
* many columns as the window fits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PosterGrid(header: @Composable () -> Unit, content: LazyGridScope.() -> Unit) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
|
||||||
|
state = rememberLazyGridState(),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = Side, end = Side, top = AppDimens.CONTENT_TOP.dp, bottom = 80.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
fullItem("header", "header", header)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Favourites tab: title with the count, library filter chips, grid of posters. */
|
||||||
|
@Composable
|
||||||
|
fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = koinViewModel()) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
when {
|
||||||
|
state.loading -> Message("Загрузка…")
|
||||||
|
state.error != null -> Message("Не удалось загрузить: ${state.error}")
|
||||||
|
else -> PosterGrid(
|
||||||
|
header = {
|
||||||
|
Column(Modifier.padding(bottom = 18.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
|
BasicText("Избранное", style = AppType.Display.copy(fontSize = 28.sp))
|
||||||
|
BasicText(state.total, Modifier.padding(bottom = 4.dp), style = AppType.MonoCount.copy(fontSize = 11.sp, color = AppColors.TextDim))
|
||||||
|
}
|
||||||
|
FlowRow(Modifier.padding(top = 15.dp), horizontalArrangement = Arrangement.spacedBy(5.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
|
for (f in state.filters) {
|
||||||
|
Chip(
|
||||||
|
f.label,
|
||||||
|
f.libraryId == state.selected,
|
||||||
|
{ vm.select(f.libraryId) },
|
||||||
|
Modifier.hoverFocus().remembered(focus, f.key, isDefault = f.key == FavoritesViewModel.ALL_KEY),
|
||||||
|
detail = f.count.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (state.items.isEmpty()) {
|
||||||
|
fullItem("empty", "text") {
|
||||||
|
BasicText(
|
||||||
|
"Здесь появятся фильмы и сериалы, отмеченные сердечком на их странице.",
|
||||||
|
Modifier.padding(top = 12.dp).widthIn(max = 550.dp),
|
||||||
|
style = AppType.Body.copy(fontSize = 14.sp, lineHeight = 20.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(state.items, key = { it.key }, contentType = { "poster" }) { p ->
|
||||||
|
PosterCard(p, { vm.target(p.key)?.let(onOpen) }, Modifier.padding(bottom = RowGap).hoverFocus().remembered(focus, p.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ready = !state.loading && state.error == null
|
||||||
|
if (ready) focus.ForgetMissing(state.filters.mapTo(HashSet()) { it.key } + state.items.map { it.key })
|
||||||
|
focus.Entry(ready = ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A person: photo, name, "12 фильмов · 3 сериала", a grid of their movies and series. */
|
||||||
|
@Composable
|
||||||
|
fun PersonScreen(screen: Screen.Person, onOpen: (Screen) -> Unit, vm: PersonViewModel = koinViewModel { parametersOf(screen.personId) }) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
when {
|
||||||
|
state.loading -> Box(Modifier.fillMaxSize())
|
||||||
|
state.error != null -> Message("Не удалось загрузить: ${state.error}")
|
||||||
|
else -> PosterGrid(
|
||||||
|
header = {
|
||||||
|
Row(Modifier.padding(bottom = 22.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
val size = PersonLayout.PHOTO.dp
|
||||||
|
if (state.photo != null) {
|
||||||
|
RemoteImage(state.photo, size / 2, Modifier.size(size))
|
||||||
|
} else {
|
||||||
|
Avatar(state.name, state.name, size, AppType.Hero.copy(fontSize = 22.sp), letters = state.letters)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
BasicText("ЧЕЛОВЕК", style = AppType.MonoLabel)
|
||||||
|
BasicText(state.name, Modifier.padding(top = 4.dp), style = AppType.Display.copy(fontSize = 28.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
BasicText(state.summary, Modifier.padding(top = 4.dp), style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextMuted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
items(state.items, key = { it.key }, contentType = { "poster" }) { p ->
|
||||||
|
PosterCard(
|
||||||
|
p,
|
||||||
|
{ vm.target(p.key)?.let(onOpen) },
|
||||||
|
Modifier.padding(bottom = RowGap).hoverFocus().remembered(focus, p.key, isDefault = p === state.items.first()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focus.Entry(ready = !state.loading && state.error == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collections and folders: the TV's plain grid of what is inside. */
|
||||||
|
@Composable
|
||||||
|
fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewModel = koinViewModel()) {
|
||||||
|
var items by remember { mutableStateOf<List<BaseItemDto>?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
LaunchedEffect(screen.parentId) {
|
||||||
|
runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message }
|
||||||
|
}
|
||||||
|
PosterGrid(
|
||||||
|
header = {
|
||||||
|
Column(Modifier.padding(bottom = 18.dp)) {
|
||||||
|
BasicText(screen.title, style = AppType.Display.copy(fontSize = 28.sp))
|
||||||
|
error?.let { BasicText("Не удалось загрузить: $it", Modifier.padding(top = 12.dp), style = AppType.Body.copy(color = AppColors.Red)) }
|
||||||
|
if (items == null && error == null) BasicText("Загрузка…", Modifier.padding(top = 12.dp), style = AppType.Body)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
items(items.orEmpty(), key = { it.id }) { item ->
|
||||||
|
Column(Modifier.padding(bottom = RowGap)) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.remembered(focus, item.id.toString())
|
||||||
|
.focusCard(FocusStyle.Poster)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.clickable(interactionSource = null, indication = null) { onOpen(item.target()) },
|
||||||
|
) {
|
||||||
|
val url = remember(item.id) { vm.library.posterUrl(item) }
|
||||||
|
RemoteImage(url?.let { RemoteImageSpec(it) }, 7.dp, Modifier.fillMaxSize())
|
||||||
|
if (url == null) BasicText(item.name.orEmpty(), Modifier.padding(8.dp), style = AppType.PosterTitle)
|
||||||
|
}
|
||||||
|
BasicText(item.label, Modifier.padding(top = 6.dp), maxLines = 2, overflow = TextOverflow.Ellipsis, style = AppType.MonoCaption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focus.Entry(ready = items != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BaseItemDto.target(): Screen = when {
|
||||||
|
type == BaseItemKind.SERIES -> Screen.Series(id)
|
||||||
|
isPlayable -> Screen.Detail(id)
|
||||||
|
else -> Screen.Items(id, name.orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Side = AppDimens.CONTENT_START.dp
|
||||||
|
private val RowGap = 18.dp
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FeaturedCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.WideCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||||
|
import ru.stenfredd.sleepyowl.ui.home.HomeState
|
||||||
|
import ru.stenfredd.sleepyowl.ui.home.HomeViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
|
||||||
|
/** Home: continue watching (the big card and four more), recently added, a row per library — as on the TV. */
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(onOpen: (Screen) -> Unit, vm: HomeViewModel = koinViewModel()) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit }
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.loading -> Message("Загрузка…")
|
||||||
|
state.error != null -> Message("Не удалось загрузить: ${state.error}")
|
||||||
|
else -> Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(top = AppDimens.CONTENT_TOP.dp, bottom = 80.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(AppDimens.SECTION_GAP.dp - 14.dp),
|
||||||
|
) {
|
||||||
|
state.featured?.let { featured ->
|
||||||
|
Column {
|
||||||
|
SectionTitle("Продолжить просмотр", state.continueCount)
|
||||||
|
// The four small cards go under the big one when the window is too narrow for them beside it.
|
||||||
|
FlowRow(
|
||||||
|
Modifier.padding(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = 13.dp, bottom = 14.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
FeaturedCard(featured, { open(featured.key) }, Modifier.hoverFocus().remembered(focus, featured.key, isDefault = true))
|
||||||
|
ContinueGrid(state, focus, open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (row in state.rows) {
|
||||||
|
key(row.key) {
|
||||||
|
ContentRow(row.title, row.count) {
|
||||||
|
items(row.items, key = { it.key }, contentType = { "poster" }) { p ->
|
||||||
|
PosterCard(p, { open(p.key) }, Modifier.hoverFocus().remembered(focus, p.key, isDefault = state.featured == null && p === state.rows.first().items.first()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focus.Entry(ready = !state.loading && state.error == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContinueGrid(state: HomeState, focus: ScreenFocus, open: (String) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
state.continueItems.chunked(2).forEach { pair ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
pair.forEach { w -> WideCard(w, { open(w.key) }, Modifier.hoverFocus().remembered(focus, w.key)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Input.kt
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.Restore
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pointer moves focus: the element under it gets the TV's focus look (ring, glow, light fill) and keyboard
|
||||||
|
* arrows go on from there; the pointer leaving it takes the focus away. Put it before the element's `focusCard` and
|
||||||
|
* `clickable` (or pass it as the modifier of a shared button or card). A text field being typed in keeps its focus:
|
||||||
|
* the pointer passing over a card must not take the typing away.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
fun Modifier.hoverFocus(): Modifier = composed {
|
||||||
|
val requester = remember { FocusRequester() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val focused = remember { booleanArrayOf(false) }
|
||||||
|
this
|
||||||
|
.onPointerEvent(PointerEventType.Enter) { if (!typing) runCatching { requester.requestFocus() } }
|
||||||
|
.onPointerEvent(PointerEventType.Exit) { if (focused[0] && !typing && !Input.keyboard) focusManager.clearFocus() }
|
||||||
|
.focusRequester(requester)
|
||||||
|
.onFocusChanged { focused[0] = it.hasFocus }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the window last got: keys or the pointer. Focus moved by keys scrolls its element into view; focus given by the
|
||||||
|
* pointer doesn't (pointing at the half-hidden card at a row's edge must not jerk the row).
|
||||||
|
*/
|
||||||
|
object Input {
|
||||||
|
var keyboard = false
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** Where the pointer last was in the window: the watched menu opens there. */
|
||||||
|
var pointer = Offset.Zero
|
||||||
|
internal set
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrolls a focused element into view, with a margin for its glow, only when keys moved the focus ([Input]). */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
internal val KeyboardBringIntoView = object : BringIntoViewSpec {
|
||||||
|
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float {
|
||||||
|
if (!Input.keyboard) return 0f
|
||||||
|
val start = offset - BRING_MARGIN
|
||||||
|
val end = offset + size + BRING_MARGIN
|
||||||
|
return when {
|
||||||
|
start < 0f -> start
|
||||||
|
end > containerSize -> (end - containerSize).coerceAtMost(start)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BRING_MARGIN = 40f
|
||||||
|
|
||||||
|
/** Keeps [Input] up to date with the pointer; on the window's root. */
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
fun Modifier.trackPointer(): Modifier = this
|
||||||
|
.onPointerEvent(PointerEventType.Move, PointerEventPass.Initial) {
|
||||||
|
Input.keyboard = false
|
||||||
|
Input.pointer = it.changes.first().position
|
||||||
|
}
|
||||||
|
.onPointerEvent(PointerEventType.Press, PointerEventPass.Initial) { Input.pointer = it.changes.first().position }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen the arrow keys enter when nothing has focus: the window starts without a focused card (a mouse user
|
||||||
|
* needs none), the first arrow focuses the screen's remembered or default element ([ScreenFocus.Entry]).
|
||||||
|
*/
|
||||||
|
class KeyboardEntry {
|
||||||
|
internal var screen: ScreenFocus? = null
|
||||||
|
|
||||||
|
/** True when there was a screen to enter. */
|
||||||
|
fun enter(): Boolean = screen?.let { it.requestFocus(); true } ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalKeyboardEntry = staticCompositionLocalOf { KeyboardEntry() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The desktop's [Restore]: back on a screen (its remembered element known) focus goes back to that element, as on the
|
||||||
|
* TV; on a first visit nothing is focused until an arrow key comes ([KeyboardEntry]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScreenFocus.Entry(ready: Boolean) {
|
||||||
|
val entry = LocalKeyboardEntry.current
|
||||||
|
DisposableEffect(this, entry) {
|
||||||
|
entry.screen = this@Entry
|
||||||
|
onDispose { if (entry.screen === this@Entry) entry.screen = null }
|
||||||
|
}
|
||||||
|
val returning = remember { lastKey != null }
|
||||||
|
if (returning) Restore(ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A [TextField] has focus. */
|
||||||
|
internal var typing = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** One-line text field: dark box, violet border while typing; Enter runs [onSubmit]. */
|
||||||
|
@Composable
|
||||||
|
fun TextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String = "",
|
||||||
|
textStyle: TextStyle = AppType.BodyLarge.copy(fontSize = 14.sp, color = AppColors.Text),
|
||||||
|
password: Boolean = false,
|
||||||
|
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||||
|
onSubmit: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(8.dp)
|
||||||
|
BasicTextField(
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
modifier
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused != focused) typing = it.isFocused
|
||||||
|
focused = it.isFocused
|
||||||
|
}
|
||||||
|
// Enter submits here: a single-line field on the desktop does not turn it into an IME action.
|
||||||
|
.onPreviewKeyEvent {
|
||||||
|
val enter = it.key == Key.Enter || it.key == Key.NumPadEnter
|
||||||
|
if (enter && it.type == KeyEventType.KeyDown) onSubmit()
|
||||||
|
enter
|
||||||
|
},
|
||||||
|
textStyle = textStyle,
|
||||||
|
singleLine = true,
|
||||||
|
cursorBrush = SolidColor(AppColors.Accent),
|
||||||
|
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
|
||||||
|
decorationBox = { inner ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.background(AppColors.BackgroundDeep, shape)
|
||||||
|
.border(if (focused) 1.dp else 0.5.dp, if (focused) AppColors.SelectedBorder else AppColors.Border, shape)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (value.isEmpty()) BasicText(placeholder, style = textStyle.copy(color = AppColors.TextFaint))
|
||||||
|
inner()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
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.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.WideCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.bleed
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.fullItem
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||||
|
import ru.stenfredd.sleepyowl.ui.library.LibraryContent
|
||||||
|
import ru.stenfredd.sleepyowl.ui.library.LibraryLayout
|
||||||
|
import ru.stenfredd.sleepyowl.ui.library.LibraryState
|
||||||
|
import ru.stenfredd.sleepyowl.ui.library.LibraryTile
|
||||||
|
import ru.stenfredd.sleepyowl.ui.library.LibraryViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The library tab, as on the TV: library tiles; under them, for the selected library, continue watching, recently
|
||||||
|
* added, watch history and the whole library as a grid with sort and filter chips. One lazy grid, so a library of
|
||||||
|
* thousands only composes what is on screen; it has as many columns as the window fits. A tile is picked by a click
|
||||||
|
* (on the TV focus picks it: here the pointer passing over the tiles would switch libraries).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = koinViewModel()) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
when {
|
||||||
|
state.loading -> Message("Загрузка…")
|
||||||
|
state.error != null -> Message("Не удалось загрузить: ${state.error}")
|
||||||
|
state.tiles.isEmpty() -> Message("На сервере нет библиотек с видео")
|
||||||
|
else -> Library(state, vm, focus, onOpen)
|
||||||
|
}
|
||||||
|
focus.Entry(ready = !state.loading && state.error == null && state.tiles.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Library(state: LibraryState, vm: LibraryViewModel, focus: ScreenFocus, onOpen: (Screen) -> Unit) {
|
||||||
|
val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit }
|
||||||
|
val grid = rememberLazyGridState()
|
||||||
|
val content = state.content
|
||||||
|
LoadMore(grid, vm)
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
// Full-width items reach from the window edge to edge, outside the grid's side padding (see [bleed]).
|
||||||
|
val fullWidth = maxWidth
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
|
||||||
|
state = grid,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = Side, end = Side, top = AppDimens.CONTENT_TOP.dp, bottom = 80.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(LibraryLayout.GRID_GAP.dp),
|
||||||
|
) {
|
||||||
|
fullItem("tiles", "tiles") {
|
||||||
|
Tiles(state, vm, focus, Modifier.bleed(fullWidth, Side))
|
||||||
|
}
|
||||||
|
if (content == null) {
|
||||||
|
state.contentError?.let { error -> fullItem("error", "text") { Note("Не удалось загрузить: $error") } }
|
||||||
|
return@LazyVerticalGrid
|
||||||
|
}
|
||||||
|
rows(content, focus, open, fullWidth)
|
||||||
|
fullItem("all", "all") { AllHeader(content, vm, focus) }
|
||||||
|
items(content.grid, key = { it.key }, contentType = { "poster" }) { p ->
|
||||||
|
PosterCard(p, { open(p.key) }, Modifier.padding(bottom = LibraryLayout.GRID_ROW_GAP.dp).hoverFocus().remembered(focus, p.key))
|
||||||
|
}
|
||||||
|
if (content.grid.isEmpty()) {
|
||||||
|
fullItem("grid-note", "text") {
|
||||||
|
Note(
|
||||||
|
when {
|
||||||
|
!content.gridComplete -> "Загрузка…"
|
||||||
|
content.unwatched -> "Всё просмотрено"
|
||||||
|
else -> "Здесь пока пусто"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyGridScope.rows(content: LibraryContent, focus: ScreenFocus, open: (String) -> Unit, fullWidth: Dp) {
|
||||||
|
val id = content.libraryId
|
||||||
|
if (content.continueItems.isNotEmpty()) {
|
||||||
|
fullItem("$id/cont", "wide-row") {
|
||||||
|
ContentRow("Продолжить просмотр", content.continueItems.size, Modifier.bleed(fullWidth, Side).padding(top = RowGap)) {
|
||||||
|
items(content.continueItems, key = { it.key }, contentType = { "wide" }) { w ->
|
||||||
|
WideCard(w, { open(w.key) }, Modifier.hoverFocus().remembered(focus, w.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ((name, title, posters) in listOf(Triple("new", "Недавно добавлено", content.latest), Triple("hist", "История просмотра", content.history))) {
|
||||||
|
if (posters.isEmpty()) continue
|
||||||
|
fullItem("$id/$name", "poster-row") {
|
||||||
|
ContentRow(title, posters.size, Modifier.bleed(fullWidth, Side).padding(top = RowGap)) {
|
||||||
|
items(posters, key = { it.key }, contentType = { "poster" }) { p ->
|
||||||
|
PosterCard(p, { open(p.key) }, Modifier.hoverFocus().remembered(focus, p.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Tiles(state: LibraryState, vm: LibraryViewModel, focus: ScreenFocus, modifier: Modifier = Modifier) {
|
||||||
|
ScrollRow(
|
||||||
|
modifier,
|
||||||
|
// The selected tile in view from the start: it can be far right when opened from the rail.
|
||||||
|
state = rememberLazyListState((state.tiles.indexOfFirst { it.id == state.selected } - 2).coerceAtLeast(0)),
|
||||||
|
contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
items(state.tiles, key = { it.key }, contentType = { "tile" }) { tile ->
|
||||||
|
LibraryTileCard(
|
||||||
|
tile,
|
||||||
|
selected = tile.id == state.selected,
|
||||||
|
onClick = { vm.selectLibrary(tile.id) },
|
||||||
|
modifier = Modifier.hoverFocus().remembered(focus, tile.key, isDefault = tile.id == state.selected),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LibraryTileCard(tile: LibraryTile, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val shape = RoundedCornerShape(LibraryLayout.TILE_CORNER.dp)
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.focusCard(TileFocus)
|
||||||
|
.size(LibraryLayout.TILE_W.dp, LibraryLayout.TILE_H.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(AppColors.Surface)
|
||||||
|
.border(0.5.dp, if (selected) AppColors.SelectedBorder else TileBorder, shape)
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||||
|
) {
|
||||||
|
TileArt(tile.art, if (selected) 1f else 0.55f)
|
||||||
|
Row(
|
||||||
|
Modifier.padding(start = 15.dp, top = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(6.dp).background(tile.dot, CircleShape))
|
||||||
|
BasicText("БИБЛИОТЕКА", style = AppType.MonoLabel.copy(fontSize = 8.5.sp, color = AppColors.TextDim))
|
||||||
|
}
|
||||||
|
Column(Modifier.align(Alignment.BottomStart).padding(start = 15.dp, end = 15.dp, bottom = 15.dp)) {
|
||||||
|
BasicText(tile.name, style = AppType.Hero.copy(fontSize = 23.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
// Reserved while the count loads, so the name doesn't jump.
|
||||||
|
BasicText(tile.stats ?: " ", Modifier.padding(top = 4.dp), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.TextMuted), maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tile's background from the mockup: three posters tilted on the right, a fade from the left. The TV bakes it into
|
||||||
|
* one bitmap for its weak GPU; here it is drawn as it is.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TileArt(urls: List<String>, alpha: Float) {
|
||||||
|
if (urls.isEmpty()) return
|
||||||
|
val w = LibraryLayout.ART_POSTER_W
|
||||||
|
val h = LibraryLayout.ART_POSTER_H
|
||||||
|
val gap = 6f
|
||||||
|
// `right:-20px; top:-20px; transform:rotate(8deg)` in mockup px, each next poster 18 px lower.
|
||||||
|
val rowWidth = urls.size * w + (urls.size - 1) * gap
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.offset((LibraryLayout.TILE_W + 10 - rowWidth).dp, (-10).dp)
|
||||||
|
.size(rowWidth.dp, (h + 9 * urls.size).dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
rotationZ = 8f
|
||||||
|
this.alpha = alpha
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
urls.forEachIndexed { i, url ->
|
||||||
|
AsyncImage(
|
||||||
|
url,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.offset((i * (w + gap)).dp, (i * 9).dp).size(w.dp, h.dp).clip(RoundedCornerShape(6.dp)),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `linear-gradient(90deg, #12141c 40%, rgba(18,20,28,.2))`
|
||||||
|
Spacer(
|
||||||
|
Modifier.fillMaxSize().background(
|
||||||
|
Brush.horizontalGradient(0f to AppColors.Surface, 0.4f to AppColors.Surface, 1f to AppColors.Surface.copy(alpha = 0.2f)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Все сериалы 171 [А–Я] [Добавлено] [Год] [Рейтинг] [Непросмотренное]"; the chips go under the title when narrow. */
|
||||||
|
@Composable
|
||||||
|
private fun AllHeader(content: LibraryContent, vm: LibraryViewModel, focus: ScreenFocus) {
|
||||||
|
FlowRow(
|
||||||
|
Modifier.padding(top = 22.dp, bottom = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(end = 11.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
BasicText(content.allTitle, style = AppType.Section)
|
||||||
|
content.allCount?.let { BasicText(it.toString(), Modifier.padding(start = 8.dp, top = 3.dp), style = AppType.MonoCount) }
|
||||||
|
}
|
||||||
|
for (chip in vm.sortChips) {
|
||||||
|
Chip(chip.label, chip.sort == content.sort, { vm.setSort(chip.sort) }, Modifier.hoverFocus().remembered(focus, chip.key))
|
||||||
|
}
|
||||||
|
Chip(
|
||||||
|
(if (content.unwatched) "✓ " else "") + "Непросмотренное",
|
||||||
|
content.unwatched,
|
||||||
|
vm::toggleUnwatched,
|
||||||
|
Modifier.hoverFocus().remembered(focus, LibraryViewModel.UNWATCHED_KEY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks for the next page when the grid's last loaded cards come into view. */
|
||||||
|
@Composable
|
||||||
|
private fun LoadMore(grid: LazyGridState, vm: LibraryViewModel) {
|
||||||
|
LaunchedEffect(grid) {
|
||||||
|
snapshotFlow {
|
||||||
|
val info = grid.layoutInfo
|
||||||
|
// With the item count, so a page that still leaves the end in view asks for the next one.
|
||||||
|
((info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= info.totalItemsCount - PREFETCH) to info.totalItemsCount
|
||||||
|
}.distinctUntilChanged().collect { (nearEnd, _) -> if (nearEnd) vm.loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Note(text: String) {
|
||||||
|
BasicText(text, Modifier.padding(top = 8.dp), style = AppType.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Side = AppDimens.CONTENT_START.dp
|
||||||
|
private val RowGap = (AppDimens.SECTION_GAP - 14).dp
|
||||||
|
private val TileBorder = Color(0xFF262A3A)
|
||||||
|
private val TileFocus = FocusStyle.Tile.copy(corner = LibraryLayout.TILE_CORNER.dp)
|
||||||
|
|
||||||
|
/** Next page is asked for this many cards before the end. */
|
||||||
|
private const val PREFETCH = 24
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
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.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.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.OwlMark
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.StatusDot
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Wordmark
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.dashedBorder
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.login.LoginState
|
||||||
|
import ru.stenfredd.sleepyowl.ui.login.LoginStep
|
||||||
|
import ru.stenfredd.sleepyowl.ui.login.LoginViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.login.ProfileModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.login.QuickConnectState
|
||||||
|
import ru.stenfredd.sleepyowl.ui.login.ServerCardModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login flow on the desktop: server → profile → login, the TV's [LoginViewModel] and look, laid out for a window of
|
||||||
|
* any size and typed on a keyboard. "Назад" walks the steps back; on the first step it leaves the screen when the
|
||||||
|
* screen came from "сменить профиль".
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = koinViewModel()) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
LaunchedEffect(state.done) { if (state.done) onDone() }
|
||||||
|
BackHandler { vm.back() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Row(Modifier.padding(start = 40.dp, top = 29.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OwlMark()
|
||||||
|
Wordmark()
|
||||||
|
}
|
||||||
|
StepChain(state.step, Modifier.align(Alignment.TopEnd).padding(end = 40.dp, top = 35.dp))
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(top = 96.dp).verticalScroll(rememberScrollState()).padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
key(state.step) {
|
||||||
|
when (state.step) {
|
||||||
|
LoginStep.Server -> ServerStep(state, vm)
|
||||||
|
LoginStep.Profile -> ProfileStep(state, vm)
|
||||||
|
LoginStep.Login -> LoginStepContent(state, vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BasicText(hint(state), Modifier.align(Alignment.BottomCenter).padding(bottom = 18.dp), style = AppType.MonoCount.copy(fontSize = 10.sp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hint(state: LoginState) = when (state.step) {
|
||||||
|
LoginStep.Server -> "серверы в локальной сети находятся сами"
|
||||||
|
LoginStep.Profile -> "Esc — сменить сервер"
|
||||||
|
LoginStep.Login -> "вход с телефона подтвердится сам · Esc — назад"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StepChain(step: LoginStep, modifier: Modifier = Modifier) {
|
||||||
|
val current = step.ordinal
|
||||||
|
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
listOf("Сервер", "Профиль", "Вход").forEachIndexed { i, label ->
|
||||||
|
if (i > 0) Spacer(Modifier.width(20.dp).height(1.dp).background(if (i <= current) AppColors.Accent else Color(0xFF262A3A)))
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.background(AppColors.Surface, RoundedCornerShape(5.dp))
|
||||||
|
.border(0.5.dp, if (i == current) AppColors.SelectedBorder else Color(0xFF262A3A), RoundedCornerShape(5.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
val dot = when {
|
||||||
|
i < current -> AppColors.Green
|
||||||
|
i == current -> AppColors.Accent
|
||||||
|
else -> Color(0xFF33384D)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(5.dp).background(dot, CircleShape))
|
||||||
|
BasicText(label, style = AppType.MonoMeta.copy(fontSize = 9.sp, letterSpacing = 0.08.em, color = if (i <= current) AppColors.Text else Color(0xFF6F7592)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Title(text: String, subtitle: @Composable () -> Unit = {}) {
|
||||||
|
BasicText(text, style = AppType.Display.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold))
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
subtitle()
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Step 1: server ----
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerStep(state: LoginState, vm: LoginViewModel) {
|
||||||
|
var address by rememberSaveable { mutableStateOf("") }
|
||||||
|
val addressFocus = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(state.manualOpen) { if (state.manualOpen) runCatching { addressFocus.requestFocus() } }
|
||||||
|
|
||||||
|
Title("Выберите сервер", subtitle = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||||
|
StatusDot(AppColors.Green, pulsing = state.searching)
|
||||||
|
val text = when {
|
||||||
|
state.searching || !state.searched -> "Ищем Jellyfin в локальной сети"
|
||||||
|
state.servers.isEmpty() -> "В локальной сети серверов нет — введите адрес"
|
||||||
|
else -> "Серверы Jellyfin"
|
||||||
|
}
|
||||||
|
BasicText(text, style = AppType.BodyLarge.copy(color = AppColors.TextMuted))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
for (card in state.servers) key(card.key) { ServerCard(card) { vm.pickServer(card.key) } }
|
||||||
|
AddServerCard(vm::openManual)
|
||||||
|
}
|
||||||
|
if (state.manualOpen) {
|
||||||
|
Row(Modifier.padding(top = 30.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TextField(
|
||||||
|
address,
|
||||||
|
{ address = it },
|
||||||
|
Modifier.width(360.dp),
|
||||||
|
placeholder = "https://jellyfin.example.ru или 192.168.1.10:8096",
|
||||||
|
textStyle = AppType.MonoMeta.copy(fontSize = 13.sp, color = AppColors.Text),
|
||||||
|
focusRequester = addressFocus,
|
||||||
|
onSubmit = { vm.connectManual(address) },
|
||||||
|
)
|
||||||
|
GradientButton(if (state.manualBusy) "Подключение…" else "Подключиться", { vm.connectManual(address) })
|
||||||
|
}
|
||||||
|
state.manualError?.let { ErrorText(it, Modifier.padding(top = 12.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerCard(card: ServerCardModel, onClick: () -> Unit) {
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.focusCard(FocusStyle(scale = 1.04f, corner = 10.dp, ring = 1.dp))
|
||||||
|
.width(230.dp)
|
||||||
|
.height(140.dp)
|
||||||
|
.background(AppColors.Surface, shape)
|
||||||
|
.border(0.5.dp, Color(0xFF262A3A), shape)
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(26.dp)
|
||||||
|
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp))
|
||||||
|
.padding(horizontal = 11.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
StatusDot(if (card.where == "LAN") AppColors.Blue else AppColors.Pink, pulsing = false)
|
||||||
|
BasicText("СЕРВЕР · ${card.where}", Modifier.weight(1f), style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted))
|
||||||
|
val ping = card.ping
|
||||||
|
when {
|
||||||
|
card.busy -> BasicText("подключение…", style = AppType.MonoCaption.copy(color = AppColors.Accent))
|
||||||
|
ping != null -> BasicText(ping, style = AppType.MonoCaption.copy(color = AppColors.Green))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
||||||
|
Column(Modifier.padding(horizontal = 12.dp, vertical = 13.dp).fillMaxSize()) {
|
||||||
|
BasicText(card.name, style = AppType.Display.copy(fontSize = 17.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
BasicText(card.address, style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextMuted), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
card.version?.let { BasicText("Jellyfin $it", style = AppType.MonoCaption.copy(fontSize = 9.5.sp, color = Color(0xFF6F7592))) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddServerCard(onClick: () -> Unit) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.focusCard(FocusStyle(scale = 1.04f, corner = 10.dp, ring = 1.dp))
|
||||||
|
.width(150.dp)
|
||||||
|
.height(140.dp)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.then(if (focused) Modifier.background(Color(0xFF171A2A), RoundedCornerShape(10.dp)) else Modifier.dashedBorder(Color(0xFF3A3F57), 1.dp, 10.dp))
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterVertically),
|
||||||
|
) {
|
||||||
|
Icon(AppIcon.Plus, AppColors.TextSecondary, Modifier.size(24.dp))
|
||||||
|
BasicText("Ввести адрес", style = AppType.BodyLarge.copy(fontWeight = FontWeight.SemiBold, color = AppColors.TextSecondary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Step 2: profile ----
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileStep(state: LoginState, vm: LoginViewModel) {
|
||||||
|
Title("Кто смотрит?", subtitle = {
|
||||||
|
BasicText(state.serverLine, style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextDim))
|
||||||
|
})
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||||
|
for (p in state.profiles) key(p.key) { ProfileTile(p) { vm.pickProfile(p.key) } }
|
||||||
|
}
|
||||||
|
state.profileError?.let { ErrorText(it, Modifier.padding(top = 16.dp)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileTile(profile: ProfileModel, onClick: () -> Unit) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.focusCard(FocusStyle(scale = 1.06f, corner = 12.dp, ring = 1.dp))
|
||||||
|
.width(120.dp)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.then(if (focused) Modifier.background(Color(0xFF171A2A), RoundedCornerShape(12.dp)) else Modifier)
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (profile.key == LoginViewModel.OTHER_KEY) {
|
||||||
|
Box(Modifier.size(90.dp).dashedBorder(Color(0xFF4A4F68), 1.dp, 45.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Icon(AppIcon.Plus, AppColors.TextMuted, Modifier.size(30.dp), strokeWidth = 1.8f)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Avatar(profile.name, profile.avatarSeed, 90.dp, AppType.Display.copy(fontSize = 38.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.em))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(9.dp))
|
||||||
|
BasicText(profile.name, style = AppType.Display.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.em), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
|
BasicText(profile.note, style = AppType.MonoCaption.copy(fontSize = 9.5.sp), maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Step 3: login ----
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoginStepContent(state: LoginState, vm: LoginViewModel) {
|
||||||
|
var user by rememberSaveable(state.prefillUser) { mutableStateOf(state.prefillUser) }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
val userFocus = remember { FocusRequester() }
|
||||||
|
val passwordFocus = remember { FocusRequester() }
|
||||||
|
val submit = { vm.loginWithPassword(user, password) }
|
||||||
|
// Typing starts at once: in the password when the name is known (a listed user), else in the name.
|
||||||
|
LaunchedEffect(Unit) { runCatching { if (state.prefillUser.isEmpty()) userFocus.requestFocus() else passwordFocus.requestFocus() } }
|
||||||
|
|
||||||
|
Title("Вход в аккаунт")
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||||
|
Panel("ПАРОЛЬ", Color(0xFFB39DDB)) {
|
||||||
|
Column(Modifier.padding(20.dp)) {
|
||||||
|
FieldLabel("Имя пользователя")
|
||||||
|
TextField(user, { user = it }, Modifier.fillMaxWidth(), placeholder = "например, alexey", focusRequester = userFocus, onSubmit = {
|
||||||
|
runCatching { passwordFocus.requestFocus() }
|
||||||
|
})
|
||||||
|
FieldLabel("Пароль", Modifier.padding(top = 12.dp))
|
||||||
|
TextField(password, { password = it }, Modifier.fillMaxWidth(), placeholder = "••••••••", password = true, focusRequester = passwordFocus, onSubmit = submit)
|
||||||
|
GradientButton(if (state.loginBusy) "Вход…" else "Войти", submit, Modifier.padding(top = 18.dp).fillMaxWidth(), big = true)
|
||||||
|
state.loginError?.let { ErrorText(it, Modifier.padding(top = 10.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Panel("БЫСТРЫЙ ВХОД С ТЕЛЕФОНА", AppColors.Pink) {
|
||||||
|
when (val qc = state.quickConnect) {
|
||||||
|
QuickConnectState.Loading -> PanelMessage("Получаем код…")
|
||||||
|
QuickConnectState.Disabled -> PanelMessage("Quick Connect выключен на сервере.\nВойдите по паролю.")
|
||||||
|
is QuickConnectState.Failed -> PanelMessage(qc.message)
|
||||||
|
is QuickConnectState.Code -> QuickConnectCode(qc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickConnectCode(qc: QuickConnectState.Code) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Row(Modifier.padding(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||||
|
Box(Modifier.size(160.dp).background(Color(0xFFF3F1FF), RoundedCornerShape(10.dp)).padding(11.dp)) {
|
||||||
|
Image(BitmapPainter(qc.qr, filterQuality = FilterQuality.None), contentDescription = "QR-код для входа", Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(11.dp)) {
|
||||||
|
Instruction(1, "Наведите камеру телефона на QR-код")
|
||||||
|
Instruction(2, "Или откройте Jellyfin → Quick Connect и введите код")
|
||||||
|
BasicText(
|
||||||
|
qc.code,
|
||||||
|
Modifier.padding(top = 3.dp),
|
||||||
|
style = AppType.MonoMeta.copy(fontSize = 30.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.14.em, color = Color.White),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||||
|
) {
|
||||||
|
StatusDot(AppColors.Green, pulsing = true)
|
||||||
|
BasicText("Ждём подтверждения", Modifier.weight(1f), style = AppType.Body.copy(fontSize = 11.sp, color = AppColors.TextMuted))
|
||||||
|
Countdown(qc.expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "код действует 9:58", recomposes once a second and only itself. */
|
||||||
|
@Composable
|
||||||
|
private fun Countdown(expiresAt: Long) {
|
||||||
|
var left by remember(expiresAt) { mutableStateOf(0L) }
|
||||||
|
LaunchedEffect(expiresAt) {
|
||||||
|
while (true) {
|
||||||
|
left = ((expiresAt - monotonicMs()) / 1000).coerceAtLeast(0)
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BasicText("код действует %d:%02d".format(left / 60, left % 60), style = AppType.MonoCaption.copy(fontSize = 10.sp, color = Color(0xFF6F7592)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Instruction(n: Int, text: String) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(20.dp).background(AppColors.Selected, CircleShape).border(0.5.dp, AppColors.SelectedBorder, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { BasicText("$n", style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.Text)) }
|
||||||
|
BasicText(text, style = AppType.BodyLarge.copy(fontSize = 12.sp, lineHeight = 17.sp, color = AppColors.TextSecondary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Panel(title: String, dot: Color, content: @Composable () -> Unit) {
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
Column(Modifier.width(390.dp).height(320.dp).background(AppColors.Surface, shape).border(0.5.dp, Color(0xFF262A3A), shape)) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(28.dp)
|
||||||
|
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||||
|
.padding(horizontal = 13.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
StatusDot(dot, pulsing = false)
|
||||||
|
BasicText(title, style = AppType.MonoMeta.copy(fontSize = 9.5.sp, letterSpacing = 0.1.em))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PanelMessage(text: String) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
BasicText(text, style = AppType.BodyLarge.copy(color = AppColors.TextMuted, textAlign = TextAlign.Center, lineHeight = 19.sp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FieldLabel(text: String, modifier: Modifier = Modifier) {
|
||||||
|
BasicText(text, modifier.padding(bottom = 7.dp), style = AppType.Body.copy(fontSize = 11.sp, color = AppColors.TextDim))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorText(text: String, modifier: Modifier = Modifier) {
|
||||||
|
BasicText(text, modifier, style = AppType.Body.copy(fontSize = 12.sp, color = AppColors.Red))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gradient action button of the login forms (rounded rectangle, unlike the pill `PrimaryButton`). */
|
||||||
|
@Composable
|
||||||
|
private fun GradientButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, big: Boolean = false) {
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.focusCard(FocusStyle(scale = 1.02f, corner = 8.dp, ring = 1.5.dp, ringColor = Color(0xFFD9D2FF), glows = FocusStyle.ButtonGlows))
|
||||||
|
.height(40.dp)
|
||||||
|
.background(AppColors.PrimaryGradient, RoundedCornerShape(8.dp))
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 17.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
BasicText(label, style = AppType.Button.copy(fontSize = if (big) 14.sp else 13.sp, color = Color.White))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.application
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import org.jetbrains.skia.Image
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
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.jellyfinModule
|
||||||
|
import ru.stenfredd.sleepyowl.playback.core.playbackModule
|
||||||
|
import ru.stenfredd.sleepyowl.ui.uiModule
|
||||||
|
import java.awt.Dimension
|
||||||
|
import java.awt.Frame
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
val instance = SingleInstance.acquire(DesktopPaths.runtime) ?: run {
|
||||||
|
System.err.println("SleepyOwl is already running: its window has been asked to show")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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()) }
|
||||||
|
|
||||||
|
application(exitProcessOnExit = false) {
|
||||||
|
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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.i("Exit")
|
||||||
|
instance.release()
|
||||||
|
KoinPlatform.stopKoin()
|
||||||
|
// Not exitProcess: on exit() the destructor of libGLX (glvnd) waits for AWT's X display lock, held by the AWT
|
||||||
|
// thread the JVM has frozen — the L1 prototype hung on about every third exit.
|
||||||
|
System.out.flush()
|
||||||
|
System.err.flush()
|
||||||
|
CLib._exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The class of this file, for its resources. */
|
||||||
|
private object Main
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import com.sun.jna.Native
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
|
||||||
|
/** libc functions the app needs. */
|
||||||
|
object CLib {
|
||||||
|
init {
|
||||||
|
Native.register("c")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exit without atexit handlers and library destructors, see [main]. */
|
||||||
|
@JvmStatic external fun _exit(status: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
object X11App {
|
||||||
|
/**
|
||||||
|
* WM_CLASS of every AWT window: Java takes the main class's name by default, and the panel then groups the
|
||||||
|
* windows apart from the `.desktop` file (`StartupWMClass=sleepyowl`). Called before the first window.
|
||||||
|
*/
|
||||||
|
fun setClassName(name: String) {
|
||||||
|
runCatching {
|
||||||
|
val f = Class.forName("sun.awt.X11.XToolkit").getDeclaredField("awtAppClassName")
|
||||||
|
f.isAccessible = true
|
||||||
|
f.set(null, name)
|
||||||
|
}.onFailure { Log.w(it, "WM_CLASS not set") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||||
|
import androidx.lifecycle.ViewModelStore
|
||||||
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
|
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen stack of the main window. Each entry owns its view models (cleared when the entry leaves the stack) and
|
||||||
|
* its saved UI state (scroll positions), as the TV's NavDisplay entries do; only the top entry is composed.
|
||||||
|
*/
|
||||||
|
class NavStack(first: Screen) {
|
||||||
|
class Entry internal constructor(val screen: Screen, internal val id: Long) : ViewModelStoreOwner {
|
||||||
|
override val viewModelStore = ViewModelStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextId = 0L
|
||||||
|
private val removed = mutableListOf<Entry>()
|
||||||
|
val entries = mutableStateListOf(Entry(first, nextId++))
|
||||||
|
|
||||||
|
val top: Screen get() = entries.last().screen
|
||||||
|
val size: Int get() = entries.size
|
||||||
|
|
||||||
|
fun push(screen: Screen) {
|
||||||
|
entries += Entry(screen, nextId++)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** False on the last screen: nothing to go back to. */
|
||||||
|
fun pop(): Boolean {
|
||||||
|
if (entries.size <= 1) return false
|
||||||
|
drop(entries.removeAt(entries.lastIndex))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetTo(screen: Screen) {
|
||||||
|
val old = entries.toList()
|
||||||
|
entries.clear()
|
||||||
|
entries += Entry(screen, nextId++)
|
||||||
|
old.forEach(::drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drop(entry: Entry) {
|
||||||
|
entry.viewModelStore.clear()
|
||||||
|
removed += entry
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Content(content: @Composable (Screen) -> Unit) {
|
||||||
|
val holder = rememberSaveableStateHolder()
|
||||||
|
val entry = entries.last()
|
||||||
|
// State of entries that left the stack is no longer needed (their view models are already cleared).
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
removed.forEach { holder.removeState(it.id) }
|
||||||
|
removed.clear()
|
||||||
|
}
|
||||||
|
key(entry.id) {
|
||||||
|
holder.SaveableStateProvider(entry.id) {
|
||||||
|
CompositionLocalProvider(LocalViewModelStoreOwner provides entry) { content(entry.screen) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Назад" (Esc, Alt+←, Backspace, the mouse's back button) goes to the innermost enabled [BackHandler] first — a step
|
||||||
|
* of the login flow, an open panel — and pops the screen stack only when none takes it.
|
||||||
|
*/
|
||||||
|
class BackDispatcher {
|
||||||
|
private val handlers = mutableListOf<() -> Boolean>()
|
||||||
|
|
||||||
|
fun add(handler: () -> Boolean): () -> Unit {
|
||||||
|
handlers += handler
|
||||||
|
return { handlers -= handler }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a handler took it. */
|
||||||
|
fun dispatch(): Boolean = handlers.asReversed().any { it() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalBackDispatcher = compositionLocalOf { BackDispatcher() }
|
||||||
|
|
||||||
|
/** [onBack] returns false to let "Назад" go further (to the screen stack). */
|
||||||
|
@Composable
|
||||||
|
fun BackHandler(onBack: () -> Boolean) {
|
||||||
|
val dispatcher = LocalBackDispatcher.current
|
||||||
|
val current = rememberUpdatedState(onBack)
|
||||||
|
DisposableEffect(dispatcher) {
|
||||||
|
val remove = dispatcher.add { current.value() }
|
||||||
|
onDispose(remove)
|
||||||
|
}
|
||||||
|
}
|
||||||
171
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Panels.kt
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
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.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
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.playback.api.SubtitleStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.player.PanelChoice
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Options of the TV player's side panels, for the mouse: the series' audio and subtitle tray now, the desktop player's
|
||||||
|
* panels in L7.
|
||||||
|
*/
|
||||||
|
|
||||||
|
internal object PanelColors {
|
||||||
|
val Background = Color(0xFF10121A)
|
||||||
|
val Header = Color(0xFF181B27)
|
||||||
|
val Edge = Color(0xFF262A3A)
|
||||||
|
val Option = Color(0xFF151824)
|
||||||
|
val OptionBorder = Color(0xFF232637)
|
||||||
|
val Selected = Color(0xFF1B1D38)
|
||||||
|
val SelectedBorder = Color(0xFF5F55E6)
|
||||||
|
val Focused = Color(0xFF1F2238)
|
||||||
|
val Ring = Color(0xFF4A4F68)
|
||||||
|
val SectionTitle = Color(0xFF6F7592)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val OptionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))))
|
||||||
|
private val ChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PanelSection(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
Column {
|
||||||
|
BasicText(title, style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle, letterSpacing = 0.1.em))
|
||||||
|
Column(Modifier.padding(top = 7.dp), verticalArrangement = Arrangement.spacedBy(4.dp), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List option with a radio mark: "◉ Русский TrueHD Atmos · 7.1". */
|
||||||
|
@Composable
|
||||||
|
fun PanelOption(choice: PanelChoice, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(7.dp)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.focusCard(OptionFocus)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(40.dp)
|
||||||
|
.background(if (focused) PanelColors.Focused else if (choice.selected) PanelColors.Selected else PanelColors.Option, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.Accent else if (choice.selected) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 11.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(13.dp).border(1.dp, if (choice.selected) AppColors.Accent else PanelColors.Ring, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (choice.selected) Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||||
|
}
|
||||||
|
BasicText(
|
||||||
|
choice.label,
|
||||||
|
Modifier.weight(1f),
|
||||||
|
style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
choice.detail?.let { BasicText(it, style = AppType.MonoCaption.copy(fontSize = 10.sp), maxLines = 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small choice in a row: size "M", colour "● Жёлтый", background "Тень". */
|
||||||
|
@Composable
|
||||||
|
fun PanelChip(choice: PanelChoice, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(15.dp)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.focusCard(ChipFocus)
|
||||||
|
.height(30.dp)
|
||||||
|
.background(if (focused) AppColors.FocusFill else if (choice.selected) PanelColors.Selected else PanelColors.Option, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.FocusFill else if (choice.selected) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
choice.swatch?.let { Box(Modifier.size(9.dp).background(it, CircleShape).border(0.5.dp, Color(0x4D000000), CircleShape)) }
|
||||||
|
BasicText(choice.label, style = AppType.Button.copy(fontSize = 11.5.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PanelList(choices: List<PanelChoice>, onPick: (PanelChoice) -> Unit) {
|
||||||
|
for (c in choices) PanelOption(c, onClick = { onPick(c) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PanelChips(choices: List<PanelChoice>, onPick: (PanelChoice) -> Unit) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
|
for (c in choices) PanelChip(c, onClick = { onPick(c) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How text subtitles will look: sample line over a dark picture-like box. */
|
||||||
|
@Composable
|
||||||
|
fun SubtitlePreview(style: SubtitleStyle, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(85.dp)
|
||||||
|
.background(Brush.linearGradient(listOf(Color(0xFF2B4A66), Color(0xFF16233A), Color(0xFF241B36))), RoundedCornerShape(7.dp))
|
||||||
|
.border(0.5.dp, PanelColors.Edge, RoundedCornerShape(7.dp))
|
||||||
|
.padding(bottom = 11.dp),
|
||||||
|
contentAlignment = Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
val box = style.background == SubtitleStyle.Background.BOX
|
||||||
|
BasicText(
|
||||||
|
"— Ты слышишь? Сигнал снова идёт со дна.",
|
||||||
|
Modifier
|
||||||
|
.then(if (box) Modifier.background(Color(0xB8000000), RoundedCornerShape(3.dp)) else Modifier)
|
||||||
|
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||||
|
style = AppType.Body.copy(
|
||||||
|
fontSize = 13.sp * style.size.scale,
|
||||||
|
lineHeight = 16.sp * style.size.scale,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color(style.color),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
shadow = if (style.background == SubtitleStyle.Background.SHADOW) Shadow(Color.Black, Offset(0f, 2f), 6f) else null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
142
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Rows.kt
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
|
/** "Недавно добавлено 12" */
|
||||||
|
@Composable
|
||||||
|
fun SectionTitle(title: String, count: Int?, modifier: Modifier = Modifier) {
|
||||||
|
Row(modifier.padding(horizontal = AppDimens.CONTENT_START.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
BasicText(title, style = AppType.Section)
|
||||||
|
count?.let { BasicText(it.toString(), Modifier.padding(bottom = 1.dp), style = AppType.MonoCount) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A titled horizontal row of cards, as on the TV, scrolled by the mouse ([ScrollRow]). */
|
||||||
|
@Composable
|
||||||
|
fun ContentRow(title: String, count: Int?, modifier: Modifier = Modifier, content: LazyListScope.() -> Unit) {
|
||||||
|
Column(modifier) {
|
||||||
|
SectionTitle(title, count)
|
||||||
|
// Vertical padding leaves room for the focused card's scale and glow.
|
||||||
|
ScrollRow(contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 14.dp), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizontal lazy row for the mouse: Shift+wheel and a touchpad's sideways swipe scroll it (Compose does both), and
|
||||||
|
* while the pointer is over it, round ‹ › buttons at its ends turn it by most of a screen.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScrollRow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: LazyListState = rememberLazyListState(),
|
||||||
|
contentPadding: PaddingValues = PaddingValues(horizontal = AppDimens.CONTENT_START.dp),
|
||||||
|
spacing: Dp = AppDimens.ROW_GAP.dp,
|
||||||
|
content: LazyListScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
var hovered by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
fun turn(direction: Int) {
|
||||||
|
val page = state.layoutInfo.viewportSize.width * PAGE * direction
|
||||||
|
scope.launch { state.animateScrollBy(page, tween(TURN_MILLIS, easing = FastOutSlowInEasing)) }
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.onPointerEvent(PointerEventType.Enter) { hovered = true }
|
||||||
|
.onPointerEvent(PointerEventType.Exit) { hovered = false },
|
||||||
|
) {
|
||||||
|
LazyRow(state = state, contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(spacing), content = content)
|
||||||
|
if (hovered && state.canScrollBackward) RowArrow(back = true, Modifier.align(Alignment.CenterStart).padding(start = 12.dp)) { turn(-1) }
|
||||||
|
if (hovered && state.canScrollForward) RowArrow(back = false, Modifier.align(Alignment.CenterEnd).padding(end = 12.dp)) { turn(1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PAGE = 0.8f
|
||||||
|
private const val TURN_MILLIS = 450
|
||||||
|
|
||||||
|
/** ‹ or ›: not focusable, the keyboard has its arrows. */
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun RowArrow(back: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
var hovered by remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.zIndex(2f)
|
||||||
|
.size(36.dp)
|
||||||
|
.background(if (hovered) AppColors.FocusFill else Color(0xE6151824), CircleShape)
|
||||||
|
.border(0.5.dp, if (hovered) AppColors.FocusFill else AppColors.Border, CircleShape)
|
||||||
|
.onPointerEvent(PointerEventType.Enter) { hovered = true }
|
||||||
|
.onPointerEvent(PointerEventType.Exit) { hovered = false }
|
||||||
|
.pointerInput(Unit) { detectTapGestures { onClick() } },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(AppIcon.ChevronRight, if (hovered) AppColors.OnFocusFill else AppColors.Text, Modifier.size(16.dp).then(if (back) Modifier.rotate(180f) else Modifier))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Загрузка…", "Не удалось загрузить: …" in the middle of the screen. */
|
||||||
|
@Composable
|
||||||
|
fun Message(text: String) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { BasicText(text, style = AppType.Body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "‹" at the top left of the full-screen pages (movie, series, person): the mouse's way back. */
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BackArrow(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
var hovered by remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.background(if (hovered) AppColors.SurfaceFocused else Color(0xB3151824), CircleShape)
|
||||||
|
.border(0.5.dp, if (hovered) AppColors.Accent else AppColors.Border, CircleShape)
|
||||||
|
.onPointerEvent(PointerEventType.Enter) { hovered = true }
|
||||||
|
.onPointerEvent(PointerEventType.Exit) { hovered = false }
|
||||||
|
.pointerInput(Unit) { detectTapGestures { onClick() } },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(AppIcon.ChevronRight, if (hovered) AppColors.Text else AppColors.TextMuted, Modifier.size(15.dp).rotate(180f))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.PosterCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.PosterModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.fullItem
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||||
|
import ru.stenfredd.sleepyowl.ui.search.PersonCardModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.search.SearchLayout
|
||||||
|
import ru.stenfredd.sleepyowl.ui.search.SearchState
|
||||||
|
import ru.stenfredd.sleepyowl.ui.search.SearchViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search tab: a plain text field (the TV's on-screen keyboard isn't needed), under it recent queries and newest items,
|
||||||
|
* or the results by library and people. The field has focus when the tab opens.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = koinViewModel()) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
val open = { key: String -> vm.open(key)?.let(onOpen) ?: Unit }
|
||||||
|
var text by rememberSaveable { mutableStateOf(state.query) }
|
||||||
|
val field = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
withFrameNanos { }
|
||||||
|
runCatching { field.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = AppDimens.CONTENT_TOP.dp, bottom = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Icon(AppIcon.Search, AppColors.Accent, Modifier.size(18.dp), strokeWidth = 2.2f)
|
||||||
|
TextField(
|
||||||
|
text,
|
||||||
|
{
|
||||||
|
text = it
|
||||||
|
vm.setQuery(it)
|
||||||
|
},
|
||||||
|
Modifier.widthIn(max = 480.dp),
|
||||||
|
placeholder = "Название, жанр, актёр",
|
||||||
|
textStyle = AppType.BodyLarge.copy(fontSize = 16.sp, color = AppColors.Text),
|
||||||
|
focusRequester = field,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Results(state, focus, vm, open) { query ->
|
||||||
|
text = query
|
||||||
|
vm.pickRecent(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focus.Entry(ready = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Results(state: SearchState, focus: ScreenFocus, vm: SearchViewModel, open: (String) -> Unit, onRecent: (String) -> Unit) {
|
||||||
|
val grid = rememberLazyGridState()
|
||||||
|
// A new query starts the results from the top.
|
||||||
|
LaunchedEffect(state.results) { if (grid.firstVisibleItemIndex > 0) grid.scrollToItem(0) }
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
|
||||||
|
state = grid,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = 16.dp, bottom = 80.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
val results = state.results
|
||||||
|
if (results == null) {
|
||||||
|
if (state.recentQueries.isNotEmpty()) {
|
||||||
|
fullItem("recent-q", "label") {
|
||||||
|
Column(Modifier.padding(bottom = 22.dp)) {
|
||||||
|
Label("НЕДАВНИЕ ЗАПРОСЫ")
|
||||||
|
FlowRow(Modifier.padding(top = 9.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
state.recentQueries.forEachIndexed { i, q ->
|
||||||
|
Chip(q, false, { onRecent(q) }, Modifier.hoverFocus().remembered(focus, "rq/$i"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.recentItems.isNotEmpty()) {
|
||||||
|
fullItem("recent-label", "label") { Label("НЕДАВНО ДОБАВЛЕНО", Modifier.padding(bottom = 13.dp)) }
|
||||||
|
posters(state.recentItems, focus, open)
|
||||||
|
}
|
||||||
|
return@LazyVerticalGrid
|
||||||
|
}
|
||||||
|
fullItem("found", "label") {
|
||||||
|
Label(if (state.searching) "ИЩЕМ…" else "НАЙДЕНО: ${results.sumOf { it.size }}")
|
||||||
|
}
|
||||||
|
if (results.isEmpty() && !state.searching) {
|
||||||
|
fullItem("none", "text") {
|
||||||
|
BasicText(
|
||||||
|
state.error?.let { "Не удалось найти: $it" } ?: "Ничего не нашлось по «${state.query.trim()}». Попробуйте часть названия или имя актёра.",
|
||||||
|
Modifier.padding(top = 12.dp).widthIn(max = 480.dp),
|
||||||
|
style = AppType.Body.copy(fontSize = 15.sp, lineHeight = 21.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (group in results) {
|
||||||
|
fullItem(group.key, "group") {
|
||||||
|
Row(Modifier.padding(top = 16.dp, bottom = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
BasicText(group.title, style = AppType.Section)
|
||||||
|
BasicText(group.size.toString(), Modifier.padding(bottom = 1.dp), style = AppType.MonoCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
posters(group.posters, focus, open)
|
||||||
|
items(group.people, key = { it.key }, contentType = { "person" }) { p ->
|
||||||
|
PersonCard(p, { open(p.key) }, Modifier.padding(bottom = 18.dp).hoverFocus().remembered(focus, p.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyGridScope.posters(list: List<PosterModel>, focus: ScreenFocus, open: (String) -> Unit) {
|
||||||
|
items(list, key = { it.key }, contentType = { "poster" }) { p ->
|
||||||
|
PosterCard(p, { open(p.key) }, Modifier.padding(bottom = 18.dp).hoverFocus().remembered(focus, p.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PersonFocus = FocusStyle(scale = 1.05f, corner = (SearchLayout.PERSON_PHOTO / 2).dp, ring = 1.dp)
|
||||||
|
|
||||||
|
/** Round photo (or initials) and the name under it. */
|
||||||
|
@Composable
|
||||||
|
private fun PersonCard(person: PersonCardModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
|
val size = SearchLayout.PERSON_PHOTO.dp
|
||||||
|
Column(modifier.width(AppDimens.POSTER_W.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.focusCard(PersonFocus)
|
||||||
|
.size(size)
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||||
|
) {
|
||||||
|
if (person.photo != null) {
|
||||||
|
RemoteImage(person.photo, size / 2, Modifier.fillMaxSize())
|
||||||
|
} else {
|
||||||
|
Avatar(person.name, person.key, size, AppType.Hero.copy(fontSize = 24.sp), letters = person.letters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BasicText(
|
||||||
|
person.name,
|
||||||
|
Modifier.padding(top = 8.dp),
|
||||||
|
style = AppType.PosterTitle.copy(textAlign = TextAlign.Center),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Label(text: String, modifier: Modifier = Modifier) {
|
||||||
|
BasicText(text, modifier, style = AppType.MonoLabel.copy(color = AppColors.TextDim))
|
||||||
|
}
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
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.detectTapGestures
|
||||||
|
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.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
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.Stable
|
||||||
|
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.saveable.listSaver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
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.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.em
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.CircleButton
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.PrimaryButton
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.ProgressLine
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.LocalWatchMenu
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.clickableWithMenu
|
||||||
|
import ru.stenfredd.sleepyowl.ui.player.SubtitleChoices
|
||||||
|
import ru.stenfredd.sleepyowl.ui.player.toChoices
|
||||||
|
import ru.stenfredd.sleepyowl.ui.series.EpisodeState
|
||||||
|
import ru.stenfredd.sleepyowl.ui.series.SeriesEpisode
|
||||||
|
import ru.stenfredd.sleepyowl.ui.series.SeriesLayout
|
||||||
|
import ru.stenfredd.sleepyowl.ui.series.SeriesModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.series.SeriesSeason
|
||||||
|
import ru.stenfredd.sleepyowl.ui.series.SeriesState
|
||||||
|
import ru.stenfredd.sleepyowl.ui.series.SeriesViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series page, as on the TV: header with "Продолжить S2 E4", the audio/subtitle choice and ♥; the season map; season
|
||||||
|
* chips; every episode of every season in one row (a divider before each season); the pointed (or focused) episode's
|
||||||
|
* description under it. A click on a season chip turns the row to that season; pointing along the row lights up its
|
||||||
|
* season. Scrolls as a whole when the window is low.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SeriesScreen(
|
||||||
|
screen: Screen.Series,
|
||||||
|
onOpen: (Screen) -> Unit,
|
||||||
|
vm: SeriesViewModel = koinViewModel { parametersOf(screen.seriesId) },
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
val item = state.item
|
||||||
|
when {
|
||||||
|
item != null -> SeriesPage(screen, item, state, focus, vm, onOpen)
|
||||||
|
state.error != null -> Message("Не удалось загрузить: ${state.error}")
|
||||||
|
}
|
||||||
|
focus.Entry(ready = item != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val KEY_PLAY = "play"
|
||||||
|
private const val KEY_TRACKS = "tracks"
|
||||||
|
private const val KEY_WATCHED = "watched"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the page highlights: the season of the chips and the map, the episode described at the bottom.
|
||||||
|
* [episode] is read only by the parts that show it, so moving along the row doesn't recompose the page.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
private class SeriesSelection(season: Int, episode: Int) {
|
||||||
|
var season by mutableIntStateOf(season)
|
||||||
|
var episode by mutableIntStateOf(episode)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Saver = listSaver<SeriesSelection, Int>({ listOf(it.season, it.episode) }, { SeriesSelection(it[0], it[1]) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeriesPage(screen: Screen.Series, item: SeriesModel, state: SeriesState, focus: ScreenFocus, vm: SeriesViewModel, onOpen: (Screen) -> Unit) {
|
||||||
|
// Opened on an episode (a new one, continue watching): it is shown first, else the next one to watch.
|
||||||
|
val start = remember(item.id) { item.episodes.indexOfFirst { it.id == screen.episodeId }.takeIf { it >= 0 } }
|
||||||
|
val first = start ?: item.nextUp
|
||||||
|
val selection = rememberSaveable(saver = SeriesSelection.Saver) { SeriesSelection(item.episodes.getOrNull(first)?.season ?: 0, first) }
|
||||||
|
// The data can change on reload (a new episode): stay in range.
|
||||||
|
val seasonIndex = selection.season.coerceIn(0, (item.seasons.size - 1).coerceAtLeast(0))
|
||||||
|
|
||||||
|
val episodesRow = rememberLazyListState(initialFirstVisibleItemIndex = item.episodes.getOrNull(first)?.rowIndex?.minus(1)?.coerceAtLeast(0) ?: 0)
|
||||||
|
val chipsRow = rememberLazyListState(initialFirstVisibleItemIndex = (seasonIndex - 1).coerceAtLeast(0))
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var trayOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun selectSeason(season: Int) {
|
||||||
|
selection.season = season
|
||||||
|
val s = item.seasons[season]
|
||||||
|
selection.episode = s.target
|
||||||
|
// The row follows the chips: the season's divider (or its next episode, deeper in the season) at the start.
|
||||||
|
val target = if (s.target == s.first) s.dividerIndex else item.episodes[s.target].rowIndex - 1
|
||||||
|
scope.launch { episodesRow.animateScrollToItem(target.coerceAtLeast(0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEpisodeFocused(index: Int) {
|
||||||
|
selection.episode = index
|
||||||
|
val season = item.episodes[index].season
|
||||||
|
if (season != selection.season) {
|
||||||
|
selection.season = season
|
||||||
|
if (chipsRow.layoutInfo.visibleItemsInfo.none { it.index == season }) scope.launch { chipsRow.animateScrollToItem((season - 1).coerceAtLeast(0)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
item.backdropUrl?.let { Backdrop(it) }
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 32.dp, bottom = 24.dp)) {
|
||||||
|
Header(
|
||||||
|
item,
|
||||||
|
state,
|
||||||
|
focus,
|
||||||
|
isDefault = start == null,
|
||||||
|
onPlay = { item.episodes.getOrNull(item.nextUp)?.let { onOpen(vm.player(it)) } },
|
||||||
|
onTracks = { trayOpen = true },
|
||||||
|
onFavorite = vm::toggleFavorite,
|
||||||
|
)
|
||||||
|
SeasonMap(item, seasonIndex, current = { selection.episode }, Modifier.padding(top = 16.dp).padding(horizontal = AppDimens.CONTENT_START.dp).fillMaxWidth())
|
||||||
|
SeasonChips(item, seasonIndex, chipsRow, focus, onClick = ::selectSeason, modifier = Modifier.padding(top = 5.dp))
|
||||||
|
EpisodeRow(
|
||||||
|
item,
|
||||||
|
episodesRow,
|
||||||
|
focus,
|
||||||
|
defaultKey = start?.let { item.episodes[it].key },
|
||||||
|
onFocused = ::onEpisodeFocused,
|
||||||
|
onClick = { onOpen(vm.player(it)) },
|
||||||
|
)
|
||||||
|
SelectedEpisode(item, selection)
|
||||||
|
}
|
||||||
|
if (trayOpen) {
|
||||||
|
val style by vm.subtitleStyle.collectAsState()
|
||||||
|
BackHandler { trayOpen = false; true }
|
||||||
|
// A click past the tray closes it.
|
||||||
|
Spacer(Modifier.fillMaxSize().background(Color(0x80050609)).pointerInput(Unit) { detectTapGestures { trayOpen = false } })
|
||||||
|
SeriesTray(item.title, state, style, vm, onClose = { trayOpen = false }, modifier = Modifier.align(Alignment.BottomCenter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TitleStyle = AppType.Display.copy(fontSize = 36.sp, lineHeight = 38.sp, letterSpacing = (-0.03).em)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Header(
|
||||||
|
item: SeriesModel,
|
||||||
|
state: SeriesState,
|
||||||
|
focus: ScreenFocus,
|
||||||
|
isDefault: Boolean,
|
||||||
|
onPlay: () -> Unit,
|
||||||
|
onTracks: () -> Unit,
|
||||||
|
onFavorite: () -> Unit,
|
||||||
|
) {
|
||||||
|
val left = AppDimens.CONTENT_START.dp
|
||||||
|
Row(Modifier.padding(horizontal = left)) {
|
||||||
|
RemoteImage(
|
||||||
|
item.poster,
|
||||||
|
8.dp,
|
||||||
|
Modifier.size(SeriesLayout.POSTER_W.dp, SeriesLayout.POSTER_H.dp).border(0.5.dp, Color(0x1AFFFFFF), RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
Column(Modifier.padding(start = 20.dp)) {
|
||||||
|
BasicText(item.label, style = AppType.MonoLabel)
|
||||||
|
BasicText(item.title, Modifier.padding(top = 9.dp), style = TitleStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Meta(item.rating, item.age, listOfNotNull(item.year, item.genres), Modifier.padding(top = 9.dp), badges = state.badges)
|
||||||
|
FlowRow(Modifier.padding(top = 13.dp), horizontalArrangement = Arrangement.spacedBy(7.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||||
|
PrimaryButton(item.playLabel, onPlay, Modifier.hoverFocus().remembered(focus, KEY_PLAY, isDefault = isDefault))
|
||||||
|
if (state.audioLabel != null || state.subtitleLabel != null) {
|
||||||
|
TracksButton(state.audioLabel, state.subtitleLabel, onTracks, Modifier.hoverFocus().remembered(focus, KEY_TRACKS))
|
||||||
|
}
|
||||||
|
CircleButton(
|
||||||
|
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
|
||||||
|
onFavorite,
|
||||||
|
Modifier.hoverFocus().remembered(focus, "favorite"),
|
||||||
|
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
|
||||||
|
)
|
||||||
|
// The whole series at once, so through the menu (it says how many episodes) rather than a toggle.
|
||||||
|
val menu = LocalWatchMenu.current
|
||||||
|
val watchedButton = remember { FocusRequester() }
|
||||||
|
CircleButton(
|
||||||
|
AppIcon.Check,
|
||||||
|
{ menu?.open(item.watch, watchedButton) },
|
||||||
|
Modifier.hoverFocus().focusRequester(watchedButton).remembered(focus, KEY_WATCHED),
|
||||||
|
tint = if (item.watch.played) AppColors.Green else Color(0xFFE3E5F2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TracksFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
|
||||||
|
|
||||||
|
/** "● Русский 5.1 ● без субтитров": opens the tray. */
|
||||||
|
@Composable
|
||||||
|
private fun TracksButton(audio: String?, subtitle: String?, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(21.dp)
|
||||||
|
val color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(TracksFocus)
|
||||||
|
.height(42.dp)
|
||||||
|
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val style = AppType.Button.copy(fontSize = 12.sp, fontWeight = FontWeight.Normal, color = color)
|
||||||
|
audio?.let {
|
||||||
|
Box(Modifier.size(5.dp).background(AppColors.Pink, CircleShape))
|
||||||
|
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
|
||||||
|
}
|
||||||
|
subtitle?.let {
|
||||||
|
Box(Modifier.padding(start = if (audio != null) 10.dp else 0.dp).size(5.dp).background(AppColors.Yellow, CircleShape))
|
||||||
|
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object MapColors {
|
||||||
|
val Watched = Color(0xFF3F8F63)
|
||||||
|
val Empty = Color(0xFF33384D)
|
||||||
|
val Label = Color(0xFF6F7592)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Season map: a bar per episode — watched green, next violet, new outlined pink — the selected episode tall and
|
||||||
|
* white. Bars shrink for long seasons; if the series still doesn't fit, the seasons around the selected one are shown.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SeasonMap(item: SeriesModel, selectedSeason: Int, current: () -> Int, modifier: Modifier) {
|
||||||
|
val layout = remember(item.seasons, selectedSeason) { mapLayout(item.seasons, selectedSeason) }
|
||||||
|
Row(modifier.height(MAP_H.dp), verticalAlignment = Alignment.Bottom) {
|
||||||
|
Row(Modifier.width(MAP_W.dp), horizontalArrangement = Arrangement.spacedBy(MAP_GAP.dp), verticalAlignment = Alignment.Bottom) {
|
||||||
|
if (layout.from > 0) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
|
||||||
|
for (s in layout.from until layout.to) {
|
||||||
|
val season = item.seasons[s]
|
||||||
|
val width = (layout.bar + layout.gap) * season.count - layout.gap
|
||||||
|
Column {
|
||||||
|
BasicText(
|
||||||
|
season.mapLabel,
|
||||||
|
style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = if (s == selectedSeason) AppColors.Text else MapColors.Label),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Box(Modifier.padding(top = 4.dp).size(width.coerceAtLeast(1f).dp, BAR_TALL.dp)) {
|
||||||
|
Spacer(Modifier.fillMaxSize().drawWithCache { onDrawBehind { drawBars(item, season, layout) } })
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.size(layout.bar.dp, BAR_TALL.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
val i = current() - season.first
|
||||||
|
alpha = if (i in 0 until season.count) 1f else 0f
|
||||||
|
translationX = i.coerceAtLeast(0) * floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(floor(layout.bar.dp.toPx()).coerceAtLeast(1f) + 1f)
|
||||||
|
}
|
||||||
|
.drawBehind { drawRect(Color.White, size = Size(floor(size.width).coerceAtLeast(1f), size.height)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (layout.to < item.seasons.size) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
|
val legend = AppType.MonoCaption
|
||||||
|
BasicText("■ просмотрено", style = legend.copy(color = AppColors.Green))
|
||||||
|
BasicText("■ следующая", style = legend.copy(color = AppColors.Accent))
|
||||||
|
BasicText("□ новая", style = legend.copy(color = AppColors.Pink))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bars of one season: plain rectangles, outlines as four 1 px sides. */
|
||||||
|
private fun DrawScope.drawBars(item: SeriesModel, season: SeriesSeason, layout: MapLayout) {
|
||||||
|
val bar = floor(layout.bar.dp.toPx()).coerceAtLeast(1f)
|
||||||
|
val step = floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(bar + 1f)
|
||||||
|
val low = floor(BAR_H.dp.toPx())
|
||||||
|
val top = size.height - low
|
||||||
|
fun outline(color: Color, x: Float) {
|
||||||
|
drawRect(color, Offset(x, top), Size(bar, 1f))
|
||||||
|
drawRect(color, Offset(x, size.height - 1f), Size(bar, 1f))
|
||||||
|
drawRect(color, Offset(x, top), Size(1f, low))
|
||||||
|
drawRect(color, Offset(x + bar - 1f, top), Size(1f, low))
|
||||||
|
}
|
||||||
|
for (i in 0 until season.count) {
|
||||||
|
val index = season.first + i
|
||||||
|
val x = i * step
|
||||||
|
when (item.episodes[index].state) {
|
||||||
|
EpisodeState.Watched -> drawRect(MapColors.Watched, Offset(x, top), Size(bar, low))
|
||||||
|
EpisodeState.InProgress, EpisodeState.Next -> drawRect(AppColors.Accent, Offset(x, top), Size(bar, low))
|
||||||
|
EpisodeState.New -> outline(AppColors.Pink, x)
|
||||||
|
EpisodeState.None -> if (bar >= 3f) outline(MapColors.Empty, x) else drawRect(MapColors.Empty, Offset(x, top), Size(bar, low))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAP_W = 600f
|
||||||
|
private const val MAP_H = 32f
|
||||||
|
private const val MAP_GAP = 14f
|
||||||
|
private const val BAR_H = 10f
|
||||||
|
private const val BAR_TALL = 17f
|
||||||
|
/** Width (dp) of one character of the map labels (JetBrains Mono advance is 0.6 em). */
|
||||||
|
private const val LABEL_CHAR = 8.5f * 0.6f
|
||||||
|
|
||||||
|
private data class MapLayout(val bar: Float, val gap: Float, val from: Int, val to: Int)
|
||||||
|
|
||||||
|
/** Bar size that fits all seasons in [MAP_W], else the widest window of seasons around [selected] at the smallest size. */
|
||||||
|
private fun mapLayout(seasons: List<SeriesSeason>, selected: Int): MapLayout {
|
||||||
|
if (seasons.isEmpty()) return MapLayout(7f, 2f, 0, 0)
|
||||||
|
fun width(s: SeriesSeason, bar: Float, gap: Float) = maxOf(s.count * (bar + gap) - gap, s.mapLabel.length * LABEL_CHAR)
|
||||||
|
fun total(range: IntRange, bar: Float, gap: Float) = range.sumOf { width(seasons[it], bar, gap).toDouble() }.toFloat() + MAP_GAP * (range.count() - 1)
|
||||||
|
for ((bar, gap) in listOf(7f to 2f, 5f to 1.5f, 3f to 1f, 2f to 1f)) {
|
||||||
|
if (total(seasons.indices, bar, gap) <= MAP_W) return MapLayout(bar, gap, 0, seasons.size)
|
||||||
|
}
|
||||||
|
val (bar, gap) = 2f to 1f
|
||||||
|
// Room for the "…" marks at both ends.
|
||||||
|
val room = MAP_W - 2 * (MAP_GAP + LABEL_CHAR)
|
||||||
|
var from = selected.coerceIn(seasons.indices)
|
||||||
|
var to = from + 1
|
||||||
|
while (true) {
|
||||||
|
val grown = when {
|
||||||
|
to < seasons.size && total(from..to, bar, gap) <= room -> from until to + 1
|
||||||
|
from > 0 && total(from - 1 until to, bar, gap) <= room -> from - 1 until to
|
||||||
|
else -> break
|
||||||
|
}
|
||||||
|
from = grown.first
|
||||||
|
to = grown.last + 1
|
||||||
|
}
|
||||||
|
// A single season longer than the map: its bars get thinner still.
|
||||||
|
val single = if (to - from == 1) (room / seasons[from].count).coerceIn(1f, bar + gap) else bar + gap
|
||||||
|
return if (to - from == 1 && single < bar + gap) MapLayout(single * 0.6f, single * 0.4f, from, to) else MapLayout(bar, gap, from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SeasonChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeasonChips(item: SeriesModel, selected: Int, state: LazyListState, focus: ScreenFocus, onClick: (Int) -> Unit, modifier: Modifier) {
|
||||||
|
ScrollRow(modifier, state = state, contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 6.dp), spacing = 6.dp) {
|
||||||
|
itemsIndexed(item.seasons, key = { _, s -> s.key }) { i, s ->
|
||||||
|
SeasonChip(s, i == selected, onClick = { onClick(i) }, modifier = Modifier.hoverFocus().remembered(focus, s.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SeasonChip(season: SeriesSeason, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(15.dp)
|
||||||
|
val color = when {
|
||||||
|
focused -> AppColors.OnFocusFill
|
||||||
|
selected -> AppColors.Text
|
||||||
|
else -> AppColors.TextSecondary
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(SeasonChipFocus)
|
||||||
|
.height(30.dp)
|
||||||
|
.background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickableWithMenu(season.watch, onClick)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
BasicText(season.label, style = AppType.Button.copy(color = color), maxLines = 1)
|
||||||
|
BasicText(season.stat, style = AppType.MonoCaption.copy(color = color.copy(alpha = 0.75f)), maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val EpisodeFocus = FocusStyle(scale = 1.06f, corner = 8.dp)
|
||||||
|
|
||||||
|
/** Every episode of the series, a "СЕЗОН N" divider before each season. */
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeRow(
|
||||||
|
item: SeriesModel,
|
||||||
|
state: LazyListState,
|
||||||
|
focus: ScreenFocus,
|
||||||
|
defaultKey: String?,
|
||||||
|
onFocused: (Int) -> Unit,
|
||||||
|
onClick: (SeriesEpisode) -> Unit,
|
||||||
|
) {
|
||||||
|
ScrollRow(state = state, contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 12.dp), spacing = 12.dp) {
|
||||||
|
for (season in item.seasons) {
|
||||||
|
item(key = "div/${season.key}", contentType = "divider") { SeasonDivider(season.label.uppercase()) }
|
||||||
|
itemsIndexed(item.episodes.subList(season.first, season.first + season.count), key = { _, e -> e.key }, contentType = { _, _ -> "episode" }) { i, e ->
|
||||||
|
val index = season.first + i
|
||||||
|
EpisodeCard(
|
||||||
|
e,
|
||||||
|
onClick = { onClick(e) },
|
||||||
|
modifier = Modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.remembered(focus, e.key, isDefault = e.key == defaultKey)
|
||||||
|
.onFocusChanged { if (it.isFocused) onFocused(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertical "СЕЗОН 2" on a dashed line. */
|
||||||
|
@Composable
|
||||||
|
private fun SeasonDivider(label: String) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(22.dp, SeriesLayout.EPISODE_H.dp)
|
||||||
|
.drawBehind {
|
||||||
|
val dash = 3.dp.toPx().roundToInt().toFloat()
|
||||||
|
var y = 0f
|
||||||
|
while (y < size.height) {
|
||||||
|
drawRect(MapColors.Empty, Offset(0f, y), Size(1f, dash.coerceAtMost(size.height - y)))
|
||||||
|
y += dash * 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
BasicText(
|
||||||
|
label,
|
||||||
|
Modifier.wrapContentSize(unbounded = true).rotate(-90f),
|
||||||
|
style = AppType.MonoCaption.copy(letterSpacing = 0.14.em),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeCard(e: SeriesEpisode, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
Column(Modifier.width(SeriesLayout.EPISODE_W.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier
|
||||||
|
.focusCard(EpisodeFocus)
|
||||||
|
.size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp)
|
||||||
|
.clickableWithMenu(e.watch, onClick),
|
||||||
|
) {
|
||||||
|
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize())
|
||||||
|
BasicText(
|
||||||
|
e.code,
|
||||||
|
Modifier.padding(start = 8.dp, top = 6.dp),
|
||||||
|
style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)),
|
||||||
|
)
|
||||||
|
badge(e)?.let { (text, color) ->
|
||||||
|
BasicText(
|
||||||
|
text,
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(7.dp)
|
||||||
|
.background(Color(0xBF0A0B10), RoundedCornerShape(3.5.dp))
|
||||||
|
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
|
||||||
|
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||||
|
style = AppType.MonoBadge.copy(fontSize = 8.5.sp, color = color),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
e.progress?.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(fontSize = 12.sp, color = if (e.state == EpisodeState.Watched) AppColors.TextMuted else AppColors.Text),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
e.meta?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption, maxLines = 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun badge(e: SeriesEpisode): Pair<String, Color>? = when (e.state) {
|
||||||
|
EpisodeState.Watched -> "✓" to AppColors.Green
|
||||||
|
EpisodeState.InProgress -> "${((e.progress ?: 0f) * 100).roundToInt()}%" to AppColors.Accent
|
||||||
|
EpisodeState.Next -> "далее" to AppColors.Accent
|
||||||
|
EpisodeState.New -> "новая" to AppColors.Pink
|
||||||
|
EpisodeState.None -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedEpisode(item: SeriesModel, selection: SeriesSelection) {
|
||||||
|
item.episodes.getOrNull(selection.episode.coerceIn(0, (item.episodes.size - 1).coerceAtLeast(0)))?.let { EpisodeDetail(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected episode under the row: "СЕЗОН 2 · СЕРИЯ 4", title, description, duration and air date. */
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeDetail(e: SeriesEpisode) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = AppDimens.CONTENT_START.dp)
|
||||||
|
.padding(top = 6.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.drawBehind { drawRect(AppColors.BorderSubtle, size = Size(size.width, 1f)) }
|
||||||
|
.padding(top = 11.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
BasicText(e.longCode, Modifier.width(126.dp).padding(top = 3.dp), style = AppType.MonoLabel.copy(letterSpacing = 0.06.em))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
BasicText(e.title, style = AppType.Section.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
e.overview?.let {
|
||||||
|
BasicText(it, Modifier.padding(top = 4.dp), style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 15.sp), maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(top = 2.dp)) {
|
||||||
|
val style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.TextDim, textAlign = TextAlign.End)
|
||||||
|
e.duration?.let { BasicText(it, Modifier.align(Alignment.End), style = style) }
|
||||||
|
e.airDate?.let { BasicText(it, Modifier.align(Alignment.End).padding(top = 5.dp), style = style) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Аудио и субтитры" tray at the bottom of the page: audio, subtitles and their look, applied at once and remembered
|
||||||
|
* for the whole series. Esc or a click past it closes it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SeriesTray(title: String, state: SeriesState, style: SubtitleStyle, vm: SeriesViewModel, onClose: () -> Unit, modifier: Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(TRAY_H.dp)
|
||||||
|
.background(AppColors.Background)
|
||||||
|
// Clicks inside stay inside (the scrim behind closes the tray).
|
||||||
|
.pointerInput(Unit) { detectTapGestures { } }
|
||||||
|
.drawBehind { drawRect(Color(0xFF262A3A), size = Size(size.width, 1f)) }
|
||||||
|
.padding(start = 48.dp, end = 48.dp, top = 12.dp, bottom = 22.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||||
|
Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
|
||||||
|
BasicText(
|
||||||
|
"АУДИО И СУБТИТРЫ · " + title.uppercase(),
|
||||||
|
Modifier.weight(1f),
|
||||||
|
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
BasicText("Запоминается для всего сериала · Esc — закрыть", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
|
||||||
|
BackArrow(onClose, Modifier.rotate(-90f))
|
||||||
|
}
|
||||||
|
Row(Modifier.padding(top = 10.dp).fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
if (state.audio.isNotEmpty()) {
|
||||||
|
TrayColumn("АУДИО", AppColors.Pink) {
|
||||||
|
PanelList(state.audio.toChoices(state.selectedAudio)) { c -> vm.selectAudio(state.audio.firstOrNull { it.key == c.key }?.index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.subtitles.isNotEmpty()) {
|
||||||
|
TrayColumn("СУБТИТРЫ", AppColors.Yellow) {
|
||||||
|
PanelList(state.subtitles.toChoices(state.selectedSubtitle)) { c -> vm.selectSubtitle(state.subtitles.firstOrNull { it.key == c.key }?.index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrayColumn("ВИД СУБТИТРОВ", AppColors.Yellow) {
|
||||||
|
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)) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TRAY_H = 300f
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.TrayColumn(title: String, dot: Color, content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
val shape = RoundedCornerShape(9.dp)
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(PanelColors.Background, shape)
|
||||||
|
.border(0.5.dp, PanelColors.Edge, shape),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(25.dp)
|
||||||
|
.background(PanelColors.Header, RoundedCornerShape(topStart = 9.dp, topEnd = 9.dp))
|
||||||
|
.padding(horizontal = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(5.dp).background(dot, CircleShape))
|
||||||
|
BasicText(title, style = AppType.MonoCaption.copy(color = AppColors.TextMuted, letterSpacing = 0.1.em))
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.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.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.Immutable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
|
||||||
|
import ru.stenfredd.sleepyowl.core.data.SubtitleMode
|
||||||
|
import ru.stenfredd.sleepyowl.core.jellyfin.Account
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||||
|
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
|
||||||
|
import ru.stenfredd.sleepyowl.playback.core.SkipMode
|
||||||
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.OwlMark
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Wordmark
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
|
||||||
|
import ru.stenfredd.sleepyowl.ui.focus.remembered
|
||||||
|
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
|
||||||
|
import ru.stenfredd.sleepyowl.ui.player.QualityCaps
|
||||||
|
import ru.stenfredd.sleepyowl.ui.player.SubtitleChoices
|
||||||
|
import ru.stenfredd.sleepyowl.ui.settings.SettingsViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
|
/** Sections on the left; the clicked one is shown on the right. */
|
||||||
|
private enum class Section(val title: String, val dot: Color) {
|
||||||
|
Playback("Воспроизведение", AppColors.Blue),
|
||||||
|
Languages("Языки", AppColors.Pink),
|
||||||
|
Subtitles("Вид субтитров", AppColors.Yellow),
|
||||||
|
Account("Аккаунт и сервер", AppColors.Green),
|
||||||
|
About("О приложении", AppColors.TextDim),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One chip of a setting. */
|
||||||
|
@Immutable
|
||||||
|
private data class Option<T>(val value: T, val label: String, val detail: String? = null, val swatch: Color? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onOpen: (Screen) -> Unit,
|
||||||
|
/** Signed out: the app goes to the login screen. */
|
||||||
|
onSignedOut: () -> Unit,
|
||||||
|
/** Another saved account became current: the app restarts on its home screen. */
|
||||||
|
onSwitched: () -> Unit,
|
||||||
|
vm: SettingsViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val focus = rememberScreenFocus()
|
||||||
|
var section by rememberSaveable { mutableStateOf(Section.Playback) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(start = AppDimens.CONTENT_START.dp, top = AppDimens.CONTENT_TOP.dp)) {
|
||||||
|
BasicText("Настройки", style = AppType.Display.copy(fontSize = 28.sp))
|
||||||
|
Row(Modifier.padding(top = 22.dp).fillMaxSize()) {
|
||||||
|
Column(Modifier.width(190.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
for (s in Section.entries) {
|
||||||
|
SectionItem(s, selected = s == section, onClick = { section = s }, Modifier.hoverFocus().remembered(focus, "section/${s.name}", isDefault = s == Section.Playback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each section scrolls on its own and starts from the top.
|
||||||
|
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||||
|
key(section) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 40.dp, end = AppDimens.CONTENT_START.dp, top = 6.dp, bottom = 60.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(26.dp),
|
||||||
|
) {
|
||||||
|
when (section) {
|
||||||
|
Section.Playback -> PlaybackSection(vm, focus)
|
||||||
|
Section.Languages -> LanguagesSection(vm, focus)
|
||||||
|
Section.Subtitles -> SubtitlesSection(vm, focus)
|
||||||
|
Section.Account -> AccountSection(vm, focus, onOpen, onSignedOut, onSwitched)
|
||||||
|
Section.About -> AboutSection(vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
focus.Entry(ready = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SectionFocus = FocusStyle(scale = 1f, corner = 8.dp, glows = FocusStyle.ButtonGlows)
|
||||||
|
|
||||||
|
/** Section in the left column: light fill under the pointer, a violet frame for the one shown. */
|
||||||
|
@Composable
|
||||||
|
private fun SectionItem(section: Section, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(8.dp)
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.focusCard(SectionFocus)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
when {
|
||||||
|
focused -> AppColors.FocusFill
|
||||||
|
selected -> AppColors.Selected
|
||||||
|
else -> Color.Transparent
|
||||||
|
},
|
||||||
|
shape,
|
||||||
|
)
|
||||||
|
.border(0.5.dp, if (selected && !focused) AppColors.SelectedBorder else Color.Transparent, shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(6.dp).background(section.dot, CircleShape))
|
||||||
|
BasicText(
|
||||||
|
section.title,
|
||||||
|
style = AppType.Rail.copy(fontSize = 13.sp, color = if (focused) AppColors.OnFocusFill else if (selected) AppColors.Text else AppColors.TextMuted),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A setting: title, an optional explanation, then its controls. */
|
||||||
|
@Composable
|
||||||
|
private fun Setting(title: String, note: String?, content: @Composable () -> Unit) {
|
||||||
|
Column {
|
||||||
|
BasicText(title, style = AppType.Section.copy(fontSize = 14.sp))
|
||||||
|
note?.let { BasicText(it, Modifier.padding(top = 5.dp).widthIn(max = 560.dp), style = AppType.Body) }
|
||||||
|
Box(Modifier.padding(top = 11.dp)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of chips, one of them chosen; wraps when the window is narrow. */
|
||||||
|
@Composable
|
||||||
|
private fun <T> Choices(id: String, options: List<Option<T>>, selected: T, focus: ScreenFocus, onPick: (T) -> Unit) {
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
for ((i, o) in options.withIndex()) {
|
||||||
|
Chip(o.label, o.value == selected, { onPick(o.value) }, Modifier.hoverFocus().remembered(focus, "$id/$i"), detail = o.detail, swatch = o.swatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Note(text: String, modifier: Modifier = Modifier, color: Color = AppColors.TextDim) {
|
||||||
|
BasicText(text, modifier.widthIn(max = 560.dp), style = AppType.Body.copy(fontSize = 10.5.sp, lineHeight = 15.sp, color = color))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Воспроизведение ---
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackSection(vm: SettingsViewModel, focus: ScreenFocus) {
|
||||||
|
val defaults by vm.defaults.collectAsState()
|
||||||
|
fun set(d: PlaybackDefaults) = vm.setDefaults(d)
|
||||||
|
|
||||||
|
Setting(
|
||||||
|
"Качество по умолчанию",
|
||||||
|
"Всё, что ниже оригинала, сервер перекодирует на лету — это нагружает его процессор. Помогает при медленной сети. " +
|
||||||
|
"В плеере качество можно сменить для одного просмотра.",
|
||||||
|
) {
|
||||||
|
val options = listOf(Option<Int?>(null, "Оригинал")) +
|
||||||
|
QualityCaps.ALL.map { (bitrate, lines) -> Option<Int?>(bitrate, "${lines}p", PlayerLabels.mbps(bitrate)) }
|
||||||
|
Choices("quality", options, defaults.maxBitrate, focus) { set(defaults.copy(maxBitrate = it)) }
|
||||||
|
}
|
||||||
|
Setting(
|
||||||
|
"Заставки",
|
||||||
|
"Опенинги, пересказ прошлых серий, анонсы. «Кнопка» — в начале заставки появляется «Пропустить заставку», " +
|
||||||
|
"«Сразу» — плеер перескакивает её сам. Работает там, где сервер знает заставки (плагин Intro Skipper).",
|
||||||
|
) {
|
||||||
|
Choices("skip-intro", skipOptions, defaults.skipIntro, focus) { set(defaults.copy(skipIntro = it)) }
|
||||||
|
}
|
||||||
|
Setting(
|
||||||
|
"Титры",
|
||||||
|
"«Кнопка» — с началом титров появляется карточка «Следующая серия» с отсчётом (или «Пропустить титры», если " +
|
||||||
|
"после них ещё что-то есть). «Сразу» — следующая серия включается, как только начались титры. «Не пропускать» — " +
|
||||||
|
"карточка за 30 с до конца серии.",
|
||||||
|
) {
|
||||||
|
Choices("skip-credits", skipOptions, defaults.skipCredits, focus) { set(defaults.copy(skipCredits = it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val skipOptions = listOf(Option(SkipMode.Button, "Кнопка"), Option(SkipMode.Auto, "Сразу"), Option(SkipMode.Off, "Не пропускать"))
|
||||||
|
|
||||||
|
// --- Языки ---
|
||||||
|
|
||||||
|
private val AudioLanguages = listOf("rus" to "Русский", "eng" to "Английский", "jpn" to "Японский")
|
||||||
|
private val SubtitleLanguages = listOf("rus" to "Русские", "eng" to "Английские")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguagesSection(vm: SettingsViewModel, focus: ScreenFocus) {
|
||||||
|
val state by vm.languages.collectAsState()
|
||||||
|
val settings = state.settings
|
||||||
|
if (settings == null) {
|
||||||
|
if (state.error == null) {
|
||||||
|
Note("Загрузка настроек с сервера…")
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Note("Не удалось загрузить настройки с сервера: ${state.error}", color = AppColors.Red)
|
||||||
|
SecondaryButton("Повторить", vm::loadLanguages, Modifier.hoverFocus().remembered(focus, "languages/retry"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fun set(s: LanguageSettings) = vm.setLanguages(s)
|
||||||
|
|
||||||
|
Setting("Язык звука", "Какую дорожку включать, если в файле их несколько. «Как в файле» — дорожка, отмеченная в файле основной.") {
|
||||||
|
val options = listOf(Option<String?>(null, "Как в файле")) +
|
||||||
|
(AudioLanguages + listOfNotNull(settings.audio?.takeIf { a -> AudioLanguages.none { it.first == a } }?.let { it to languageName(it) }))
|
||||||
|
.map { (code, name) -> Option<String?>(code, name) }
|
||||||
|
Choices("audio", options, settings.audio, focus) { set(settings.copy(audio = it)) }
|
||||||
|
}
|
||||||
|
Setting("Субтитры", null) {
|
||||||
|
val options = listOf(
|
||||||
|
Option(SubtitleMode.Default, "Как в файле"),
|
||||||
|
Option(SubtitleMode.None, "Выкл"),
|
||||||
|
Option(SubtitleMode.OnlyForced, "Только надписи"),
|
||||||
|
Option(SubtitleMode.Smart, "Если звук не на моём языке"),
|
||||||
|
Option(SubtitleMode.Always, "Всегда"),
|
||||||
|
)
|
||||||
|
Choices("subtitles", options, settings.subtitleMode, focus) { set(settings.copy(subtitleMode = it)) }
|
||||||
|
}
|
||||||
|
if (settings.subtitleMode != SubtitleMode.None) {
|
||||||
|
Setting("Язык субтитров", null) {
|
||||||
|
val options = listOf(Option<String?>(null, "Любой")) +
|
||||||
|
(SubtitleLanguages + listOfNotNull(settings.subtitleLanguage?.takeIf { s -> SubtitleLanguages.none { it.first == s } }?.let { it to languageName(it) }))
|
||||||
|
.map { (code, name) -> Option<String?>(code, name) }
|
||||||
|
Choices("subtitle-language", options, settings.subtitleLanguage, focus) { set(settings.copy(subtitleLanguage = it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val account = vm.accounts.collectAsState().value.current
|
||||||
|
Note(
|
||||||
|
"Языки хранятся на сервере в профиле ${account?.userName.orEmpty()} и действуют во всех приложениях Jellyfin. " +
|
||||||
|
"Дорожки, выбранные вручную для фильма или сериала, запоминаются на этом компьютере и важнее этих настроек.",
|
||||||
|
)
|
||||||
|
state.error?.let { Note(it, color = AppColors.Red) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "fra" → "Французский". */
|
||||||
|
private fun languageName(code: String): String =
|
||||||
|
java.util.Locale.forLanguageTag(code).getDisplayLanguage(java.util.Locale.forLanguageTag("ru")).replaceFirstChar { it.titlecase() }.ifEmpty { code }
|
||||||
|
|
||||||
|
// --- Вид субтитров ---
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SubtitlesSection(vm: SettingsViewModel, focus: ScreenFocus) {
|
||||||
|
val style by vm.subtitleStyle.collectAsState()
|
||||||
|
fun set(s: SubtitleStyle) = vm.setSubtitleStyle(s)
|
||||||
|
|
||||||
|
SubtitlePreview(style, Modifier.widthIn(max = 420.dp))
|
||||||
|
Setting("Размер", null) {
|
||||||
|
Choices("sub-size", SubtitleStyle.Size.entries.map { Option(it, it.name) }, style.size, focus) { set(style.copy(size = it)) }
|
||||||
|
}
|
||||||
|
Setting("Цвет", null) {
|
||||||
|
val options = SubtitleChoices.colors(style).map { Option(it.key.substringAfter('/').toInt(), it.label, swatch = it.swatch) }
|
||||||
|
Choices("sub-color", options, style.color, focus) { set(style.copy(color = it)) }
|
||||||
|
}
|
||||||
|
Setting("Фон", null) {
|
||||||
|
val options = SubtitleChoices.backgrounds(style).map { Option(SubtitleStyle.Background.valueOf(it.key.substringAfter('/')), it.label) }
|
||||||
|
Choices("sub-background", options, style.background, focus) { set(style.copy(background = it)) }
|
||||||
|
}
|
||||||
|
Note(
|
||||||
|
"Для текстовых субтитров (SRT, VTT) на этом компьютере, для всех профилей. У субтитров ASS своё оформление из файла, " +
|
||||||
|
"графические (PGS, DVD) показываются как есть. То же можно настроить в плеере.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Аккаунт и сервер ---
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountSection(vm: SettingsViewModel, focus: ScreenFocus, onOpen: (Screen) -> Unit, onSignedOut: () -> Unit, onSwitched: () -> Unit) {
|
||||||
|
val state by vm.accounts.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// The action waiting for the second click ("Точно выйти?"); the pointer leaving it cancels it.
|
||||||
|
var confirm by remember { mutableStateOf<String?>(null) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
fun Modifier.confirmable(id: String) = onFocusChanged { if (!it.isFocused && confirm == id) confirm = null }
|
||||||
|
|
||||||
|
val current = state.current ?: return
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
Avatar(current.userName, current.userId.toString(), 48.dp, AppType.Hero.copy(fontSize = 20.sp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
BasicText(current.userName, style = AppType.Section.copy(fontSize = 18.sp, fontWeight = FontWeight.Bold))
|
||||||
|
val server = state.server
|
||||||
|
val name = server?.name?.ifEmpty { null } ?: current.serverName.ifEmpty { null }
|
||||||
|
BasicText(
|
||||||
|
listOfNotNull(name?.let { "Сервер «$it»" } ?: "Сервер", server?.version?.let { "Jellyfin $it" }).joinToString(" · "),
|
||||||
|
style = AppType.MonoMeta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoLine("Адрес", current.serverUrl)
|
||||||
|
InfoLine("Дома", state.localUrl?.let { "напрямую, $it" } ?: "сервер в локальной сети не найден — через адрес выше")
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
SecondaryButton("Сменить профиль", { onOpen(Screen.Login) }, Modifier.hoverFocus().remembered(focus, "account/switch"))
|
||||||
|
SecondaryButton(
|
||||||
|
when {
|
||||||
|
busy -> "Выходим…"
|
||||||
|
confirm == "logout" -> "Точно выйти?"
|
||||||
|
else -> "Выйти"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
when {
|
||||||
|
busy -> Unit
|
||||||
|
confirm != "logout" -> confirm = "logout"
|
||||||
|
else -> {
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
vm.logout()
|
||||||
|
onSignedOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Modifier.hoverFocus().remembered(focus, "account/logout").confirmable("logout"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Note("«Выйти» забывает этот профиль на компьютере; войти снова — паролем или Quick Connect.")
|
||||||
|
|
||||||
|
Setting("Другие профили на этом компьютере", if (state.others.isEmpty()) "Других нет. Добавить — «Сменить профиль» → «Другой пользователь»." else null) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
for (a in state.others) {
|
||||||
|
OtherAccount(
|
||||||
|
a,
|
||||||
|
confirmForget = confirm == "forget/${a.id}",
|
||||||
|
onSwitch = {
|
||||||
|
if (!busy) {
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
vm.switchTo(a.id)
|
||||||
|
onSwitched()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onForget = {
|
||||||
|
if (confirm != "forget/${a.id}") {
|
||||||
|
confirm = "forget/${a.id}"
|
||||||
|
} else {
|
||||||
|
confirm = null
|
||||||
|
vm.forget(a.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focus = focus,
|
||||||
|
modifier = Modifier.confirmable("forget/${a.id}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OtherAccount(account: Account, confirmForget: Boolean, onSwitch: () -> Unit, onForget: () -> Unit, focus: ScreenFocus, modifier: Modifier) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Avatar(account.userName, account.userId.toString(), 30.dp, AppType.CardTitle)
|
||||||
|
Column(Modifier.width(190.dp)) {
|
||||||
|
BasicText(account.userName, style = AppType.CardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
BasicText(account.serverName.ifEmpty { account.serverUrl }, style = AppType.MonoCaption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
Chip("Переключиться", false, onSwitch, Modifier.hoverFocus().remembered(focus, "other/${account.id}/switch"))
|
||||||
|
Chip(if (confirmForget) "Точно забыть?" else "Забыть", false, onForget, modifier.hoverFocus().remembered(focus, "other/${account.id}/forget"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoLine(key: String, value: String) {
|
||||||
|
Row {
|
||||||
|
BasicText(key, Modifier.width(60.dp), style = AppType.MonoMeta.copy(color = AppColors.TextFaint))
|
||||||
|
BasicText(value, style = AppType.MonoMeta, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- О приложении ---
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AboutSection(vm: SettingsViewModel) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OwlMark()
|
||||||
|
Wordmark()
|
||||||
|
}
|
||||||
|
BasicText("Версия ${vm.about.version}", style = AppType.MonoMeta)
|
||||||
|
BasicText(vm.about.device, style = AppType.MonoMeta.copy(color = AppColors.TextDim))
|
||||||
|
}
|
||||||
|
// The list of components and their licences comes with the package (L10): the desktop is built from other parts.
|
||||||
|
Note("Приложение — свободное ПО под лицензией GPL-3.0.")
|
||||||
|
}
|
||||||
252
desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Shell.kt
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxHeight
|
||||||
|
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.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.State
|
||||||
|
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.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
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.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
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.OwlMark
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Wordmark
|
||||||
|
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
|
||||||
|
import ru.stenfredd.sleepyowl.ui.shell.Tab
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** From this width the rail stays open with its labels; narrower, it shows icons and opens over the content on hover. */
|
||||||
|
private val PinnedRailFrom = 1100.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root screens (the tabs) on the desktop: the TV's side rail, made for the mouse. A wide window keeps it open and
|
||||||
|
* lays the content out beside it; a narrow one keeps it collapsed, and pointing at it opens it over the content.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Shell(
|
||||||
|
current: Tab,
|
||||||
|
userName: String,
|
||||||
|
userSeed: String,
|
||||||
|
libraries: List<RailLibrary>,
|
||||||
|
onTab: (Tab) -> Unit,
|
||||||
|
onLibrary: (UUID) -> Unit,
|
||||||
|
onProfile: () -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val pinned = maxWidth >= PinnedRailFrom
|
||||||
|
val railWidth = if (pinned) AppDimens.RAIL_OPEN.dp else AppDimens.RAIL_COLLAPSED.dp
|
||||||
|
Box(Modifier.fillMaxSize().padding(start = railWidth)) { content() }
|
||||||
|
Rail(current, userName, userSeed, libraries, pinned, onTab, onLibrary, onProfile, Modifier.zIndex(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun Rail(
|
||||||
|
current: Tab,
|
||||||
|
userName: String,
|
||||||
|
userSeed: String,
|
||||||
|
libraries: List<RailLibrary>,
|
||||||
|
pinned: Boolean,
|
||||||
|
onTab: (Tab) -> Unit,
|
||||||
|
onLibrary: (UUID) -> Unit,
|
||||||
|
onProfile: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var hovered by remember { mutableStateOf(false) }
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val open = pinned || hovered || focused
|
||||||
|
// A pinned rail does not animate: it is open from the first frame.
|
||||||
|
val progress = animateFloatAsState(if (open) 1f else 0f, if (pinned) tween(0) else tween(150), label = "rail")
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(if (open) AppDimens.RAIL_OPEN.dp else AppDimens.RAIL_COLLAPSED.dp)
|
||||||
|
.onPointerEvent(PointerEventType.Enter) { hovered = true }
|
||||||
|
.onPointerEvent(PointerEventType.Exit) {
|
||||||
|
hovered = false
|
||||||
|
// The pointer gave an item focus (hoverFocus); leaving the rail closes it, as on the TV.
|
||||||
|
if (focused) focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
.onFocusChanged { focused = it.hasFocus }
|
||||||
|
.drawBehind {
|
||||||
|
val p = progress.value
|
||||||
|
val w = lerp(AppDimens.RAIL_COLLAPSED.dp.toPx(), AppDimens.RAIL_OPEN.dp.toPx(), p)
|
||||||
|
drawRect(lerp(AppColors.Rail, AppColors.RailOpen, if (pinned) 1f else p), size = Size(w, size.height))
|
||||||
|
drawRect(AppColors.BorderSubtle, topLeft = Offset(w - 1f, 0f), size = Size(1f, size.height))
|
||||||
|
// Edge shadow over the content, only while the rail is opened over it.
|
||||||
|
if (!pinned && p > 0f) {
|
||||||
|
val shadow = 40.dp.toPx()
|
||||||
|
drawRect(
|
||||||
|
Brush.horizontalGradient(listOf(Color.Black.copy(alpha = 0.5f * p), Color.Transparent), startX = w, endX = w + shadow),
|
||||||
|
topLeft = Offset(w, 0f),
|
||||||
|
size = Size(shadow, size.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 9.dp, vertical = 28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(start = 1.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OwlMark()
|
||||||
|
Wordmark(Modifier.graphicsLayer { alpha = progress.value })
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
fun item(tab: Tab) = RailItem(tab.label, tab.icon, tab == current, progress, onClick = { if (tab != current) onTab(tab) })
|
||||||
|
for (tab in Tab.entries) if (tab != Tab.Settings) item(tab)
|
||||||
|
if (libraries.isNotEmpty()) {
|
||||||
|
RailDivider(progress)
|
||||||
|
Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
for (library in libraries) {
|
||||||
|
RailItem(library.name, library.icon, selected = false, progress, onClick = { onLibrary(library.id) }, height = 30.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
item(Tab.Settings)
|
||||||
|
ProfileItem(userName, userSeed, progress, onProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill of the item under the pointer (or keyboard focus), growing with the rail panel. */
|
||||||
|
private fun Modifier.railHighlight(lit: Boolean, color: Color, progress: State<Float>) = drawBehind {
|
||||||
|
if (!lit) return@drawBehind
|
||||||
|
val collapsed = (AppDimens.RAIL_COLLAPSED - 18).dp.toPx()
|
||||||
|
val w = lerp(collapsed, size.width, progress.value)
|
||||||
|
drawRoundRect(color, size = Size(w, size.height), cornerRadius = CornerRadius(8.dp.toPx()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The open rail marks the current tab with a fill (the closed one with the bar on its edge). */
|
||||||
|
private fun Modifier.selectedFill(selected: Boolean, progress: State<Float>) = drawBehind {
|
||||||
|
if (selected) drawRoundRect(AppColors.SurfaceFocused, alpha = progress.value, cornerRadius = CornerRadius(8.dp.toPx()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RailDivider(progress: State<Float>) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxWidth().height(11.dp).drawBehind {
|
||||||
|
val collapsed = (AppDimens.RAIL_COLLAPSED - 18).dp.toPx()
|
||||||
|
val w = lerp(collapsed, size.width, progress.value)
|
||||||
|
drawRect(AppColors.BorderSubtle, topLeft = Offset(0f, (size.height / 2).toInt().toFloat()), size = Size(w, 1.dp.toPx().coerceAtLeast(1f)))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RailItem(label: String, icon: AppIcon, selected: Boolean, progress: State<Float>, onClick: () -> Unit, height: Dp = 36.dp) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val content = when {
|
||||||
|
focused -> AppColors.OnFocusFill
|
||||||
|
selected -> AppColors.Text
|
||||||
|
else -> AppColors.TextDim
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(height)) {
|
||||||
|
// Selected tab marker on the rail's left edge, visible while the rail is closed.
|
||||||
|
if (selected) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(vertical = 9.dp)
|
||||||
|
.width(2.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = -9.dp.toPx()
|
||||||
|
alpha = 1f - progress.value
|
||||||
|
}
|
||||||
|
.background(AppColors.Accent, RoundedCornerShape(1.dp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.hoverFocus()
|
||||||
|
.selectedFill(selected, progress)
|
||||||
|
.railHighlight(focused, AppColors.FocusFill, progress)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 11.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||||
|
) {
|
||||||
|
Icon(icon, content, Modifier.size(16.dp))
|
||||||
|
BasicText(
|
||||||
|
label,
|
||||||
|
Modifier.graphicsLayer { alpha = progress.value },
|
||||||
|
style = AppType.Rail.copy(color = content),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileItem(userName: String, userSeed: String, progress: State<Float>, onClick: () -> Unit) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(38.dp)
|
||||||
|
.hoverFocus()
|
||||||
|
.railHighlight(focused, AppColors.SurfaceFocused, progress)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 7.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||||
|
) {
|
||||||
|
Avatar(userName, userSeed, 24.dp, AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize))
|
||||||
|
Column(Modifier.graphicsLayer { alpha = progress.value }) {
|
||||||
|
BasicText(userName, style = AppType.CardTitle.copy(fontSize = AppType.Rail.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false)
|
||||||
|
BasicText("сменить профиль", style = AppType.MonoCaption.copy(fontSize = AppType.MonoBadge.fontSize), maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.net.StandardProtocolFamily
|
||||||
|
import java.net.UnixDomainSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
import java.nio.channels.FileLock
|
||||||
|
import java.nio.channels.ServerSocketChannel
|
||||||
|
import java.nio.channels.SocketChannel
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One app per user: the first instance holds a lock on `sleepyowl.lock` and listens on the socket `sleepyowl.sock`
|
||||||
|
* next to it; a second start finds the lock taken, asks the first one to show its window and exits.
|
||||||
|
*/
|
||||||
|
class SingleInstance private constructor(private val lock: FileLock, private val server: ServerSocketChannel) {
|
||||||
|
/** [onShow] runs on a background thread for every later start of the app. */
|
||||||
|
fun listen(onShow: () -> Unit) {
|
||||||
|
thread(name = "single-instance", isDaemon = true) {
|
||||||
|
while (server.isOpen) {
|
||||||
|
val client = runCatching { server.accept() }.getOrNull() ?: break
|
||||||
|
client.use { runCatching { it.read(ByteBuffer.allocate(16)) } }
|
||||||
|
Log.i("Started again: showing the window")
|
||||||
|
onShow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
runCatching { server.close() }
|
||||||
|
runCatching { lock.release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** This process is the app, or null when another one is running (it has been asked to show itself). */
|
||||||
|
fun acquire(dir: File): SingleInstance? {
|
||||||
|
dir.mkdirs()
|
||||||
|
val channel = FileChannel.open(File(dir, "sleepyowl.lock").toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)
|
||||||
|
val lock = runCatching { channel.tryLock() }.getOrNull()
|
||||||
|
val socket = File(dir, "sleepyowl.sock")
|
||||||
|
if (lock == null) {
|
||||||
|
channel.close()
|
||||||
|
runCatching {
|
||||||
|
SocketChannel.open(UnixDomainSocketAddress.of(socket.toPath())).use { it.write(ByteBuffer.wrap("show\n".toByteArray())) }
|
||||||
|
}.onFailure { Log.w(it, "The running instance did not answer") }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Left by a run that crashed: the lock is ours, so nobody listens on it.
|
||||||
|
socket.delete()
|
||||||
|
val server = ServerSocketChannel.open(StandardProtocolFamily.UNIX).apply { bind(UnixDomainSocketAddress.of(socket.toPath())) }
|
||||||
|
return SingleInstance(lock, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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.detectTapGestures
|
||||||
|
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.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
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 kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.LocalWatchMenu
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.MenuAction
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.WatchMenu
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.actions
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.kindLabel
|
||||||
|
import ru.stenfredd.sleepyowl.ui.menu.status
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts the watched menu over [content]: the right button on a card (or the ✓ of a series page) opens it where the
|
||||||
|
* pointer is, as a context menu; a click past it or Esc closes it. Marking a season or series marks every episode on
|
||||||
|
* the server. The TV's menu ([WatchMenu]) with the desktop's look.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WatchMenuHost(content: @Composable () -> Unit) {
|
||||||
|
val menu = remember { WatchMenu() }
|
||||||
|
val vm: WatchMenuViewModel = koinViewModel()
|
||||||
|
// Not the menu's scope: a request goes on when the menu is closed before the server answers.
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun close(shown: WatchMenu.Shown) {
|
||||||
|
if (menu.shown !== shown) return
|
||||||
|
menu.shown = null
|
||||||
|
runCatching { shown.returnTo.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalWatchMenu provides menu) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
content()
|
||||||
|
menu.shown?.let { shown ->
|
||||||
|
key(shown) {
|
||||||
|
var saving by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
val at = remember { Input.pointer }
|
||||||
|
BackHandler { close(shown); true }
|
||||||
|
Spacer(Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures(onPress = { close(shown) }) })
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val bounds = constraints
|
||||||
|
MenuCard(
|
||||||
|
shown.target,
|
||||||
|
saving = saving,
|
||||||
|
error = error,
|
||||||
|
onPick = { played ->
|
||||||
|
if (saving == null) {
|
||||||
|
saving = played
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
vm.setPlayed(shown.target.id, played)
|
||||||
|
close(shown)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(e, "Watched menu: marking failed")
|
||||||
|
saving = null
|
||||||
|
error = e.message ?: e.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// At the pointer, moved in to stay whole inside the window.
|
||||||
|
modifier = Modifier.layout { measurable, c ->
|
||||||
|
val p = measurable.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)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the item is, how much of it is watched, and the actions. [saving]: the action being sent (true = watched). */
|
||||||
|
@Composable
|
||||||
|
private fun MenuCard(target: WatchTarget, saving: Boolean?, error: String?, onPick: (Boolean) -> Unit, modifier: Modifier) {
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.width(350.dp)
|
||||||
|
.background(PanelColors.Background, shape)
|
||||||
|
.border(0.5.dp, PanelColors.Edge, shape)
|
||||||
|
// Clicks inside stay inside (the layer behind closes the menu).
|
||||||
|
.pointerInput(Unit) { detectTapGestures { } }
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
BasicText(target.kindLabel(), style = AppType.MonoLabel.copy(color = AppColors.Green, letterSpacing = 0.1.em))
|
||||||
|
BasicText(target.title, Modifier.padding(top = 6.dp), style = AppType.Section.copy(fontSize = 15.sp, lineHeight = 19.sp), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
target.subtitle?.let { BasicText(it, Modifier.padding(top = 4.dp), style = AppType.MonoMeta, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
target.status()?.let {
|
||||||
|
BasicText(it, Modifier.padding(top = 7.dp), style = AppType.Body.copy(color = if (target.played) AppColors.Green else AppColors.TextMuted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
|
for (action in target.actions()) ActionRow(action, saving = saving == action.played, onClick = { onPick(action.played) })
|
||||||
|
}
|
||||||
|
error?.let { BasicText("Не удалось сохранить: $it", style = AppType.Body.copy(color = AppColors.Red), maxLines = 3, overflow = TextOverflow.Ellipsis) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
|
||||||
|
|
||||||
|
/** "✓ Отметить просмотренным" with what it touches under it. */
|
||||||
|
@Composable
|
||||||
|
private fun ActionRow(action: MenuAction, saving: Boolean, onClick: () -> Unit) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val shape = RoundedCornerShape(7.dp)
|
||||||
|
val tint = if (action.played) AppColors.Green else AppColors.TextSecondary
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.hoverFocus()
|
||||||
|
.focusCard(ActionFocus)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(if (action.detail != null || saving) 48.dp else 38.dp)
|
||||||
|
.background(if (focused) Color(0xFF1F2238) else AppColors.SurfaceRaised, shape)
|
||||||
|
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF232637), shape)
|
||||||
|
.onFocusChanged { focused = it.isFocused }
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||||
|
.padding(horizontal = 11.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Icon(action.icon, tint, Modifier.size(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
BasicText(action.label, style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
(if (saving) "Сохраняем…" else action.detail)?.let {
|
||||||
|
BasicText(it, Modifier.padding(top = 4.dp), style = AppType.MonoCaption.copy(fontSize = 9.5.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
desktop/src/main/resources/icons/sleepyowl-128.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
desktop/src/main/resources/icons/sleepyowl-16.png
Normal file
|
After Width: | Height: | Size: 758 B |
BIN
desktop/src/main/resources/icons/sleepyowl-24.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
desktop/src/main/resources/icons/sleepyowl-256.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
desktop/src/main/resources/icons/sleepyowl-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
desktop/src/main/resources/icons/sleepyowl-48.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
desktop/src/main/resources/icons/sleepyowl-512.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
desktop/src/main/resources/icons/sleepyowl-64.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.desktop
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.junit.Test
|
||||||
|
import org.koin.core.annotation.KoinExperimentalAPI
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.test.verify.verify
|
||||||
|
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.ui.uiModule
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** The desktop app's Koin graph, as [main] starts it: every definition finds what it needs. */
|
||||||
|
class DesktopGraphTest {
|
||||||
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
|
@Test
|
||||||
|
fun graphIsComplete() {
|
||||||
|
module { includes(jellyfinModule, dataModule, playbackModule, uiModule, desktopModule(UiScale(1f))) }.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).
|
||||||
|
extraTypes = listOf(UUID::class, OkHttpClient.Builder::class, File::class, EngineProvider::class),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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.text.BasicText
|
||||||
|
import androidx.compose.ui.ImageComposeScene
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import org.jetbrains.skia.EncodedImageFormat
|
||||||
|
import org.junit.Test
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
|
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
|
||||||
|
import ru.stenfredd.sleepyowl.ui.shell.Tab
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The side rail on a wide window (pinned open with labels) and on a narrow one (icons only), drawn without a window
|
||||||
|
* into `build/reports/desktop/shell-<width>.png` to look at.
|
||||||
|
*/
|
||||||
|
class ShellRenderTest {
|
||||||
|
@Test
|
||||||
|
fun wideAndNarrow() {
|
||||||
|
for (width in listOf(1280, 900)) render(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(width: Int) {
|
||||||
|
val libraries = listOf(
|
||||||
|
RailLibrary(UUID.randomUUID(), "Фильмы", AppIcon.Film),
|
||||||
|
RailLibrary(UUID.randomUUID(), "Сериалы", AppIcon.Tv),
|
||||||
|
RailLibrary(UUID.randomUUID(), "Аниме", AppIcon.Library),
|
||||||
|
)
|
||||||
|
val scene = ImageComposeScene(width, 640, Density(1f)) {
|
||||||
|
Box(Modifier.fillMaxSize().background(AppColors.Background)) {
|
||||||
|
Shell(Tab.Home, "Claude", "seed", libraries, onTab = {}, onLibrary = {}, onProfile = {}) {
|
||||||
|
BasicText("Главная", style = AppType.Hero)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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("shell-$width.png").writeBytes(png)
|
||||||
|
scene.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
|||||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
# Dispatchers.Main on the desktop (AWT event thread): viewModelScope runs there.
|
||||||
|
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
|
||||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" }
|
media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" }
|
||||||
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ include(":playback:core")
|
|||||||
include(":playback:engine-exo")
|
include(":playback:engine-exo")
|
||||||
include(":playback:engine-vlc")
|
include(":playback:engine-vlc")
|
||||||
include(":ui:shared")
|
include(":ui:shared")
|
||||||
|
|
||||||
|
// SleepyOwl for the Linux desktop (LINUX_PLAN.md).
|
||||||
|
include(":desktop")
|
||||||
|
|||||||
11
tools/linux/desktop.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# SleepyOwl for the desktop without Gradle around the process: ./tools/linux/desktop.sh
|
||||||
|
# DESKTOP_NOBUILD=1 — do not build first. SLEEPYOWL_SCALE=1.25 — interface scale (until the settings have it, L9).
|
||||||
|
# Files go to the XDG places (~/.config/sleepyowl, ~/.cache/sleepyowl, ~/.local/state/sleepyowl/log); the test stand
|
||||||
|
# (tools/linux/stand.sh) points them at its own directory.
|
||||||
|
set -e
|
||||||
|
R=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1}
|
||||||
|
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath)
|
||||||
|
exec "$JAVA_HOME/bin/java" -Dsleepyowl.repo="$R" --add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED --add-opens=java.desktop/sun.awt=ALL-UNNAMED \
|
||||||
|
-cp "$(cat "$R/desktop/build/desktop.classpath")" ru.stenfredd.sleepyowl.desktop.MainKt "$@"
|
||||||
70
tools/linux/stand.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test stand of the desktop app (LINUX_PLAN.md §3 «Как проверяем»): a private virtual display :87 with the real
|
||||||
|
# window manager (Muffin, in its own D-Bus session), so window, mouse and keyboard tests do not touch the user's screen.
|
||||||
|
#
|
||||||
|
# stand.sh up start Xvfb :87 (1920×1080) and Muffin, if not running
|
||||||
|
# stand.sh down stop the app, Muffin and Xvfb
|
||||||
|
# stand.sh app [args] (re)start the app on :87 in the background; output in $STAND/app.out
|
||||||
|
# stand.sh stop stop the app
|
||||||
|
# stand.sh shot NAME screenshot of the display → $STAND/shots/NAME.png
|
||||||
|
# stand.sh in CMD… input on :87 (tools/linux/xinput.py): click X Y, move X Y, key NAME, type TEXT, …
|
||||||
|
# stand.sh log the app's log (sleepyowl.log)
|
||||||
|
#
|
||||||
|
# The app's files live in $STAND (default ~/.cache/sleepyowl-test): its own config with the test account,
|
||||||
|
# cache, log and lock — the user's own ~/.config/sleepyowl is never used. Gradle builds before `app` unless
|
||||||
|
# DESKTOP_NOBUILD=1.
|
||||||
|
set -e
|
||||||
|
R=$(cd "$(dirname "$0")/../.." && pwd)
|
||||||
|
STAND=${STAND:-$HOME/.cache/sleepyowl-test}
|
||||||
|
D=:87
|
||||||
|
export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1}
|
||||||
|
mkdir -p "$STAND/shots" "$STAND/run"
|
||||||
|
|
||||||
|
running() { [ -f "$STAND/run/$1.pid" ] && kill -0 "$(cat "$STAND/run/$1.pid")" 2>/dev/null; }
|
||||||
|
|
||||||
|
up() {
|
||||||
|
if ! running xvfb; then
|
||||||
|
Xvfb $D -screen 0 1920x1080x24 -nolisten tcp >"$STAND/run/xvfb.out" 2>&1 &
|
||||||
|
echo $! >"$STAND/run/xvfb.pid"
|
||||||
|
for _ in $(seq 50); do xdpyinfo -display $D >/dev/null 2>&1 && break; sleep 0.1; done
|
||||||
|
fi
|
||||||
|
if ! running muffin; then
|
||||||
|
DISPLAY=$D setsid dbus-run-session muffin --replace >"$STAND/run/muffin.out" 2>&1 &
|
||||||
|
echo $! >"$STAND/run/muffin.pid"
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
echo "stand up on $D"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_app() {
|
||||||
|
if running app; then
|
||||||
|
kill "$(cat "$STAND/run/app.pid")" 2>/dev/null || true
|
||||||
|
for _ in $(seq 50); do running app || break; sleep 0.1; done
|
||||||
|
running app && kill -9 "$(cat "$STAND/run/app.pid")" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$STAND/run/app.pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
up) up ;;
|
||||||
|
down)
|
||||||
|
stop_app
|
||||||
|
for p in muffin xvfb; do running $p && pkill -P "$(cat "$STAND/run/$p.pid")" 2>/dev/null; running $p && kill "$(cat "$STAND/run/$p.pid")"; rm -f "$STAND/run/$p.pid"; done
|
||||||
|
echo "stand down" ;;
|
||||||
|
app)
|
||||||
|
shift
|
||||||
|
up >/dev/null
|
||||||
|
stop_app
|
||||||
|
[ -n "$DESKTOP_NOBUILD" ] || (cd "$R" && ./gradlew -q :desktop:classes :desktop:desktopClasspath :desktop-proto:jar :desktop-proto:protoClasspath)
|
||||||
|
DISPLAY=$D XDG_CONFIG_HOME="$STAND/config" XDG_CACHE_HOME="$STAND/cache" XDG_STATE_HOME="$STAND/state" \
|
||||||
|
SLEEPYOWL_RUNTIME_DIR="$STAND/run" DESKTOP_NOBUILD=1 "$R/tools/linux/desktop.sh" "$@" >"$STAND/app.out" 2>&1 &
|
||||||
|
echo $! >"$STAND/run/app.pid"
|
||||||
|
echo "app started, pid $(cat "$STAND/run/app.pid")" ;;
|
||||||
|
stop) stop_app ;;
|
||||||
|
shot)
|
||||||
|
import -display $D -window root "$STAND/shots/$2.png"
|
||||||
|
echo "$STAND/shots/$2.png" ;;
|
||||||
|
in) shift; DISPLAY=$D python3 "$R/tools/linux/xinput.py" "$@" ;;
|
||||||
|
log) cat "$STAND/state/sleepyowl/log/sleepyowl.log" ;;
|
||||||
|
*) sed -n '2,17p' "$0"; exit 1 ;;
|
||||||
|
esac
|
||||||
110
tools/linux/watchdata.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Watch data of the test account on the server, before and after a test run (LINUX_PLAN.md §3 «Как проверяем»).
|
||||||
|
|
||||||
|
watchdata.py snapshot [FILE] saves the user's watch data (positions, played, counts, dates, favourites)
|
||||||
|
watchdata.py diff [FILE] what the run changed, compared with the snapshot
|
||||||
|
watchdata.py restore [FILE] puts the changed items back as they were in the snapshot
|
||||||
|
|
||||||
|
FILE defaults to $STAND/watchdata.json. The user is the test account: its token is taken from the test stand's saved accounts
|
||||||
|
($STAND/config/sleepyowl/jellyfin_session.preferences_pb, STAND=~/.cache/sleepyowl-test) or from JF_TOKEN.
|
||||||
|
JF_USER picks another saved account by name. Server: JF_URL, default the LAN address.
|
||||||
|
|
||||||
|
Restore writes the saved UserData back (POST /UserItems/{id}/UserData). An item the run started from nothing (not in
|
||||||
|
the snapshot) is reset: played and its date removed (DELETE /UserPlayedItems), position and count back to 0.
|
||||||
|
The resume list follows LastPlayedDate, so its order comes back with the dates.
|
||||||
|
"""
|
||||||
|
import json, os, re, sys, urllib.request
|
||||||
|
|
||||||
|
STAND = os.environ.get("STAND") or os.path.expanduser("~/.cache/sleepyowl-test")
|
||||||
|
URL = os.environ.get("JF_URL", "http://192.168.1.10:8096")
|
||||||
|
USER = os.environ.get("JF_USER", "test")
|
||||||
|
FIELDS = ("PlaybackPositionTicks", "PlayCount", "Played", "LastPlayedDate", "IsFavorite")
|
||||||
|
|
||||||
|
|
||||||
|
def account():
|
||||||
|
token = os.environ.get("JF_TOKEN")
|
||||||
|
path = os.path.join(STAND, "config", "sleepyowl", "jellyfin_session.preferences_pb")
|
||||||
|
raw = open(path, "rb").read().decode("utf-8", "replace") if os.path.exists(path) else ""
|
||||||
|
m = re.search(r"\[\{.*\}\]", raw)
|
||||||
|
for a in json.loads(m.group(0)) if m else []:
|
||||||
|
if a["userName"] == USER:
|
||||||
|
return token or a["token"], a["userId"]
|
||||||
|
sys.exit(f"no saved account {USER!r} in {path}: log in on the stand first (or set JF_TOKEN and JF_USER_ID)")
|
||||||
|
|
||||||
|
|
||||||
|
def api(method, path, body=None):
|
||||||
|
req = urllib.request.Request(URL + path, method=method, data=None if body is None else json.dumps(body).encode())
|
||||||
|
req.add_header("Authorization", f'MediaBrowser Token="{TOKEN}"')
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
data = r.read()
|
||||||
|
return json.loads(data) if data else None
|
||||||
|
|
||||||
|
|
||||||
|
def current():
|
||||||
|
"""Every item with any watch data: resumable, played or favourite (episodes, movies, series)."""
|
||||||
|
items = {}
|
||||||
|
for flt in ("IsResumable", "IsPlayed", "IsFavorite"):
|
||||||
|
res = api("GET", f"/Items?userId={USER_ID}&Recursive=true&Filters={flt}&Fields=UserData&EnableImages=false")
|
||||||
|
for it in res["Items"]:
|
||||||
|
ud = it.get("UserData", {})
|
||||||
|
items[it["Id"]] = {"Name": it.get("SeriesName", "") + (" · " if it.get("SeriesName") else "") + it.get("Name", ""),
|
||||||
|
**{k: ud.get(k) for k in FIELDS}}
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def user_data(item_id):
|
||||||
|
ud = api("GET", f"/UserItems/{item_id}/UserData?userId={USER_ID}")
|
||||||
|
return {k: ud.get(k) for k in FIELDS}
|
||||||
|
|
||||||
|
|
||||||
|
def changes(before, after):
|
||||||
|
out = []
|
||||||
|
for item_id in sorted(set(before) | set(after)):
|
||||||
|
b, a = before.get(item_id), after.get(item_id)
|
||||||
|
if b is None:
|
||||||
|
a = {**a, **user_data(item_id)}
|
||||||
|
if a is None:
|
||||||
|
a = {"Name": b["Name"], **user_data(item_id)}
|
||||||
|
if b is None or any(b[k] != a[k] for k in FIELDS):
|
||||||
|
out.append((item_id, b, a))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def show(item_id, b, a):
|
||||||
|
name = (b or a)["Name"]
|
||||||
|
diff = ", ".join(f"{k}: {None if b is None else b[k]} → {a[k]}" for k in FIELDS if b is None or b[k] != a[k])
|
||||||
|
print(f" {name} ({item_id}): {diff}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2 or sys.argv[1] not in ("snapshot", "diff", "restore"):
|
||||||
|
sys.exit(__doc__)
|
||||||
|
path = sys.argv[2] if len(sys.argv) > 2 else os.path.join(STAND, "watchdata.json")
|
||||||
|
if sys.argv[1] == "snapshot":
|
||||||
|
items = current()
|
||||||
|
json.dump(items, open(path, "w"), ensure_ascii=False, indent=1)
|
||||||
|
print(f"{len(items)} items with watch data saved to {path}")
|
||||||
|
return
|
||||||
|
before = json.load(open(path))
|
||||||
|
diff = changes(before, current())
|
||||||
|
if not diff:
|
||||||
|
print("no changes")
|
||||||
|
return
|
||||||
|
for c in diff: show(*c)
|
||||||
|
if sys.argv[1] == "diff":
|
||||||
|
return
|
||||||
|
for item_id, b, _ in diff:
|
||||||
|
if b is None:
|
||||||
|
api("DELETE", f"/UserPlayedItems/{item_id}?userId={USER_ID}")
|
||||||
|
api("POST", f"/UserItems/{item_id}/UserData?userId={USER_ID}", {"PlaybackPositionTicks": 0, "PlayCount": 0, "Played": False})
|
||||||
|
else:
|
||||||
|
api("POST", f"/UserItems/{item_id}/UserData?userId={USER_ID}", {k: b[k] for k in FIELDS})
|
||||||
|
left = changes(before, current())
|
||||||
|
print(f"restored {len(diff)} items" + ("" if not left else f"; still different: {len(left)}"))
|
||||||
|
for c in left: show(*c)
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN, USER_ID = (os.environ["JF_TOKEN"], os.environ["JF_USER_ID"]) if os.environ.get("JF_USER_ID") else account()
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
87
tools/linux/xinput.py
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mouse and keyboard on an X display through XTest (python3-xlib), for the test stand (stand.sh in …).
|
||||||
|
|
||||||
|
xinput.py click X Y [BUTTON] move there and click (1 left, 3 right, 8 back)
|
||||||
|
xinput.py dclick X Y double click
|
||||||
|
xinput.py move X Y
|
||||||
|
xinput.py key NAME [NAME…] keys by keysym name: Escape Return Tab BackSpace Left …; "alt+Left" for a chord
|
||||||
|
xinput.py type TEXT types Latin text, digits and punctuation
|
||||||
|
xinput.py wait SECONDS
|
||||||
|
Several commands in one call are separated by ",": xinput.py click 10 10 , key Return
|
||||||
|
"""
|
||||||
|
import sys, time
|
||||||
|
from Xlib import X, XK, display
|
||||||
|
from Xlib.ext import xtest
|
||||||
|
|
||||||
|
d = display.Display()
|
||||||
|
MODS = {"alt": "Alt_L", "ctrl": "Control_L", "shift": "Shift_L"}
|
||||||
|
|
||||||
|
|
||||||
|
def code(name):
|
||||||
|
keysym = XK.string_to_keysym(name)
|
||||||
|
if keysym == 0 and len(name) == 1:
|
||||||
|
keysym = ord(name)
|
||||||
|
c = d.keysym_to_keycode(keysym)
|
||||||
|
if not c:
|
||||||
|
sys.exit(f"no key for {name!r}")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def press(c, down):
|
||||||
|
xtest.fake_input(d, X.KeyPress if down else X.KeyRelease, c)
|
||||||
|
d.sync()
|
||||||
|
|
||||||
|
|
||||||
|
def key(chord):
|
||||||
|
*mods, name = chord.split("+")
|
||||||
|
for m in mods: press(code(MODS[m]), True)
|
||||||
|
c = code(name)
|
||||||
|
press(c, True); time.sleep(0.03); press(c, False)
|
||||||
|
for m in reversed(mods): press(code(MODS[m]), False)
|
||||||
|
time.sleep(0.08)
|
||||||
|
|
||||||
|
|
||||||
|
def move(x, y):
|
||||||
|
xtest.fake_input(d, X.MotionNotify, x=int(x), y=int(y)); d.sync(); time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def click(x, y, button=1, times=1):
|
||||||
|
move(x, y)
|
||||||
|
for _ in range(times):
|
||||||
|
xtest.fake_input(d, X.ButtonPress, int(button)); d.sync(); time.sleep(0.03)
|
||||||
|
xtest.fake_input(d, X.ButtonRelease, int(button)); d.sync(); time.sleep(0.06)
|
||||||
|
|
||||||
|
|
||||||
|
SHIFTED = {c: True for c in '~!@#$%^&*()_+{}|:"<>?ABCDEFGHIJKLMNOPQRSTUVWXYZ'}
|
||||||
|
NAMES = {" ": "space", ".": "period", ":": "colon", "/": "slash", "-": "minus", "_": "underscore", "@": "at"}
|
||||||
|
|
||||||
|
|
||||||
|
def type_text(text):
|
||||||
|
for ch in text:
|
||||||
|
name = NAMES.get(ch, ch)
|
||||||
|
c = code(name)
|
||||||
|
shift = ch in SHIFTED
|
||||||
|
if shift: press(code("Shift_L"), True)
|
||||||
|
press(c, True); press(c, False)
|
||||||
|
if shift: press(code("Shift_L"), False)
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd):
|
||||||
|
op, args = cmd[0], cmd[1:]
|
||||||
|
if op == "click": click(args[0], args[1], args[2] if len(args) > 2 else 1)
|
||||||
|
elif op == "dclick": click(args[0], args[1], times=2)
|
||||||
|
elif op == "move": move(*args)
|
||||||
|
elif op == "key": [key(a) for a in args]
|
||||||
|
elif op == "type": type_text(" ".join(args))
|
||||||
|
elif op == "wait": time.sleep(float(args[0]))
|
||||||
|
else: sys.exit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
cmds, cur = [], []
|
||||||
|
for a in sys.argv[1:]:
|
||||||
|
if a == ",": cmds.append(cur); cur = []
|
||||||
|
else: cur.append(a)
|
||||||
|
if cur: cmds.append(cur)
|
||||||
|
if not cmds: sys.exit(__doc__)
|
||||||
|
for c in cmds: run(c)
|
||||||
@@ -8,6 +8,8 @@ wordmark on a flat navy background, 1254×1254):
|
|||||||
@color/ic_launcher_background
|
@color/ic_launcher_background
|
||||||
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
|
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
|
||||||
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
|
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
|
||||||
|
desktop/src/main/resources/icons/sleepyowl-<N>.png desktop icon N×N (16…512): owl on a navy rounded square —
|
||||||
|
window icon, .desktop file, the .deb's hicolor theme
|
||||||
|
|
||||||
The in-app marks belong to ui:shared: the TV uses them as resources, the desktop reads the same files from its
|
The in-app marks belong to ui:shared: the TV uses them as resources, the desktop reads the same files from its
|
||||||
classpath.
|
classpath.
|
||||||
@@ -24,7 +26,7 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image, ImageDraw
|
||||||
from scipy import ndimage
|
from scipy import ndimage
|
||||||
|
|
||||||
BG = np.array([5, 9, 39]) / 255 # the logo's background
|
BG = np.array([5, 9, 39]) / 255 # the logo's background
|
||||||
@@ -34,6 +36,8 @@ WORD_ROWS = (870, 1080)
|
|||||||
# Where the pictures go, relative to the repository: the TV app's own and the shared UI's resources.
|
# Where the pictures go, relative to the repository: the TV app's own and the shared UI's resources.
|
||||||
APP_RES = "app/src/main/res/"
|
APP_RES = "app/src/main/res/"
|
||||||
SHARED_RES = "ui/shared/src/androidMain/res/"
|
SHARED_RES = "ui/shared/src/androidMain/res/"
|
||||||
|
DESKTOP_ICONS = "desktop/src/main/resources/icons/"
|
||||||
|
DESKTOP_SIZES = (16, 24, 32, 48, 64, 128, 256, 512)
|
||||||
|
|
||||||
DP = 2 # xhdpi
|
DP = 2 # xhdpi
|
||||||
|
|
||||||
@@ -128,6 +132,17 @@ def build(src):
|
|||||||
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
|
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
|
||||||
out[APP_RES + "drawable-xxxhdpi/ic_launcher_owl.png"] = fg
|
out[APP_RES + "drawable-xxxhdpi/ic_launcher_owl.png"] = fg
|
||||||
|
|
||||||
|
# Desktop icons: the owl's body fills ~80 % of a navy square with rounded corners (as the panel's other icons).
|
||||||
|
big = 1024
|
||||||
|
plate = Image.new("L", (big, big), 0)
|
||||||
|
ImageDraw.Draw(plate).rounded_rectangle((0, 0, big - 1, big - 1), radius=big * 0.22, fill=255)
|
||||||
|
owl_d = fit(owl, round(owl.height * big * 0.4 / body_radius))
|
||||||
|
icon = Image.new("RGBA", (big, big), tuple(int(c * 255 + 0.5) for c in BG) + (255,))
|
||||||
|
icon.alpha_composite(owl_d, ((big - owl_d.width) // 2, (big - owl_d.height) // 2))
|
||||||
|
icon.putalpha(plate)
|
||||||
|
for n in DESKTOP_SIZES:
|
||||||
|
out[DESKTOP_ICONS + f"sleepyowl-{n}.png"] = icon.convert("RGBa").resize((n, n), Image.LANCZOS).convert("RGBA")
|
||||||
|
|
||||||
# In-app marks, drawn at exactly these sizes (Brand.kt).
|
# In-app marks, drawn at exactly these sizes (Brand.kt).
|
||||||
out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
|
out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
|
||||||
out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
|
out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.ui.menu
|
||||||
|
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
internal actual fun Modifier.secondaryClick(onClick: () -> Unit): Modifier = this
|
||||||
@@ -128,6 +128,12 @@ class LibraryViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A tile clicked (the desktop): that library now. */
|
||||||
|
fun selectLibrary(id: UUID) {
|
||||||
|
selectJob?.cancel()
|
||||||
|
if (id != _state.value.selected) select(id)
|
||||||
|
}
|
||||||
|
|
||||||
fun setSort(sort: ItemSort) {
|
fun setSort(sort: ItemSort) {
|
||||||
val content = _state.value.content ?: return
|
val content = _state.value.content ?: return
|
||||||
if (content.sort != sort) restartGrid(content.libraryId, sort, content.unwatched)
|
if (content.sort != sort) restartGrid(content.libraryId, sort, content.unwatched)
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ class WatchMenu {
|
|||||||
val LocalWatchMenu = staticCompositionLocalOf<WatchMenu?> { null }
|
val LocalWatchMenu = staticCompositionLocalOf<WatchMenu?> { null }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click of a card or chip that also opens the watched menu for [target]: OK held down (a long press), or the remote's
|
* Click of a card or chip that also opens the watched menu for [target]: OK held down (a long press), the remote's
|
||||||
* Menu key where it has one. Without a target it's a plain click. Put it where `clickable` would go.
|
* Menu key where it has one, or the right mouse button on the desktop. Without a target it's a plain click. Put it
|
||||||
|
* where `clickable` would go.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modifier {
|
fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modifier {
|
||||||
@@ -79,12 +80,16 @@ fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modif
|
|||||||
val open = remember(menu, target) { { menu.open(target, self) } }
|
val open = remember(menu, target) { { menu.open(target, self) } }
|
||||||
return this
|
return this
|
||||||
.focusRequester(self)
|
.focusRequester(self)
|
||||||
|
.secondaryClick(open)
|
||||||
.onKeyEvent { e ->
|
.onKeyEvent { e ->
|
||||||
(e.key == Key.Menu).also { if (it && e.type == KeyEventType.KeyUp) open() }
|
(e.key == Key.Menu).also { if (it && e.type == KeyEventType.KeyUp) open() }
|
||||||
}
|
}
|
||||||
.combinedClickable(interactionSource = null, indication = null, onLongClick = open, onClick = onClick)
|
.combinedClickable(interactionSource = null, indication = null, onLongClick = open, onClick = onClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The right mouse button runs [onClick]; nothing on the TV. */
|
||||||
|
internal expect fun Modifier.secondaryClick(onClick: () -> Unit): Modifier
|
||||||
|
|
||||||
class WatchMenuViewModel(private val watch: WatchRepository) : ViewModel() {
|
class WatchMenuViewModel(private val watch: WatchRepository) : ViewModel() {
|
||||||
/** Screens reload on their own when it's done (the cache tells them the user data changed). */
|
/** Screens reload on their own when it's done (the cache tells them the user data changed). */
|
||||||
suspend fun setPlayed(id: UUID, played: Boolean) = watch.setPlayed(id, played)
|
suspend fun setPlayed(id: UUID, played: Boolean) = watch.setPlayed(id, played)
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ class SearchViewModel(
|
|||||||
|
|
||||||
fun toggleLayout() = _state.update { it.copy(latin = !it.latin) }
|
fun toggleLayout() = _state.update { it.copy(latin = !it.latin) }
|
||||||
|
|
||||||
private fun setQuery(query: String) {
|
/** The whole query at once: the desktop's text field. */
|
||||||
|
fun setQuery(query: String) {
|
||||||
val q = query.take(MAX_LENGTH)
|
val q = query.take(MAX_LENGTH)
|
||||||
if (q == _state.value.query) return
|
if (q == _state.value.query) return
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.stenfredd.sleepyowl.ui.menu
|
||||||
|
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||||
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
internal actual fun Modifier.secondaryClick(onClick: () -> Unit): Modifier =
|
||||||
|
onPointerEvent(PointerEventType.Press) { if (it.buttons.isSecondaryPressed) onClick() }
|
||||||