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 0000000..109875b Binary files /dev/null and b/desktop/src/main/resources/icons/sleepyowl-128.png differ 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 0000000..2e64767 Binary files /dev/null and b/desktop/src/main/resources/icons/sleepyowl-16.png differ 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 0000000..281b311 Binary files /dev/null and b/desktop/src/main/resources/icons/sleepyowl-24.png differ diff --git a/desktop/src/main/resources/icons/sleepyowl-256.png b/desktop/src/main/resources/icons/sleepyowl-256.png new file mode 100644 index 0000000..1ba3e7a Binary files /dev/null and b/desktop/src/main/resources/icons/sleepyowl-256.png differ 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 0000000..90c6a12 Binary files /dev/null and b/desktop/src/main/resources/icons/sleepyowl-32.png differ diff --git a/desktop/src/main/resources/icons/sleepyowl-48.png b/desktop/src/main/resources/icons/sleepyowl-48.png new file mode 100644 index 0000000..ec2fdd2 Binary files /dev/null and b/desktop/src/main/resources/icons/sleepyowl-48.png differ diff --git a/desktop/src/main/resources/icons/sleepyowl-512.png b/desktop/src/main/resources/icons/sleepyowl-512.png new file mode 100644 index 0000000..d6ed553 Binary files /dev/null and b/desktop/src/main/resources/icons/sleepyowl-512.png differ diff --git a/desktop/src/main/resources/icons/sleepyowl-64.png b/desktop/src/main/resources/icons/sleepyowl-64.png new file mode 100644 index 0000000..10f422f Binary files /dev/null and b/desktop/src/main/resources/icons/sleepyowl-64.png differ 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() }