Desktop app: shell and library screens

This commit is contained in:
2026-10-26 23:46:29 +03:00
parent 69048452b8
commit 01c1b304ef
46 changed files with 4700 additions and 5 deletions

44
desktop/build.gradle.kts Normal file
View File

@@ -0,0 +1,44 @@
// SleepyOwl for the Linux desktop (LINUX_PLAN.md): windows, desktop screens, later the mpv player and system integration.
// Run: ./gradlew :desktop:run, or tools/linux/desktop.sh without Gradle around the process.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.multiplatform)
}
kotlin { jvmToolchain(21) }
dependencies {
implementation(project(":ui:shared"))
implementation(compose.desktop.currentOs)
implementation(libs.koin.core)
implementation(libs.koin.compose.viewmodel)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.jna)
testImplementation(libs.junit)
testImplementation(libs.koin.test)
}
// XToolkit: the windows' WM_CLASS (sun.awt.X11.XToolkit.awtAppClassName), set before the first window.
val awtOpens = listOf("--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED")
compose.desktop {
application {
mainClass = "ru.stenfredd.sleepyowl.desktop.MainKt"
jvmArgs += awtOpens + "-Dsleepyowl.repo=${rootDir.path}"
}
}
tasks.withType<Test>().configureEach { jvmArgs(awtOpens) }
// Classpath into a file: tools/linux/desktop.sh starts the app without Gradle.
tasks.register("desktopClasspath") {
val cp = sourceSets.main.get().runtimeClasspath
val out = layout.buildDirectory.file("desktop.classpath")
inputs.files(cp)
outputs.file(out)
doLast { out.get().asFile.writeText(cp.joinToString(":")) }
}

View File

@@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Name=SleepyOwl
GenericName=Jellyfin client
GenericName[ru]=Клиент Jellyfin
Comment=Movies and series from your Jellyfin server
Comment[ru]=Фильмы и сериалы с вашего сервера Jellyfin
Exec=sleepyowl
Icon=sleepyowl
Terminal=false
Categories=AudioVideo;Video;Player;
Keywords=jellyfin;movies;series;video;кино;сериалы;
StartupWMClass=sleepyowl
StartupNotify=true

View File

@@ -0,0 +1,241 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isAltPressed
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import org.koin.compose.koinInject
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.model.LibraryType
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.library.LibraryMemory
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
import ru.stenfredd.sleepyowl.ui.shell.Tab
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
import java.awt.AWTEvent
import java.awt.Toolkit
import java.awt.event.AWTEventListener
import java.awt.event.MouseEvent
import javax.swing.SwingUtilities
/** The main window's content: restores the session, then the login screen or the tabs, with "Назад" everywhere. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun App(window: java.awt.Window, keys: BackKeys) {
val session = koinInject<JellyfinSession>()
var stack by remember { mutableStateOf<NavStack?>(null) }
LaunchedEffect(Unit) {
val loggedIn = session.restore()
Log.i("Session restored: %s", if (loggedIn) "logged in" else "login screen")
stack = NavStack(if (loggedIn) root(session, Tab.Home) else Screen.Login)
}
val dispatcher = remember { BackDispatcher() }
val entry = remember { KeyboardEntry() }
val onBack = { stack?.let { back(dispatcher, it) } }
BackButton(window) { onBack() }
keys.onBack = { onBack() }
val focusManager = LocalFocusManager.current
keys.onArrow = { direction -> focusManager.moveFocus(direction) || entry.enter() }
keys.onSearch = { stack?.takeIf { it.top != Screen.Login }?.resetTo(root(session, Tab.Search)) }
Box(Modifier.fillMaxSize().background(AppColors.Background).trackPointer()) {
val nav = stack ?: return@Box
CompositionLocalProvider(
LocalBackDispatcher provides dispatcher,
LocalKeyboardEntry provides entry,
LocalBringIntoViewSpec provides KeyboardBringIntoView,
) {
WatchMenuHost { Navigation(nav, session) { onBack() } }
}
}
}
/** A handler of the screen first, then the stack. The last screen stays: closing the window is the window's job. */
private fun back(dispatcher: BackDispatcher, stack: NavStack) {
if (!dispatcher.dispatch()) stack.pop()
}
/**
* Keys for the window's `onKeyEvent`: it gets every key the focused element did not take, and keys with nothing
* focused at all (a modifier on the content would miss those). Esc, Alt+← and Backspace go back (Backspace is a text
* field's own while one is focused, even an empty one); Ctrl+F and / open the search. Arrows move focus as the TV's
* remote does (Compose on the desktop moves it only with Tab); with nothing focused they enter the screen
* ([KeyboardEntry]).
*/
class BackKeys {
var onBack: () -> Unit = {}
var onArrow: (FocusDirection) -> Boolean = { false }
var onSearch: () -> Unit = {}
/** Every key first: from now on focus moved by keys scrolls into view ([Input]). */
fun onPreview(event: KeyEvent): Boolean {
Input.keyboard = true
return false
}
fun onKey(event: KeyEvent): Boolean {
val down = event.type == KeyEventType.KeyDown
val back = event.key == Key.Escape || (event.key == Key.Backspace && !typing) || (event.isAltPressed && event.key == Key.DirectionLeft)
if (back) {
if (down) onBack()
return true
}
val search = (event.isCtrlPressed && event.key == Key.F) || (event.key == Key.Slash && !typing)
if (search) {
if (down) onSearch()
return true
}
val direction = Arrows[event.key]
if (direction != null && !event.isAltPressed) return !down || onArrow(direction)
return false
}
private companion object {
val Arrows = mapOf(
Key.DirectionUp to FocusDirection.Up,
Key.DirectionDown to FocusDirection.Down,
Key.DirectionLeft to FocusDirection.Left,
Key.DirectionRight to FocusDirection.Right,
)
}
}
/**
* The mouse's back button over [window]. X11 sends it as button 8, AWT passes it on as its button 6, and Compose
* drops buttons past 5 — so it is taken from AWT directly.
*/
@Composable
private fun BackButton(window: java.awt.Window, onBack: () -> Unit) {
val current = rememberUpdatedState(onBack)
DisposableEffect(window) {
val listener = AWTEventListener { e ->
if (e is MouseEvent && e.id == MouseEvent.MOUSE_PRESSED && e.button == 6 && SwingUtilities.getWindowAncestor(e.component) == window) {
current.value()
}
}
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK)
onDispose { Toolkit.getDefaultToolkit().removeAWTEventListener(listener) }
}
}
@Composable
private fun Navigation(stack: NavStack, session: JellyfinSession, onBack: () -> Unit) {
val player = koinInject<ProtoPlayer>()
fun open(screen: Screen) {
if (screen is Screen.Player) player.play(screen) else stack.push(screen)
}
stack.Content { screen ->
when (screen) {
Screen.Login -> LoginScreen(onDone = { stack.resetTo(root(session, Tab.Home)) })
is Screen.Root -> Root(
screen.tab,
session,
onTab = { stack.resetTo(root(session, it)) },
onLibrary = { id ->
val account = session.session.value?.accountId.orEmpty()
stack.resetTo(Screen.Root(Tab.Library, account, library = id))
},
onOpen = ::open,
onSignedOut = { stack.resetTo(Screen.Login) },
onSwitched = { stack.resetTo(root(session, Tab.Home)) },
)
is Screen.Detail -> Page(onBack) { DetailsScreen(screen, onOpen = ::open) }
is Screen.Series -> Page(onBack) { SeriesScreen(screen, onOpen = ::open) }
is Screen.Person -> Page(onBack) { PersonScreen(screen, onOpen = ::open) }
is Screen.Items -> Page(onBack) { ItemsScreen(screen, onOpen = ::open) }
// The player opens in its own window; licences and the debug tour are the TV's.
is Screen.Player, is Screen.License, is Screen.Tour -> Unit
}
}
}
/** A full-screen page (no rail) with "‹" at the top left. */
@Composable
private fun Page(onBack: () -> Unit, content: @Composable () -> Unit) {
Box(Modifier.fillMaxSize()) {
content()
BackArrow(onBack, Modifier.padding(start = 14.dp, top = 10.dp))
}
}
private fun root(session: JellyfinSession, tab: Tab) = Screen.Root(tab, session.session.value?.accountId.orEmpty())
/** Account → its libraries on the rail: the next tab's rail starts with them instead of appearing a frame later. */
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
@Composable
private fun Root(
tab: Tab,
session: JellyfinSession,
onTab: (Tab) -> Unit,
onLibrary: (java.util.UUID) -> Unit,
onOpen: (Screen) -> Unit,
onSignedOut: () -> Unit,
onSwitched: () -> Unit,
) {
val repository = koinInject<LibraryRepository>()
val memory = koinInject<LibraryMemory>()
val user by session.session.collectAsState()
val account = user?.accountId.orEmpty()
val libraries by produceState(railLibraries.takeIf { it.first == account }?.second.orEmpty(), account) {
value = runCatching { repository.views() }.onFailure { Log.w(it, "Libraries not loaded") }.getOrNull().orEmpty().map { view ->
val icon = when (view.type) {
LibraryType.Movies -> AppIcon.Film
LibraryType.Shows -> AppIcon.Tv
else -> AppIcon.Library
}
RailLibrary(view.id, view.name, icon)
}
railLibraries = account to value
}
Shell(
current = tab,
userName = user?.userName.orEmpty(),
userSeed = user?.userId?.toString().orEmpty(),
libraries = libraries,
onTab = onTab,
onLibrary = { id ->
memory.setLibrary(account, id)
onLibrary(id)
},
onProfile = { onOpen(Screen.Login) },
) {
when (tab) {
Tab.Home -> HomeScreen(onOpen = onOpen)
Tab.Library -> LibraryScreen(onOpen = onOpen)
Tab.Search -> SearchScreen(onOpen = onOpen)
Tab.Favorites -> FavoritesScreen(onOpen = onOpen)
Tab.Settings -> SettingsScreen(onOpen = onOpen, onSignedOut = onSignedOut, onSwitched = onSwitched)
}
}
}

View File

@@ -0,0 +1,108 @@
package ru.stenfredd.sleepyowl.desktop
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import okhttp3.OkHttpClient
import okio.Path.Companion.toOkioPath
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import ru.stenfredd.sleepyowl.core.common.AppDirs
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.common.desktopAppDirs
import ru.stenfredd.sleepyowl.core.jellyfin.DesktopJellyfinPlatform
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinPlatform
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.sleepyowl.ui.UiPlatform
import java.io.File
/**
* Koin: the desktop parts of the shared core and UI. The shared modules are listed in [main]; the mpv engine comes
* with L6.
*/
fun desktopModule(scale: UiScale) = module {
single { desktopAppDirs() }
singleOf(::PreferenceStores)
single<JellyfinPlatform> { DesktopJellyfinPlatform() }
single<DeviceCapabilitiesProbe> { DesktopCapabilitiesProbe() }
single<UiPlatform> { DesktopUiPlatform(scale) }
singleOf(::ProtoPlayer)
}
/** The places of the app's files besides [AppDirs]: the log and the single-instance lock. */
object DesktopPaths {
/** `~/.local/state/sleepyowl/log`. */
val log: File = xdg("XDG_STATE_HOME", ".local/state").resolve("log")
/**
* `$XDG_RUNTIME_DIR/sleepyowl`, or the cache when there is no runtime dir. `SLEEPYOWL_RUNTIME_DIR` replaces it
* for the test stand: its app runs beside the user's own, and the runtime dir also leads to PipeWire.
*/
val runtime: File = System.getenv("SLEEPYOWL_RUNTIME_DIR")?.takeIf { it.isNotBlank() }?.let(::File)
?: System.getenv("XDG_RUNTIME_DIR")?.takeIf { it.isNotBlank() }?.let { File(it, "sleepyowl") }
?: xdg("XDG_CACHE_HOME", ".cache").resolve("run")
private fun xdg(variable: String, default: String) =
File(System.getenv(variable)?.takeIf { it.isNotBlank() } ?: (System.getProperty("user.home") + "/" + default), "sleepyowl")
}
/**
* The UI's size: 1 dp of the TV layouts is 1 px at 100 % (LINUX_PLAN.md §1.4), times the system's own scale (GDK_SCALE,
* Xft.dpi). [factor] is "Масштаб интерфейса"; until the settings have it (L9) it comes from `SLEEPYOWL_SCALE`.
*/
class UiScale(val factor: Float) {
companion object {
fun fromEnvironment() = UiScale(System.getenv("SLEEPYOWL_SCALE")?.toFloatOrNull()?.coerceIn(0.5f, 3f) ?: 1f)
}
}
private class DesktopUiPlatform(private val scale: UiScale) : UiPlatform {
override val density: Float
get() = scale.factor * systemScale()
override val version = "${BuildInfo.VERSION} · desktop"
override val device = "${System.getProperty("os.name")} ${System.getProperty("os.version")} · ${hostname()}"
override val tourAvailable = false
}
/** Until the mpv engine (L6): the probe has nothing to report, every format is mpv's. */
private class DesktopCapabilitiesProbe : DeviceCapabilitiesProbe {
override val capabilities = DeviceCapabilities(
manufacturer = "PC",
model = hostname(),
soc = System.getProperty("os.arch"),
sdkInt = 0,
abis = listOf(System.getProperty("os.arch")),
videoDecoders = emptyList(),
audioDecoders = emptyList(),
displayHdrTypes = emptySet(),
displayModes = emptyList(),
passthroughEncodings = emptySet(),
)
}
object BuildInfo {
const val VERSION = "0.1.0"
}
/**
* Images: the API's client (`ServerRouting` sends them to the LAN address at home), a disk cache in
* `~/.cache/sleepyowl/images` that keeps posters across launches, a memory cache of a quarter of the heap.
*/
fun imageLoader(context: PlatformContext, http: OkHttpClient, dirs: AppDirs): ImageLoader = ImageLoader.Builder(context)
.memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.25).build() }
.diskCache { DiskCache.Builder().directory(File(dirs.files, "images").toOkioPath()).maxSizeBytes(500L * 1024 * 1024).build() }
.components { add(OkHttpNetworkFetcherFactory(callFactory = { http })) }
.crossfade(false)
.build()
internal fun hostname(): String = runCatching { File("/etc/hostname").readText().trim() }.getOrNull()?.ifEmpty { null } ?: "Linux"
/** Pixels per dp of the main screen as Java sees it (GDK_SCALE / Xft.dpi). */
internal fun systemScale(): Float = runCatching {
java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX.toFloat()
}.getOrDefault(1f)

View File

@@ -0,0 +1,317 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.Avatar
import ru.stenfredd.sleepyowl.ui.components.Chip
import ru.stenfredd.sleepyowl.ui.components.CircleButton
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.FormatBadge
import ru.stenfredd.sleepyowl.ui.components.PosterCard
import ru.stenfredd.sleepyowl.ui.components.PrimaryButton
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.details.CastModel
import ru.stenfredd.sleepyowl.ui.details.DetailsModel
import ru.stenfredd.sleepyowl.ui.details.DetailsState
import ru.stenfredd.sleepyowl.ui.details.DetailsViewModel
import ru.stenfredd.sleepyowl.ui.details.TrackOption
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.remembered
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
import ru.stenfredd.sleepyowl.ui.details.DetailsLayout as L
/** Movie (or episode) page, as on the TV: backdrop, poster, title, actions, track choice, cast, similar. */
@Composable
fun DetailsScreen(
screen: Screen.Detail,
onOpen: (Screen) -> Unit,
vm: DetailsViewModel = koinViewModel { parametersOf(screen.itemId) },
) {
val state by vm.state.collectAsState()
val focus = rememberScreenFocus()
val item = state.item
when {
item != null -> Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 80.dp)) {
Hero(item, state, focus, vm, onOpen)
if (item.cast.isNotEmpty()) Cast(item, focus, onOpen)
if (state.similar.isNotEmpty()) {
ContentRow("Похожие", null, Modifier.padding(top = 6.dp)) {
items(state.similar, key = { it.key }, contentType = { "poster" }) { p ->
PosterCard(p, { vm.similarTarget(p.key)?.let(onOpen) }, Modifier.hoverFocus().remembered(focus, p.key))
}
}
}
}
state.error != null -> Message("Не удалось загрузить: ${state.error}")
}
// "С начала" disappears once the movie is watched to the end: focus "Смотреть" instead.
val restartShown = (item?.resumeMs ?: 0) > 0
LaunchedEffect(restartShown) {
if (!restartShown && focus.lastKey == KEY_RESTART) focus.lastKey = KEY_PLAY
}
focus.Entry(ready = item != null)
}
private const val KEY_PLAY = "play"
private const val KEY_RESTART = "restart"
/** First screen of the page: backdrop, poster with formats, title, actions and the track choice. */
@Composable
private fun Hero(item: DetailsModel, state: DetailsState, focus: ScreenFocus, vm: DetailsViewModel, onOpen: (Screen) -> Unit) {
Box(Modifier.fillMaxWidth().heightIn(min = L.PAGE_H.dp)) {
item.backdropUrl?.let { Backdrop(it) }
Column(Modifier.offset(L.POSTER_X.dp, L.POSTER_Y.dp).width(L.POSTER_W.dp)) {
val shape = RoundedCornerShape(L.POSTER_CORNER.dp)
RemoteImage(
item.poster,
L.POSTER_CORNER.dp,
Modifier.size(L.POSTER_W.dp, L.POSTER_H.dp).border(0.5.dp, Color(0x1AFFFFFF), shape),
)
if (item.badges.isNotEmpty()) {
FlowRow(Modifier.padding(top = 11.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
item.badges.forEach { FormatBadge(it) }
}
}
item.fileLine?.let { BasicText(it, Modifier.padding(top = 7.dp), style = AppType.MonoMeta.copy(color = AppColors.TextDim)) }
}
Column(Modifier.padding(start = 320.dp, end = 60.dp, top = L.POSTER_Y.dp, bottom = 24.dp)) {
BasicText(item.label, style = AppType.MonoLabel)
BasicText(item.title, Modifier.padding(top = 12.dp), style = TitleStyle, maxLines = 2, overflow = TextOverflow.Ellipsis)
item.subtitle?.let { BasicText(it, Modifier.padding(top = 8.dp), style = AppType.BodyLarge.copy(fontSize = 15.sp), maxLines = 1, overflow = TextOverflow.Ellipsis) }
Meta(item.rating, item.age, listOfNotNull(item.year, item.runtime, item.genres), Modifier.padding(top = 11.dp))
item.description?.let {
BasicText(
it,
Modifier.padding(top = 11.dp).widthIn(max = 640.dp),
style = AppType.Body.copy(fontSize = 13.sp, lineHeight = 19.5.sp, color = AppColors.TextSecondary),
maxLines = 6,
overflow = TextOverflow.Ellipsis,
)
}
Actions(item, focus, vm, onOpen, Modifier.padding(top = 18.dp))
if (item.audio.isNotEmpty() || item.subtitles.isNotEmpty()) PlaybackOptions(item, state, focus, vm, Modifier.padding(top = 20.dp))
}
}
}
private val TitleStyle = AppType.Display.copy(fontSize = 46.sp, lineHeight = 48.sp, letterSpacing = (-0.03).em)
/**
* The backdrop at the top right, 70 % of the window wide, dimmed and fading into the background at its left and
* bottom edges, as the TV's baked one.
*/
@Composable
fun Backdrop(url: String, height: Dp = L.PAGE_H.dp) {
BoxWithConstraints(Modifier.fillMaxWidth().height(height)) {
val w = maxWidth * 0.7f
val h = minOf(w * 9f / 16f, maxHeight)
val bg = AppColors.Background
Box(Modifier.align(Alignment.TopEnd).size(w, h)) {
AsyncImage(url, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, alpha = 0.7f)
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to bg.copy(alpha = 0.55f), 1f to bg.copy(alpha = 0.8f))))
Spacer(Modifier.fillMaxSize().background(Brush.horizontalGradient(0f to bg, 0.14f to bg.copy(alpha = 0.6f), 0.4f to Color.Transparent)))
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0.65f to Color.Transparent, 1f to bg)))
}
}
}
/** "★ 7.9 16+ 2024 2 ч 14 мин Драма, Приключения" */
@Composable
fun Meta(rating: String?, age: String?, rest: List<String>, modifier: Modifier, badges: List<String> = emptyList()) {
val text = AppType.Body.copy(fontSize = 12.sp, lineHeight = 12.sp, color = AppColors.TextSecondary)
FlowRow(modifier, horizontalArrangement = Arrangement.spacedBy(9.dp), verticalArrangement = Arrangement.spacedBy(6.dp), itemVerticalAlignment = Alignment.CenterVertically) {
rating?.let { BasicText(it, style = text.copy(color = AppColors.Yellow, fontWeight = FontWeight.SemiBold)) }
age?.let {
BasicText(
it,
Modifier.border(0.5.dp, Color(0xFF4A4F68), RoundedCornerShape(3.dp)).padding(horizontal = 5.dp, vertical = 2.dp),
style = text.copy(fontSize = 10.sp, lineHeight = 10.sp),
)
}
rest.forEach { BasicText(it, style = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }
if (badges.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { badges.forEach { FormatBadge(it) } }
}
}
@Composable
private fun Actions(item: DetailsModel, focus: ScreenFocus, vm: DetailsViewModel, onOpen: (Screen) -> Unit, modifier: Modifier) {
FlowRow(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
val resume = item.resumeMs > 0
PrimaryButton(
when {
!resume -> "Смотреть"
item.remainingMinutes != null -> "Продолжить · ещё ${item.remainingMinutes} мин"
else -> "Продолжить"
},
{ vm.player(fromStart = false)?.let(onOpen) },
Modifier.hoverFocus().remembered(focus, KEY_PLAY, isDefault = true),
)
if (resume) SecondaryButton("С начала", { vm.player(fromStart = true)?.let(onOpen) }, Modifier.hoverFocus().remembered(focus, KEY_RESTART))
CircleButton(
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
vm::toggleFavorite,
Modifier.hoverFocus().remembered(focus, "favorite"),
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
)
CircleButton(AppIcon.Check, vm::togglePlayed, Modifier.hoverFocus().remembered(focus, "watched"), tint = if (item.played) AppColors.Green else Color(0xFFE3E5F2))
}
}
/** "Параметры воспроизведения": audio and subtitle chips, passed to the player when it starts. */
@Composable
private fun PlaybackOptions(item: DetailsModel, state: DetailsState, focus: ScreenFocus, vm: DetailsViewModel, modifier: Modifier) {
val shape = RoundedCornerShape(10.dp)
Column(
modifier
.widthIn(max = 640.dp)
.fillMaxWidth()
.background(PanelColor, shape)
.border(0.5.dp, Color(0xFF262A3A), shape),
) {
Row(
Modifier
.fillMaxWidth()
.height(26.dp)
.background(Color(0xFF181B27), RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
BasicText(
"ПАРАМЕТРЫ ВОСПРОИЗВЕДЕНИЯ",
Modifier.padding(start = 6.dp).weight(1f),
style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted),
)
BasicText("применятся при запуске", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
}
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
Column(Modifier.padding(vertical = 5.dp)) {
if (item.audio.isNotEmpty()) OptionRow("Аудио", AppColors.Pink, item.audio, state.audio, focus, vm::selectAudio)
if (item.subtitles.isNotEmpty()) OptionRow("Субтитры", AppColors.Yellow, item.subtitles, state.subtitle, focus, vm::selectSubtitle)
}
}
}
private val PanelColor = Color(0xFF111319)
@Composable
private fun OptionRow(title: String, color: Color, options: List<TrackOption>, selected: Int?, focus: ScreenFocus, onPick: (Int?) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.padding(start = 12.dp).width(78.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(5.dp).background(color, CircleShape))
BasicText(title, Modifier.padding(start = 5.dp), style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 11.sp, color = AppColors.TextDim))
}
// Files can have a dozen subtitle tracks: the chips scroll instead of wrapping, opened on the chosen one.
val selectedAt = options.indexOfFirst { it.index == selected }
ScrollRow(
state = rememberLazyListState(initialFirstVisibleItemIndex = if (selectedAt > 1) selectedAt - 1 else 0),
contentPadding = PaddingValues(start = 2.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
spacing = 5.dp,
) {
items(options, key = { it.key }) { o ->
Chip(o.label, o.index == selected, { onPick(o.index) }, Modifier.hoverFocus().remembered(focus, o.key), detail = o.detail)
}
}
}
}
@Composable
private fun Cast(item: DetailsModel, focus: ScreenFocus, onOpen: (Screen) -> Unit) {
Column(Modifier.padding(top = 10.dp)) {
SectionTitle(item.castTitle, null)
ScrollRow(contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 15.dp), spacing = 12.dp) {
items(item.cast, key = { it.key }) { p ->
CastChip(p, { p.personId?.let { onOpen(Screen.Person(it)) } }, Modifier.hoverFocus().remembered(focus, p.key))
}
}
}
}
private val CastFocus = FocusStyle(scale = 1.03f, corner = 27.5.dp, ring = 1.dp, glows = listOf(FocusStyle.Glow(17.dp, 0.dp, Color(0x737C5CFF))))
/** Actor pill: photo (or initials), name, role; opens the person's page. */
@Composable
private fun CastChip(person: CastModel, onClick: () -> Unit, modifier: Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(27.5.dp)
Row(
modifier
.focusCard(CastFocus)
.height(55.dp)
.background(if (focused) Color(0xFF1F2238) else AppColors.Surface, shape)
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF262A3A), shape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(start = 8.dp, end = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
if (person.image != null) {
RemoteImage(person.image, (L.AVATAR / 2).dp, Modifier.size(L.AVATAR.dp))
} else {
Avatar(person.name, person.key, L.AVATAR.dp, AppType.CardTitle.copy(fontSize = 14.sp), letters = person.letters)
}
Column {
BasicText(person.name, style = AppType.CardTitle.copy(fontSize = 12.sp, fontWeight = FontWeight.SemiBold), maxLines = 1)
person.role?.let {
BasicText(it, Modifier.padding(top = 2.dp).widthIn(max = 160.dp), style = AppType.Body.copy(fontSize = 9.5.sp, lineHeight = 11.sp, color = AppColors.TextDim), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}

View File

@@ -0,0 +1,211 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import ru.stenfredd.sleepyowl.ui.BrowseViewModel
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.Avatar
import ru.stenfredd.sleepyowl.ui.components.Chip
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.PosterCard
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.components.fullItem
import ru.stenfredd.sleepyowl.ui.favorites.FavoritesViewModel
import ru.stenfredd.sleepyowl.ui.focus.ForgetMissing
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.remembered
import ru.stenfredd.sleepyowl.ui.isPlayable
import ru.stenfredd.sleepyowl.ui.label
import ru.stenfredd.sleepyowl.ui.person.PersonLayout
import ru.stenfredd.sleepyowl.ui.person.PersonViewModel
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
/*
* Screens that are one grid of posters under a header: favourites, a person, a collection or folder. The grid has as
* many columns as the window fits.
*/
@Composable
private fun PosterGrid(header: @Composable () -> Unit, content: LazyGridScope.() -> Unit) {
LazyVerticalGrid(
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
state = rememberLazyGridState(),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = Side, end = Side, top = AppDimens.CONTENT_TOP.dp, bottom = 80.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
fullItem("header", "header", header)
content()
}
}
/** Favourites tab: title with the count, library filter chips, grid of posters. */
@Composable
fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = koinViewModel()) {
val state by vm.state.collectAsState()
val focus = rememberScreenFocus()
when {
state.loading -> Message("Загрузка…")
state.error != null -> Message("Не удалось загрузить: ${state.error}")
else -> PosterGrid(
header = {
Column(Modifier.padding(bottom = 18.dp)) {
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
BasicText("Избранное", style = AppType.Display.copy(fontSize = 28.sp))
BasicText(state.total, Modifier.padding(bottom = 4.dp), style = AppType.MonoCount.copy(fontSize = 11.sp, color = AppColors.TextDim))
}
FlowRow(Modifier.padding(top = 15.dp), horizontalArrangement = Arrangement.spacedBy(5.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
for (f in state.filters) {
Chip(
f.label,
f.libraryId == state.selected,
{ vm.select(f.libraryId) },
Modifier.hoverFocus().remembered(focus, f.key, isDefault = f.key == FavoritesViewModel.ALL_KEY),
detail = f.count.toString(),
)
}
}
}
},
) {
if (state.items.isEmpty()) {
fullItem("empty", "text") {
BasicText(
"Здесь появятся фильмы и сериалы, отмеченные сердечком на их странице.",
Modifier.padding(top = 12.dp).widthIn(max = 550.dp),
style = AppType.Body.copy(fontSize = 14.sp, lineHeight = 20.sp),
)
}
}
items(state.items, key = { it.key }, contentType = { "poster" }) { p ->
PosterCard(p, { vm.target(p.key)?.let(onOpen) }, Modifier.padding(bottom = RowGap).hoverFocus().remembered(focus, p.key))
}
}
}
val ready = !state.loading && state.error == null
if (ready) focus.ForgetMissing(state.filters.mapTo(HashSet()) { it.key } + state.items.map { it.key })
focus.Entry(ready = ready)
}
/** A person: photo, name, "12 фильмов · 3 сериала", a grid of their movies and series. */
@Composable
fun PersonScreen(screen: Screen.Person, onOpen: (Screen) -> Unit, vm: PersonViewModel = koinViewModel { parametersOf(screen.personId) }) {
val state by vm.state.collectAsState()
val focus = rememberScreenFocus()
when {
state.loading -> Box(Modifier.fillMaxSize())
state.error != null -> Message("Не удалось загрузить: ${state.error}")
else -> PosterGrid(
header = {
Row(Modifier.padding(bottom = 22.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val size = PersonLayout.PHOTO.dp
if (state.photo != null) {
RemoteImage(state.photo, size / 2, Modifier.size(size))
} else {
Avatar(state.name, state.name, size, AppType.Hero.copy(fontSize = 22.sp), letters = state.letters)
}
Column {
BasicText("ЧЕЛОВЕК", style = AppType.MonoLabel)
BasicText(state.name, Modifier.padding(top = 4.dp), style = AppType.Display.copy(fontSize = 28.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
BasicText(state.summary, Modifier.padding(top = 4.dp), style = AppType.MonoMeta.copy(fontSize = 11.sp, color = AppColors.TextMuted))
}
}
},
) {
items(state.items, key = { it.key }, contentType = { "poster" }) { p ->
PosterCard(
p,
{ vm.target(p.key)?.let(onOpen) },
Modifier.padding(bottom = RowGap).hoverFocus().remembered(focus, p.key, isDefault = p === state.items.first()),
)
}
}
}
focus.Entry(ready = !state.loading && state.error == null)
}
/** Collections and folders: the TV's plain grid of what is inside. */
@Composable
fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewModel = koinViewModel()) {
var items by remember { mutableStateOf<List<BaseItemDto>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val focus = rememberScreenFocus()
LaunchedEffect(screen.parentId) {
runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message }
}
PosterGrid(
header = {
Column(Modifier.padding(bottom = 18.dp)) {
BasicText(screen.title, style = AppType.Display.copy(fontSize = 28.sp))
error?.let { BasicText("Не удалось загрузить: $it", Modifier.padding(top = 12.dp), style = AppType.Body.copy(color = AppColors.Red)) }
if (items == null && error == null) BasicText("Загрузка…", Modifier.padding(top = 12.dp), style = AppType.Body)
}
},
) {
items(items.orEmpty(), key = { it.id }) { item ->
Column(Modifier.padding(bottom = RowGap)) {
Box(
Modifier
.hoverFocus()
.remembered(focus, item.id.toString())
.focusCard(FocusStyle.Poster)
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clickable(interactionSource = null, indication = null) { onOpen(item.target()) },
) {
val url = remember(item.id) { vm.library.posterUrl(item) }
RemoteImage(url?.let { RemoteImageSpec(it) }, 7.dp, Modifier.fillMaxSize())
if (url == null) BasicText(item.name.orEmpty(), Modifier.padding(8.dp), style = AppType.PosterTitle)
}
BasicText(item.label, Modifier.padding(top = 6.dp), maxLines = 2, overflow = TextOverflow.Ellipsis, style = AppType.MonoCaption)
}
}
}
focus.Entry(ready = items != null)
}
private fun BaseItemDto.target(): Screen = when {
type == BaseItemKind.SERIES -> Screen.Series(id)
isPlayable -> Screen.Detail(id)
else -> Screen.Items(id, name.orEmpty())
}
private val Side = AppDimens.CONTENT_START.dp
private val RowGap = 18.dp

View File

@@ -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)) }
}
}
}
}

View File

@@ -0,0 +1,186 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.ui.focus.Restore
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
/**
* The pointer moves focus: the element under it gets the TV's focus look (ring, glow, light fill) and keyboard
* arrows go on from there; the pointer leaving it takes the focus away. Put it before the element's `focusCard` and
* `clickable` (or pass it as the modifier of a shared button or card). A text field being typed in keeps its focus:
* the pointer passing over a card must not take the typing away.
*/
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.hoverFocus(): Modifier = composed {
val requester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val focused = remember { booleanArrayOf(false) }
this
.onPointerEvent(PointerEventType.Enter) { if (!typing) runCatching { requester.requestFocus() } }
.onPointerEvent(PointerEventType.Exit) { if (focused[0] && !typing && !Input.keyboard) focusManager.clearFocus() }
.focusRequester(requester)
.onFocusChanged { focused[0] = it.hasFocus }
}
/**
* What the window last got: keys or the pointer. Focus moved by keys scrolls its element into view; focus given by the
* pointer doesn't (pointing at the half-hidden card at a row's edge must not jerk the row).
*/
object Input {
var keyboard = false
internal set
/** Where the pointer last was in the window: the watched menu opens there. */
var pointer = Offset.Zero
internal set
}
/** Scrolls a focused element into view, with a margin for its glow, only when keys moved the focus ([Input]). */
@OptIn(ExperimentalFoundationApi::class)
internal val KeyboardBringIntoView = object : BringIntoViewSpec {
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float {
if (!Input.keyboard) return 0f
val start = offset - BRING_MARGIN
val end = offset + size + BRING_MARGIN
return when {
start < 0f -> start
end > containerSize -> (end - containerSize).coerceAtMost(start)
else -> 0f
}
}
}
private const val BRING_MARGIN = 40f
/** Keeps [Input] up to date with the pointer; on the window's root. */
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.trackPointer(): Modifier = this
.onPointerEvent(PointerEventType.Move, PointerEventPass.Initial) {
Input.keyboard = false
Input.pointer = it.changes.first().position
}
.onPointerEvent(PointerEventType.Press, PointerEventPass.Initial) { Input.pointer = it.changes.first().position }
/**
* The screen the arrow keys enter when nothing has focus: the window starts without a focused card (a mouse user
* needs none), the first arrow focuses the screen's remembered or default element ([ScreenFocus.Entry]).
*/
class KeyboardEntry {
internal var screen: ScreenFocus? = null
/** True when there was a screen to enter. */
fun enter(): Boolean = screen?.let { it.requestFocus(); true } ?: false
}
val LocalKeyboardEntry = staticCompositionLocalOf { KeyboardEntry() }
/**
* The desktop's [Restore]: back on a screen (its remembered element known) focus goes back to that element, as on the
* TV; on a first visit nothing is focused until an arrow key comes ([KeyboardEntry]).
*/
@Composable
fun ScreenFocus.Entry(ready: Boolean) {
val entry = LocalKeyboardEntry.current
DisposableEffect(this, entry) {
entry.screen = this@Entry
onDispose { if (entry.screen === this@Entry) entry.screen = null }
}
val returning = remember { lastKey != null }
if (returning) Restore(ready)
}
/** A [TextField] has focus. */
internal var typing = false
private set
/** One-line text field: dark box, violet border while typing; Enter runs [onSubmit]. */
@Composable
fun TextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "",
textStyle: TextStyle = AppType.BodyLarge.copy(fontSize = 14.sp, color = AppColors.Text),
password: Boolean = false,
focusRequester: FocusRequester = remember { FocusRequester() },
onSubmit: () -> Unit = {},
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(8.dp)
BasicTextField(
value,
onValueChange,
modifier
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused != focused) typing = it.isFocused
focused = it.isFocused
}
// Enter submits here: a single-line field on the desktop does not turn it into an IME action.
.onPreviewKeyEvent {
val enter = it.key == Key.Enter || it.key == Key.NumPadEnter
if (enter && it.type == KeyEventType.KeyDown) onSubmit()
enter
},
textStyle = textStyle,
singleLine = true,
cursorBrush = SolidColor(AppColors.Accent),
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
decorationBox = { inner ->
Box(
Modifier
.fillMaxWidth()
.height(40.dp)
.background(AppColors.BackgroundDeep, shape)
.border(if (focused) 1.dp else 0.5.dp, if (focused) AppColors.SelectedBorder else AppColors.Border, shape)
.padding(horizontal = 12.dp),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) BasicText(placeholder, style = textStyle.copy(color = AppColors.TextFaint))
inner()
}
},
)
}

View File

@@ -0,0 +1,288 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import kotlinx.coroutines.flow.distinctUntilChanged
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.Chip
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.PosterCard
import ru.stenfredd.sleepyowl.ui.components.WideCard
import ru.stenfredd.sleepyowl.ui.components.bleed
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.components.fullItem
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.remembered
import ru.stenfredd.sleepyowl.ui.library.LibraryContent
import ru.stenfredd.sleepyowl.ui.library.LibraryLayout
import ru.stenfredd.sleepyowl.ui.library.LibraryState
import ru.stenfredd.sleepyowl.ui.library.LibraryTile
import ru.stenfredd.sleepyowl.ui.library.LibraryViewModel
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
/**
* The library tab, as on the TV: library tiles; under them, for the selected library, continue watching, recently
* added, watch history and the whole library as a grid with sort and filter chips. One lazy grid, so a library of
* thousands only composes what is on screen; it has as many columns as the window fits. A tile is picked by a click
* (on the TV focus picks it: here the pointer passing over the tiles would switch libraries).
*/
@Composable
fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = koinViewModel()) {
val state by vm.state.collectAsState()
val focus = rememberScreenFocus()
when {
state.loading -> Message("Загрузка…")
state.error != null -> Message("Не удалось загрузить: ${state.error}")
state.tiles.isEmpty() -> Message("На сервере нет библиотек с видео")
else -> Library(state, vm, focus, onOpen)
}
focus.Entry(ready = !state.loading && state.error == null && state.tiles.isNotEmpty())
}
@Composable
private fun Library(state: LibraryState, vm: LibraryViewModel, focus: ScreenFocus, onOpen: (Screen) -> Unit) {
val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit }
val grid = rememberLazyGridState()
val content = state.content
LoadMore(grid, vm)
BoxWithConstraints(Modifier.fillMaxSize()) {
// Full-width items reach from the window edge to edge, outside the grid's side padding (see [bleed]).
val fullWidth = maxWidth
LazyVerticalGrid(
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
state = grid,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = Side, end = Side, top = AppDimens.CONTENT_TOP.dp, bottom = 80.dp),
horizontalArrangement = Arrangement.spacedBy(LibraryLayout.GRID_GAP.dp),
) {
fullItem("tiles", "tiles") {
Tiles(state, vm, focus, Modifier.bleed(fullWidth, Side))
}
if (content == null) {
state.contentError?.let { error -> fullItem("error", "text") { Note("Не удалось загрузить: $error") } }
return@LazyVerticalGrid
}
rows(content, focus, open, fullWidth)
fullItem("all", "all") { AllHeader(content, vm, focus) }
items(content.grid, key = { it.key }, contentType = { "poster" }) { p ->
PosterCard(p, { open(p.key) }, Modifier.padding(bottom = LibraryLayout.GRID_ROW_GAP.dp).hoverFocus().remembered(focus, p.key))
}
if (content.grid.isEmpty()) {
fullItem("grid-note", "text") {
Note(
when {
!content.gridComplete -> "Загрузка…"
content.unwatched -> "Всё просмотрено"
else -> "Здесь пока пусто"
},
)
}
}
}
}
}
private fun LazyGridScope.rows(content: LibraryContent, focus: ScreenFocus, open: (String) -> Unit, fullWidth: Dp) {
val id = content.libraryId
if (content.continueItems.isNotEmpty()) {
fullItem("$id/cont", "wide-row") {
ContentRow("Продолжить просмотр", content.continueItems.size, Modifier.bleed(fullWidth, Side).padding(top = RowGap)) {
items(content.continueItems, key = { it.key }, contentType = { "wide" }) { w ->
WideCard(w, { open(w.key) }, Modifier.hoverFocus().remembered(focus, w.key))
}
}
}
}
for ((name, title, posters) in listOf(Triple("new", "Недавно добавлено", content.latest), Triple("hist", "История просмотра", content.history))) {
if (posters.isEmpty()) continue
fullItem("$id/$name", "poster-row") {
ContentRow(title, posters.size, Modifier.bleed(fullWidth, Side).padding(top = RowGap)) {
items(posters, key = { it.key }, contentType = { "poster" }) { p ->
PosterCard(p, { open(p.key) }, Modifier.hoverFocus().remembered(focus, p.key))
}
}
}
}
}
@Composable
private fun Tiles(state: LibraryState, vm: LibraryViewModel, focus: ScreenFocus, modifier: Modifier = Modifier) {
ScrollRow(
modifier,
// The selected tile in view from the start: it can be far right when opened from the rail.
state = rememberLazyListState((state.tiles.indexOfFirst { it.id == state.selected } - 2).coerceAtLeast(0)),
contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 8.dp),
) {
items(state.tiles, key = { it.key }, contentType = { "tile" }) { tile ->
LibraryTileCard(
tile,
selected = tile.id == state.selected,
onClick = { vm.selectLibrary(tile.id) },
modifier = Modifier.hoverFocus().remembered(focus, tile.key, isDefault = tile.id == state.selected),
)
}
}
}
@Composable
private fun LibraryTileCard(tile: LibraryTile, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(LibraryLayout.TILE_CORNER.dp)
Box(
modifier
.focusCard(TileFocus)
.size(LibraryLayout.TILE_W.dp, LibraryLayout.TILE_H.dp)
.clip(shape)
.background(AppColors.Surface)
.border(0.5.dp, if (selected) AppColors.SelectedBorder else TileBorder, shape)
.clickable(interactionSource = null, indication = null, onClick = onClick),
) {
TileArt(tile.art, if (selected) 1f else 0.55f)
Row(
Modifier.padding(start = 15.dp, top = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(Modifier.size(6.dp).background(tile.dot, CircleShape))
BasicText("БИБЛИОТЕКА", style = AppType.MonoLabel.copy(fontSize = 8.5.sp, color = AppColors.TextDim))
}
Column(Modifier.align(Alignment.BottomStart).padding(start = 15.dp, end = 15.dp, bottom = 15.dp)) {
BasicText(tile.name, style = AppType.Hero.copy(fontSize = 23.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
// Reserved while the count loads, so the name doesn't jump.
BasicText(tile.stats ?: " ", Modifier.padding(top = 4.dp), style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.TextMuted), maxLines = 1)
}
}
}
/**
* The tile's background from the mockup: three posters tilted on the right, a fade from the left. The TV bakes it into
* one bitmap for its weak GPU; here it is drawn as it is.
*/
@Composable
private fun TileArt(urls: List<String>, alpha: Float) {
if (urls.isEmpty()) return
val w = LibraryLayout.ART_POSTER_W
val h = LibraryLayout.ART_POSTER_H
val gap = 6f
// `right:-20px; top:-20px; transform:rotate(8deg)` in mockup px, each next poster 18 px lower.
val rowWidth = urls.size * w + (urls.size - 1) * gap
Box(
Modifier
.offset((LibraryLayout.TILE_W + 10 - rowWidth).dp, (-10).dp)
.size(rowWidth.dp, (h + 9 * urls.size).dp)
.graphicsLayer {
rotationZ = 8f
this.alpha = alpha
},
) {
urls.forEachIndexed { i, url ->
AsyncImage(
url,
contentDescription = null,
modifier = Modifier.offset((i * (w + gap)).dp, (i * 9).dp).size(w.dp, h.dp).clip(RoundedCornerShape(6.dp)),
contentScale = ContentScale.Crop,
)
}
}
// `linear-gradient(90deg, #12141c 40%, rgba(18,20,28,.2))`
Spacer(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(0f to AppColors.Surface, 0.4f to AppColors.Surface, 1f to AppColors.Surface.copy(alpha = 0.2f)),
),
)
}
/** "Все сериалы 171 [А–Я] [Добавлено] [Год] [Рейтинг] [Непросмотренное]"; the chips go under the title when narrow. */
@Composable
private fun AllHeader(content: LibraryContent, vm: LibraryViewModel, focus: ScreenFocus) {
FlowRow(
Modifier.padding(top = 22.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
Row(Modifier.padding(end = 11.dp), verticalAlignment = Alignment.CenterVertically) {
BasicText(content.allTitle, style = AppType.Section)
content.allCount?.let { BasicText(it.toString(), Modifier.padding(start = 8.dp, top = 3.dp), style = AppType.MonoCount) }
}
for (chip in vm.sortChips) {
Chip(chip.label, chip.sort == content.sort, { vm.setSort(chip.sort) }, Modifier.hoverFocus().remembered(focus, chip.key))
}
Chip(
(if (content.unwatched) "✓ " else "") + "Непросмотренное",
content.unwatched,
vm::toggleUnwatched,
Modifier.hoverFocus().remembered(focus, LibraryViewModel.UNWATCHED_KEY),
)
}
}
/** Asks for the next page when the grid's last loaded cards come into view. */
@Composable
private fun LoadMore(grid: LazyGridState, vm: LibraryViewModel) {
LaunchedEffect(grid) {
snapshotFlow {
val info = grid.layoutInfo
// With the item count, so a page that still leaves the end in view asks for the next one.
((info.visibleItemsInfo.lastOrNull()?.index ?: 0) >= info.totalItemsCount - PREFETCH) to info.totalItemsCount
}.distinctUntilChanged().collect { (nearEnd, _) -> if (nearEnd) vm.loadMore() }
}
}
@Composable
private fun Note(text: String) {
BasicText(text, Modifier.padding(top = 8.dp), style = AppType.Body)
}
private val Side = AppDimens.CONTENT_START.dp
private val RowGap = (AppDimens.SECTION_GAP - 14).dp
private val TileBorder = Color(0xFF262A3A)
private val TileFocus = FocusStyle.Tile.copy(corner = LibraryLayout.TILE_CORNER.dp)
/** Next page is asked for this many cards before the end. */
private const val PREFETCH = 24

View File

@@ -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))
}
}

View File

@@ -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

View File

@@ -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") }
}
}

View File

@@ -0,0 +1,99 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import ru.stenfredd.sleepyowl.ui.Screen
/**
* The screen stack of the main window. Each entry owns its view models (cleared when the entry leaves the stack) and
* its saved UI state (scroll positions), as the TV's NavDisplay entries do; only the top entry is composed.
*/
class NavStack(first: Screen) {
class Entry internal constructor(val screen: Screen, internal val id: Long) : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
private var nextId = 0L
private val removed = mutableListOf<Entry>()
val entries = mutableStateListOf(Entry(first, nextId++))
val top: Screen get() = entries.last().screen
val size: Int get() = entries.size
fun push(screen: Screen) {
entries += Entry(screen, nextId++)
}
/** False on the last screen: nothing to go back to. */
fun pop(): Boolean {
if (entries.size <= 1) return false
drop(entries.removeAt(entries.lastIndex))
return true
}
fun resetTo(screen: Screen) {
val old = entries.toList()
entries.clear()
entries += Entry(screen, nextId++)
old.forEach(::drop)
}
private fun drop(entry: Entry) {
entry.viewModelStore.clear()
removed += entry
}
@Composable
fun Content(content: @Composable (Screen) -> Unit) {
val holder = rememberSaveableStateHolder()
val entry = entries.last()
// State of entries that left the stack is no longer needed (their view models are already cleared).
if (removed.isNotEmpty()) {
removed.forEach { holder.removeState(it.id) }
removed.clear()
}
key(entry.id) {
holder.SaveableStateProvider(entry.id) {
CompositionLocalProvider(LocalViewModelStoreOwner provides entry) { content(entry.screen) }
}
}
}
}
/**
* "Назад" (Esc, Alt+←, Backspace, the mouse's back button) goes to the innermost enabled [BackHandler] first — a step
* of the login flow, an open panel — and pops the screen stack only when none takes it.
*/
class BackDispatcher {
private val handlers = mutableListOf<() -> Boolean>()
fun add(handler: () -> Boolean): () -> Unit {
handlers += handler
return { handlers -= handler }
}
/** True when a handler took it. */
fun dispatch(): Boolean = handlers.asReversed().any { it() }
}
val LocalBackDispatcher = compositionLocalOf { BackDispatcher() }
/** [onBack] returns false to let "Назад" go further (to the screen stack). */
@Composable
fun BackHandler(onBack: () -> Boolean) {
val dispatcher = LocalBackDispatcher.current
val current = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) {
val remove = dispatcher.add { current.value() }
onDispose(remove)
}
}

View File

@@ -0,0 +1,171 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.player.PanelChoice
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
/*
* Options of the TV player's side panels, for the mouse: the series' audio and subtitle tray now, the desktop player's
* panels in L7.
*/
internal object PanelColors {
val Background = Color(0xFF10121A)
val Header = Color(0xFF181B27)
val Edge = Color(0xFF262A3A)
val Option = Color(0xFF151824)
val OptionBorder = Color(0xFF232637)
val Selected = Color(0xFF1B1D38)
val SelectedBorder = Color(0xFF5F55E6)
val Focused = Color(0xFF1F2238)
val Ring = Color(0xFF4A4F68)
val SectionTitle = Color(0xFF6F7592)
}
private val OptionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))))
private val ChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows)
@Composable
fun PanelSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Column {
BasicText(title, style = AppType.MonoCaption.copy(color = PanelColors.SectionTitle, letterSpacing = 0.1.em))
Column(Modifier.padding(top = 7.dp), verticalArrangement = Arrangement.spacedBy(4.dp), content = content)
}
}
/** List option with a radio mark: "◉ Русский TrueHD Atmos · 7.1". */
@Composable
fun PanelOption(choice: PanelChoice, onClick: () -> Unit, modifier: Modifier = Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(7.dp)
Row(
modifier
.hoverFocus()
.focusCard(OptionFocus)
.fillMaxWidth()
.height(40.dp)
.background(if (focused) PanelColors.Focused else if (choice.selected) PanelColors.Selected else PanelColors.Option, shape)
.border(0.5.dp, if (focused) AppColors.Accent else if (choice.selected) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 11.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier.size(13.dp).border(1.dp, if (choice.selected) AppColors.Accent else PanelColors.Ring, CircleShape),
contentAlignment = Alignment.Center,
) {
if (choice.selected) Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
}
BasicText(
choice.label,
Modifier.weight(1f),
style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
choice.detail?.let { BasicText(it, style = AppType.MonoCaption.copy(fontSize = 10.sp), maxLines = 1) }
}
}
/** Small choice in a row: size "M", colour "● Жёлтый", background "Тень". */
@Composable
fun PanelChip(choice: PanelChoice, onClick: () -> Unit, modifier: Modifier = Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(15.dp)
Row(
modifier
.hoverFocus()
.focusCard(ChipFocus)
.height(30.dp)
.background(if (focused) AppColors.FocusFill else if (choice.selected) PanelColors.Selected else PanelColors.Option, shape)
.border(0.5.dp, if (focused) AppColors.FocusFill else if (choice.selected) PanelColors.SelectedBorder else PanelColors.OptionBorder, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
choice.swatch?.let { Box(Modifier.size(9.dp).background(it, CircleShape).border(0.5.dp, Color(0x4D000000), CircleShape)) }
BasicText(choice.label, style = AppType.Button.copy(fontSize = 11.5.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)))
}
}
@Composable
fun PanelList(choices: List<PanelChoice>, onPick: (PanelChoice) -> Unit) {
for (c in choices) PanelOption(c, onClick = { onPick(c) })
}
@Composable
fun PanelChips(choices: List<PanelChoice>, onPick: (PanelChoice) -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
for (c in choices) PanelChip(c, onClick = { onPick(c) })
}
}
/** How text subtitles will look: sample line over a dark picture-like box. */
@Composable
fun SubtitlePreview(style: SubtitleStyle, modifier: Modifier = Modifier) {
Box(
modifier
.fillMaxWidth()
.height(85.dp)
.background(Brush.linearGradient(listOf(Color(0xFF2B4A66), Color(0xFF16233A), Color(0xFF241B36))), RoundedCornerShape(7.dp))
.border(0.5.dp, PanelColors.Edge, RoundedCornerShape(7.dp))
.padding(bottom = 11.dp),
contentAlignment = Alignment.BottomCenter,
) {
val box = style.background == SubtitleStyle.Background.BOX
BasicText(
"— Ты слышишь? Сигнал снова идёт со дна.",
Modifier
.then(if (box) Modifier.background(Color(0xB8000000), RoundedCornerShape(3.dp)) else Modifier)
.padding(horizontal = 7.dp, vertical = 2.dp),
style = AppType.Body.copy(
fontSize = 13.sp * style.size.scale,
lineHeight = 16.sp * style.size.scale,
fontWeight = FontWeight.SemiBold,
color = Color(style.color),
textAlign = TextAlign.Center,
shadow = if (style.background == SubtitleStyle.Background.SHADOW) Shadow(Color.Black, Offset(0f, 2f), 6f) else null,
),
)
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,142 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.launch
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
/** "Недавно добавлено 12" */
@Composable
fun SectionTitle(title: String, count: Int?, modifier: Modifier = Modifier) {
Row(modifier.padding(horizontal = AppDimens.CONTENT_START.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
BasicText(title, style = AppType.Section)
count?.let { BasicText(it.toString(), Modifier.padding(bottom = 1.dp), style = AppType.MonoCount) }
}
}
/** A titled horizontal row of cards, as on the TV, scrolled by the mouse ([ScrollRow]). */
@Composable
fun ContentRow(title: String, count: Int?, modifier: Modifier = Modifier, content: LazyListScope.() -> Unit) {
Column(modifier) {
SectionTitle(title, count)
// Vertical padding leaves room for the focused card's scale and glow.
ScrollRow(contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 14.dp), content = content)
}
}
/**
* A horizontal lazy row for the mouse: Shift+wheel and a touchpad's sideways swipe scroll it (Compose does both), and
* while the pointer is over it, round ‹ › buttons at its ends turn it by most of a screen.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ScrollRow(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(horizontal = AppDimens.CONTENT_START.dp),
spacing: Dp = AppDimens.ROW_GAP.dp,
content: LazyListScope.() -> Unit,
) {
var hovered by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
fun turn(direction: Int) {
val page = state.layoutInfo.viewportSize.width * PAGE * direction
scope.launch { state.animateScrollBy(page, tween(TURN_MILLIS, easing = FastOutSlowInEasing)) }
}
Box(
modifier
.onPointerEvent(PointerEventType.Enter) { hovered = true }
.onPointerEvent(PointerEventType.Exit) { hovered = false },
) {
LazyRow(state = state, contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(spacing), content = content)
if (hovered && state.canScrollBackward) RowArrow(back = true, Modifier.align(Alignment.CenterStart).padding(start = 12.dp)) { turn(-1) }
if (hovered && state.canScrollForward) RowArrow(back = false, Modifier.align(Alignment.CenterEnd).padding(end = 12.dp)) { turn(1) }
}
}
private const val PAGE = 0.8f
private const val TURN_MILLIS = 450
/** ‹ or ›: not focusable, the keyboard has its arrows. */
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun RowArrow(back: Boolean, modifier: Modifier, onClick: () -> Unit) {
var hovered by remember { mutableStateOf(false) }
Box(
modifier
.zIndex(2f)
.size(36.dp)
.background(if (hovered) AppColors.FocusFill else Color(0xE6151824), CircleShape)
.border(0.5.dp, if (hovered) AppColors.FocusFill else AppColors.Border, CircleShape)
.onPointerEvent(PointerEventType.Enter) { hovered = true }
.onPointerEvent(PointerEventType.Exit) { hovered = false }
.pointerInput(Unit) { detectTapGestures { onClick() } },
contentAlignment = Alignment.Center,
) {
Icon(AppIcon.ChevronRight, if (hovered) AppColors.OnFocusFill else AppColors.Text, Modifier.size(16.dp).then(if (back) Modifier.rotate(180f) else Modifier))
}
}
/** "Загрузка…", "Не удалось загрузить: …" in the middle of the screen. */
@Composable
fun Message(text: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { BasicText(text, style = AppType.Body) }
}
/** "‹" at the top left of the full-screen pages (movie, series, person): the mouse's way back. */
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun BackArrow(onClick: () -> Unit, modifier: Modifier = Modifier) {
var hovered by remember { mutableStateOf(false) }
Box(
modifier
.size(32.dp)
.background(if (hovered) AppColors.SurfaceFocused else Color(0xB3151824), CircleShape)
.border(0.5.dp, if (hovered) AppColors.Accent else AppColors.Border, CircleShape)
.onPointerEvent(PointerEventType.Enter) { hovered = true }
.onPointerEvent(PointerEventType.Exit) { hovered = false }
.pointerInput(Unit) { detectTapGestures { onClick() } },
contentAlignment = Alignment.Center,
) {
Icon(AppIcon.ChevronRight, if (hovered) AppColors.Text else AppColors.TextMuted, Modifier.size(15.dp).rotate(180f))
}
}

View File

@@ -0,0 +1,200 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.Avatar
import ru.stenfredd.sleepyowl.ui.components.Chip
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.components.PosterCard
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.components.fullItem
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.remembered
import ru.stenfredd.sleepyowl.ui.search.PersonCardModel
import ru.stenfredd.sleepyowl.ui.search.SearchLayout
import ru.stenfredd.sleepyowl.ui.search.SearchState
import ru.stenfredd.sleepyowl.ui.search.SearchViewModel
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
/**
* Search tab: a plain text field (the TV's on-screen keyboard isn't needed), under it recent queries and newest items,
* or the results by library and people. The field has focus when the tab opens.
*/
@Composable
fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = koinViewModel()) {
val state by vm.state.collectAsState()
val focus = rememberScreenFocus()
val open = { key: String -> vm.open(key)?.let(onOpen) ?: Unit }
var text by rememberSaveable { mutableStateOf(state.query) }
val field = remember { FocusRequester() }
LaunchedEffect(Unit) {
withFrameNanos { }
runCatching { field.requestFocus() }
}
Column(Modifier.fillMaxSize()) {
Row(
Modifier.padding(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = AppDimens.CONTENT_TOP.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(AppIcon.Search, AppColors.Accent, Modifier.size(18.dp), strokeWidth = 2.2f)
TextField(
text,
{
text = it
vm.setQuery(it)
},
Modifier.widthIn(max = 480.dp),
placeholder = "Название, жанр, актёр",
textStyle = AppType.BodyLarge.copy(fontSize = 16.sp, color = AppColors.Text),
focusRequester = field,
)
}
Results(state, focus, vm, open) { query ->
text = query
vm.pickRecent(query)
}
}
focus.Entry(ready = true)
}
@Composable
private fun Results(state: SearchState, focus: ScreenFocus, vm: SearchViewModel, open: (String) -> Unit, onRecent: (String) -> Unit) {
val grid = rememberLazyGridState()
// A new query starts the results from the top.
LaunchedEffect(state.results) { if (grid.firstVisibleItemIndex > 0) grid.scrollToItem(0) }
LazyVerticalGrid(
columns = GridCells.FixedSize(AppDimens.POSTER_W.dp),
state = grid,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = AppDimens.CONTENT_START.dp, end = AppDimens.CONTENT_START.dp, top = 16.dp, bottom = 80.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
val results = state.results
if (results == null) {
if (state.recentQueries.isNotEmpty()) {
fullItem("recent-q", "label") {
Column(Modifier.padding(bottom = 22.dp)) {
Label("НЕДАВНИЕ ЗАПРОСЫ")
FlowRow(Modifier.padding(top = 9.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
state.recentQueries.forEachIndexed { i, q ->
Chip(q, false, { onRecent(q) }, Modifier.hoverFocus().remembered(focus, "rq/$i"))
}
}
}
}
}
if (state.recentItems.isNotEmpty()) {
fullItem("recent-label", "label") { Label("НЕДАВНО ДОБАВЛЕНО", Modifier.padding(bottom = 13.dp)) }
posters(state.recentItems, focus, open)
}
return@LazyVerticalGrid
}
fullItem("found", "label") {
Label(if (state.searching) "ИЩЕМ…" else "НАЙДЕНО: ${results.sumOf { it.size }}")
}
if (results.isEmpty() && !state.searching) {
fullItem("none", "text") {
BasicText(
state.error?.let { "Не удалось найти: $it" } ?: "Ничего не нашлось по «${state.query.trim()}». Попробуйте часть названия или имя актёра.",
Modifier.padding(top = 12.dp).widthIn(max = 480.dp),
style = AppType.Body.copy(fontSize = 15.sp, lineHeight = 21.sp),
)
}
}
for (group in results) {
fullItem(group.key, "group") {
Row(Modifier.padding(top = 16.dp, bottom = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
BasicText(group.title, style = AppType.Section)
BasicText(group.size.toString(), Modifier.padding(bottom = 1.dp), style = AppType.MonoCount)
}
}
posters(group.posters, focus, open)
items(group.people, key = { it.key }, contentType = { "person" }) { p ->
PersonCard(p, { open(p.key) }, Modifier.padding(bottom = 18.dp).hoverFocus().remembered(focus, p.key))
}
}
}
}
private fun LazyGridScope.posters(list: List<PosterModel>, focus: ScreenFocus, open: (String) -> Unit) {
items(list, key = { it.key }, contentType = { "poster" }) { p ->
PosterCard(p, { open(p.key) }, Modifier.padding(bottom = 18.dp).hoverFocus().remembered(focus, p.key))
}
}
private val PersonFocus = FocusStyle(scale = 1.05f, corner = (SearchLayout.PERSON_PHOTO / 2).dp, ring = 1.dp)
/** Round photo (or initials) and the name under it. */
@Composable
private fun PersonCard(person: PersonCardModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
val size = SearchLayout.PERSON_PHOTO.dp
Column(modifier.width(AppDimens.POSTER_W.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
Modifier
.focusCard(PersonFocus)
.size(size)
.clickable(interactionSource = null, indication = null, onClick = onClick),
) {
if (person.photo != null) {
RemoteImage(person.photo, size / 2, Modifier.fillMaxSize())
} else {
Avatar(person.name, person.key, size, AppType.Hero.copy(fontSize = 24.sp), letters = person.letters)
}
}
BasicText(
person.name,
Modifier.padding(top = 8.dp),
style = AppType.PosterTitle.copy(textAlign = TextAlign.Center),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun Label(text: String, modifier: Modifier = Modifier) {
BasicText(text, modifier, style = AppType.MonoLabel.copy(color = AppColors.TextDim))
}

View File

@@ -0,0 +1,644 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.CircleButton
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.PrimaryButton
import ru.stenfredd.sleepyowl.ui.components.ProgressLine
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.remembered
import ru.stenfredd.sleepyowl.ui.menu.LocalWatchMenu
import ru.stenfredd.sleepyowl.ui.menu.clickableWithMenu
import ru.stenfredd.sleepyowl.ui.player.SubtitleChoices
import ru.stenfredd.sleepyowl.ui.player.toChoices
import ru.stenfredd.sleepyowl.ui.series.EpisodeState
import ru.stenfredd.sleepyowl.ui.series.SeriesEpisode
import ru.stenfredd.sleepyowl.ui.series.SeriesLayout
import ru.stenfredd.sleepyowl.ui.series.SeriesModel
import ru.stenfredd.sleepyowl.ui.series.SeriesSeason
import ru.stenfredd.sleepyowl.ui.series.SeriesState
import ru.stenfredd.sleepyowl.ui.series.SeriesViewModel
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
import kotlin.math.floor
import kotlin.math.roundToInt
/**
* Series page, as on the TV: header with "Продолжить S2 E4", the audio/subtitle choice and ♥; the season map; season
* chips; every episode of every season in one row (a divider before each season); the pointed (or focused) episode's
* description under it. A click on a season chip turns the row to that season; pointing along the row lights up its
* season. Scrolls as a whole when the window is low.
*/
@Composable
fun SeriesScreen(
screen: Screen.Series,
onOpen: (Screen) -> Unit,
vm: SeriesViewModel = koinViewModel { parametersOf(screen.seriesId) },
) {
val state by vm.state.collectAsState()
val focus = rememberScreenFocus()
val item = state.item
when {
item != null -> SeriesPage(screen, item, state, focus, vm, onOpen)
state.error != null -> Message("Не удалось загрузить: ${state.error}")
}
focus.Entry(ready = item != null)
}
private const val KEY_PLAY = "play"
private const val KEY_TRACKS = "tracks"
private const val KEY_WATCHED = "watched"
/**
* What the page highlights: the season of the chips and the map, the episode described at the bottom.
* [episode] is read only by the parts that show it, so moving along the row doesn't recompose the page.
*/
@Stable
private class SeriesSelection(season: Int, episode: Int) {
var season by mutableIntStateOf(season)
var episode by mutableIntStateOf(episode)
companion object {
val Saver = listSaver<SeriesSelection, Int>({ listOf(it.season, it.episode) }, { SeriesSelection(it[0], it[1]) })
}
}
@Composable
private fun SeriesPage(screen: Screen.Series, item: SeriesModel, state: SeriesState, focus: ScreenFocus, vm: SeriesViewModel, onOpen: (Screen) -> Unit) {
// Opened on an episode (a new one, continue watching): it is shown first, else the next one to watch.
val start = remember(item.id) { item.episodes.indexOfFirst { it.id == screen.episodeId }.takeIf { it >= 0 } }
val first = start ?: item.nextUp
val selection = rememberSaveable(saver = SeriesSelection.Saver) { SeriesSelection(item.episodes.getOrNull(first)?.season ?: 0, first) }
// The data can change on reload (a new episode): stay in range.
val seasonIndex = selection.season.coerceIn(0, (item.seasons.size - 1).coerceAtLeast(0))
val episodesRow = rememberLazyListState(initialFirstVisibleItemIndex = item.episodes.getOrNull(first)?.rowIndex?.minus(1)?.coerceAtLeast(0) ?: 0)
val chipsRow = rememberLazyListState(initialFirstVisibleItemIndex = (seasonIndex - 1).coerceAtLeast(0))
val scope = rememberCoroutineScope()
var trayOpen by rememberSaveable { mutableStateOf(false) }
fun selectSeason(season: Int) {
selection.season = season
val s = item.seasons[season]
selection.episode = s.target
// The row follows the chips: the season's divider (or its next episode, deeper in the season) at the start.
val target = if (s.target == s.first) s.dividerIndex else item.episodes[s.target].rowIndex - 1
scope.launch { episodesRow.animateScrollToItem(target.coerceAtLeast(0)) }
}
fun onEpisodeFocused(index: Int) {
selection.episode = index
val season = item.episodes[index].season
if (season != selection.season) {
selection.season = season
if (chipsRow.layoutInfo.visibleItemsInfo.none { it.index == season }) scope.launch { chipsRow.animateScrollToItem((season - 1).coerceAtLeast(0)) }
}
}
Box(Modifier.fillMaxSize()) {
item.backdropUrl?.let { Backdrop(it) }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 32.dp, bottom = 24.dp)) {
Header(
item,
state,
focus,
isDefault = start == null,
onPlay = { item.episodes.getOrNull(item.nextUp)?.let { onOpen(vm.player(it)) } },
onTracks = { trayOpen = true },
onFavorite = vm::toggleFavorite,
)
SeasonMap(item, seasonIndex, current = { selection.episode }, Modifier.padding(top = 16.dp).padding(horizontal = AppDimens.CONTENT_START.dp).fillMaxWidth())
SeasonChips(item, seasonIndex, chipsRow, focus, onClick = ::selectSeason, modifier = Modifier.padding(top = 5.dp))
EpisodeRow(
item,
episodesRow,
focus,
defaultKey = start?.let { item.episodes[it].key },
onFocused = ::onEpisodeFocused,
onClick = { onOpen(vm.player(it)) },
)
SelectedEpisode(item, selection)
}
if (trayOpen) {
val style by vm.subtitleStyle.collectAsState()
BackHandler { trayOpen = false; true }
// A click past the tray closes it.
Spacer(Modifier.fillMaxSize().background(Color(0x80050609)).pointerInput(Unit) { detectTapGestures { trayOpen = false } })
SeriesTray(item.title, state, style, vm, onClose = { trayOpen = false }, modifier = Modifier.align(Alignment.BottomCenter))
}
}
}
private val TitleStyle = AppType.Display.copy(fontSize = 36.sp, lineHeight = 38.sp, letterSpacing = (-0.03).em)
@Composable
private fun Header(
item: SeriesModel,
state: SeriesState,
focus: ScreenFocus,
isDefault: Boolean,
onPlay: () -> Unit,
onTracks: () -> Unit,
onFavorite: () -> Unit,
) {
val left = AppDimens.CONTENT_START.dp
Row(Modifier.padding(horizontal = left)) {
RemoteImage(
item.poster,
8.dp,
Modifier.size(SeriesLayout.POSTER_W.dp, SeriesLayout.POSTER_H.dp).border(0.5.dp, Color(0x1AFFFFFF), RoundedCornerShape(8.dp)),
)
Column(Modifier.padding(start = 20.dp)) {
BasicText(item.label, style = AppType.MonoLabel)
BasicText(item.title, Modifier.padding(top = 9.dp), style = TitleStyle, maxLines = 1, overflow = TextOverflow.Ellipsis)
Meta(item.rating, item.age, listOfNotNull(item.year, item.genres), Modifier.padding(top = 9.dp), badges = state.badges)
FlowRow(Modifier.padding(top = 13.dp), horizontalArrangement = Arrangement.spacedBy(7.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
PrimaryButton(item.playLabel, onPlay, Modifier.hoverFocus().remembered(focus, KEY_PLAY, isDefault = isDefault))
if (state.audioLabel != null || state.subtitleLabel != null) {
TracksButton(state.audioLabel, state.subtitleLabel, onTracks, Modifier.hoverFocus().remembered(focus, KEY_TRACKS))
}
CircleButton(
if (item.favorite) AppIcon.HeartFilled else AppIcon.Heart,
onFavorite,
Modifier.hoverFocus().remembered(focus, "favorite"),
tint = if (item.favorite) AppColors.Pink else Color(0xFFE3E5F2),
)
// The whole series at once, so through the menu (it says how many episodes) rather than a toggle.
val menu = LocalWatchMenu.current
val watchedButton = remember { FocusRequester() }
CircleButton(
AppIcon.Check,
{ menu?.open(item.watch, watchedButton) },
Modifier.hoverFocus().focusRequester(watchedButton).remembered(focus, KEY_WATCHED),
tint = if (item.watch.played) AppColors.Green else Color(0xFFE3E5F2),
)
}
}
}
}
private val TracksFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
/** "● Русский 5.1 ● без субтитров": opens the tray. */
@Composable
private fun TracksButton(audio: String?, subtitle: String?, onClick: () -> Unit, modifier: Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(21.dp)
val color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)
Row(
modifier
.focusCard(TracksFocus)
.height(42.dp)
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, shape)
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val style = AppType.Button.copy(fontSize = 12.sp, fontWeight = FontWeight.Normal, color = color)
audio?.let {
Box(Modifier.size(5.dp).background(AppColors.Pink, CircleShape))
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
}
subtitle?.let {
Box(Modifier.padding(start = if (audio != null) 10.dp else 0.dp).size(5.dp).background(AppColors.Yellow, CircleShape))
BasicText(it, Modifier.padding(start = 6.dp), style = style, maxLines = 1)
}
}
}
private object MapColors {
val Watched = Color(0xFF3F8F63)
val Empty = Color(0xFF33384D)
val Label = Color(0xFF6F7592)
}
/**
* Season map: a bar per episode — watched green, next violet, new outlined pink — the selected episode tall and
* white. Bars shrink for long seasons; if the series still doesn't fit, the seasons around the selected one are shown.
*/
@Composable
private fun SeasonMap(item: SeriesModel, selectedSeason: Int, current: () -> Int, modifier: Modifier) {
val layout = remember(item.seasons, selectedSeason) { mapLayout(item.seasons, selectedSeason) }
Row(modifier.height(MAP_H.dp), verticalAlignment = Alignment.Bottom) {
Row(Modifier.width(MAP_W.dp), horizontalArrangement = Arrangement.spacedBy(MAP_GAP.dp), verticalAlignment = Alignment.Bottom) {
if (layout.from > 0) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
for (s in layout.from until layout.to) {
val season = item.seasons[s]
val width = (layout.bar + layout.gap) * season.count - layout.gap
Column {
BasicText(
season.mapLabel,
style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = if (s == selectedSeason) AppColors.Text else MapColors.Label),
maxLines = 1,
)
Box(Modifier.padding(top = 4.dp).size(width.coerceAtLeast(1f).dp, BAR_TALL.dp)) {
Spacer(Modifier.fillMaxSize().drawWithCache { onDrawBehind { drawBars(item, season, layout) } })
Spacer(
Modifier
.size(layout.bar.dp, BAR_TALL.dp)
.graphicsLayer {
val i = current() - season.first
alpha = if (i in 0 until season.count) 1f else 0f
translationX = i.coerceAtLeast(0) * floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(floor(layout.bar.dp.toPx()).coerceAtLeast(1f) + 1f)
}
.drawBehind { drawRect(Color.White, size = Size(floor(size.width).coerceAtLeast(1f), size.height)) },
)
}
}
}
if (layout.to < item.seasons.size) BasicText("…", style = AppType.MonoCaption.copy(color = MapColors.Label))
}
Spacer(Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
val legend = AppType.MonoCaption
BasicText("■ просмотрено", style = legend.copy(color = AppColors.Green))
BasicText("■ следующая", style = legend.copy(color = AppColors.Accent))
BasicText("□ новая", style = legend.copy(color = AppColors.Pink))
}
}
}
/** Bars of one season: plain rectangles, outlines as four 1 px sides. */
private fun DrawScope.drawBars(item: SeriesModel, season: SeriesSeason, layout: MapLayout) {
val bar = floor(layout.bar.dp.toPx()).coerceAtLeast(1f)
val step = floor((layout.bar + layout.gap).dp.toPx()).coerceAtLeast(bar + 1f)
val low = floor(BAR_H.dp.toPx())
val top = size.height - low
fun outline(color: Color, x: Float) {
drawRect(color, Offset(x, top), Size(bar, 1f))
drawRect(color, Offset(x, size.height - 1f), Size(bar, 1f))
drawRect(color, Offset(x, top), Size(1f, low))
drawRect(color, Offset(x + bar - 1f, top), Size(1f, low))
}
for (i in 0 until season.count) {
val index = season.first + i
val x = i * step
when (item.episodes[index].state) {
EpisodeState.Watched -> drawRect(MapColors.Watched, Offset(x, top), Size(bar, low))
EpisodeState.InProgress, EpisodeState.Next -> drawRect(AppColors.Accent, Offset(x, top), Size(bar, low))
EpisodeState.New -> outline(AppColors.Pink, x)
EpisodeState.None -> if (bar >= 3f) outline(MapColors.Empty, x) else drawRect(MapColors.Empty, Offset(x, top), Size(bar, low))
}
}
}
private const val MAP_W = 600f
private const val MAP_H = 32f
private const val MAP_GAP = 14f
private const val BAR_H = 10f
private const val BAR_TALL = 17f
/** Width (dp) of one character of the map labels (JetBrains Mono advance is 0.6 em). */
private const val LABEL_CHAR = 8.5f * 0.6f
private data class MapLayout(val bar: Float, val gap: Float, val from: Int, val to: Int)
/** Bar size that fits all seasons in [MAP_W], else the widest window of seasons around [selected] at the smallest size. */
private fun mapLayout(seasons: List<SeriesSeason>, selected: Int): MapLayout {
if (seasons.isEmpty()) return MapLayout(7f, 2f, 0, 0)
fun width(s: SeriesSeason, bar: Float, gap: Float) = maxOf(s.count * (bar + gap) - gap, s.mapLabel.length * LABEL_CHAR)
fun total(range: IntRange, bar: Float, gap: Float) = range.sumOf { width(seasons[it], bar, gap).toDouble() }.toFloat() + MAP_GAP * (range.count() - 1)
for ((bar, gap) in listOf(7f to 2f, 5f to 1.5f, 3f to 1f, 2f to 1f)) {
if (total(seasons.indices, bar, gap) <= MAP_W) return MapLayout(bar, gap, 0, seasons.size)
}
val (bar, gap) = 2f to 1f
// Room for the "…" marks at both ends.
val room = MAP_W - 2 * (MAP_GAP + LABEL_CHAR)
var from = selected.coerceIn(seasons.indices)
var to = from + 1
while (true) {
val grown = when {
to < seasons.size && total(from..to, bar, gap) <= room -> from until to + 1
from > 0 && total(from - 1 until to, bar, gap) <= room -> from - 1 until to
else -> break
}
from = grown.first
to = grown.last + 1
}
// A single season longer than the map: its bars get thinner still.
val single = if (to - from == 1) (room / seasons[from].count).coerceIn(1f, bar + gap) else bar + gap
return if (to - from == 1 && single < bar + gap) MapLayout(single * 0.6f, single * 0.4f, from, to) else MapLayout(bar, gap, from, to)
}
private val SeasonChipFocus = FocusStyle(scale = 1f, corner = 15.dp, glows = FocusStyle.ButtonGlows)
@Composable
private fun SeasonChips(item: SeriesModel, selected: Int, state: LazyListState, focus: ScreenFocus, onClick: (Int) -> Unit, modifier: Modifier) {
ScrollRow(modifier, state = state, contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 6.dp), spacing = 6.dp) {
itemsIndexed(item.seasons, key = { _, s -> s.key }) { i, s ->
SeasonChip(s, i == selected, onClick = { onClick(i) }, modifier = Modifier.hoverFocus().remembered(focus, s.key))
}
}
}
@Composable
private fun SeasonChip(season: SeriesSeason, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(15.dp)
val color = when {
focused -> AppColors.OnFocusFill
selected -> AppColors.Text
else -> AppColors.TextSecondary
}
Row(
modifier
.focusCard(SeasonChipFocus)
.height(30.dp)
.background(if (focused) AppColors.FocusFill else if (selected) Color(0xFF211D4A) else AppColors.SurfaceRaised, shape)
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
.onFocusChanged { focused = it.isFocused }
.clickableWithMenu(season.watch, onClick)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
BasicText(season.label, style = AppType.Button.copy(color = color), maxLines = 1)
BasicText(season.stat, style = AppType.MonoCaption.copy(color = color.copy(alpha = 0.75f)), maxLines = 1)
}
}
private val EpisodeFocus = FocusStyle(scale = 1.06f, corner = 8.dp)
/** Every episode of the series, a "СЕЗОН N" divider before each season. */
@Composable
private fun EpisodeRow(
item: SeriesModel,
state: LazyListState,
focus: ScreenFocus,
defaultKey: String?,
onFocused: (Int) -> Unit,
onClick: (SeriesEpisode) -> Unit,
) {
ScrollRow(state = state, contentPadding = PaddingValues(horizontal = AppDimens.CONTENT_START.dp, vertical = 12.dp), spacing = 12.dp) {
for (season in item.seasons) {
item(key = "div/${season.key}", contentType = "divider") { SeasonDivider(season.label.uppercase()) }
itemsIndexed(item.episodes.subList(season.first, season.first + season.count), key = { _, e -> e.key }, contentType = { _, _ -> "episode" }) { i, e ->
val index = season.first + i
EpisodeCard(
e,
onClick = { onClick(e) },
modifier = Modifier
.hoverFocus()
.remembered(focus, e.key, isDefault = e.key == defaultKey)
.onFocusChanged { if (it.isFocused) onFocused(index) },
)
}
}
}
}
/** Vertical "СЕЗОН 2" on a dashed line. */
@Composable
private fun SeasonDivider(label: String) {
Box(
Modifier
.size(22.dp, SeriesLayout.EPISODE_H.dp)
.drawBehind {
val dash = 3.dp.toPx().roundToInt().toFloat()
var y = 0f
while (y < size.height) {
drawRect(MapColors.Empty, Offset(0f, y), Size(1f, dash.coerceAtMost(size.height - y)))
y += dash * 2
}
},
contentAlignment = Alignment.Center,
) {
BasicText(
label,
Modifier.wrapContentSize(unbounded = true).rotate(-90f),
style = AppType.MonoCaption.copy(letterSpacing = 0.14.em),
maxLines = 1,
)
}
}
@Composable
private fun EpisodeCard(e: SeriesEpisode, onClick: () -> Unit, modifier: Modifier) {
Column(Modifier.width(SeriesLayout.EPISODE_W.dp)) {
Box(
modifier
.focusCard(EpisodeFocus)
.size(SeriesLayout.EPISODE_W.dp, SeriesLayout.EPISODE_H.dp)
.clickableWithMenu(e.watch, onClick),
) {
RemoteImage(e.image, 8.dp, Modifier.fillMaxSize())
BasicText(
e.code,
Modifier.padding(start = 8.dp, top = 6.dp),
style = AppType.MonoMeta.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color(0xE0FFFFFF)),
)
badge(e)?.let { (text, color) ->
BasicText(
text,
Modifier
.align(Alignment.TopEnd)
.padding(7.dp)
.background(Color(0xBF0A0B10), RoundedCornerShape(3.5.dp))
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
.padding(horizontal = 5.dp, vertical = 2.dp),
style = AppType.MonoBadge.copy(fontSize = 8.5.sp, color = color),
)
}
e.progress?.let {
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 4.dp).fillMaxWidth().height(3.dp))
}
}
BasicText(
e.title,
Modifier.padding(top = 7.dp),
style = AppType.PosterTitle.copy(fontSize = 12.sp, color = if (e.state == EpisodeState.Watched) AppColors.TextMuted else AppColors.Text),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
e.meta?.let { BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption, maxLines = 1) }
}
}
private fun badge(e: SeriesEpisode): Pair<String, Color>? = when (e.state) {
EpisodeState.Watched -> "✓" to AppColors.Green
EpisodeState.InProgress -> "${((e.progress ?: 0f) * 100).roundToInt()}%" to AppColors.Accent
EpisodeState.Next -> "далее" to AppColors.Accent
EpisodeState.New -> "новая" to AppColors.Pink
EpisodeState.None -> null
}
@Composable
private fun SelectedEpisode(item: SeriesModel, selection: SeriesSelection) {
item.episodes.getOrNull(selection.episode.coerceIn(0, (item.episodes.size - 1).coerceAtLeast(0)))?.let { EpisodeDetail(it) }
}
/** The selected episode under the row: "СЕЗОН 2 · СЕРИЯ 4", title, description, duration and air date. */
@Composable
private fun EpisodeDetail(e: SeriesEpisode) {
Row(
Modifier
.padding(horizontal = AppDimens.CONTENT_START.dp)
.padding(top = 6.dp)
.fillMaxWidth()
.drawBehind { drawRect(AppColors.BorderSubtle, size = Size(size.width, 1f)) }
.padding(top = 11.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
BasicText(e.longCode, Modifier.width(126.dp).padding(top = 3.dp), style = AppType.MonoLabel.copy(letterSpacing = 0.06.em))
Column(Modifier.weight(1f)) {
BasicText(e.title, style = AppType.Section.copy(fontSize = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
e.overview?.let {
BasicText(it, Modifier.padding(top = 4.dp), style = AppType.Body.copy(fontSize = 11.sp, lineHeight = 15.sp), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
Column(Modifier.padding(top = 2.dp)) {
val style = AppType.MonoMeta.copy(fontSize = 10.sp, color = AppColors.TextDim, textAlign = TextAlign.End)
e.duration?.let { BasicText(it, Modifier.align(Alignment.End), style = style) }
e.airDate?.let { BasicText(it, Modifier.align(Alignment.End).padding(top = 5.dp), style = style) }
}
}
}
/**
* "Аудио и субтитры" tray at the bottom of the page: audio, subtitles and their look, applied at once and remembered
* for the whole series. Esc or a click past it closes it.
*/
@Composable
private fun SeriesTray(title: String, state: SeriesState, style: SubtitleStyle, vm: SeriesViewModel, onClose: () -> Unit, modifier: Modifier) {
Column(
modifier
.fillMaxWidth()
.height(TRAY_H.dp)
.background(AppColors.Background)
// Clicks inside stay inside (the scrim behind closes the tray).
.pointerInput(Unit) { detectTapGestures { } }
.drawBehind { drawRect(Color(0xFF262A3A), size = Size(size.width, 1f)) }
.padding(start = 48.dp, end = 48.dp, top = 12.dp, bottom = 22.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Box(Modifier.size(6.dp).background(AppColors.Accent, CircleShape))
BasicText(
"АУДИО И СУБТИТРЫ · " + title.uppercase(),
Modifier.weight(1f),
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary, letterSpacing = 0.1.em),
maxLines = 1,
)
BasicText("Запоминается для всего сериала · Esc — закрыть", style = AppType.MonoCaption.copy(color = Color(0xFF6F7592)))
BackArrow(onClose, Modifier.rotate(-90f))
}
Row(Modifier.padding(top = 10.dp).fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (state.audio.isNotEmpty()) {
TrayColumn("АУДИО", AppColors.Pink) {
PanelList(state.audio.toChoices(state.selectedAudio)) { c -> vm.selectAudio(state.audio.firstOrNull { it.key == c.key }?.index) }
}
}
if (state.subtitles.isNotEmpty()) {
TrayColumn("СУБТИТРЫ", AppColors.Yellow) {
PanelList(state.subtitles.toChoices(state.selectedSubtitle)) { c -> vm.selectSubtitle(state.subtitles.firstOrNull { it.key == c.key }?.index) }
}
}
TrayColumn("ВИД СУБТИТРОВ", AppColors.Yellow) {
PanelSection("РАЗМЕР") { PanelChips(SubtitleChoices.sizes(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
PanelSection("ЦВЕТ") { PanelChips(SubtitleChoices.colors(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
PanelSection("ФОН") { PanelChips(SubtitleChoices.backgrounds(style)) { vm.setSubtitleStyle(SubtitleChoices.apply(style, it)) } }
}
}
}
}
private const val TRAY_H = 300f
@Composable
private fun RowScope.TrayColumn(title: String, dot: Color, content: @Composable ColumnScope.() -> Unit) {
val shape = RoundedCornerShape(9.dp)
Column(
Modifier
.weight(1f)
.fillMaxHeight()
.background(PanelColors.Background, shape)
.border(0.5.dp, PanelColors.Edge, shape),
) {
Row(
Modifier
.fillMaxWidth()
.height(25.dp)
.background(PanelColors.Header, RoundedCornerShape(topStart = 9.dp, topEnd = 9.dp))
.padding(horizontal = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(Modifier.size(5.dp).background(dot, CircleShape))
BasicText(title, style = AppType.MonoCaption.copy(color = AppColors.TextMuted, letterSpacing = 0.1.em))
}
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
content = content,
)
}
}

View File

@@ -0,0 +1,431 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
import ru.stenfredd.sleepyowl.core.data.SubtitleMode
import ru.stenfredd.sleepyowl.core.jellyfin.Account
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
import ru.stenfredd.sleepyowl.playback.core.SkipMode
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.Avatar
import ru.stenfredd.sleepyowl.ui.components.Chip
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.OwlMark
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
import ru.stenfredd.sleepyowl.ui.components.Wordmark
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.rememberScreenFocus
import ru.stenfredd.sleepyowl.ui.focus.remembered
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
import ru.stenfredd.sleepyowl.ui.player.QualityCaps
import ru.stenfredd.sleepyowl.ui.player.SubtitleChoices
import ru.stenfredd.sleepyowl.ui.settings.SettingsViewModel
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
/** Sections on the left; the clicked one is shown on the right. */
private enum class Section(val title: String, val dot: Color) {
Playback("Воспроизведение", AppColors.Blue),
Languages("Языки", AppColors.Pink),
Subtitles("Вид субтитров", AppColors.Yellow),
Account("Аккаунт и сервер", AppColors.Green),
About("О приложении", AppColors.TextDim),
}
/** One chip of a setting. */
@Immutable
private data class Option<T>(val value: T, val label: String, val detail: String? = null, val swatch: Color? = null)
/**
* Settings tab, as on the TV without its TV-only settings (video output, Dolby Vision, the debug tour): sections on the
* left, the chosen section's settings on the right as rows of chips. Every choice applies at once. The desktop's own
* settings (interface scale, the player window) come with L9.
*/
@Composable
fun SettingsScreen(
onOpen: (Screen) -> Unit,
/** Signed out: the app goes to the login screen. */
onSignedOut: () -> Unit,
/** Another saved account became current: the app restarts on its home screen. */
onSwitched: () -> Unit,
vm: SettingsViewModel = koinViewModel(),
) {
val focus = rememberScreenFocus()
var section by rememberSaveable { mutableStateOf(Section.Playback) }
Column(Modifier.fillMaxSize().padding(start = AppDimens.CONTENT_START.dp, top = AppDimens.CONTENT_TOP.dp)) {
BasicText("Настройки", style = AppType.Display.copy(fontSize = 28.sp))
Row(Modifier.padding(top = 22.dp).fillMaxSize()) {
Column(Modifier.width(190.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
for (s in Section.entries) {
SectionItem(s, selected = s == section, onClick = { section = s }, Modifier.hoverFocus().remembered(focus, "section/${s.name}", isDefault = s == Section.Playback))
}
}
// Each section scrolls on its own and starts from the top.
Box(Modifier.weight(1f).fillMaxHeight()) {
key(section) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = 40.dp, end = AppDimens.CONTENT_START.dp, top = 6.dp, bottom = 60.dp),
verticalArrangement = Arrangement.spacedBy(26.dp),
) {
when (section) {
Section.Playback -> PlaybackSection(vm, focus)
Section.Languages -> LanguagesSection(vm, focus)
Section.Subtitles -> SubtitlesSection(vm, focus)
Section.Account -> AccountSection(vm, focus, onOpen, onSignedOut, onSwitched)
Section.About -> AboutSection(vm)
}
}
}
}
}
}
focus.Entry(ready = true)
}
private val SectionFocus = FocusStyle(scale = 1f, corner = 8.dp, glows = FocusStyle.ButtonGlows)
/** Section in the left column: light fill under the pointer, a violet frame for the one shown. */
@Composable
private fun SectionItem(section: Section, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(8.dp)
Row(
modifier
.focusCard(SectionFocus)
.fillMaxWidth()
.height(36.dp)
.background(
when {
focused -> AppColors.FocusFill
selected -> AppColors.Selected
else -> Color.Transparent
},
shape,
)
.border(0.5.dp, if (selected && !focused) AppColors.SelectedBorder else Color.Transparent, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Box(Modifier.size(6.dp).background(section.dot, CircleShape))
BasicText(
section.title,
style = AppType.Rail.copy(fontSize = 13.sp, color = if (focused) AppColors.OnFocusFill else if (selected) AppColors.Text else AppColors.TextMuted),
maxLines = 1,
)
}
}
/** A setting: title, an optional explanation, then its controls. */
@Composable
private fun Setting(title: String, note: String?, content: @Composable () -> Unit) {
Column {
BasicText(title, style = AppType.Section.copy(fontSize = 14.sp))
note?.let { BasicText(it, Modifier.padding(top = 5.dp).widthIn(max = 560.dp), style = AppType.Body) }
Box(Modifier.padding(top = 11.dp)) { content() }
}
}
/** A row of chips, one of them chosen; wraps when the window is narrow. */
@Composable
private fun <T> Choices(id: String, options: List<Option<T>>, selected: T, focus: ScreenFocus, onPick: (T) -> Unit) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
for ((i, o) in options.withIndex()) {
Chip(o.label, o.value == selected, { onPick(o.value) }, Modifier.hoverFocus().remembered(focus, "$id/$i"), detail = o.detail, swatch = o.swatch)
}
}
}
@Composable
private fun Note(text: String, modifier: Modifier = Modifier, color: Color = AppColors.TextDim) {
BasicText(text, modifier.widthIn(max = 560.dp), style = AppType.Body.copy(fontSize = 10.5.sp, lineHeight = 15.sp, color = color))
}
// --- Воспроизведение ---
@Composable
private fun PlaybackSection(vm: SettingsViewModel, focus: ScreenFocus) {
val defaults by vm.defaults.collectAsState()
fun set(d: PlaybackDefaults) = vm.setDefaults(d)
Setting(
"Качество по умолчанию",
"Всё, что ниже оригинала, сервер перекодирует на лету — это нагружает его процессор. Помогает при медленной сети. " +
"В плеере качество можно сменить для одного просмотра.",
) {
val options = listOf(Option<Int?>(null, "Оригинал")) +
QualityCaps.ALL.map { (bitrate, lines) -> Option<Int?>(bitrate, "${lines}p", PlayerLabels.mbps(bitrate)) }
Choices("quality", options, defaults.maxBitrate, focus) { set(defaults.copy(maxBitrate = it)) }
}
Setting(
"Заставки",
"Опенинги, пересказ прошлых серий, анонсы. «Кнопка» — в начале заставки появляется «Пропустить заставку», " +
"«Сразу» — плеер перескакивает её сам. Работает там, где сервер знает заставки (плагин Intro Skipper).",
) {
Choices("skip-intro", skipOptions, defaults.skipIntro, focus) { set(defaults.copy(skipIntro = it)) }
}
Setting(
"Титры",
"«Кнопка» — с началом титров появляется карточка «Следующая серия» с отсчётом (или «Пропустить титры», если " +
"после них ещё что-то есть). «Сразу» — следующая серия включается, как только начались титры. «Не пропускать» — " +
"карточка за 30 с до конца серии.",
) {
Choices("skip-credits", skipOptions, defaults.skipCredits, focus) { set(defaults.copy(skipCredits = it)) }
}
}
private val skipOptions = listOf(Option(SkipMode.Button, "Кнопка"), Option(SkipMode.Auto, "Сразу"), Option(SkipMode.Off, "Не пропускать"))
// --- Языки ---
private val AudioLanguages = listOf("rus" to "Русский", "eng" to "Английский", "jpn" to "Японский")
private val SubtitleLanguages = listOf("rus" to "Русские", "eng" to "Английские")
@Composable
private fun LanguagesSection(vm: SettingsViewModel, focus: ScreenFocus) {
val state by vm.languages.collectAsState()
val settings = state.settings
if (settings == null) {
if (state.error == null) {
Note("Загрузка настроек с сервера…")
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Note("Не удалось загрузить настройки с сервера: ${state.error}", color = AppColors.Red)
SecondaryButton("Повторить", vm::loadLanguages, Modifier.hoverFocus().remembered(focus, "languages/retry"))
}
}
return
}
fun set(s: LanguageSettings) = vm.setLanguages(s)
Setting("Язык звука", "Какую дорожку включать, если в файле их несколько. «Как в файле» — дорожка, отмеченная в файле основной.") {
val options = listOf(Option<String?>(null, "Как в файле")) +
(AudioLanguages + listOfNotNull(settings.audio?.takeIf { a -> AudioLanguages.none { it.first == a } }?.let { it to languageName(it) }))
.map { (code, name) -> Option<String?>(code, name) }
Choices("audio", options, settings.audio, focus) { set(settings.copy(audio = it)) }
}
Setting("Субтитры", null) {
val options = listOf(
Option(SubtitleMode.Default, "Как в файле"),
Option(SubtitleMode.None, "Выкл"),
Option(SubtitleMode.OnlyForced, "Только надписи"),
Option(SubtitleMode.Smart, "Если звук не на моём языке"),
Option(SubtitleMode.Always, "Всегда"),
)
Choices("subtitles", options, settings.subtitleMode, focus) { set(settings.copy(subtitleMode = it)) }
}
if (settings.subtitleMode != SubtitleMode.None) {
Setting("Язык субтитров", null) {
val options = listOf(Option<String?>(null, "Любой")) +
(SubtitleLanguages + listOfNotNull(settings.subtitleLanguage?.takeIf { s -> SubtitleLanguages.none { it.first == s } }?.let { it to languageName(it) }))
.map { (code, name) -> Option<String?>(code, name) }
Choices("subtitle-language", options, settings.subtitleLanguage, focus) { set(settings.copy(subtitleLanguage = it)) }
}
}
val account = vm.accounts.collectAsState().value.current
Note(
"Языки хранятся на сервере в профиле ${account?.userName.orEmpty()} и действуют во всех приложениях Jellyfin. " +
"Дорожки, выбранные вручную для фильма или сериала, запоминаются на этом компьютере и важнее этих настроек.",
)
state.error?.let { Note(it, color = AppColors.Red) }
}
/** "fra" → "Французский". */
private fun languageName(code: String): String =
java.util.Locale.forLanguageTag(code).getDisplayLanguage(java.util.Locale.forLanguageTag("ru")).replaceFirstChar { it.titlecase() }.ifEmpty { code }
// --- Вид субтитров ---
@Composable
private fun SubtitlesSection(vm: SettingsViewModel, focus: ScreenFocus) {
val style by vm.subtitleStyle.collectAsState()
fun set(s: SubtitleStyle) = vm.setSubtitleStyle(s)
SubtitlePreview(style, Modifier.widthIn(max = 420.dp))
Setting("Размер", null) {
Choices("sub-size", SubtitleStyle.Size.entries.map { Option(it, it.name) }, style.size, focus) { set(style.copy(size = it)) }
}
Setting("Цвет", null) {
val options = SubtitleChoices.colors(style).map { Option(it.key.substringAfter('/').toInt(), it.label, swatch = it.swatch) }
Choices("sub-color", options, style.color, focus) { set(style.copy(color = it)) }
}
Setting("Фон", null) {
val options = SubtitleChoices.backgrounds(style).map { Option(SubtitleStyle.Background.valueOf(it.key.substringAfter('/')), it.label) }
Choices("sub-background", options, style.background, focus) { set(style.copy(background = it)) }
}
Note(
"Для текстовых субтитров (SRT, VTT) на этом компьютере, для всех профилей. У субтитров ASS своё оформление из файла, " +
"графические (PGS, DVD) показываются как есть. То же можно настроить в плеере.",
)
}
// --- Аккаунт и сервер ---
@Composable
private fun AccountSection(vm: SettingsViewModel, focus: ScreenFocus, onOpen: (Screen) -> Unit, onSignedOut: () -> Unit, onSwitched: () -> Unit) {
val state by vm.accounts.collectAsState()
val scope = rememberCoroutineScope()
// The action waiting for the second click ("Точно выйти?"); the pointer leaving it cancels it.
var confirm by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
fun Modifier.confirmable(id: String) = onFocusChanged { if (!it.isFocused && confirm == id) confirm = null }
val current = state.current ?: return
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Avatar(current.userName, current.userId.toString(), 48.dp, AppType.Hero.copy(fontSize = 20.sp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
BasicText(current.userName, style = AppType.Section.copy(fontSize = 18.sp, fontWeight = FontWeight.Bold))
val server = state.server
val name = server?.name?.ifEmpty { null } ?: current.serverName.ifEmpty { null }
BasicText(
listOfNotNull(name?.let { "Сервер «$it»" } ?: "Сервер", server?.version?.let { "Jellyfin $it" }).joinToString(" · "),
style = AppType.MonoMeta,
)
}
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoLine("Адрес", current.serverUrl)
InfoLine("Дома", state.localUrl?.let { "напрямую, $it" } ?: "сервер в локальной сети не найден — через адрес выше")
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton("Сменить профиль", { onOpen(Screen.Login) }, Modifier.hoverFocus().remembered(focus, "account/switch"))
SecondaryButton(
when {
busy -> "Выходим…"
confirm == "logout" -> "Точно выйти?"
else -> "Выйти"
},
{
when {
busy -> Unit
confirm != "logout" -> confirm = "logout"
else -> {
busy = true
scope.launch {
vm.logout()
onSignedOut()
}
}
}
},
Modifier.hoverFocus().remembered(focus, "account/logout").confirmable("logout"),
)
}
Note("«Выйти» забывает этот профиль на компьютере; войти снова — паролем или Quick Connect.")
Setting("Другие профили на этом компьютере", if (state.others.isEmpty()) "Других нет. Добавить — «Сменить профиль» → «Другой пользователь»." else null) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
for (a in state.others) {
OtherAccount(
a,
confirmForget = confirm == "forget/${a.id}",
onSwitch = {
if (!busy) {
busy = true
scope.launch {
vm.switchTo(a.id)
onSwitched()
}
}
},
onForget = {
if (confirm != "forget/${a.id}") {
confirm = "forget/${a.id}"
} else {
confirm = null
vm.forget(a.id)
}
},
focus = focus,
modifier = Modifier.confirmable("forget/${a.id}"),
)
}
}
}
}
@Composable
private fun OtherAccount(account: Account, confirmForget: Boolean, onSwitch: () -> Unit, onForget: () -> Unit, focus: ScreenFocus, modifier: Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Avatar(account.userName, account.userId.toString(), 30.dp, AppType.CardTitle)
Column(Modifier.width(190.dp)) {
BasicText(account.userName, style = AppType.CardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
BasicText(account.serverName.ifEmpty { account.serverUrl }, style = AppType.MonoCaption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Chip("Переключиться", false, onSwitch, Modifier.hoverFocus().remembered(focus, "other/${account.id}/switch"))
Chip(if (confirmForget) "Точно забыть?" else "Забыть", false, onForget, modifier.hoverFocus().remembered(focus, "other/${account.id}/forget"))
}
}
@Composable
private fun InfoLine(key: String, value: String) {
Row {
BasicText(key, Modifier.width(60.dp), style = AppType.MonoMeta.copy(color = AppColors.TextFaint))
BasicText(value, style = AppType.MonoMeta, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
// --- О приложении ---
@Composable
private fun AboutSection(vm: SettingsViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OwlMark()
Wordmark()
}
BasicText("Версия ${vm.about.version}", style = AppType.MonoMeta)
BasicText(vm.about.device, style = AppType.MonoMeta.copy(color = AppColors.TextDim))
}
// The list of components and their licences comes with the package (L10): the desktop is built from other parts.
Note("Приложение — свободное ПО под лицензией GPL-3.0.")
}

View File

@@ -0,0 +1,252 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.Avatar
import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.components.OwlMark
import ru.stenfredd.sleepyowl.ui.components.Wordmark
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
import ru.stenfredd.sleepyowl.ui.shell.Tab
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
import java.util.UUID
/** From this width the rail stays open with its labels; narrower, it shows icons and opens over the content on hover. */
private val PinnedRailFrom = 1100.dp
/**
* Root screens (the tabs) on the desktop: the TV's side rail, made for the mouse. A wide window keeps it open and
* lays the content out beside it; a narrow one keeps it collapsed, and pointing at it opens it over the content.
*/
@Composable
fun Shell(
current: Tab,
userName: String,
userSeed: String,
libraries: List<RailLibrary>,
onTab: (Tab) -> Unit,
onLibrary: (UUID) -> Unit,
onProfile: () -> Unit,
content: @Composable () -> Unit,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val pinned = maxWidth >= PinnedRailFrom
val railWidth = if (pinned) AppDimens.RAIL_OPEN.dp else AppDimens.RAIL_COLLAPSED.dp
Box(Modifier.fillMaxSize().padding(start = railWidth)) { content() }
Rail(current, userName, userSeed, libraries, pinned, onTab, onLibrary, onProfile, Modifier.zIndex(1f))
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun Rail(
current: Tab,
userName: String,
userSeed: String,
libraries: List<RailLibrary>,
pinned: Boolean,
onTab: (Tab) -> Unit,
onLibrary: (UUID) -> Unit,
onProfile: () -> Unit,
modifier: Modifier = Modifier,
) {
var hovered by remember { mutableStateOf(false) }
var focused by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
val open = pinned || hovered || focused
// A pinned rail does not animate: it is open from the first frame.
val progress = animateFloatAsState(if (open) 1f else 0f, if (pinned) tween(0) else tween(150), label = "rail")
Column(
modifier
.fillMaxHeight()
.width(if (open) AppDimens.RAIL_OPEN.dp else AppDimens.RAIL_COLLAPSED.dp)
.onPointerEvent(PointerEventType.Enter) { hovered = true }
.onPointerEvent(PointerEventType.Exit) {
hovered = false
// The pointer gave an item focus (hoverFocus); leaving the rail closes it, as on the TV.
if (focused) focusManager.clearFocus()
}
.onFocusChanged { focused = it.hasFocus }
.drawBehind {
val p = progress.value
val w = lerp(AppDimens.RAIL_COLLAPSED.dp.toPx(), AppDimens.RAIL_OPEN.dp.toPx(), p)
drawRect(lerp(AppColors.Rail, AppColors.RailOpen, if (pinned) 1f else p), size = Size(w, size.height))
drawRect(AppColors.BorderSubtle, topLeft = Offset(w - 1f, 0f), size = Size(1f, size.height))
// Edge shadow over the content, only while the rail is opened over it.
if (!pinned && p > 0f) {
val shadow = 40.dp.toPx()
drawRect(
Brush.horizontalGradient(listOf(Color.Black.copy(alpha = 0.5f * p), Color.Transparent), startX = w, endX = w + shadow),
topLeft = Offset(w, 0f),
size = Size(shadow, size.height),
)
}
}
.padding(horizontal = 9.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Row(Modifier.padding(start = 1.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OwlMark()
Wordmark(Modifier.graphicsLayer { alpha = progress.value })
}
@Composable
fun item(tab: Tab) = RailItem(tab.label, tab.icon, tab == current, progress, onClick = { if (tab != current) onTab(tab) })
for (tab in Tab.entries) if (tab != Tab.Settings) item(tab)
if (libraries.isNotEmpty()) {
RailDivider(progress)
Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(2.dp)) {
for (library in libraries) {
RailItem(library.name, library.icon, selected = false, progress, onClick = { onLibrary(library.id) }, height = 30.dp)
}
}
} else {
Spacer(Modifier.weight(1f))
}
item(Tab.Settings)
ProfileItem(userName, userSeed, progress, onProfile)
}
}
/** Fill of the item under the pointer (or keyboard focus), growing with the rail panel. */
private fun Modifier.railHighlight(lit: Boolean, color: Color, progress: State<Float>) = drawBehind {
if (!lit) return@drawBehind
val collapsed = (AppDimens.RAIL_COLLAPSED - 18).dp.toPx()
val w = lerp(collapsed, size.width, progress.value)
drawRoundRect(color, size = Size(w, size.height), cornerRadius = CornerRadius(8.dp.toPx()))
}
/** The open rail marks the current tab with a fill (the closed one with the bar on its edge). */
private fun Modifier.selectedFill(selected: Boolean, progress: State<Float>) = drawBehind {
if (selected) drawRoundRect(AppColors.SurfaceFocused, alpha = progress.value, cornerRadius = CornerRadius(8.dp.toPx()))
}
@Composable
private fun RailDivider(progress: State<Float>) {
Box(
Modifier.fillMaxWidth().height(11.dp).drawBehind {
val collapsed = (AppDimens.RAIL_COLLAPSED - 18).dp.toPx()
val w = lerp(collapsed, size.width, progress.value)
drawRect(AppColors.BorderSubtle, topLeft = Offset(0f, (size.height / 2).toInt().toFloat()), size = Size(w, 1.dp.toPx().coerceAtLeast(1f)))
},
)
}
@Composable
private fun RailItem(label: String, icon: AppIcon, selected: Boolean, progress: State<Float>, onClick: () -> Unit, height: Dp = 36.dp) {
var focused by remember { mutableStateOf(false) }
val content = when {
focused -> AppColors.OnFocusFill
selected -> AppColors.Text
else -> AppColors.TextDim
}
Box(Modifier.fillMaxWidth().height(height)) {
// Selected tab marker on the rail's left edge, visible while the rail is closed.
if (selected) {
Box(
Modifier
.padding(vertical = 9.dp)
.width(2.dp)
.fillMaxHeight()
.graphicsLayer {
translationX = -9.dp.toPx()
alpha = 1f - progress.value
}
.background(AppColors.Accent, RoundedCornerShape(1.dp)),
)
}
Row(
Modifier
.fillMaxSize()
.hoverFocus()
.selectedFill(selected, progress)
.railHighlight(focused, AppColors.FocusFill, progress)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 11.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
Icon(icon, content, Modifier.size(16.dp))
BasicText(
label,
Modifier.graphicsLayer { alpha = progress.value },
style = AppType.Rail.copy(color = content),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
softWrap = false,
)
}
}
}
@Composable
private fun ProfileItem(userName: String, userSeed: String, progress: State<Float>, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
Row(
Modifier
.fillMaxWidth()
.height(38.dp)
.hoverFocus()
.railHighlight(focused, AppColors.SurfaceFocused, progress)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Avatar(userName, userSeed, 24.dp, AppType.CardTitle.copy(fontSize = AppType.PosterTitle.fontSize))
Column(Modifier.graphicsLayer { alpha = progress.value }) {
BasicText(userName, style = AppType.CardTitle.copy(fontSize = AppType.Rail.fontSize), maxLines = 1, overflow = TextOverflow.Ellipsis, softWrap = false)
BasicText("сменить профиль", style = AppType.MonoCaption.copy(fontSize = AppType.MonoBadge.fontSize), maxLines = 1, softWrap = false)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,187 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.menu.LocalWatchMenu
import ru.stenfredd.sleepyowl.ui.menu.MenuAction
import ru.stenfredd.sleepyowl.ui.menu.WatchMenu
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuViewModel
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
import ru.stenfredd.sleepyowl.ui.menu.actions
import ru.stenfredd.sleepyowl.ui.menu.kindLabel
import ru.stenfredd.sleepyowl.ui.menu.status
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
import kotlin.math.roundToInt
/**
* Hosts the watched menu over [content]: the right button on a card (or the ✓ of a series page) opens it where the
* pointer is, as a context menu; a click past it or Esc closes it. Marking a season or series marks every episode on
* the server. The TV's menu ([WatchMenu]) with the desktop's look.
*/
@Composable
fun WatchMenuHost(content: @Composable () -> Unit) {
val menu = remember { WatchMenu() }
val vm: WatchMenuViewModel = koinViewModel()
// Not the menu's scope: a request goes on when the menu is closed before the server answers.
val scope = rememberCoroutineScope()
fun close(shown: WatchMenu.Shown) {
if (menu.shown !== shown) return
menu.shown = null
runCatching { shown.returnTo.requestFocus() }
}
CompositionLocalProvider(LocalWatchMenu provides menu) {
Box(Modifier.fillMaxSize()) {
content()
menu.shown?.let { shown ->
key(shown) {
var saving by remember { mutableStateOf<Boolean?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val at = remember { Input.pointer }
BackHandler { close(shown); true }
Spacer(Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures(onPress = { close(shown) }) })
BoxWithConstraints(Modifier.fillMaxSize()) {
val bounds = constraints
MenuCard(
shown.target,
saving = saving,
error = error,
onPick = { played ->
if (saving == null) {
saving = played
error = null
scope.launch {
try {
vm.setPlayed(shown.target.id, played)
close(shown)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Watched menu: marking failed")
saving = null
error = e.message ?: e.toString()
}
}
}
},
// At the pointer, moved in to stay whole inside the window.
modifier = Modifier.layout { measurable, c ->
val p = measurable.measure(c.copy(minWidth = 0, minHeight = 0))
val x = at.x.roundToInt().coerceAtMost(bounds.maxWidth - p.width).coerceAtLeast(0)
val y = at.y.roundToInt().coerceAtMost(bounds.maxHeight - p.height).coerceAtLeast(0)
layout(c.maxWidth, c.maxHeight) { p.place(IntOffset(x, y)) }
},
)
}
}
}
}
}
}
/** What the item is, how much of it is watched, and the actions. [saving]: the action being sent (true = watched). */
@Composable
private fun MenuCard(target: WatchTarget, saving: Boolean?, error: String?, onPick: (Boolean) -> Unit, modifier: Modifier) {
val shape = RoundedCornerShape(10.dp)
Column(
modifier
.width(350.dp)
.background(PanelColors.Background, shape)
.border(0.5.dp, PanelColors.Edge, shape)
// Clicks inside stay inside (the layer behind closes the menu).
.pointerInput(Unit) { detectTapGestures { } }
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column {
BasicText(target.kindLabel(), style = AppType.MonoLabel.copy(color = AppColors.Green, letterSpacing = 0.1.em))
BasicText(target.title, Modifier.padding(top = 6.dp), style = AppType.Section.copy(fontSize = 15.sp, lineHeight = 19.sp), maxLines = 2, overflow = TextOverflow.Ellipsis)
target.subtitle?.let { BasicText(it, Modifier.padding(top = 4.dp), style = AppType.MonoMeta, maxLines = 1, overflow = TextOverflow.Ellipsis) }
target.status()?.let {
BasicText(it, Modifier.padding(top = 7.dp), style = AppType.Body.copy(color = if (target.played) AppColors.Green else AppColors.TextMuted))
}
}
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
for (action in target.actions()) ActionRow(action, saving = saving == action.played, onClick = { onPick(action.played) })
}
error?.let { BasicText("Не удалось сохранить: $it", style = AppType.Body.copy(color = AppColors.Red), maxLines = 3, overflow = TextOverflow.Ellipsis) }
}
}
private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
/** "✓ Отметить просмотренным" with what it touches under it. */
@Composable
private fun ActionRow(action: MenuAction, saving: Boolean, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(7.dp)
val tint = if (action.played) AppColors.Green else AppColors.TextSecondary
Row(
Modifier
.hoverFocus()
.focusCard(ActionFocus)
.fillMaxWidth()
.height(if (action.detail != null || saving) 48.dp else 38.dp)
.background(if (focused) Color(0xFF1F2238) else AppColors.SurfaceRaised, shape)
.border(0.5.dp, if (focused) AppColors.Accent else Color(0xFF232637), shape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 11.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(action.icon, tint, Modifier.size(14.dp))
Column(Modifier.weight(1f)) {
BasicText(action.label, style = AppType.BodyLarge.copy(fontWeight = FontWeight.Medium, color = AppColors.Text), maxLines = 1, overflow = TextOverflow.Ellipsis)
(if (saving) "Сохраняем…" else action.detail)?.let {
BasicText(it, Modifier.padding(top = 4.dp), style = AppType.MonoCaption.copy(fontSize = 9.5.sp), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -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),
)
}
}

View File

@@ -0,0 +1,49 @@
package ru.stenfredd.sleepyowl.desktop
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.EncodedImageFormat
import org.junit.Test
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
import ru.stenfredd.sleepyowl.ui.shell.Tab
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
import java.io.File
import java.util.UUID
/**
* The side rail on a wide window (pinned open with labels) and on a narrow one (icons only), drawn without a window
* into `build/reports/desktop/shell-<width>.png` to look at.
*/
class ShellRenderTest {
@Test
fun wideAndNarrow() {
for (width in listOf(1280, 900)) render(width)
}
private fun render(width: Int) {
val libraries = listOf(
RailLibrary(UUID.randomUUID(), "Фильмы", AppIcon.Film),
RailLibrary(UUID.randomUUID(), "Сериалы", AppIcon.Tv),
RailLibrary(UUID.randomUUID(), "Аниме", AppIcon.Library),
)
val scene = ImageComposeScene(width, 640, Density(1f)) {
Box(Modifier.fillMaxSize().background(AppColors.Background)) {
Shell(Tab.Home, "Claude", "seed", libraries, onTab = {}, onLibrary = {}, onProfile = {}) {
BasicText("Главная", style = AppType.Hero)
}
}
}
var frame = 0L
repeat(20) { scene.render(frame); frame += 16_000_000L }
val png = checkNotNull(scene.render(frame).encodeToData(EncodedImageFormat.PNG)).bytes
File("build/reports/desktop").apply { mkdirs() }.resolve("shell-$width.png").writeBytes(png)
scene.close()
}
}