From 01c1b304efa4dc1de059d531609b2efd49a26ccc Mon Sep 17 00:00:00 2001 From: StenFredd Date: Mon, 26 Oct 2026 23:46:29 +0300 Subject: [PATCH] Desktop app: shell and library screens --- .../sleepyowl/core/common/Log.desktop.kt | 26 + .../core/jellyfin/DesktopJellyfinPlatform.kt | 17 +- desktop/build.gradle.kts | 44 ++ desktop/packaging/sleepyowl.desktop | 14 + .../ru/stenfredd/sleepyowl/desktop/App.kt | 241 +++++++ .../sleepyowl/desktop/DesktopModule.kt | 108 +++ .../sleepyowl/desktop/DetailsScreen.kt | 317 +++++++++ .../sleepyowl/desktop/GridScreens.kt | 211 ++++++ .../stenfredd/sleepyowl/desktop/HomeScreen.kt | 84 +++ .../ru/stenfredd/sleepyowl/desktop/Input.kt | 186 +++++ .../sleepyowl/desktop/LibraryScreen.kt | 288 ++++++++ .../sleepyowl/desktop/LoginScreen.kt | 427 ++++++++++++ .../ru/stenfredd/sleepyowl/desktop/Main.kt | 78 +++ .../ru/stenfredd/sleepyowl/desktop/Native.kt | 28 + .../stenfredd/sleepyowl/desktop/Navigation.kt | 99 +++ .../ru/stenfredd/sleepyowl/desktop/Panels.kt | 171 +++++ .../sleepyowl/desktop/ProtoPlayer.kt | 43 ++ .../ru/stenfredd/sleepyowl/desktop/Rows.kt | 142 ++++ .../sleepyowl/desktop/SearchScreen.kt | 200 ++++++ .../sleepyowl/desktop/SeriesScreen.kt | 644 ++++++++++++++++++ .../sleepyowl/desktop/SettingsScreen.kt | 431 ++++++++++++ .../ru/stenfredd/sleepyowl/desktop/Shell.kt | 252 +++++++ .../sleepyowl/desktop/SingleInstance.kt | 57 ++ .../stenfredd/sleepyowl/desktop/WatchMenu.kt | 187 +++++ .../main/resources/icons/sleepyowl-128.png | Bin 0 -> 17421 bytes .../src/main/resources/icons/sleepyowl-16.png | Bin 0 -> 758 bytes .../src/main/resources/icons/sleepyowl-24.png | Bin 0 -> 1343 bytes .../main/resources/icons/sleepyowl-256.png | Bin 0 -> 52248 bytes .../src/main/resources/icons/sleepyowl-32.png | Bin 0 -> 2058 bytes .../src/main/resources/icons/sleepyowl-48.png | Bin 0 -> 3692 bytes .../main/resources/icons/sleepyowl-512.png | Bin 0 -> 167651 bytes .../src/main/resources/icons/sleepyowl-64.png | Bin 0 -> 5778 bytes .../sleepyowl/desktop/DesktopGraphTest.kt | 27 + .../sleepyowl/desktop/ShellRenderTest.kt | 49 ++ gradle/libs.versions.toml | 2 + settings.gradle.kts | 3 + tools/linux/desktop.sh | 11 + tools/linux/stand.sh | 70 ++ tools/linux/watchdata.py | 110 +++ tools/linux/xinput.py | 87 +++ tools/logo/make_assets.py | 17 +- .../ui/menu/SecondaryClick.android.kt | 5 + .../sleepyowl/ui/library/LibraryViewModel.kt | 6 + .../sleepyowl/ui/menu/WatchTarget.kt | 9 +- .../sleepyowl/ui/search/SearchViewModel.kt | 3 +- .../ui/menu/SecondaryClick.desktop.kt | 11 + 46 files changed, 4700 insertions(+), 5 deletions(-) create mode 100644 desktop/build.gradle.kts create mode 100644 desktop/packaging/sleepyowl.desktop create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopModule.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DetailsScreen.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/GridScreens.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/HomeScreen.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Input.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/LibraryScreen.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/LoginScreen.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Main.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Native.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Navigation.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Panels.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/ProtoPlayer.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Rows.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SearchScreen.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SeriesScreen.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SettingsScreen.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Shell.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SingleInstance.kt create mode 100644 desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/WatchMenu.kt create mode 100644 desktop/src/main/resources/icons/sleepyowl-128.png create mode 100644 desktop/src/main/resources/icons/sleepyowl-16.png create mode 100644 desktop/src/main/resources/icons/sleepyowl-24.png create mode 100644 desktop/src/main/resources/icons/sleepyowl-256.png create mode 100644 desktop/src/main/resources/icons/sleepyowl-32.png create mode 100644 desktop/src/main/resources/icons/sleepyowl-48.png create mode 100644 desktop/src/main/resources/icons/sleepyowl-512.png create mode 100644 desktop/src/main/resources/icons/sleepyowl-64.png create mode 100644 desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopGraphTest.kt create mode 100644 desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/ShellRenderTest.kt create mode 100755 tools/linux/desktop.sh create mode 100755 tools/linux/stand.sh create mode 100644 tools/linux/watchdata.py create mode 100755 tools/linux/xinput.py create mode 100644 ui/shared/src/androidMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/SecondaryClick.android.kt create mode 100644 ui/shared/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/SecondaryClick.desktop.kt diff --git a/core/common/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/core/common/Log.desktop.kt b/core/common/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/core/common/Log.desktop.kt index ba9ca0c..3172f36 100644 --- a/core/common/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/core/common/Log.desktop.kt +++ b/core/common/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/core/common/Log.desktop.kt @@ -1,11 +1,33 @@ 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.format.DateTimeFormatter 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') +/** 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 platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array) { @@ -14,5 +36,9 @@ internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, messa synchronized(System.err) { System.err.println(line) t?.printStackTrace() + file?.let { out -> + out.println(line) + t?.printStackTrace(out) + } } } diff --git a/core/jellyfin/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/core/jellyfin/DesktopJellyfinPlatform.kt b/core/jellyfin/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/core/jellyfin/DesktopJellyfinPlatform.kt index f6b9db0..3b06a1d 100644 --- a/core/jellyfin/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/core/jellyfin/DesktopJellyfinPlatform.kt +++ b/core/jellyfin/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/core/jellyfin/DesktopJellyfinPlatform.kt @@ -1,8 +1,23 @@ package ru.stenfredd.sleepyowl.core.jellyfin +import org.jellyfin.sdk.JellyfinOptions +import org.jellyfin.sdk.model.DeviceInfo import java.io.File +import java.util.UUID class DesktopJellyfinPlatform : JellyfinPlatform { // /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 } } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts new file mode 100644 index 0000000..a2d8dc2 --- /dev/null +++ b/desktop/build.gradle.kts @@ -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().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(":")) } +} diff --git a/desktop/packaging/sleepyowl.desktop b/desktop/packaging/sleepyowl.desktop new file mode 100644 index 0000000..07f6775 --- /dev/null +++ b/desktop/packaging/sleepyowl.desktop @@ -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 diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt new file mode 100644 index 0000000..fce35c2 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/App.kt @@ -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() + var stack by remember { mutableStateOf(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() + 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> = "" 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() + val memory = koinInject() + 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) + } + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopModule.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopModule.kt new file mode 100644 index 0000000..4a3418e --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopModule.kt @@ -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 { DesktopJellyfinPlatform() } + single { DesktopCapabilitiesProbe() } + single { 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) diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DetailsScreen.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DetailsScreen.kt new file mode 100644 index 0000000..e620005 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/DetailsScreen.kt @@ -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, modifier: Modifier, badges: List = 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, 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) + } + } + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/GridScreens.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/GridScreens.kt new file mode 100644 index 0000000..8a458d5 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/GridScreens.kt @@ -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?>(null) } + var error by remember { mutableStateOf(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 diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/HomeScreen.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/HomeScreen.kt new file mode 100644 index 0000000..9696584 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/HomeScreen.kt @@ -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)) } + } + } + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Input.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Input.kt new file mode 100644 index 0000000..2171f7f --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Input.kt @@ -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() + } + }, + ) +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/LibraryScreen.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/LibraryScreen.kt new file mode 100644 index 0000000..64acbc6 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/LibraryScreen.kt @@ -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, 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 diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/LoginScreen.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/LoginScreen.kt new file mode 100644 index 0000000..e6beb2a --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/LoginScreen.kt @@ -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)) + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Main.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Main.kt new file mode 100644 index 0000000..3a50d06 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Main.kt @@ -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 diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Native.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Native.kt new file mode 100644 index 0000000..1bb1452 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Native.kt @@ -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") } + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Navigation.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Navigation.kt new file mode 100644 index 0000000..17682eb --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Navigation.kt @@ -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() + 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) + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Panels.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Panels.kt new file mode 100644 index 0000000..b241cb2 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Panels.kt @@ -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, onPick: (PanelChoice) -> Unit) { + for (c in choices) PanelOption(c, onClick = { onPick(c) }) +} + +@Composable +fun PanelChips(choices: List, 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, + ), + ) + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/ProtoPlayer.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/ProtoPlayer.kt new file mode 100644 index 0000000..441aebb --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/ProtoPlayer.kt @@ -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() + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Rows.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Rows.kt new file mode 100644 index 0000000..57eb4a7 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Rows.kt @@ -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)) + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SearchScreen.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SearchScreen.kt new file mode 100644 index 0000000..7033a58 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SearchScreen.kt @@ -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, 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)) +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SeriesScreen.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SeriesScreen.kt new file mode 100644 index 0000000..009983d --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SeriesScreen.kt @@ -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({ 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, 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? = 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, + ) + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SettingsScreen.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SettingsScreen.kt new file mode 100644 index 0000000..82716c7 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SettingsScreen.kt @@ -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(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 Choices(id: String, options: List>, 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(null, "Оригинал")) + + QualityCaps.ALL.map { (bitrate, lines) -> Option(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(null, "Как в файле")) + + (AudioLanguages + listOfNotNull(settings.audio?.takeIf { a -> AudioLanguages.none { it.first == a } }?.let { it to languageName(it) })) + .map { (code, name) -> Option(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(null, "Любой")) + + (SubtitleLanguages + listOfNotNull(settings.subtitleLanguage?.takeIf { s -> SubtitleLanguages.none { it.first == s } }?.let { it to languageName(it) })) + .map { (code, name) -> Option(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(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.") +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Shell.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Shell.kt new file mode 100644 index 0000000..e36d7dc --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/Shell.kt @@ -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, + 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, + 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) = 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) = drawBehind { + if (selected) drawRoundRect(AppColors.SurfaceFocused, alpha = progress.value, cornerRadius = CornerRadius(8.dp.toPx())) +} + +@Composable +private fun RailDivider(progress: State) { + 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, 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, 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) + } + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SingleInstance.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SingleInstance.kt new file mode 100644 index 0000000..3da4b52 --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/SingleInstance.kt @@ -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) + } + } +} diff --git a/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/WatchMenu.kt b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/WatchMenu.kt new file mode 100644 index 0000000..87379fb --- /dev/null +++ b/desktop/src/main/kotlin/ru/stenfredd/sleepyowl/desktop/WatchMenu.kt @@ -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(null) } + var error by remember { mutableStateOf(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) + } + } + } +} diff --git a/desktop/src/main/resources/icons/sleepyowl-128.png b/desktop/src/main/resources/icons/sleepyowl-128.png new file mode 100644 index 0000000000000000000000000000000000000000..109875b05bda6ba72049e27d43f810990f5f1425 GIT binary patch literal 17421 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 QKL7v#07*qoM6N<$f(hQ!^Z)<= literal 0 HcmV?d00001 diff --git a/desktop/src/main/resources/icons/sleepyowl-16.png b/desktop/src/main/resources/icons/sleepyowl-16.png new file mode 100644 index 0000000000000000000000000000000000000000..2e64767e0efed94fce1d514151ab414f3b017217 GIT binary patch literal 758 zcmVq zJ#1V>6otP#^WNLHKVI+JP5|2&WW}+NgCZx2`6)r52nrviQ#*#1BtiM9h!ldnQ5w3N0uKsQ8xLDXL%%FXpRWwAP!whR76X zp{NlAr#X(Cx^43D8`Es{o)TJ1yCy^lh){qS(6_~}u1=oC7>jc`)u7ExxWZi6Vr}jb zRe!)IXAb%1+7oW>`UDklW0f}%E6U>ZSpvU7nj7MzM?Zed?_H1eV=qygf)CCfaC;}i ziC@wH9Xm1-}R5T{2x8a?ITqZ*yv<9zGpBc3BTQQ+#q#hjttL+3A0T@x7T1H z47sqOnEGE_{`nbx;GwD*5utDi?Zy)I$^!HCguVU<6D#LmZ4n&IF%`g0w8^(O`pj2W z*jNro6NeEnAgDU(!8}LNKXgW2wt7Q8TpjS;PffnQ)M3W%Bh-mBFCcRDMIId}hzKHOf%JLU1tWs~zf3Og-LqbMTg99fXbK`C<^Re~ o%6OPT1f{I=wy66eiyvJ54~ug+H}VA$Gynhq07*qoM6N<$f&<`S761SM literal 0 HcmV?d00001 diff --git a/desktop/src/main/resources/icons/sleepyowl-24.png b/desktop/src/main/resources/icons/sleepyowl-24.png new file mode 100644 index 0000000000000000000000000000000000000000..281b31130a95dc71a5bf86842609643a561c9568 GIT binary patch literal 1343 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 Z2udkazzRsvFw0BE=P& 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 N002ovPDHLkV1lZy_3{7! literal 0 HcmV?d00001 diff --git a/desktop/src/main/resources/icons/sleepyowl-32.png b/desktop/src/main/resources/icons/sleepyowl-32.png new file mode 100644 index 0000000000000000000000000000000000000000..90c6a123ae3abd46770d83b7e1787e27a13f533f GIT binary patch literal 2058 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 literal 0 HcmV?d00001 diff --git a/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopGraphTest.kt b/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopGraphTest.kt new file mode 100644 index 0000000..71ce3d5 --- /dev/null +++ b/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/DesktopGraphTest.kt @@ -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), + ) + } +} diff --git a/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/ShellRenderTest.kt b/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/ShellRenderTest.kt new file mode 100644 index 0000000..1e846ae --- /dev/null +++ b/desktop/src/test/kotlin/ru/stenfredd/sleepyowl/desktop/ShellRenderTest.kt @@ -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-.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() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4fe67f8..191df07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,8 @@ compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } 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-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-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" } media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 95878c4..9874bd7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,3 +26,6 @@ include(":playback:core") include(":playback:engine-exo") include(":playback:engine-vlc") include(":ui:shared") + +// SleepyOwl for the Linux desktop (LINUX_PLAN.md). +include(":desktop") diff --git a/tools/linux/desktop.sh b/tools/linux/desktop.sh new file mode 100755 index 0000000..0fd2215 --- /dev/null +++ b/tools/linux/desktop.sh @@ -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 "$@" diff --git a/tools/linux/stand.sh b/tools/linux/stand.sh new file mode 100755 index 0000000..a3b9bff --- /dev/null +++ b/tools/linux/stand.sh @@ -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 diff --git a/tools/linux/watchdata.py b/tools/linux/watchdata.py new file mode 100644 index 0000000..5e225d0 --- /dev/null +++ b/tools/linux/watchdata.py @@ -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() diff --git a/tools/linux/xinput.py b/tools/linux/xinput.py new file mode 100755 index 0000000..4fccdd2 --- /dev/null +++ b/tools/linux/xinput.py @@ -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) diff --git a/tools/logo/make_assets.py b/tools/logo/make_assets.py index f1d3772..de6348c 100644 --- a/tools/logo/make_assets.py +++ b/tools/logo/make_assets.py @@ -8,6 +8,8 @@ wordmark on a flat navy background, 1254×1254): @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_wordmark.png "SleepyOwl" alone, transparent + desktop/src/main/resources/icons/sleepyowl-.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 classpath. @@ -24,7 +26,7 @@ import argparse import os import numpy as np -from PIL import Image +from PIL import Image, ImageDraw from scipy import ndimage 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. APP_RES = "app/src/main/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 @@ -128,6 +132,17 @@ def build(src): 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 + # 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). out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP) out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP) diff --git a/ui/shared/src/androidMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/SecondaryClick.android.kt b/ui/shared/src/androidMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/SecondaryClick.android.kt new file mode 100644 index 0000000..1bf329d --- /dev/null +++ b/ui/shared/src/androidMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/SecondaryClick.android.kt @@ -0,0 +1,5 @@ +package ru.stenfredd.sleepyowl.ui.menu + +import androidx.compose.ui.Modifier + +internal actual fun Modifier.secondaryClick(onClick: () -> Unit): Modifier = this diff --git a/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/library/LibraryViewModel.kt b/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/library/LibraryViewModel.kt index 4a1e896..eda944a 100644 --- a/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/library/LibraryViewModel.kt +++ b/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/library/LibraryViewModel.kt @@ -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) { val content = _state.value.content ?: return if (content.sort != sort) restartGrid(content.libraryId, sort, content.unwatched) diff --git a/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/WatchTarget.kt b/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/WatchTarget.kt index 2e1722d..e23b539 100644 --- a/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/WatchTarget.kt +++ b/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/WatchTarget.kt @@ -68,8 +68,9 @@ class WatchMenu { val LocalWatchMenu = staticCompositionLocalOf { 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 - * Menu key where it has one. Without a target it's a plain click. Put it where `clickable` would go. + * 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, or the right mouse button on the desktop. Without a target it's a plain click. Put it + * where `clickable` would go. */ @Composable 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) } } return this .focusRequester(self) + .secondaryClick(open) .onKeyEvent { e -> (e.key == Key.Menu).also { if (it && e.type == KeyEventType.KeyUp) open() } } .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() { /** 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) diff --git a/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/search/SearchViewModel.kt b/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/search/SearchViewModel.kt index fd629d1..29b273f 100644 --- a/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/search/SearchViewModel.kt +++ b/ui/shared/src/commonMain/kotlin/ru/stenfredd/sleepyowl/ui/search/SearchViewModel.kt @@ -97,7 +97,8 @@ class SearchViewModel( 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) if (q == _state.value.query) return searchJob?.cancel() diff --git a/ui/shared/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/SecondaryClick.desktop.kt b/ui/shared/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/SecondaryClick.desktop.kt new file mode 100644 index 0000000..82ba3a5 --- /dev/null +++ b/ui/shared/src/desktopMain/kotlin/ru/stenfredd/sleepyowl/ui/menu/SecondaryClick.desktop.kt @@ -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() }