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