Shared KMP core and UI, switch to Koin

This commit is contained in:
2026-10-25 20:28:07 +03:00
parent f63b09a9d3
commit 69048452b8
161 changed files with 6626 additions and 4485 deletions

View File

@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.kotlin.compose)
}
// UI shared by the TV (android) and desktop versions: view models, navigation keys, card models and labels,
// theme, icons and visual components. Screens with remote-control focus logic stay in :app.
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.ui.shared"
compileSdk = 37
minSdk = 26
// Fonts and the logo pictures (res/font, res/drawable-xhdpi); the desktop reads the same files as resources.
androidResources { enable = true }
}
jvm("desktop")
sourceSets {
commonMain.dependencies {
api(project(":core:data"))
api(project(":playback:core"))
api(libs.cmp.runtime)
api(libs.cmp.foundation)
api(libs.cmp.ui)
api(libs.androidx.lifecycle.viewmodel)
implementation(libs.koin.core.viewmodel)
implementation(libs.coil.compose)
implementation(libs.zxing.core)
}
getByName("desktopMain") {
resources.srcDir("src/androidMain/res")
}
getByName("desktopTest").dependencies {
implementation(libs.junit)
implementation(libs.koin.test)
implementation(libs.cmp.desktop.linux.x64)
}
}
}

View File

@@ -0,0 +1,8 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.Bitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
actual fun argbImageBitmap(pixels: IntArray, width: Int, height: Int): ImageBitmap =
Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888).asImageBitmap()

View File

@@ -0,0 +1,48 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Shader
import android.util.LruCache
import kotlin.math.roundToInt
/**
* The TV's BlurHash placeholders ([BlurHash]): decoded tiny, then upscaled to a quarter of the card with
* the rounded corners baked in, like the real images ([CardImageTransformation]); drawn stretched to the card it is a
* single cheap bitmap draw. Decoding runs off the main thread; results are cached by hash and size.
*/
internal object BlurHashPlaceholders {
private val cache = LruCache<String, Bitmap>(CACHE_ENTRIES)
/** A placeholder bitmap for a [widthPx]×[heightPx] card with [radiusPx] corners and [shade], or null for a broken hash. */
fun placeholder(hash: String, widthPx: Int, heightPx: Int, radiusPx: Float, shade: ImageShade?): Bitmap? {
val w = (widthPx / SCALE).coerceAtLeast(1)
val h = (heightPx / SCALE).coerceAtLeast(1)
val key = "$hash:$w:$h:$radiusPx:${shade?.cacheKey}"
cache.get(key)?.let { return it }
val smallH = (DECODE_SIZE * heightPx / widthPx.toFloat()).roundToInt().coerceAtLeast(1)
val small = BlurHash.decode(hash, DECODE_SIZE, smallH)?.let { Bitmap.createBitmap(it, DECODE_SIZE, smallH, Bitmap.Config.ARGB_8888) } ?: return null
val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
paint.shader = BitmapShader(small, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
setLocalMatrix(Matrix().apply { setScale(w / small.width.toFloat(), h / small.height.toFloat()) })
}
val r = radiusPx / SCALE
val canvas = Canvas(out)
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), r, r, paint)
// The same shade as the real image, or the text over a bright blur would be unreadable.
shade?.let { canvas.drawShade(it, w, h) }
small.recycle()
cache.put(key, out)
return out
}
private const val DECODE_SIZE = 16
/** The placeholder is a quarter of the card: it is a blur, upscaling it costs nothing visible. */
private const val SCALE = 4
private const val CACHE_ENTRIES = 300
}

View File

@@ -0,0 +1,11 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import ru.stenfredd.sleepyowl.ui.shared.R
@Composable
internal actual fun owlPainter() = painterResource(R.drawable.logo_owl)
@Composable
internal actual fun wordmarkPainter() = painterResource(R.drawable.logo_wordmark)

View File

@@ -0,0 +1,62 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.graphics.Shader
import androidx.compose.ui.graphics.toArgb
import coil3.size.Dimension
import coil3.size.Size
import coil3.transform.Transformation
/**
* Rounded corners and the text shade baked into the bitmap once, on Coil's decode thread.
* The TV this was measured on redraws the whole screen every frame (no dirty-region rendering), and every
* translucent layer over an image costs GPU time on every frame; baked, the card is a single bitmap draw.
*/
class CardImageTransformation(private val radiusPx: Float, private val shade: ImageShade?) : Transformation() {
override val cacheKey = "card:$radiusPx:${shade?.cacheKey}"
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
// Crop to the card's own size first: when the server image has other proportions (posters aren't all 2:3),
// cropping it later at draw time would cut the baked corners off.
val w = (size.width as? Dimension.Pixels)?.px ?: input.width
val h = (size.height as? Dimension.Pixels)?.px ?: input.height
val output = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
val scale = maxOf(w.toFloat() / input.width, h.toFloat() / input.height)
paint.shader = BitmapShader(input, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
setLocalMatrix(
Matrix().apply {
setScale(scale, scale)
postTranslate((w - input.width * scale) / 2f, (h - input.height * scale) / 2f)
},
)
}
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), radiusPx, radiusPx, paint)
shade?.let { canvas.drawShade(it, w, h) }
return output
}
}
/** Draws [shade] over the bottom of a [w]×[h] image already on this canvas, only over its own (non-transparent) pixels. */
internal fun Canvas.drawShade(shade: ImageShade, w: Int, h: Int) {
val top = h * (1f - shade.height)
val gradient = Paint()
gradient.shader = LinearGradient(
0f, h.toFloat(), 0f, top,
shade.stops.map { it.second.toArgb() }.toIntArray(),
shade.stops.map { it.first }.toFloatArray(),
Shader.TileMode.CLAMP,
)
// Only over the image's own pixels, so the rounded corners stay transparent.
gradient.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
drawRect(0f, top, w.toFloat(), h.toFloat(), gradient)
}

View File

@@ -0,0 +1,95 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.Bitmap
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.LruCache
import androidx.compose.ui.graphics.asImageBitmap
import java.nio.ByteBuffer
import kotlin.math.roundToInt
internal actual fun cachedGlow(w: Int, h: Int, corner: Int, layers: List<GlowLayer>) = AndroidGlows.get(w, h, corner, layers)
// The TV's glows: rendered at half resolution (a blur loses nothing when upscaled), layers composited in float.
private object AndroidGlows {
private const val SCALE = 0.5f
private data class Key(val w: Int, val h: Int, val corner: Int, val layers: List<GlowLayer>)
// Bytes of all cached bitmaps; ~4 MB is a few dozen glows.
private val cache = object : LruCache<Key, GlowCache.Glow>(4 * 1024 * 1024) {
override fun sizeOf(key: Key, value: GlowCache.Glow) = value.bitmap.width * value.bitmap.height * 4
}
fun get(w: Int, h: Int, corner: Int, layers: List<GlowLayer>): GlowCache.Glow {
val key = Key(w, h, corner, layers)
return cache.get(key) ?: render(key).also { cache.put(key, it) }
}
private fun render(key: Key): GlowCache.Glow {
// A blurred edge fades out at roughly 1× the CSS blur radius beyond the spread.
val margin = key.layers.maxOf { it.blurPx + it.spreadPx }
val w = ((key.w + 2 * margin) * SCALE).roundToInt().coerceAtLeast(1)
val h = ((key.h + 2 * margin) * SCALE).roundToInt().coerceAtLeast(1)
// Each layer's coverage is drawn at full strength (255 levels) and the layers are composited in float:
// a dim colour stored directly in 8 bits has only 15–60 levels per channel, so the tail shows bands
// several px wide and R, G, B step at different places — a visible staircase with colour shifts.
val mask = Bitmap.createBitmap(w, h, Bitmap.Config.ALPHA_8)
val canvas = Canvas(mask)
canvas.scale(SCALE, SCALE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val stride = mask.rowBytes
val coverage = ByteBuffer.allocate(stride * h)
val premul = FloatArray(w * h * 4)
for (layer in key.layers) {
mask.eraseColor(0)
// CSS blur radius B is a Gaussian with sigma B/2; BlurMaskFilter's radius r has sigma ≈ 0.57735·r + 0.5.
val radius = ((layer.blurPx / 2 - 0.5f) / 0.57735f).coerceAtLeast(0.5f)
paint.maskFilter = BlurMaskFilter(radius, BlurMaskFilter.Blur.NORMAL)
val s = layer.spreadPx
val rect = RectF(margin - s, margin - s, margin + key.w + s, margin + key.h + s)
val corner = key.corner + s
canvas.drawRoundRect(rect, corner, corner, paint)
coverage.rewind()
mask.copyPixelsToBuffer(coverage)
val px = coverage.array()
val c = layer.color
for (y in 0 until h) for (x in 0 until w) {
val v = px[y * stride + x].toInt() and 0xFF
if (v == 0) continue
val a = c.alpha * v / 255f
val i = (y * w + x) * 4
premul[i] = c.red * a + premul[i] * (1 - a)
premul[i + 1] = c.green * a + premul[i + 1] * (1 - a)
premul[i + 2] = c.blue * a + premul[i + 2] * (1 - a)
premul[i + 3] = a + premul[i + 3] * (1 - a)
}
}
// To 8 bits with ±1.5 levels of noise: invisible, but the average is smooth. White noise rather than
// a finer pattern, since the bitmap is upscaled 2× and the filtering would average a fine pattern out.
val out = ByteBuffer.allocate(w * h * 4)
val bytes = out.array()
for (i in 0 until w * h) {
val o = i * 4
if (premul[o + 3] <= 0f) continue
val n = 3f * noise(i) - 1.5f
val a = (premul[o + 3] * 255f + n).roundToInt().coerceIn(0, 255)
for (k in 0..2) bytes[o + k] = (premul[o + k] * 255f + n).roundToInt().coerceIn(0, a).toByte()
bytes[o + 3] = a.toByte()
}
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(out)
return GlowCache.Glow(bitmap.asImageBitmap(), margin)
}
/** Hash-based white noise, 0..1. */
private fun noise(i: Int): Float {
var x = i * 0x27d4eb2d
x = (x xor (x ushr 15)) * 0x2c1b3c6d
x = (x xor (x ushr 12)) * 0x297a2d39
x = x xor (x ushr 15)
return (x ushr 8) / 16777216f
}
}

View File

@@ -0,0 +1,70 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transformations
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.ui.theme.AppColors
/**
* The TV's server image: corners and the optional text shade are baked into the bitmap when it is decoded
* ([CardImageTransformation]), instead of clipping and blending every frame. Until the image arrives the card shows
* its BlurHash placeholder (decoded off the main thread), or a plain rounded rectangle. The image size comes from
* the URL, so Coil only decodes. No crossfade: it would redraw every visible card for its duration.
*/
@Composable
actual fun RemoteImage(image: RemoteImageSpec?, corner: Dp, modifier: Modifier, shade: ImageShade?, contentScale: ContentScale) {
val url = image?.url
var loaded by remember(url) { mutableStateOf(false) }
val shape = remember(corner) { RoundedCornerShape(corner) }
val radius = with(LocalDensity.current) { corner.toPx() }
var boxSize by remember { mutableStateOf(IntSize.Zero) }
val placeholder by produceState<ImageBitmap?>(null, image?.blurHash, boxSize, radius, shade) {
val hash = image?.blurHash
value = if (hash == null || boxSize == IntSize.Zero) null else withContext(Dispatchers.Default) { BlurHashPlaceholders.placeholder(hash, boxSize.width, boxSize.height, radius, shade)?.asImageBitmap() }
}
val background = when {
loaded -> modifier
placeholder != null -> modifier.drawBehind {
placeholder?.let { drawImage(it, srcOffset = IntOffset.Zero, dstSize = boxSize) }
}
else -> modifier.background(AppColors.SurfaceRaised, shape)
}
Box(background.onSizeChanged { boxSize = it }) {
if (url != null) {
val context = LocalContext.current
val request = remember(url, radius, shade) {
ImageRequest.Builder(context).data(url).transformations(CardImageTransformation(radius, shade)).build()
}
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale,
onSuccess = { loaded = true },
)
}
}
}

View File

@@ -0,0 +1,20 @@
package ru.stenfredd.sleepyowl.ui.theme
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import ru.stenfredd.sleepyowl.ui.shared.R
internal actual fun onestFontFamily() = FontFamily(
Font(R.font.onest_400, FontWeight.Normal),
Font(R.font.onest_500, FontWeight.Medium),
Font(R.font.onest_600, FontWeight.SemiBold),
Font(R.font.onest_700, FontWeight.Bold),
Font(R.font.onest_800, FontWeight.ExtraBold),
)
internal actual fun monoFontFamily() = FontFamily(
Font(R.font.jetbrains_mono_400, FontWeight.Normal),
Font(R.font.jetbrains_mono_500, FontWeight.Medium),
Font(R.font.jetbrains_mono_600, FontWeight.SemiBold),
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,46 @@
package ru.stenfredd.sleepyowl.ui
import androidx.lifecycle.ViewModel
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import java.util.UUID
/** Plain item lists of the old prototype, still used for collections and folders ([ItemsScreen]). */
class Library(private val session: JellyfinSession) {
private val api get() = session.api
private val userId get() = session.requireSession().userId
suspend fun children(parentId: UUID): List<BaseItemDto> = session.call {
libraryApi.getItems(
userId = userId,
parentId = parentId,
sortBy = listOf(ItemSortBy.PARENT_INDEX_NUMBER, ItemSortBy.INDEX_NUMBER, ItemSortBy.SORT_NAME),
limit = 1000,
).content.items
}
fun posterUrl(item: BaseItemDto): String? {
val tag = item.imageTags?.get(ImageType.PRIMARY) ?: return null
return api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY, maxHeight = 360, tag = tag)
}
}
/** The prototype's grid of collections and folders (`ItemsScreen`). */
class BrowseViewModel(val library: Library, val session: JellyfinSession) : ViewModel()
val BaseItemDto.isPlayable get() = type in setOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO)
val BaseItemDto.label: String
get() = when (type) {
BaseItemKind.EPISODE -> listOfNotNull(
seriesName,
if (parentIndexNumber != null && indexNumber != null) "S%02dE%02d".format(parentIndexNumber, indexNumber) else null,
name,
).joinToString(" · ")
else -> listOfNotNull(name, productionYear?.toString()).joinToString(" · ")
}

View File

@@ -0,0 +1,42 @@
package ru.stenfredd.sleepyowl.ui
import ru.stenfredd.sleepyowl.playback.api.EngineId
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import java.util.UUID
sealed interface Screen {
/** Login flow; also "сменить профиль" from the menu (then Back returns to the app). */
data object Login : Screen
/**
* Root screens with the side rail. [account] keeps each account's screens (and their view models) apart:
* after a profile switch the stack is rebuilt, and an equal entry would be kept with the old account's state.
* [library]: the library tab opened on a library from the rail; a new entry, so the tab starts over on it.
*/
data class Root(val tab: ru.stenfredd.sleepyowl.ui.shell.Tab, val account: String, val library: UUID? = null) : Screen
/** Collections and folders: the prototype's plain grid until they get a real screen. */
data class Items(val parentId: UUID, val title: String) : Screen
data class Detail(val itemId: UUID) : Screen
/** A person: photo, name and their movies and series. */
data class Person(val personId: UUID) : Screen
/** Series page; [episodeId] = the episode to show first (a new episode, continue watching), else the next one to watch. */
data class Series(val seriesId: UUID, val episodeId: UUID? = null) : Screen
data class Player(
val itemId: UUID,
val engine: EngineId?,
val startPositionMs: Long,
/** Tracks to start with instead of the file's defaults. */
val streams: StreamSelection? = null,
/** Start at this fraction of the runtime instead of [startPositionMs]. */
val startFraction: Double? = null,
) : Screen
/** Text of a licence from "О приложении" in the settings; [asset] is a file in `assets/licenses`. */
data class License(val name: String, val license: String, val asset: String) : Screen
/** Debug tour: plays a prepared list of files covering every format on the server, see [TourScreen]. */
data class Tour(val startIndex: Int = 0) : Screen
}

View File

@@ -0,0 +1,42 @@
package ru.stenfredd.sleepyowl.ui
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
import ru.stenfredd.sleepyowl.ui.details.DetailsViewModel
import ru.stenfredd.sleepyowl.ui.favorites.FavoritesViewModel
import ru.stenfredd.sleepyowl.ui.home.HomeViewModel
import ru.stenfredd.sleepyowl.ui.library.LibraryMemory
import ru.stenfredd.sleepyowl.ui.library.LibraryViewModel
import ru.stenfredd.sleepyowl.ui.login.LoginViewModel
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuViewModel
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.person.PersonViewModel
import ru.stenfredd.sleepyowl.ui.player.PlayerViewModel
import ru.stenfredd.sleepyowl.ui.search.SearchViewModel
import ru.stenfredd.sleepyowl.ui.series.SeriesViewModel
import ru.stenfredd.sleepyowl.ui.settings.SettingsViewModel
/**
* Koin: the shared view models and what they need besides the core. The app adds its [UiPlatform]. View models with
* an id (Details, Person, Series) get it from `parametersOf(id)`: Koin hands an injected parameter to the constructor
* argument of its type.
*/
val uiModule = module {
singleOf(::LibraryMemory)
singleOf(::Library)
singleOf(::CardMapper)
viewModelOf(::BrowseViewModel)
viewModelOf(::HomeViewModel)
viewModelOf(::LibraryViewModel)
viewModelOf(::DetailsViewModel)
viewModelOf(::SeriesViewModel)
viewModelOf(::PersonViewModel)
viewModelOf(::SearchViewModel)
viewModelOf(::FavoritesViewModel)
viewModelOf(::LoginViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::PlayerViewModel)
viewModelOf(::WatchMenuViewModel)
}

View File

@@ -0,0 +1,16 @@
package ru.stenfredd.sleepyowl.ui
/** What the shared view models need to know about the platform; the TV app and the desktop app provide it. */
interface UiPlatform {
/** Pixels per dp: server images are requested at exactly the size they are shown. */
val density: Float
/** Version line of "О приложении": "0.1.0 (1) · release". */
val version: String
/** The device line of "О приложении": "Xiaomi MiTV-MOEU1 · Android 14". */
val device: String
/** The debug tour can be started from the settings (a debug build with its list of files). */
val tourAvailable: Boolean
}

View File

@@ -0,0 +1,67 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
/**
* User avatar from the mockup: a gradient circle with the first letter of the name,
* `linear-gradient(145deg, oklch(62% .13 h), oklch(40% .12 h+40))`; the hue comes from [seed] (the user id),
* so a user keeps the same colour everywhere.
*/
@Composable
fun Avatar(name: String, seed: String, size: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letters: String = name.trim().take(1).uppercase()) {
val colors = remember(seed) { avatarColors(seed) }
Box(
modifier
.size(size)
.drawWithCache {
val brush = Brush.linearGradient(colors, start = Offset(this.size.width * 0.2f, 0f), end = Offset(this.size.width * 0.8f, this.size.height))
onDrawBehind { drawCircle(brush) }
},
contentAlignment = Alignment.Center,
) {
BasicText(letters, style = textStyle)
}
}
fun avatarColors(seed: String): List<Color> {
val hue = Math.floorMod(seed.hashCode(), 360).toDouble()
return listOf(oklch(0.62, 0.13, hue), oklch(0.40, 0.12, hue + 40))
}
/** CSS `oklch()` → sRGB. */
private fun oklch(l: Double, c: Double, hueDeg: Double): Color {
val h = hueDeg * PI / 180
val a = c * cos(h)
val b = c * sin(h)
val l1 = cube(l + 0.3963377774 * a + 0.2158037573 * b)
val m1 = cube(l - 0.1055613458 * a - 0.0638541728 * b)
val s1 = cube(l - 0.0894841775 * a - 1.2914855480 * b)
val r = 4.0767416621 * l1 - 3.3077115913 * m1 + 0.2309699292 * s1
val g = -1.2684380046 * l1 + 2.6097574011 * m1 - 0.3413193965 * s1
val bl = -0.0041960863 * l1 - 0.7034186147 * m1 + 1.7076147010 * s1
return Color(gamma(r), gamma(g), gamma(bl))
}
private fun cube(x: Double) = x * x * x
private fun gamma(x: Double): Float {
val v = x.coerceIn(0.0, 1.0)
return (if (v <= 0.0031308) 12.92 * v else 1.055 * v.pow(1 / 2.4) - 0.055).toFloat()
}

View File

@@ -0,0 +1,6 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.ui.graphics.ImageBitmap
/** A bitmap of [width]×[height] ARGB pixels (not premultiplied), row by row: QR codes, BlurHash placeholders. */
expect fun argbImageBitmap(pixels: IntArray, width: Int, height: Int): ImageBitmap

View File

@@ -0,0 +1,73 @@
package ru.stenfredd.sleepyowl.ui.components
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
/**
* Placeholder from the image's BlurHash (sent by the server with every item): a blurred colour sketch of the picture
* while it loads. Decoded tiny (the hash holds only a few colour components anyway); [RemoteImage] turns the pixels
* into the placeholder bitmap of its platform.
*/
object BlurHash {
/** Standard BlurHash decoding (https://blurha.sh), punch 1: [width]×[height] opaque ARGB pixels, or null for a broken hash. */
fun decode(hash: String, width: Int, height: Int): IntArray? {
if (hash.length < 6) return null
val sizeFlag = decode83(hash, 0, 1)
val numX = sizeFlag % 9 + 1
val numY = sizeFlag / 9 + 1
if (hash.length != 4 + 2 * numX * numY) return null
val maxAc = (decode83(hash, 1, 2) + 1) / 166f
val colors = Array(numX * numY) { i ->
if (i == 0) {
val v = decode83(hash, 2, 6)
floatArrayOf(srgbToLinear(v shr 16), srgbToLinear((v shr 8) and 255), srgbToLinear(v and 255))
} else {
val v = decode83(hash, 4 + i * 2, 6 + i * 2)
floatArrayOf(signedPow2((v / (19 * 19) - 9) / 9f) * maxAc, signedPow2(((v / 19) % 19 - 9) / 9f) * maxAc, signedPow2((v % 19 - 9) / 9f) * maxAc)
}
}
val cosX = FloatArray(width * numX) { cos(Math.PI * (it / numX) * (it % numX) / width).toFloat() }
val cosY = FloatArray(height * numY) { cos(Math.PI * (it / numY) * (it % numY) / height).toFloat() }
val pixels = IntArray(width * height)
for (y in 0 until height) for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numY) for (i in 0 until numX) {
val basis = cosX[x * numX + i] * cosY[y * numY + j]
val c = colors[j * numX + i]
r += c[0] * basis
g += c[1] * basis
b += c[2] * basis
}
pixels[y * width + x] = (0xFF shl 24) or (linearToSrgb(r) shl 16) or (linearToSrgb(g) shl 8) or linearToSrgb(b)
}
return pixels
}
private const val CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~"
private fun decode83(s: String, from: Int, to: Int): Int {
var v = 0
for (i in from until to) {
val d = CHARS.indexOf(s[i])
if (d < 0) return 0
v = v * 83 + d
}
return v
}
private fun srgbToLinear(v: Int): Float {
val f = v / 255f
return if (f <= 0.04045f) f / 12.92f else ((f + 0.055f) / 1.055f).pow(2.4f)
}
private fun linearToSrgb(v: Float): Int {
val f = v.coerceIn(0f, 1f)
return if (f <= 0.0031308f) (f * 12.92f * 255 + 0.5f).toInt() else ((1.055f * f.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
private fun signedPow2(v: Float) = (v * v).withSign(v)
}

View File

@@ -0,0 +1,39 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The app's logo: the owl and the "SleepyOwl" wordmark from `design/logo/sleepyowl.png`, cut out of its navy
* background by `tools/logo/make_assets.py`. The pictures are made at the default sizes here (xhdpi), so on the TV
* they are drawn pixel for pixel; other sizes resample them.
*/
@Composable
fun OwlMark(modifier: Modifier = Modifier, size: Dp = 36.dp) {
Image(owlPainter(), null, modifier.size(size))
}
@Composable
fun Wordmark(modifier: Modifier = Modifier, height: Dp = 18.dp) {
val painter = wordmarkPainter()
Image(
painter,
"SleepyOwl",
modifier.height(height).aspectRatio(painter.intrinsicSize.width / painter.intrinsicSize.height),
)
}
/** `drawable-xhdpi/logo_owl.png` of ui:shared: a resource on the TV, from the classpath on the desktop. */
@Composable
internal expect fun owlPainter(): Painter
/** `drawable-xhdpi/logo_wordmark.png`. */
@Composable
internal expect fun wordmarkPainter(): Painter

View File

@@ -0,0 +1,149 @@
package ru.stenfredd.sleepyowl.ui.components
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.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
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.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.dp
import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
private val PrimaryFocus = FocusStyle(
scale = 1.05f,
corner = 21.dp,
ringColor = Color(0xFFD9D2FF),
// `0 0 36px 8px rgba(124,92,255,.65), 0 0 100px rgba(70,120,255,.35)`
glows = listOf(FocusStyle.Glow(18.dp, 4.dp, Color(0xA67C5CFF)), FocusStyle.Glow(50.dp, 0.dp, Color(0x594678FF))),
)
private val LightFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
/** Main action: gradient pill "▶ Смотреть". */
@Composable
fun PrimaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: AppIcon? = AppIcon.Play) {
Row(
modifier
.focusCard(PrimaryFocus)
.height(42.dp)
.background(AppColors.PrimaryGradient, RoundedCornerShape(21.dp))
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(start = 15.dp, end = 19.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
icon?.let { Icon(it, Color.White, Modifier.size(15.dp)) }
BasicText(label, style = AppType.Button.copy(fontSize = 14.sp, color = Color.White))
}
}
/** Secondary pill ("С начала"): dark, filled light when focused. */
@Composable
fun SecondaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
var focused by remember { mutableStateOf(false) }
Box(
modifier
.focusCard(LightFocus)
.height(42.dp)
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, RoundedCornerShape(21.dp))
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, RoundedCornerShape(21.dp))
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 15.dp),
contentAlignment = Alignment.Center,
) {
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)))
}
}
/** Round icon button (favourite, watched); [tint] is the icon colour when not focused. */
@Composable
fun CircleButton(icon: AppIcon, onClick: () -> Unit, modifier: Modifier = Modifier, tint: Color = Color(0xFFE3E5F2), size: Dp = 42.dp) {
var focused by remember { mutableStateOf(false) }
Box(
modifier
.focusCard(LightFocus.copy(corner = size / 2))
.size(size)
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, CircleShape)
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, CircleShape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(icon, if (focused) AppColors.OnFocusFill else tint, Modifier.size(size * 0.36f))
}
}
/** Option chip: "Русский TrueHD Atmos 7.1" (or "● Жёлтый" with a [swatch]); selected = violet, focused = light fill. */
@Composable
fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, detail: String? = null, swatch: Color? = null) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(14.5.dp)
val textColor = when {
focused -> AppColors.OnFocusFill
selected -> AppColors.Text
else -> AppColors.TextSecondary
}
Row(
modifier
.focusCard(FocusStyle(scale = 1f, corner = 14.5.dp, glows = FocusStyle.ButtonGlows))
.height(29.dp)
.background(
when {
focused -> AppColors.FocusFill
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 }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 11.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
swatch?.let { Box(Modifier.size(9.dp).background(it, CircleShape).border(0.5.dp, Color(0x4D000000), CircleShape)) }
BasicText(label, Modifier.widthIn(max = 190.dp), style = AppType.Button.copy(fontSize = 11.sp, fontWeight = FontWeight.Normal, color = textColor), maxLines = 1, overflow = TextOverflow.Ellipsis)
detail?.let { BasicText(it, style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = textColor.copy(alpha = 0.65f)), maxLines = 1) }
}
}
/** Format badge: "4K", "HDR10", "Atmos" with its colour. */
@Composable
fun FormatBadge(text: String, modifier: Modifier = Modifier) {
val color = when (text) {
"4K" -> AppColors.Blue
"HDR10", "HDR10+", "DV", "HLG", "HDR" -> AppColors.Yellow
"Atmos", "5.1", "7.1", "FLAC", "TrueHD", "DTS", "DTS:X" -> AppColors.Pink
else -> AppColors.TextMuted
}
BasicText(
text,
modifier
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
.padding(horizontal = 5.dp, vertical = 2.dp),
style = AppType.MonoCaption.copy(color = color),
)
}

View File

@@ -0,0 +1,268 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
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.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
import ru.stenfredd.sleepyowl.ui.menu.clickableWithMenu
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
/** What a poster card shows. Built by view models from server data; composables never see SDK DTOs. */
@Immutable
data class PosterModel(
val key: String,
val title: String,
val caption: String?,
val image: RemoteImageSpec?,
/** Format badge at the bottom left: "4K", "1080p". */
val badge: String? = null,
/** Watch progress 0..1, or null. */
val progress: Float? = null,
val isNew: Boolean = false,
val watched: Boolean = false,
/** Heart in the corner (favourites); shown instead of the watched mark. */
val favorite: Boolean = false,
/** What a long press marks watched or unwatched. */
val watch: WatchTarget? = null,
)
/** Episode still or backdrop with a title over it: "Continue watching" cards. */
@Immutable
data class WideModel(
val key: String,
val title: String,
val subtitle: String,
val image: RemoteImageSpec?,
val progress: Float?,
val watch: WatchTarget? = null,
)
private val PosterCorner = 7.dp
private val WideCorner = 8.dp
private val FeaturedCorner = 11.dp
private val WideShade = ImageShade(0.7f, listOf(0f to Color(0xF006070C), 0.75f to Color(0x4006070C), 1f to Color.Transparent))
private val FeaturedShade = ImageShade(0.8f, listOf(0f to Color(0xF506070C), 0.6f to Color(0x9906070C), 1f to Color.Transparent))
@Composable
fun PosterCard(model: PosterModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier.width(AppDimens.POSTER_W.dp)) {
Box(
Modifier
.focusCard(FocusStyle.Poster)
.size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp)
.clickableWithMenu(model.watch, onClick),
) {
RemoteImage(model.image, PosterCorner, Modifier.fillMaxSize())
if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp))
if (model.favorite) {
FavoriteBadge(Modifier.align(Alignment.TopEnd).padding(6.dp))
} else if (model.watched) {
WatchedBadge(Modifier.align(Alignment.TopEnd).padding(6.dp))
}
// The mockup's bottom shade under the badge costs a card-sized blend; a small dark plate does the same job.
model.badge?.let {
BasicText(
it,
Modifier
.align(Alignment.BottomStart)
.padding(6.dp)
.background(Color(0x9906070C), RoundedCornerShape(3.dp))
.padding(horizontal = 4.dp, vertical = 1.dp),
style = AppType.MonoCaption.copy(color = Color(0xD9FFFFFF)),
)
}
model.progress?.let {
ProgressLine(
it,
Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 5.dp).fillMaxWidth().height(2.5.dp),
)
}
}
BasicText(
model.title,
Modifier.padding(top = 8.dp),
style = AppType.PosterTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
model.caption?.let {
BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
fun WideCard(model: WideModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.focusCard(FocusStyle.Wide)
.size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp)
.clickableWithMenu(model.watch, onClick),
) {
RemoteImage(model.image, WideCorner, Modifier.fillMaxSize(), shade = WideShade)
Column(Modifier.align(Alignment.BottomStart).padding(start = 10.dp, end = 10.dp, bottom = 11.dp)) {
BasicText(model.title, style = AppType.CardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
BasicText(
model.subtitle,
Modifier.padding(top = 3.dp),
style = AppType.MonoMeta,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
model.progress?.let {
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 8.dp, vertical = 5.dp).fillMaxWidth().height(3.dp))
}
}
}
/** The big first "Continue watching" card on the home screen. */
@Immutable
data class FeaturedModel(
val key: String,
val label: String,
val title: String,
val subtitle: String,
val description: String?,
val image: RemoteImageSpec?,
val progress: Float,
val remaining: String?,
val watch: WatchTarget? = null,
)
@Composable
fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.focusCard(FocusStyle.Featured)
.size(410.dp, 230.5.dp)
.clickableWithMenu(model.watch, onClick),
) {
RemoteImage(model.image, FeaturedCorner, Modifier.fillMaxSize(), shade = FeaturedShade)
Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) {
BasicText(model.label, style = AppType.MonoLabel)
BasicText(
model.title,
Modifier.padding(top = 5.dp),
style = AppType.Hero,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BasicText(model.subtitle, Modifier.padding(top = 4.dp), style = AppType.BodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
model.description?.let {
BasicText(
it,
Modifier.padding(top = 7.dp).widthIn(max = 350.dp),
style = AppType.Body,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(Modifier.padding(top = 11.dp), verticalAlignment = Alignment.CenterVertically) {
PlayPill("Продолжить")
Spacer(Modifier.width(10.dp))
ProgressLine(model.progress, Modifier.weight(1f).height(3.dp))
model.remaining?.let {
Spacer(Modifier.width(10.dp))
BasicText(it, style = AppType.MonoMeta.copy(fontSize = AppType.MonoCount.fontSize))
}
}
}
}
}
/** Non-focusable gradient "▶ label" pill, part of a larger focusable card. */
@Composable
fun PlayPill(label: String, modifier: Modifier = Modifier) {
Row(
modifier
.height(28.dp)
.background(AppColors.PrimaryGradient, RoundedCornerShape(14.dp))
.padding(start = 9.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
PlayIcon(Modifier.size(12.dp))
BasicText(label, style = AppType.Button)
}
}
/** Rounded track with a gradient fill, one draw call, no clipping. */
@Composable
fun ProgressLine(fraction: Float, modifier: Modifier = Modifier) {
Spacer(
modifier.drawWithCache {
val r = CornerRadius(size.height / 2)
val fill = size.width * fraction.coerceIn(0f, 1f)
onDrawBehind {
drawRoundRect(AppColors.ProgressTrack, cornerRadius = r)
if (fill > 0f) drawRoundRect(AppColors.ProgressGradient, size = Size(fill, size.height), cornerRadius = r)
}
},
)
}
@Composable
fun NewBadge(modifier: Modifier = Modifier) {
BasicText(
"НОВОЕ",
modifier
.background(AppColors.NewBadge, RoundedCornerShape(3.dp))
.padding(horizontal = 4.5.dp, vertical = 2.dp),
style = AppType.MonoBadge.copy(color = Color.White),
)
}
@Composable
fun WatchedBadge(modifier: Modifier = Modifier) {
Box(
modifier
.size(18.dp)
.background(Color(0x2E7EE0A3), CircleShape)
.border(0.5.dp, AppColors.Green, CircleShape),
contentAlignment = Alignment.Center,
) {
CheckIcon(Modifier.size(9.dp), AppColors.Green)
}
}
/** Pink heart in a dark circle (`rgba(10,11,16,.6)`, pink border): favourites. */
@Composable
fun FavoriteBadge(modifier: Modifier = Modifier) {
Box(
modifier
.size(19.dp)
.background(Color(0x990A0B10), CircleShape)
.border(0.5.dp, AppColors.Pink.copy(alpha = 0.6f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(AppIcon.HeartFilled, AppColors.Pink, Modifier.size(10.dp))
}
}

View File

@@ -0,0 +1,71 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.PaintingStyle
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.cos
/**
* Dashed rounded frame (`border: 2px dashed`), rasterised once per size into a bitmap: a dashed path is
* re-tessellated every frame by the GPU, and the test TV redraws the whole screen on every frame.
*/
fun Modifier.dashedBorder(color: Color, width: Dp, corner: Dp, dash: Dp = 6.dp, gap: Dp = 4.dp): Modifier = drawWithCache {
val w = ceil(size.width).toInt().coerceAtLeast(1)
val h = ceil(size.height).toInt().coerceAtLeast(1)
val bitmap = ImageBitmap(w, h)
val stroke = width.toPx()
val paint = Paint().apply {
isAntiAlias = true
style = PaintingStyle.Stroke
strokeWidth = stroke
this.color = color
pathEffect = PathEffect.dashPathEffect(floatArrayOf(dash.toPx(), gap.toPx()), 0f)
}
val r = corner.toPx()
Canvas(bitmap).drawRoundRect(stroke / 2, stroke / 2, w - stroke / 2, h - stroke / 2, r, r, paint)
onDrawBehind { drawImage(bitmap) }
}
/**
* Status dot; [pulsing] fades it in and out while something is in progress. The fade is stepped at ~8 frames
* a second: the test TV redraws the whole screen for any change (~17 ms of GPU on the login screen), so a smooth
* 60 fps pulse would keep its GPU busy for nothing.
*/
@Composable
fun StatusDot(color: Color, pulsing: Boolean, modifier: Modifier = Modifier, size: Dp = 6.dp) {
val alpha = remember { mutableFloatStateOf(1f) }
if (pulsing) {
LaunchedEffect(Unit) {
try {
var t = 0
while (true) {
alpha.floatValue = 0.625f + 0.375f * cos(t * PI / 6).toFloat()
t++
delay(PULSE_STEP_MS)
}
} finally {
alpha.floatValue = 1f
}
}
}
Spacer(modifier.size(size).drawBehind { drawCircle(color, alpha = alpha.floatValue) })
}
private const val PULSE_STEP_MS = 120L

View File

@@ -0,0 +1,142 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusEventModifierNode
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.node.invalidatePlacement
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.stenfredd.sleepyowl.ui.theme.AppColors
/** Focus look of a card: scale, outer ring and glow, as `style-focus` in the mockup (sizes in dp). */
@Immutable
data class FocusStyle(
val scale: Float,
val corner: Dp,
val ring: Dp = 1.5.dp,
val ringColor: Color = AppColors.Accent,
val glows: List<Glow> = CardGlows,
/**
* False: focus shows at once. Over video (the player) every frame redraws the controls' shades too, so an
* animated focus change costs ~8 expensive frames instead of one.
*/
val animate: Boolean = true,
) {
@Immutable
data class Glow(val blur: Dp, val spread: Dp, val color: Color)
companion object {
/** `0 0 32px 6px rgba(124,92,255,.55), 0 0 90px 14px rgba(70,120,255,.22)` */
val CardGlows = listOf(
Glow(16.dp, 3.dp, Color(0x8C7C5CFF)),
Glow(45.dp, 7.dp, Color(0x384678FF)),
)
/** `0 0 30px 6px rgba(124,92,255,.5)` — buttons and chips. */
val ButtonGlows = listOf(Glow(15.dp, 3.dp, Color(0x807C5CFF)))
val Poster = FocusStyle(scale = 1.07f, corner = 7.dp)
val Wide = FocusStyle(scale = 1.05f, corner = 8.dp)
val Featured = FocusStyle(scale = 1.025f, corner = 11.dp)
val Tile = FocusStyle(scale = 1.03f, corner = 11.dp, ring = 1.dp)
}
}
/**
* Focus decoration for a card: animates scale, ring and glow when the element (or a child) gets focus.
* Put it before `clip` and `clickable`/`focusable` so the glow isn't clipped and focus events reach it.
*
* Implemented as a modifier node: the animation only touches the layer and draw phases,
* a focus change never recomposes the card.
*/
fun Modifier.focusCard(style: FocusStyle): Modifier = this then FocusCardElement(style)
private data class FocusCardElement(val style: FocusStyle) : ModifierNodeElement<FocusCardNode>() {
override fun create() = FocusCardNode(style)
override fun update(node: FocusCardNode) {
node.style = style
node.invalidateDraw()
}
}
private class FocusCardNode(var style: FocusStyle) : Modifier.Node(), FocusEventModifierNode, LayoutModifierNode, DrawModifierNode {
private val progress = Animatable(0f)
private var focused = false
override fun onFocusEvent(focusState: FocusState) {
if (focusState.hasFocus == focused) return
focused = focusState.hasFocus
if (!isAttached) return
invalidatePlacement()
val target = if (focused) 1f else 0f
coroutineScope.launch { if (style.animate) progress.animateTo(target, tween(if (focused) 180 else 140)) else progress.snapTo(target) }
}
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
// Focused card on top so its scaled edge and glow aren't covered by neighbours.
placeable.placeWithLayer(IntOffset.Zero, zIndex = if (focused) 1f else 0f) {
val s = 1f + (style.scale - 1f) * progress.value
scaleX = s
scaleY = s
}
}
}
override fun ContentDrawScope.draw() {
val p = progress.value
if (p > 0f) {
val corner = style.corner.toPx()
if (style.glows.isNotEmpty()) drawGlow(corner, p)
val ring = style.ring.toPx()
drawRoundRect(
color = style.ringColor,
topLeft = Offset(-ring / 2, -ring / 2),
size = Size(size.width + ring, size.height + ring),
cornerRadius = CornerRadius(corner + ring / 2),
style = Stroke(ring),
alpha = p,
)
}
drawContent()
}
private fun ContentDrawScope.drawGlow(corner: Float, p: Float) {
val glow = GlowCache.get(
size.width,
size.height,
corner,
style.glows.map { GlowLayer(it.blur.toPx(), it.spread.toPx(), it.color) },
)
drawImage(
image = glow.bitmap,
dstOffset = IntOffset(-glow.marginPx.toInt(), -glow.marginPx.toInt()),
dstSize = IntSize(
(size.width + 2 * glow.marginPx).toInt(),
(size.height + 2 * glow.marginPx).toInt(),
),
alpha = p,
)
}
}

View File

@@ -0,0 +1,31 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import kotlin.math.roundToInt
/**
* One soft glow layer, CSS `box-shadow: 0 0 <blur> <spread> <color>`, sizes in px.
*/
data class GlowLayer(val blurPx: Float, val spreadPx: Float, val color: Color)
/**
* Pre-rendered focus glows. The mockup's glows are 30–120 px blurred shadows; blurring them every frame
* is too slow for weak TVs, so each (size, shape, layers) combination is rendered once into a bitmap
* at half resolution (a blur loses nothing when upscaled) and then drawn as a plain image.
* Only the focused element draws its glow, and focusable elements of one kind share a size,
* so the cache stays small.
*/
object GlowCache {
class Glow(val bitmap: ImageBitmap, val marginPx: Float)
fun get(widthPx: Float, heightPx: Float, cornerPx: Float, layers: List<GlowLayer>): Glow =
cachedGlow(widthPx.roundToInt(), heightPx.roundToInt(), cornerPx.roundToInt(), layers)
}
/**
* The glow of a [w]×[h] px element with [corner] px corners, drawn [GlowCache.Glow.marginPx] beyond its edges:
* rendered on the first request and cached. The TV bakes it with care for its 8-bit bands and weak GPU,
* the desktop simply.
*/
internal expect fun cachedGlow(w: Int, h: Int, corner: Int, layers: List<GlowLayer>): GlowCache.Glow

View File

@@ -0,0 +1,27 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
/*
* Screens built as one lazy grid of posters (library, favourites, search results, a person's films): headers and
* rows of cards are full-width items of the grid, posters are its cells, so only what is on screen is composed.
*/
/** A full-width item of the grid (header, row of cards). */
fun LazyGridScope.fullItem(key: String, type: String, content: @Composable () -> Unit) =
item(key = key, span = { GridItemSpan(maxLineSpan) }, contentType = type) { content() }
/**
* Lays a full-width item out [width] wide starting [side] to the left of the grid's content, i.e. outside the grid's
* side padding: rows of cards then scroll from the screen edge to edge, as on home.
*/
fun Modifier.bleed(width: Dp, side: Dp) = layout { measurable, constraints ->
val w = width.roundToPx()
val placeable = measurable.measure(constraints.copy(minWidth = w, maxWidth = w))
layout(constraints.maxWidth, placeable.height) { placeable.place(-side.roundToPx(), 0) }
}

View File

@@ -0,0 +1,74 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
import kotlin.math.ceil
private const val HEART = "M12 20s-7.5-4.6-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"
/**
* Icons from the mockup's inline SVGs (24×24 viewBox). Paths are rasterised into a small bitmap once per
* size and colour, then drawn as an image: weak TV GPUs re-rasterise antialiased paths every frame.
*/
enum class AppIcon(val pathData: String, val stroked: Boolean = true) {
Search("M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14zM20 20l-3.5-3.5"),
Home("M4 11l8-7 8 7v9h-5v-6H9v6H4z"),
Library("M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM3 9h18M8 5v4M16 5v4"),
Heart(HEART),
HeartFilled(HEART, stroked = false),
Check("M5 12.5l4.5 4.5L19 7.5"),
Plus("M12 5v14M5 12h14"),
Play("M7 4.5v15l13-7.5z", stroked = false),
Pause("M6 4h4v16H6zM14 4h4v16h-4z", stroked = false),
Replay("M4 12a8 8 0 1 0 2.5-5.8M4 4v4.5h4.5"),
Forward("M20 12a8 8 0 1 1-2.5-5.8M20 4v4.5h-4.5"),
Previous("M19 5v14L9 12zM5 5h3v14H5z", stroked = false),
Next("M5 5v14l10-7zM16 5h3v14h-3z", stroked = false),
ChevronDown("M6 9l6 6 6-6"),
ChevronRight("M9 6l6 6-6 6"),
Settings("M4 7h9M17 7h3M4 17h3M11 17h9M15 4.5v5M9 14.5v5"),
/** A movie library on the rail. */
Film("M5 4h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8 4v16M16 4v16M4 9h4M4 15h4M16 9h4M16 15h4"),
/** A series library on the rail. */
Tv("M5 7h14a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM8.5 3l3.5 4 3.5-4"),
;
internal val path: Path by lazy { PathParser().parsePathString(pathData).toPath() }
}
@Composable
fun Icon(icon: AppIcon, color: Color, modifier: Modifier = Modifier, strokeWidth: Float = 2f) {
Spacer(
modifier.drawWithCache {
val bitmapSize = IntSize(ceil(size.width).toInt().coerceAtLeast(1), ceil(size.height).toInt().coerceAtLeast(1))
val bitmap = ImageBitmap(bitmapSize.width, bitmapSize.height)
val k = size.minDimension / 24f
val style = if (icon.stroked) Stroke(strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round) else Fill
CanvasDrawScope().draw(this, layoutDirection, androidx.compose.ui.graphics.Canvas(bitmap), bitmapSize.toSize()) {
scale(k, k, pivot = Offset.Zero) { drawPath(icon.path, color, style = style) }
}
onDrawBehind { drawImage(bitmap) }
},
)
}
@Composable
fun PlayIcon(modifier: Modifier = Modifier, color: Color = Color.White) = Icon(AppIcon.Play, color, modifier)
@Composable
fun CheckIcon(modifier: Modifier = Modifier, color: Color) = Icon(AppIcon.Check, color, modifier, strokeWidth = 3f)

View File

@@ -0,0 +1,29 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
/** A server image: URL of exactly the displayed size and the BlurHash shown while it loads. */
@Immutable
data class RemoteImageSpec(val url: String, val blurHash: String? = null)
/**
* Bottom shade under text over an image: [stops] go from the bottom edge (0) to the top of the shade (1),
* which covers the bottom [height] fraction of the image.
*/
@Immutable
data class ImageShade(val height: Float, val stops: List<Pair<Float, Color>>) {
val cacheKey = "$height:" + stops.joinToString(",") { "${it.first}=${it.second.toArgb()}" }
}
/**
* Server image with rounded corners and an optional text [shade] at the bottom; while it loads, its BlurHash
* placeholder or a plain rounded rectangle.
*/
@Composable
expect fun RemoteImage(image: RemoteImageSpec?, corner: Dp, modifier: Modifier = Modifier, shade: ImageShade? = null, contentScale: ContentScale = ContentScale.Crop)

View File

@@ -0,0 +1,261 @@
package ru.stenfredd.sleepyowl.ui.details
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.ImageUrls
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.core.data.model.PersonKind
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import java.util.Locale
import java.util.UUID
import kotlin.math.roundToInt
/** One audio or subtitle choice; [index] is the server's stream index, null = subtitles off. */
@Immutable
data class TrackOption(val key: String, val index: Int?, val label: String, val detail: String?)
@Immutable
data class CastModel(val key: String, val name: String, val role: String?, val letters: String, val image: RemoteImageSpec?, val personId: UUID? = null)
/** Everything the movie (or episode) page shows, as display strings. */
@Immutable
data class DetailsModel(
val id: UUID,
/** "ФИЛЬМ", "СЕРИЯ · S1 · E3". */
val label: String,
val title: String,
/** Episode title under the series title. */
val subtitle: String?,
val rating: String?,
val age: String?,
val year: String?,
val runtime: String?,
val genres: String?,
val description: String?,
val poster: RemoteImageSpec?,
val backdropUrl: String?,
val badges: List<String>,
val fileLine: String?,
val resumeMs: Long,
val remainingMinutes: Int?,
val favorite: Boolean,
val played: Boolean,
val audio: List<TrackOption>,
val subtitles: List<TrackOption>,
val castTitle: String,
val cast: List<CastModel>,
)
@Immutable
data class DetailsState(
val loading: Boolean = true,
val error: String? = null,
val item: DetailsModel? = null,
/** Chosen audio stream index. */
val audio: Int? = null,
/** Chosen subtitle stream index, null = off. */
val subtitle: Int? = null,
val similar: List<PosterModel> = emptyList(),
)
/** Movie or episode page: details, the track choice passed on to the player, favourite and watched. */
class DetailsViewModel(
private val itemId: UUID,
private val library: LibraryRepository,
private val watch: WatchRepository,
private val images: ImageUrls,
private val cards: CardMapper,
private val prefs: PlaybackPreferences,
cache: SessionCache,
) : ViewModel() {
private val _state = MutableStateFlow(DetailsState())
val state: StateFlow<DetailsState> = _state.asStateFlow()
/** The user picked tracks on this page: keep the choice over reloads. */
private var tracksChosen = false
private var similarTargets: Map<String, Screen> = emptyMap()
/** Tracks are remembered per series (an episode) or per movie, see [PlaybackPreferences]. */
private var memoryKey: UUID = itemId
private var media: MediaInfo? = null
init {
viewModelScope.launch { load() }
// Back from the player (progress) or watched/favourite changed elsewhere: reload.
viewModelScope.launch { cache.invalidations.collect { load() } }
}
fun similarTarget(key: String): Screen? = similarTargets[key]
fun selectAudio(index: Int?) {
tracksChosen = true
_state.update { it.copy(audio = index) }
viewModelScope.launch { TrackChoices.rememberAudio(prefs, memoryKey, media, index) }
}
fun selectSubtitle(index: Int?) {
tracksChosen = true
_state.update { it.copy(subtitle = index) }
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, memoryKey, media, index) }
}
/** The player screen for this item: from the resume point unless [fromStart], with the chosen tracks. */
fun player(fromStart: Boolean): Screen.Player? {
val s = _state.value
val item = s.item ?: return null
return Screen.Player(
itemId = item.id,
engine = null,
startPositionMs = if (fromStart) 0 else item.resumeMs,
streams = StreamSelection(s.audio, s.subtitle),
)
}
fun toggleFavorite() = toggle({ it.favorite }, { m, v -> m.copy(favorite = v) }) { watch.setFavorite(itemId, it) }
fun togglePlayed() = toggle({ it.played }, { m, v -> m.copy(played = v) }) { watch.setPlayed(itemId, it) }
/** Shows the new value at once and reverts it if the server refuses. */
private fun toggle(get: (DetailsModel) -> Boolean, set: (DetailsModel, Boolean) -> DetailsModel, send: suspend (Boolean) -> Unit) {
val item = _state.value.item ?: return
val value = !get(item)
_state.update { s -> s.copy(item = s.item?.let { set(it, value) }) }
viewModelScope.launch {
try {
send(value)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Details: update failed")
_state.update { s -> s.copy(item = s.item?.let { set(it, !value) }) }
}
}
}
private suspend fun load() {
try {
coroutineScope {
val similar = async { runCatching { library.similar(itemId) }.getOrDefault(emptyList()) }
val details = library.details(itemId)
val model = withContext(Dispatchers.Default) { details.toModel() }
val media = details.media
this@DetailsViewModel.media = media
memoryKey = details.summary.seriesId ?: itemId
val initial = media?.let { TrackChoices.initial(prefs, memoryKey, it) }
_state.update { s ->
val keep = tracksChosen && s.item != null
s.copy(
loading = false,
error = null,
item = model,
audio = if (keep) s.audio else initial?.first,
subtitle = if (keep) s.subtitle else initial?.second,
)
}
// Similar items only make sense for movies; an episode would get random other series.
val related = if (details.summary.kind == ItemKind.Episode) emptyList() else similar.await()
val (posters, targets) = withContext(Dispatchers.Default) {
related.map { cards.poster(it, SIMILAR) } to related.associate { cards.key(SIMILAR, it) to cards.target(it) }
}
similarTargets = targets
_state.update { it.copy(similar = posters) }
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Details: load failed")
// Keep what is shown when a reload fails.
_state.update { if (it.item == null) it.copy(loading = false, error = e.message ?: e.toString()) else it }
}
}
private fun ItemDetails.toModel(): DetailsModel {
val item = summary
val episode = item.kind == ItemKind.Episode
val media = media
val directors = people.filter { it.kind == PersonKind.Director }.map { it.name }
val actors = people.filter { it.kind == PersonKind.Actor }.take(CAST_LIMIT)
val progress = item.user.progress
return DetailsModel(
id = item.id,
label = if (episode) {
listOfNotNull("СЕРИЯ", item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" · ")
} else {
"ФИЛЬМ"
},
title = if (episode) item.seriesName ?: item.name else item.name,
subtitle = if (episode) item.name else null,
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
// "RU-16+", "age6+" → "16+", "6+": the rating system's prefix says nothing on screen. "PG-13" stays.
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
year = item.year?.toString(),
runtime = MediaLabels.runtime(item.runtimeMs),
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
description = item.overview,
poster = (if (episode) item.seriesPrimary ?: item.primary else item.primary)?.let {
RemoteImageSpec(images.url(it, cards.px(DetailsLayout.POSTER_W), cards.px(DetailsLayout.POSTER_H)), it.blurHash)
},
backdropUrl = item.backdrop?.let { images.url(it, cards.px(DetailsLayout.BACKDROP_W), cards.px(DetailsLayout.BACKDROP_H), quality = 80) },
badges = MediaLabels.badges(media),
fileLine = MediaLabels.fileLine(media),
resumeMs = item.user.positionMs.takeIf { progress != null } ?: 0,
remainingMinutes = if (progress != null && item.runtimeMs != null) (item.runtimeMs!! * (1 - progress) / 60_000).roundToInt() else null,
favorite = item.user.favorite,
played = item.user.played,
audio = TrackChoices.audio(media),
subtitles = TrackChoices.subtitles(media),
castTitle = listOfNotNull("В ролях", directors.takeIf { it.isNotEmpty() }?.let { "режиссёр " + it.joinToString(", ") }).joinToString(" · "),
cast = actors.map { p ->
CastModel(
key = "cast/${p.id}",
name = p.name,
role = p.role,
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
image = p.image?.let { RemoteImageSpec(images.url(it, cards.px(DetailsLayout.AVATAR), cards.px(DetailsLayout.AVATAR)), it.blurHash) },
personId = p.id,
)
},
)
}
private companion object {
const val SIMILAR = "similar"
const val CAST_LIMIT = 20
}
}
/** Sizes (dp) of the page shared by the layout, the image requests and the baked backdrop. */
object DetailsLayout {
const val PAGE_W = 960f
const val PAGE_H = 540f
const val POSTER_X = 60f
const val POSTER_Y = 48f
const val POSTER_W = 220f
const val POSTER_H = 330f
const val POSTER_CORNER = 11f
/** The backdrop covers 70 % of the page width and height at the top right (see [BackdropTransformation]). */
const val BACKDROP_W = 672f
const val BACKDROP_H = 378f
const val AVATAR = 39f
}

View File

@@ -0,0 +1,142 @@
package ru.stenfredd.sleepyowl.ui.details
import ru.stenfredd.sleepyowl.core.data.model.AudioTrack
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.core.data.model.SubtitleTrack
import ru.stenfredd.sleepyowl.core.data.model.VideoRange
import java.util.Locale
/** How the item page names languages, codecs and formats: "Русский", "TrueHD Atmos 7.1", "HEVC 2160p HDR10 · 62.4 ГБ". */
object MediaLabels {
private val russian = Locale.forLanguageTag("ru")
// The server gives ISO 639-2 codes; Locale knows most, but not the bibliographic variants (fre, ger, chi …).
private val languages = mapOf(
"rus" to "Русский", "eng" to "English", "ukr" to "Українська", "jpn" to "日本語", "kor" to "한국어",
"chi" to "中文", "zho" to "中文", "fre" to "Français", "fra" to "Français", "ger" to "Deutsch", "deu" to "Deutsch",
"spa" to "Español", "ita" to "Italiano", "por" to "Português", "pol" to "Polski", "tur" to "Türkçe",
)
/** Language in its own name for the common ones (as the mockup: "Русский", "English"), else in Russian. */
fun language(code: String?): String? {
val c = code?.lowercase()?.takeIf { it.isNotBlank() && it != "und" } ?: return null
languages[c]?.let { return it }
val name = Locale.forLanguageTag(c).getDisplayLanguage(russian)
return name.takeIf { it.isNotBlank() && !it.equals(c, ignoreCase = true) }?.replaceFirstChar { it.titlecase(russian) } ?: c
}
fun audioLabel(track: AudioTrack): String =
listOfNotNull(language(track.language), track.name).joinToString(" · ").ifEmpty { track.title }
/** "TrueHD Atmos 7.1" */
fun audioDetail(track: AudioTrack): String? = listOfNotNull(
audioCodec(track.codec, track.profile),
"Atmos".takeIf { track.atmos },
channels(track.channels),
).joinToString(" ").ifEmpty { null }
fun subtitleLabel(track: SubtitleTrack): String {
val name = track.name ?: "надписи".takeIf { track.forced }
return listOfNotNull(language(track.language), name).joinToString(" · ").ifEmpty { track.title }
}
fun subtitleDetail(track: SubtitleTrack): String? = when (val codec = track.codec?.lowercase()) {
null -> null
"subrip", "srt" -> "SRT"
"ass", "ssa" -> "ASS"
"pgssub", "hdmv_pgs_subtitle" -> "PGS"
"dvdsub", "dvd_subtitle", "vobsub" -> "VOBSUB"
"webvtt", "vtt" -> "VTT"
"mov_text" -> "TX3G"
else -> codec.uppercase()
}
/** "4K", "HDR10", "Atmos": resolution, dynamic range and the best sound of the file. */
fun badges(media: MediaInfo?): List<String> {
media ?: return emptyList()
val video = media.video
val width = video?.width ?: 0
val height = video?.height ?: 0
return buildList {
when {
width >= 3200 || height >= 2000 -> add("4K")
width >= 1800 || height >= 1000 -> add("1080p")
width >= 1200 || height >= 700 -> add("720p")
}
range(video?.range)?.let(::add)
val audio = media.audio
when {
audio.any { it.atmos } -> add("Atmos")
audio.any { (it.channels ?: 0) >= 8 } -> add("7.1")
audio.any { (it.channels ?: 0) >= 6 } -> add("5.1")
}
}
}
/** "HEVC 2160p HDR10 · 62.4 ГБ" */
fun fileLine(media: MediaInfo?): String? {
media ?: return null
val video = media.video
val format = listOfNotNull(
videoCodec(video?.codec),
video?.height?.let { "${it}p" },
range(video?.range)?.let { if (it == "DV" && video?.dvProfile != null) "DV P${video.dvProfile}" else it },
).joinToString(" ")
val size = media.sizeBytes?.takeIf { it > 0 }?.let { String.format(Locale.US, "%.1f ГБ", it / 1e9) }
return listOfNotNull(format.ifEmpty { null }, size).joinToString(" · ").ifEmpty { null }
}
/** "2 ч 14 мин" */
fun runtime(ms: Long?): String? {
val minutes = ((ms ?: return null) / 60_000).toInt().takeIf { it > 0 } ?: return null
return if (minutes >= 60) "${minutes / 60} ч ${minutes % 60} мин" else "$minutes мин"
}
private fun range(range: VideoRange?): String? = when (range) {
VideoRange.HDR10 -> "HDR10"
VideoRange.HDR10Plus -> "HDR10+"
VideoRange.HLG -> "HLG"
VideoRange.DolbyVision -> "DV"
VideoRange.Other -> "HDR"
VideoRange.SDR, null -> null
}
fun videoCodec(codec: String?): String? = when (codec?.lowercase()) {
null -> null
"hevc", "h265" -> "HEVC"
"h264", "avc" -> "H.264"
"mpeg2video" -> "MPEG-2"
"mpeg4" -> "MPEG-4"
"vc1" -> "VC-1"
else -> codec.uppercase()
}
private fun audioCodec(codec: String?, profile: String?): String? = when (codec?.lowercase()) {
null -> null
"ac3" -> "AC3"
"eac3" -> "E-AC3"
"truehd" -> "TrueHD"
"dts" -> when {
profile == null -> "DTS"
profile.contains("DTS:X") -> "DTS:X"
profile.contains("MA") -> "DTS-HD MA"
profile.contains("HRA") || profile.contains("HD") -> "DTS-HD"
else -> "DTS"
}
"aac" -> "AAC"
"flac" -> "FLAC"
"opus" -> "Opus"
"mp3" -> "MP3"
"pcm_s16le", "pcm_s24le", "pcm_bluray" -> "PCM"
else -> codec.uppercase()
}
private fun channels(n: Int?): String? = when (n) {
null, 0 -> null
1 -> "1.0"
2 -> "2.0"
6 -> "5.1"
8 -> "7.1"
else -> "${n}ch"
}
}

View File

@@ -0,0 +1,55 @@
package ru.stenfredd.sleepyowl.ui.details
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.playback.core.TrackPick
import java.util.UUID
/** Audio and subtitle choices of a file as the movie page and the series tray show them. */
object TrackChoices {
fun audio(media: MediaInfo?): List<TrackOption> =
media?.audio.orEmpty().map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) }
/** "Выкл" first, then the tracks; empty when the file has no subtitles. */
fun subtitles(media: MediaInfo?): List<TrackOption> =
if (media == null || media.subtitles.isEmpty()) {
emptyList()
} else {
listOf(TrackOption("sub/off", null, "Выкл", null)) +
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }
}
/**
* The tracks picked last time for this movie or series ([key]), found in [media]: (audio, subtitle), or null if
* nothing is remembered. Unmatched audio stays null (the file's default is used); a remembered subtitle that this
* file doesn't have falls back to the default too.
*/
suspend fun remembered(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?>? {
val memory = prefs.tracks(key) ?: return null
val audio = memory.audio?.bestOf(media.audio) { TrackPick(it.language, it.name, it.codec) }?.index
val subtitle = when {
memory.subtitleOff -> null
else -> memory.subtitle?.bestOf(media.subtitles) { TrackPick(it.language, it.name, it.codec) }?.index ?: media.defaultSubtitleIndex
}
return audio to subtitle
}
/** Remembered tracks, else the file's defaults: (audio, subtitle). */
suspend fun initial(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?> {
val remembered = remembered(prefs, key, media)
return Pair(
remembered?.first ?: media.defaultAudioIndex ?: media.audio.firstOrNull()?.index,
if (remembered != null) remembered.second else media.defaultSubtitleIndex,
)
}
suspend fun rememberAudio(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
val track = media?.audio?.firstOrNull { it.index == index } ?: return
prefs.rememberAudio(key, TrackPick(track.language, track.name, track.codec))
}
suspend fun rememberSubtitle(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
val pick = media?.subtitles?.firstOrNull { it.index == index }?.let { TrackPick(it.language, it.name, it.codec) }
prefs.rememberSubtitle(key, pick)
}
}

View File

@@ -0,0 +1,116 @@
package ru.stenfredd.sleepyowl.ui.favorites
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.model.plural
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** "Фильмы 3": a library with favourites, or everything ([libraryId] null). */
@Immutable
data class FavoriteFilter(val key: String, val libraryId: UUID?, val label: String, val count: Int)
@Immutable
data class FavoritesState(
val loading: Boolean = true,
val error: String? = null,
/** "7 тайтлов". */
val total: String = "",
val filters: List<FavoriteFilter> = emptyList(),
val selected: UUID? = null,
val items: List<PosterModel> = emptyList(),
)
/**
* Favourites tab: favourite movies and series of every library, filtered by library (the mockup's Всё / Фильмы /
* Сериалы / Аниме are this server's libraries). Fetched per library with its own item type (fast on the server),
* reloaded when watch state or favourites change.
*/
class FavoritesViewModel(
private val library: LibraryRepository,
private val watch: WatchRepository,
private val cache: SessionCache,
private val cards: CardMapper,
) : ViewModel() {
private val _state = MutableStateFlow(FavoritesState())
val state: StateFlow<FavoritesState> = _state.asStateFlow()
private var byLibrary: List<Pair<LibraryView, List<ItemSummary>>> = emptyList()
private val targets = ConcurrentHashMap<String, Screen>()
init {
viewModelScope.launch { load() }
viewModelScope.launch { cache.invalidations.collect { load() } }
}
fun target(key: String): Screen? = targets[key]
fun select(libraryId: UUID?) {
if (libraryId == _state.value.selected) return
viewModelScope.launch { publish(libraryId) }
}
private suspend fun load() {
try {
byLibrary = coroutineScope {
library.views().map { view -> async { view to watch.favorites(view) } }.awaitAll()
}
publish(_state.value.selected)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Favourites failed")
if (_state.value.loading) _state.value = FavoritesState(loading = false, error = e.message ?: e.toString())
}
}
private suspend fun publish(selected: UUID?) {
val withItems = byLibrary.filter { it.second.isNotEmpty() }
// A library whose last favourite was removed has no chip any more: back to everything.
val filter = selected?.takeIf { id -> withItems.any { it.first.id == id } }
val total = withItems.sumOf { it.second.size }
val shown = withItems.filter { filter == null || it.first.id == filter }.flatMap { it.second }.sortedBy { it.name.lowercase() }
val items = withContext(Dispatchers.Default) {
shown.map { item ->
targets[cards.key(ROW, item)] = cards.target(item)
cards.kindPoster(item, ROW).copy(favorite = true)
}
}
_state.update {
FavoritesState(
loading = false,
total = "$total ${plural(total, "тайтл", "тайтла", "тайтлов")}",
filters = listOf(FavoriteFilter(ALL_KEY, null, "Всё", total)) +
withItems.map { (view, list) -> FavoriteFilter("fav/${view.id}", view.id, view.name, list.size) },
selected = filter,
items = items,
)
}
}
companion object {
const val ALL_KEY = "fav/all"
private const val ROW = "fav"
}
}

View File

@@ -0,0 +1,79 @@
package ru.stenfredd.sleepyowl.ui.focus
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
/**
* Remembers which element of a screen had focus and puts focus back there when the user returns
* (Back from a details page): navigation disposes the screen, its state holder keeps [lastKey]
* and the lazy lists' scroll positions, and [Restore] focuses the same card again.
*
* Every focusable element of the screen gets a stable [key] via [Modifier.remembered].
* Only the element whose key matches gets a [FocusRequester], so the cost per card is one focus callback.
*/
@Stable
class ScreenFocus(initialKey: String?) {
var lastKey: String? by mutableStateOf(initialKey)
set
internal val requester = FocusRequester()
/** Focuses the element with [lastKey] (after it has been composed with that key). */
fun requestFocus() {
runCatching { requester.requestFocus() }
}
}
private val ScreenFocusSaver = Saver<ScreenFocus, String>(
save = { it.lastKey.orEmpty() },
restore = { ScreenFocus(it.ifEmpty { null }) },
)
@Composable
fun rememberScreenFocus(): ScreenFocus {
return rememberSaveable(saver = ScreenFocusSaver) { ScreenFocus(null) }
}
/**
* Marks a focusable element with [key]. [isDefault] marks the element to focus on the first visit.
*/
fun Modifier.remembered(focus: ScreenFocus, key: String, isDefault: Boolean = false): Modifier {
val target = focus.lastKey ?: if (isDefault) key else null
return this
.then(if (target == key) Modifier.focusRequester(focus.requester) else Modifier)
.onFocusChanged { if (it.isFocused) focus.lastKey = key }
}
/**
* Focuses the remembered (or default) element once [ready] becomes true, after the content is laid out.
*/
@Composable
fun ScreenFocus.Restore(ready: Boolean) {
LaunchedEffect(ready) {
if (!ready) return@LaunchedEffect
// Two frames: the content may still recompose once (see [ForgetMissing]) before the requester is in place.
withFrameNanos { }
withFrameNanos { }
runCatching { requester.requestFocus() }
}
}
/**
* Forgets the remembered element when it is no longer among [keys] (an item left the list while the screen was in
* the back stack, e.g. removed from favourites), so the default element gets focus instead of none.
*/
@Composable
fun ScreenFocus.ForgetMissing(keys: Set<String>) {
SideEffect { if (lastKey != null && lastKey !in keys) lastKey = null }
}

View File

@@ -0,0 +1,98 @@
package ru.stenfredd.sleepyowl.ui.home
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.HomeContent
import ru.stenfredd.sleepyowl.core.data.HomeRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.FeaturedModel
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.WideModel
import ru.stenfredd.sleepyowl.ui.model.CardMapper
@Immutable
data class HomeRow(val key: String, val title: String, val count: Int?, val items: List<PosterModel>)
@Immutable
data class HomeState(
val loading: Boolean = true,
val error: String? = null,
val featured: FeaturedModel? = null,
val continueItems: List<WideModel> = emptyList(),
val continueCount: Int = 0,
val rows: List<HomeRow> = emptyList(),
)
/** Home screen: [HomeRepository] content (shown at once from the last snapshot, then refreshed) as card models. */
class HomeViewModel(
private val home: HomeRepository,
private val cards: CardMapper,
) : ViewModel() {
private val _state = MutableStateFlow(HomeState())
val state: StateFlow<HomeState> = _state.asStateFlow()
/** Card key → where it leads; replaced with every update. */
private var targets: Map<String, Screen> = emptyMap()
init {
val started = System.nanoTime()
var first = true
viewModelScope.launch {
home.home()
// ~100 cards with their image URLs: built off the main thread.
.map { build(it) }
.flowOn(Dispatchers.Default)
.catch {
Log.w(it, "Home load failed")
_state.value = HomeState(loading = false, error = it.message ?: it.toString())
}
.collect { (state, newTargets) ->
if (first) Log.i("Home shown in %d ms", (System.nanoTime() - started) / 1_000_000)
first = false
targets = newTargets
_state.value = state
}
}
}
fun target(key: String): Screen? = targets[key]
private fun build(content: HomeContent): Pair<HomeState, Map<String, Screen>> {
val newTargets = HashMap<String, Screen>()
fun <T> ItemSummary.card(row: String, map: (ItemSummary, String) -> T): T {
newTargets[cards.key(row, this)] = cards.target(this)
return map(this, row)
}
val rows = buildList {
if (content.latest.isNotEmpty()) add(HomeRow("latest", "Недавно добавлено", null, content.latest.map { it.card("latest", cards::poster) }))
content.libraries.forEach { lib ->
val key = "lib-${lib.library.id}"
if (lib.items.isNotEmpty()) add(HomeRow(key, lib.library.name, lib.total, lib.items.map { it.card(key, cards::poster) }))
}
}
val cont = content.continueWatching
val state = HomeState(
loading = false,
featured = cont.firstOrNull()?.card(CONTINUE, cards::featured),
continueItems = cont.drop(1).take(4).map { it.card(CONTINUE, cards::wide) },
continueCount = cont.size,
rows = rows,
)
return state to newTargets
}
private companion object {
const val CONTINUE = "continue"
}
}

View File

@@ -0,0 +1,40 @@
package ru.stenfredd.sleepyowl.ui.library
import ru.stenfredd.sleepyowl.core.data.model.ItemSort
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* What the library tab was left at, for this run of the app: the selected library of each account and the grid's
* sort and filter of each library. The tab's view model goes away when another tab opens.
*/
class LibraryMemory() {
private val libraries = ConcurrentHashMap<String, UUID>()
private val sorts = ConcurrentHashMap<UUID, Pair<ItemSort, Boolean>>()
fun library(account: String): UUID? = libraries[account]
fun setLibrary(account: String, id: UUID) {
libraries[account] = id
}
/** Sort and "unwatched only" of a library's grid. */
fun sort(library: UUID): Pair<ItemSort, Boolean> = sorts[library] ?: (ItemSort.Name to false)
fun setSort(library: UUID, sort: ItemSort, unwatched: Boolean) {
sorts[library] = sort to unwatched
}
}
/** Sizes (dp) shared by the layout and the image requests. */
object LibraryLayout {
const val TILE_W = 260f
const val TILE_H = 110f
const val TILE_CORNER = 11f
/** Posters of a tile's mosaic: 120×180 px in the mockup. */
const val ART_POSTER_W = 60f
const val ART_POSTER_H = 90f
/** Grid: 6 posters of 220 px with 28 px between them, rows 36 px apart. */
const val GRID_GAP = 14
const val GRID_ROW_GAP = 18
}

View File

@@ -0,0 +1,361 @@
package ru.stenfredd.sleepyowl.ui.library
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
import ru.stenfredd.sleepyowl.core.data.model.ItemSort
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.LibraryType
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.WideModel
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.model.daysAgo
import ru.stenfredd.sleepyowl.ui.model.plural
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** A library tile: name, "158 фильмов · 3 новинки" (once counted) and up to three poster URLs for its mosaic. */
@Immutable
data class LibraryTile(val key: String, val id: UUID, val name: String, val stats: String?, val dot: Color, val art: List<String>)
@Immutable
data class SortChip(val key: String, val sort: ItemSort, val label: String)
/** What the screen shows under the tiles for one library. */
@Immutable
data class LibraryContent(
val libraryId: UUID,
val continueItems: List<WideModel>,
val latest: List<PosterModel>,
val history: List<PosterModel>,
/** "Все фильмы", "Все сериалы". */
val allTitle: String,
val allCount: Int?,
val sort: ItemSort,
val unwatched: Boolean,
val grid: List<PosterModel>,
/** The grid holds every matching item; false while more pages remain or the first one is loading. */
val gridComplete: Boolean,
)
@Immutable
data class LibraryState(
val loading: Boolean = true,
val error: String? = null,
val tiles: List<LibraryTile> = emptyList(),
/** The library whose content is shown; follows focus on the tiles with a short delay. */
val selected: UUID? = null,
val content: LibraryContent? = null,
val contentError: String? = null,
)
/**
* The library tab: tiles of the user's libraries and, for the selected one, its continue watching, newest items,
* watch history and the whole library as a grid (loaded page by page, sorted and filtered).
*
* Content of every library visited is kept, so moving between tiles shows it at once and refreshes it in the background.
*/
class LibraryViewModel(
private val session: JellyfinSession,
private val library: LibraryRepository,
private val watch: WatchRepository,
private val cache: SessionCache,
private val cards: CardMapper,
private val memory: LibraryMemory,
) : ViewModel() {
private val _state = MutableStateFlow(LibraryState())
val state: StateFlow<LibraryState> = _state.asStateFlow()
val sortChips = listOf(
SortChip("sort/name", ItemSort.Name, "А–Я"),
SortChip("sort/added", ItemSort.DateAdded, "Добавлено"),
SortChip("sort/year", ItemSort.Year, "Год"),
SortChip("sort/rating", ItemSort.Rating, "Рейтинг"),
)
/** The loaded part of a library's grid. */
private class Grid(val sort: ItemSort, val unwatched: Boolean, val items: List<ItemSummary>, val complete: Boolean)
private val account = session.requireSession().accountId
private var views: List<LibraryView> = emptyList()
private val contents = HashMap<UUID, LibraryContent>()
private val grids = HashMap<UUID, Grid>()
/** Card key → where it leads, for every library loaded. */
private val targets = ConcurrentHashMap<String, Screen>()
private var selectJob: Job? = null
private var loadJob: Job? = null
private var pageJob: Job? = null
init {
viewModelScope.launch { loadViews() }
viewModelScope.launch { cache.invalidations.collect { _state.value.selected?.let { load(it, refresh = true) } } }
}
fun target(key: String): Screen? = targets[key]
/** Focus moved to a tile: show that library once focus stays there a moment (fast moves skip libraries). */
fun focusLibrary(id: UUID) {
selectJob?.cancel()
if (id == _state.value.selected) return
selectJob = viewModelScope.launch {
delay(SELECT_DELAY_MS)
select(id)
}
}
fun setSort(sort: ItemSort) {
val content = _state.value.content ?: return
if (content.sort != sort) restartGrid(content.libraryId, sort, content.unwatched)
}
fun toggleUnwatched() {
val content = _state.value.content ?: return
restartGrid(content.libraryId, content.sort, !content.unwatched)
}
/** The grid shows its last cards: fetch the next page. */
fun loadMore() {
val id = _state.value.selected ?: return
val grid = grids[id] ?: return
if (grid.complete || pageJob?.isActive == true) return
pageJob = viewModelScope.launch {
try {
val page = fetchGrid(id, grid.sort, grid.unwatched, grid.items.size, PAGE)
if (grids[id] !== grid) return@launch
grids[id] = Grid(grid.sort, grid.unwatched, grid.items + page, page.size < PAGE)
publishGrid(id)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Library page failed")
}
}
}
private suspend fun loadViews() {
try {
views = library.views()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Libraries failed")
_state.value = LibraryState(loading = false, error = e.message ?: e.toString())
return
}
val selected = memory.library(account)?.takeIf { id -> views.any { it.id == id } } ?: views.firstOrNull()?.id
_state.value = LibraryState(
loading = false,
tiles = views.mapIndexed { i, v -> LibraryTile(tileKey(v.id), v.id, v.name, null, TILE_DOTS[i % TILE_DOTS.size], emptyList()) },
selected = selected,
)
selected?.let { load(it, refresh = false) }
views.forEach { view -> viewModelScope.launch { tileStats(view) } }
}
private fun select(id: UUID) {
memory.setLibrary(account, id)
_state.update { it.copy(selected = id, content = contents[id], contentError = null) }
load(id, refresh = false)
}
/** Count and newest items of a library for its tile. Both are shared with home through the cache. */
private suspend fun tileStats(view: LibraryView) = coroutineScope {
val total = async { runCatching { library.count(countQuery(view.id, unwatched = false)) }.getOrNull() }
val latest = async { runCatching { library.latest(view.id) }.getOrNull().orEmpty() }
val newCount = latest.await().count(cards::isNew)
val stats = total.await()?.let { n ->
val unit = when (view.type) {
LibraryType.Movies -> plural(n, "фильм", "фильма", "фильмов")
LibraryType.Shows -> plural(n, "сериал", "сериала", "сериалов")
else -> plural(n, "тайтл", "тайтла", "тайтлов")
}
"$n $unit" + if (newCount > 0) " · $newCount ${plural(newCount, "новинка", "новинки", "новинок")}" else ""
}
val art = latest.await().take(3).mapNotNull { item ->
val ref = (if (item.kind == ItemKind.Episode) item.seriesPrimary else null) ?: item.primary
cards.image(ref, LibraryLayout.ART_POSTER_W, LibraryLayout.ART_POSTER_H)?.url
}
_state.update { s -> s.copy(tiles = s.tiles.map { if (it.id == view.id) it.copy(stats = stats, art = art) else it }) }
}
/**
* Loads the rows and the first page(s) of the grid of library [id]. [refresh] (watch state changed) keeps the
* grid's sort and reloads as many items as were loaded, so the focused card stays.
*/
private fun load(id: UUID, refresh: Boolean) {
loadJob?.cancel()
loadJob = viewModelScope.launch {
val started = monotonicMs()
val view = views.firstOrNull { it.id == id } ?: return@launch
val (sort, unwatched) = grids[id]?.let { it.sort to it.unwatched } ?: memory.sort(id)
val loaded = if (refresh) grids[id]?.items?.size ?: 0 else 0
try {
coroutineScope {
val cont = async { watch.continueWatching(ROW_LIMIT, id) }
val latest = async { library.latest(id) }
val history = async { watch.history(0, HISTORY_FETCH, id).items }
val count = async { runCatching { library.count(countQuery(id, unwatched)) }.getOrNull() }
val limit = maxOf(PAGE, loaded)
val grid = fetchGrid(id, sort, unwatched, 0, limit)
grids[id] = Grid(sort, unwatched, grid, grid.size < limit)
val content = withContext(Dispatchers.Default) {
build(view, sort, unwatched, cont.await(), latest.await(), history.await(), grids[id]!!, count.await())
}
contents[id] = content
if (_state.value.selected == id) _state.update { it.copy(content = content, contentError = null) }
}
Log.i("Library loaded in %d ms", monotonicMs() - started)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Library content failed")
if (_state.value.selected == id && contents[id] == null) _state.update { it.copy(contentError = e.message ?: e.toString()) }
}
}
}
private fun restartGrid(id: UUID, sort: ItemSort, unwatched: Boolean) {
memory.setSort(id, sort, unwatched)
pageJob?.cancel()
val grid = Grid(sort, unwatched, emptyList(), complete = false)
grids[id] = grid
// The chips switch at once; the old cards stay until the new ones come (~0.1 s): an emptied grid would
// shorten the screen and make it jump.
contents[id]?.let { contents[id] = it.copy(sort = sort, unwatched = unwatched) }
_state.update { it.copy(content = contents[id]) }
pageJob = viewModelScope.launch {
try {
coroutineScope {
launch {
val count = runCatching { library.count(countQuery(id, unwatched)) }.getOrNull() ?: return@launch
val content = contents[id]?.takeIf { it.sort == sort && it.unwatched == unwatched } ?: return@launch
contents[id] = content.copy(allCount = count)
if (_state.value.selected == id) _state.update { it.copy(content = contents[id]) }
}
val page = fetchGrid(id, sort, unwatched, 0, PAGE)
if (grids[id] !== grid) return@coroutineScope
grids[id] = Grid(sort, unwatched, page, page.size < PAGE)
publishGrid(id)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Library grid failed")
}
}
}
private suspend fun publishGrid(id: UUID) {
val grid = grids[id] ?: return
val posters = withContext(Dispatchers.Default) { grid.items.map { gridPoster(id, it) } }
val content = contents[id] ?: return
val updated = content.copy(grid = posters, gridComplete = grid.complete)
contents[id] = updated
if (_state.value.selected == id) _state.update { it.copy(content = updated) }
}
private suspend fun fetchGrid(id: UUID, sort: ItemSort, unwatched: Boolean, start: Int, limit: Int): List<ItemSummary> =
// Without the total: counting costs the server ~0.2 s per request, the count comes from [LibraryRepository.count].
library.items(gridQuery(id, sort, unwatched), start, limit, withTotal = false).items
private fun gridQuery(id: UUID, sort: ItemSort, unwatched: Boolean) = ItemQuery(
parentId = id,
kinds = kinds(id),
sort = sort,
descending = sort != ItemSort.Name,
unplayedOnly = unwatched,
)
/** Same query as home's library count, so it is shared through the cache. */
private fun countQuery(id: UUID, unwatched: Boolean) = ItemQuery(parentId = id, kinds = kinds(id), unplayedOnly = unwatched)
private fun kinds(id: UUID) = views.firstOrNull { it.id == id }?.itemKinds ?: ItemQuery().kinds
private fun build(
view: LibraryView,
sort: ItemSort,
unwatched: Boolean,
cont: List<ItemSummary>,
latest: List<ItemSummary>,
history: List<ItemSummary>,
grid: Grid,
count: Int?,
): LibraryContent {
val id = view.id
fun row(name: String) = "$id/$name"
return LibraryContent(
libraryId = id,
continueItems = cont.map { item -> remember(row("cont"), item) { cards.wide(item, row("cont")) } },
latest = latest.map { item -> remember(row("new"), item) { cards.poster(item, row("new")) } },
// An episode stands for its series: one card per series, at its latest watched episode.
history = history.distinctBy { it.seriesId ?: it.id }.take(ROW_LIMIT).map { item ->
remember(row("hist"), item) { cards.poster(item, row("hist")).copy(caption = item.user.lastPlayedAt?.let(::daysAgo)) }
},
allTitle = when (view.type) {
LibraryType.Movies -> "Все фильмы"
LibraryType.Shows -> "Все сериалы"
else -> "Всё"
},
allCount = count,
sort = sort,
unwatched = unwatched,
grid = grid.items.map { gridPoster(id, it) },
gridComplete = grid.complete,
)
}
private fun gridPoster(id: UUID, item: ItemSummary): PosterModel {
val row = "$id/all"
return remember(row, item) {
val poster = cards.poster(item, row)
if (item.kind != ItemKind.Series) return@remember poster
// A series: "3 сезона · ★ 8.2" instead of the year.
val seasons = item.childCount?.takeIf { it > 0 }?.let { "$it ${plural(it, "сезон", "сезона", "сезонов")}" }
poster.copy(caption = listOfNotNull(seasons, item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null })
}
}
private inline fun <T> remember(row: String, item: ItemSummary, card: () -> T): T {
targets[cards.key(row, item)] = cards.target(item)
return card()
}
companion object {
fun tileKey(id: UUID) = "tile/$id"
const val UNWATCHED_KEY = "filter/unwatched"
private const val PAGE = 60
private const val ROW_LIMIT = 20
/** History comes as episodes; fetched with room for several episodes of one series. */
private const val HISTORY_FETCH = 60
private const val SELECT_DELAY_MS = 300L
/** Tile dots in the mockup's order: blue, lilac, pink. */
private val TILE_DOTS = listOf(AppColors.Blue, Color(0xFFB39DDB), AppColors.Pink)
}
}

View File

@@ -0,0 +1,430 @@
package ru.stenfredd.sleepyowl.ui.login
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.jellyfin.Account
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.core.jellyfin.PublicUser
import ru.stenfredd.sleepyowl.core.jellyfin.QuickConnectExpiredException
import ru.stenfredd.sleepyowl.core.jellyfin.Server
import ru.stenfredd.sleepyowl.core.jellyfin.ServerLogin
import ru.stenfredd.sleepyowl.core.jellyfin.ServerNotFoundException
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
enum class LoginStep { Server, Profile, Login }
@Immutable
data class ServerCardModel(
val key: String,
val name: String,
val address: String,
/** "LAN" when found on the local network, "ИНТЕРНЕТ" for a saved or typed address. */
val where: String,
val ping: String?,
val version: String?,
val busy: Boolean = false,
)
@Immutable
data class ProfileModel(val key: String, val name: String, val note: String, val avatarSeed: String)
@Immutable
sealed interface QuickConnectState {
data object Loading : QuickConnectState
data object Disabled : QuickConnectState
data class Failed(val message: String) : QuickConnectState
data class Code(val code: String, val qr: ImageBitmap, val expiresAt: Long) : QuickConnectState
}
@Immutable
data class LoginState(
val step: LoginStep,
val servers: List<ServerCardModel> = emptyList(),
val searching: Boolean = false,
/** A search has finished at least once. */
val searched: Boolean = false,
val manualOpen: Boolean = false,
val manualBusy: Boolean = false,
val manualError: String? = null,
val serverLine: String = "",
val profiles: List<ProfileModel> = emptyList(),
val profileError: String? = null,
/** Name to put into the login form when a listed user needs a password. */
val prefillUser: String = "",
val loginBusy: Boolean = false,
val loginError: String? = null,
val quickConnect: QuickConnectState = QuickConnectState.Loading,
/** Logged in: the screen leaves to the app. */
val done: Boolean = false,
)
/**
* Login flow from the mockup: server (found on the LAN, saved, or typed) → profile ("Кто смотрит?": accounts saved on
* this TV and the server's public users) → login (Quick Connect code with QR, or name and password).
* Opened from the app's "сменить профиль" it starts at the profile step of the current server.
*/
class LoginViewModel(
private val session: JellyfinSession,
private val login: ServerLogin,
) : ViewModel() {
private val history = ArrayDeque<LoginStep>()
private val _state: MutableStateFlow<LoginState>
val state: StateFlow<LoginState>
/** Remembered focus per step, so Back returns to the card that was chosen. */
val focus: Map<LoginStep, ScreenFocus> = LoginStep.entries.associateWith { ScreenFocus(null) }
private var discovered: List<Server> = emptyList()
private var probed: Map<String, Server> = emptyMap()
private var typed: Server? = null
private var server: Server? = null
private var publicUsers: List<PublicUser> = emptyList()
private var stepJob: Job? = null
init {
val accounts = session.accounts.value
val current = session.session.value?.let { s -> accounts.firstOrNull { it.id == s.accountId } } ?: accounts.firstOrNull()
_state = MutableStateFlow(LoginState(step = LoginStep.Server))
state = _state.asStateFlow()
history += LoginStep.Server
if (current != null) {
// Straight to "Кто смотрит?" for the server used last; Back goes to the server list.
val saved = current.toServer()
server = saved
history += LoginStep.Profile
_state.update { it.copy(step = LoginStep.Profile, serverLine = serverLine(saved), profiles = profiles(saved)) }
viewModelScope.launch { loadPublicUsers(saved) }
// Accounts moved over from the prototype have no server name yet.
if (saved.name.isEmpty()) viewModelScope.launch {
login.probe(saved.requestUrl)?.let { info ->
if (server == saved) _state.update { it.copy(serverLine = serverLine(saved.copy(name = info.name))) }
}
}
}
enterStep()
}
/** Back inside the flow; false on the first step (the screen itself closes). */
fun back(): Boolean {
if (history.size <= 1 || _state.value.loginBusy) return false
history.removeLast()
_state.update { it.copy(step = history.last(), loginError = null, manualError = null) }
enterStep()
return true
}
// ---- Server step ----
fun openManual() {
_state.update { it.copy(manualOpen = true, manualError = null) }
}
fun connectManual(address: String) {
if (_state.value.manualBusy) return
_state.update { it.copy(manualBusy = true, manualError = null) }
viewModelScope.launch {
try {
val found = login.find(address)
typed = found
publishServers()
pick(found)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Server not found at %s", address)
_state.update {
it.copy(manualError = if (e is ServerNotFoundException) "Сервер не найден. Проверьте адрес." else describe(e))
}
} finally {
_state.update { it.copy(manualBusy = false) }
}
}
}
fun pickServer(key: String) {
val target = servers().firstOrNull { cardKey(it) == key } ?: return
if (_state.value.servers.any { it.busy }) return
viewModelScope.launch { pick(target) }
}
private suspend fun pick(target: Server) {
server = target
markBusy(cardKey(target))
loadPublicUsers(target)
markBusy(null)
val profiles = profiles(target)
_state.update { it.copy(serverLine = serverLine(target), profiles = profiles, prefillUser = "") }
// Nothing to choose from (users hidden, none saved): straight to the login form.
if (profiles.size == 1) go(LoginStep.Login) else go(LoginStep.Profile)
}
private fun markBusy(key: String?) {
_state.update { s -> s.copy(servers = s.servers.map { it.copy(busy = it.key == key) }) }
}
// ---- Profile step ----
fun pickProfile(key: String) {
val target = server ?: return
when {
key == OTHER_KEY -> {
_state.update { it.copy(prefillUser = "") }
go(LoginStep.Login)
}
key.startsWith(ACCOUNT_PREFIX) -> viewModelScope.launch {
runCatching { session.switchTo(key.removePrefix(ACCOUNT_PREFIX)) }
.onSuccess { _state.update { it.copy(done = true) } }
.onFailure { e -> _state.update { it.copy(profileError = describe(e)) } }
}
key.startsWith(USER_PREFIX) -> {
val user = publicUsers.firstOrNull { it.id.toString() == key.removePrefix(USER_PREFIX) } ?: return
_state.update { it.copy(prefillUser = user.name) }
focus.getValue(LoginStep.Login).lastKey = if (user.hasPassword) PASSWORD_KEY else null
if (user.hasPassword) {
go(LoginStep.Login)
} else {
viewModelScope.launch {
runCatching { login.login(target, user.name, "") }
.onSuccess { _state.update { it.copy(done = true) } }
.onFailure { go(LoginStep.Login) }
}
}
}
}
}
// ---- Login step ----
fun loginWithPassword(userName: String, password: String) {
val target = server ?: return
if (_state.value.loginBusy) return
if (userName.isBlank()) {
_state.update { it.copy(loginError = "Введите имя пользователя") }
return
}
_state.update { it.copy(loginBusy = true, loginError = null) }
viewModelScope.launch {
try {
login.login(target, userName, password)
_state.update { it.copy(done = true) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Login failed")
_state.update { it.copy(loginError = describe(e)) }
} finally {
_state.update { it.copy(loginBusy = false) }
}
}
}
private suspend fun runQuickConnect(target: Server) {
while (true) {
_state.update { it.copy(quickConnect = QuickConnectState.Loading) }
val code = try {
login.startQuickConnect(target)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Quick Connect not started")
_state.update { it.copy(quickConnect = QuickConnectState.Failed(describe(e))) }
delay(RETRY_MS)
continue
}
if (code == null) {
_state.update { it.copy(quickConnect = QuickConnectState.Disabled) }
return
}
val qr = withContext(Dispatchers.Default) { qrBitmap(quickConnectUrl(target, code.code)) }
val expiresAt = monotonicMs() + QUICK_CONNECT_LIFETIME_MS
_state.update { it.copy(quickConnect = QuickConnectState.Code(formatCode(code.code), qr, expiresAt)) }
// Poll until approved; the server drops the request after its lifetime, then a new code is shown.
while (true) {
delay(POLL_MS)
val approved = try {
login.isQuickConnectApproved(target, code.secret)
} catch (e: QuickConnectExpiredException) {
break
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.d(e, "Quick Connect state not read")
if (monotonicMs() > expiresAt) break else continue
}
if (approved) {
try {
login.loginQuickConnect(target, code.secret)
_state.update { it.copy(done = true) }
return
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Quick Connect login failed")
_state.update { it.copy(loginError = describe(e)) }
break
}
}
}
}
}
// ---- Steps ----
private fun go(step: LoginStep) {
if (history.last() == step) return
history += step
if (step == LoginStep.Login && _state.value.prefillUser.isEmpty()) focus.getValue(LoginStep.Login).lastKey = null
_state.update { it.copy(step = step, loginError = null, profileError = null) }
enterStep()
}
/** Starts the background work of the current step: LAN search, or the Quick Connect code. */
private fun enterStep() {
stepJob?.cancel()
stepJob = when (_state.value.step) {
LoginStep.Server -> viewModelScope.launch { searchLoop() }
LoginStep.Profile -> null
LoginStep.Login -> server?.let { target -> viewModelScope.launch { runQuickConnect(target) } }
}
}
private suspend fun searchLoop() = coroutineScope {
publishServers()
// Saved servers that weren't found on the LAN: their ping and version through the saved address.
launch {
for (s in savedServers().filter { it.id !in probed }) {
login.probe(s.url)?.let { probed = probed + (s.id to it.copy(id = s.id, name = s.name.ifEmpty { it.name })) }
}
publishServers()
}
while (isActive) {
_state.update { it.copy(searching = true) }
discovered = login.discover()
_state.update { it.copy(searching = false, searched = true) }
publishServers()
delay(SEARCH_INTERVAL_MS)
}
}
private suspend fun loadPublicUsers(target: Server) {
publicUsers = try {
login.publicUsers(target)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.d(e, "Public users not loaded")
emptyList()
}
if (server == target) _state.update { it.copy(profiles = profiles(target)) }
}
// ---- Models ----
/** Discovered servers first, then saved ones that weren't found, then a typed one. */
private fun servers(): List<Server> {
val byId = LinkedHashMap<String, Server>()
discovered.forEach { byId[it.id] = it }
savedServers().forEach { saved -> if (saved.id !in byId) byId[saved.id] = probed[saved.id]?.copy(url = saved.url) ?: saved }
typed?.let { if (it.id !in byId) byId[it.id] = it }
return byId.values.toList()
}
private fun savedServers(): List<Server> = session.accounts.value.distinctBy { it.serverId }.map { it.toServer() }
private fun publishServers() {
val busy = _state.value.servers.firstOrNull { it.busy }?.key
_state.update { s ->
s.copy(servers = servers().map { srv ->
val key = cardKey(srv)
ServerCardModel(
key = key,
name = srv.name.ifEmpty { "Jellyfin" },
address = displayAddress(srv.localUrl ?: srv.url),
where = if (srv.localUrl != null && discovered.any { it.id == srv.id }) "LAN" else "ИНТЕРНЕТ",
ping = srv.pingMs?.let { "$it мс" },
version = srv.version,
busy = key == busy,
)
})
}
}
private fun profiles(target: Server): List<ProfileModel> {
val currentId = session.session.value?.accountId
val saved = session.accounts.value.filter { it.serverId == target.id }
val savedUsers = saved.map { it.userId }.toSet()
return saved.map { a ->
ProfileModel(ACCOUNT_PREFIX + a.id, a.userName, if (a.id == currentId) "сейчас" else lastUsed(a.lastUsedAt), a.userId.toString())
} + publicUsers.filter { it.id !in savedUsers }.map { u ->
ProfileModel(USER_PREFIX + u.id, u.name, if (u.hasPassword) "пароль" else "без пароля", u.id.toString())
} + ProfileModel(OTHER_KEY, "Другой", "QR или пароль", "")
}
private fun Account.toServer() = Server(serverId, serverName, serverUrl, localUrl)
private fun cardKey(s: Server) = "srv-" + s.id
private fun serverLine(s: Server) = listOf(s.name, displayAddress(s.url)).filter { it.isNotEmpty() }.joinToString(" · ")
private fun displayAddress(url: String) = url.removePrefix("http://").removePrefix("https://")
private fun lastUsed(at: Long): String {
if (at <= 0) return ""
val zone = ZoneId.systemDefault()
val days = ChronoUnit.DAYS.between(java.time.Instant.ofEpochMilli(at).atZone(zone).toLocalDate(), LocalDate.now(zone))
return when {
days <= 0 -> "сегодня"
days == 1L -> "вчера"
days in 2..4 -> "$days дня назад"
days < 30 -> "$days дней назад"
else -> "давно"
}
}
/** The phone opens the server's web app on its Quick Connect page with the code filled in. */
private fun quickConnectUrl(target: Server, code: String) = "${target.url}/web/#/quickconnect?code=$code"
private fun formatCode(code: String) = if (code.length == 6) code.substring(0, 3) + " " + code.substring(3) else code
private fun describe(e: Throwable): String {
val status = generateSequence(e) { it.cause }.filterIsInstance<InvalidStatusException>().firstOrNull()?.status
return when (status) {
401 -> "Неверное имя пользователя или пароль"
403 -> "Вход запрещён для этого пользователя"
null -> "Нет связи с сервером"
else -> "Сервер ответил ошибкой $status"
}
}
companion object {
const val OTHER_KEY = "other"
const val PASSWORD_KEY = "password"
private const val ACCOUNT_PREFIX = "acc-"
private const val USER_PREFIX = "usr-"
private const val SEARCH_INTERVAL_MS = 8_000L
private const val POLL_MS = 3_000L
private const val RETRY_MS = 5_000L
/** Jellyfin forgets a Quick Connect request after 10 minutes. */
private const val QUICK_CONNECT_LIFETIME_MS = 10 * 60_000L
}
}

View File

@@ -0,0 +1,24 @@
package ru.stenfredd.sleepyowl.ui.login
import androidx.compose.ui.graphics.ImageBitmap
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import ru.stenfredd.sleepyowl.ui.components.argbImageBitmap
/**
* QR code of [text], one pixel per module, no quiet zone (the white card around it is the margin).
* Drawn scaled up without filtering, so the edges stay sharp.
*/
fun qrBitmap(text: String): ImageBitmap {
val hints = mapOf(EncodeHintType.MARGIN to 0, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M)
val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, hints)
val pixels = IntArray(matrix.width * matrix.height) { i ->
if (matrix.get(i % matrix.width, i / matrix.width)) QR_DARK else QR_LIGHT
}
return argbImageBitmap(pixels, matrix.width, matrix.height)
}
private const val QR_DARK = 0xFF0B0C12.toInt()
private const val QR_LIGHT = 0xFFF3F1FF.toInt()

View File

@@ -0,0 +1,147 @@
package ru.stenfredd.sleepyowl.ui.menu
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.lifecycle.ViewModel
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.model.plural
import java.util.UUID
import kotlin.math.roundToInt
/** What the watched menu marks: a movie, an episode, or every episode of a season or a series. */
@Immutable
data class WatchTarget(
val id: UUID,
val kind: ItemKind,
/** The movie or series name. */
val title: String,
/** Which part of the series: "S1 · E4 · Пилот", "Сезон 2"; the year of a movie. */
val subtitle: String? = null,
/** Watched; of a season or series — every episode. */
val played: Boolean,
/** A started movie or episode, 0..1. */
val progress: Float? = null,
/** Episodes of a season or series and how many are watched: known on the series page. */
val episodes: Int? = null,
val watchedEpisodes: Int? = null,
/** Some episode of a season or series is started (has a place to continue from). */
val started: Boolean = false,
/** Unwatched episodes of a season or series, from a card (the server doesn't tell watched ones there). */
val unplayed: Int? = null,
) {
val isGroup: Boolean get() = kind == ItemKind.Series || kind == ItemKind.Season
}
/** Opens the watched menu; provided by [WatchMenuHost] above every screen. */
@Stable
class WatchMenu {
/** The opening being shown, or null; the host clears it when the menu closes. */
var shown: Shown? by mutableStateOf(null)
/** Shows the menu for [target]; focus goes back to [returnTo] when it closes. */
fun open(target: WatchTarget, returnTo: FocusRequester) {
shown = Shown(target, returnTo)
}
/** One opening of the menu (a class: a reply that comes late must not close the next one). */
class Shown(val target: WatchTarget, val returnTo: FocusRequester)
}
val LocalWatchMenu = staticCompositionLocalOf<WatchMenu?> { null }
/**
* Click of a card or chip that also opens the watched menu for [target]: OK held down (a long press), or the remote's
* Menu key where it has one. Without a target it's a plain click. Put it where `clickable` would go.
*/
@Composable
fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modifier {
val menu = LocalWatchMenu.current
if (target == null || menu == null) return clickable(interactionSource = null, indication = null, onClick = onClick)
val self = remember { FocusRequester() }
val open = remember(menu, target) { { menu.open(target, self) } }
return this
.focusRequester(self)
.onKeyEvent { e ->
(e.key == Key.Menu).also { if (it && e.type == KeyEventType.KeyUp) open() }
}
.combinedClickable(interactionSource = null, indication = null, onLongClick = open, onClick = onClick)
}
class WatchMenuViewModel(private val watch: WatchRepository) : ViewModel() {
/** Screens reload on their own when it's done (the cache tells them the user data changed). */
suspend fun setPlayed(id: UUID, played: Boolean) = watch.setPlayed(id, played)
}
/** One action of the watched menu: mark watched ([played]) or unwatched, and what it touches ([detail]). */
class MenuAction(val played: Boolean, val icon: AppIcon, val label: String, val detail: String?)
/** "СЕРИЯ", "СЕЗОН", "СЕРИАЛ", "ФИЛЬМ": the menu's title. */
fun WatchTarget.kindLabel(): String = when (kind) {
ItemKind.Episode -> "СЕРИЯ"
ItemKind.Season -> "СЕЗОН"
ItemKind.Series -> "СЕРИАЛ"
else -> "ФИЛЬМ"
}
private fun episodesWord(n: Int) = plural(n, "серия", "серии", "серий")
/** "Просмотрено 12 из 62 серий", "Просмотрено 42%", "Не просмотрен". */
fun WatchTarget.status(): String? {
val feminine = kind == ItemKind.Episode
if (!isGroup) {
return when {
played -> if (feminine) "Просмотрена" else "Просмотрен"
progress != null -> "Просмотрено ${(progress * 100).roundToInt()}%"
else -> if (feminine) "Не просмотрена" else "Не просмотрен"
}
}
return when {
played -> "Все серии просмотрены"
episodes != null && watchedEpisodes != null -> when (watchedEpisodes) {
0 -> "Ни одной серии не просмотрено"
else -> "Просмотрено $watchedEpisodes из $episodes ${plural(episodes, "серии", "серий", "серий")}"
}
unplayed != null && unplayed > 0 -> "Не просмотрено: $unplayed ${episodesWord(unplayed)}"
else -> null
}
}
/**
* Only what changes something: "watched" unless it already is, "unwatched" if anything is watched or started.
* A card doesn't tell whether some episodes of a series are watched, so it offers both.
*/
fun WatchTarget.actions(): List<MenuAction> = buildList {
if (!isGroup) {
val ending = if (kind == ItemKind.Episode) "ой" else "ым"
if (!played) add(MenuAction(true, AppIcon.Check, "Отметить просмотренн$ending", null))
if (played || progress != null) {
add(MenuAction(false, AppIcon.Replay, "Отметить непросмотренн$ending", if (progress != null) "Место остановки сбросится" else null))
}
return@buildList
}
val noun = if (kind == ItemKind.Season) "сезон" else "сериал"
if (!played) {
add(MenuAction(true, AppIcon.Check, "Отметить весь $noun просмотренным", episodes?.let { "$it ${episodesWord(it)}" } ?: "Все серии"))
}
if (played || started || watchedEpisodes == null || watchedEpisodes > 0) {
add(MenuAction(false, AppIcon.Replay, "Отметить весь $noun непросмотренным", "Отметки и места остановки всех серий сбросятся"))
}
}

View File

@@ -0,0 +1,163 @@
package ru.stenfredd.sleepyowl.ui.model
import ru.stenfredd.sleepyowl.core.data.ImageUrls
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.UiPlatform
import ru.stenfredd.sleepyowl.ui.components.FeaturedModel
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.components.WideModel
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import kotlin.math.roundToInt
/**
* Turns server items into card models. Image URLs ask the server for exactly the card's size in pixels.
* Card keys are "<row>/<item id>": the same item can appear in several rows, and focus is remembered per key.
*/
class CardMapper(
private val images: ImageUrls,
platform: UiPlatform,
) {
private val density = platform.density
fun px(dp: Float) = (dp * density).roundToInt()
fun key(row: String, item: ItemSummary) = "$row/${item.id}"
fun poster(item: ItemSummary, row: String): PosterModel {
// A single new episode comes as the episode itself: show it as its series, poster included.
val episode = item.kind == ItemKind.Episode
return PosterModel(
key = key(row, item),
title = (if (episode) item.seriesName else null) ?: item.name,
caption = if (episode) {
listOfNotNull(item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" · ").ifEmpty { null }
} else {
listOfNotNull(item.year?.toString(), item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null }
},
image = image(if (episode) item.seriesPrimary ?: item.primary else item.primary, AppDimens.POSTER_W.toFloat(), AppDimens.POSTER_H.toFloat()),
badge = item.formatBadge(),
progress = item.user.progress,
isNew = isNew(item),
watched = item.user.played,
watch = watchTarget(item),
)
}
/** Poster captioned "Фильм · 2021" / "Сериал · 2020": lists that mix movies and series. */
fun kindPoster(item: ItemSummary, row: String): PosterModel = poster(item, row).copy(
caption = listOfNotNull(
when (item.kind) {
ItemKind.Movie -> "Фильм"
ItemKind.Series -> "Сериал"
else -> null
},
item.year?.toString(),
).joinToString(" · ").ifEmpty { null },
)
fun wide(item: ItemSummary, row: String): WideModel = WideModel(
key = key(row, item),
title = item.seriesName ?: item.name,
subtitle = listOfNotNull(item.episodeLabel() ?: item.year?.toString(), item.remaining()).joinToString(" · "),
image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()),
progress = item.user.progress,
watch = watchTarget(item),
)
fun featured(item: ItemSummary, row: String): FeaturedModel {
val isEpisode = item.kind == ItemKind.Episode
return FeaturedModel(
key = key(row, item),
label = if (isEpisode) "СЕРИАЛ" else "ФИЛЬМ",
title = item.seriesName ?: item.name,
subtitle = if (isEpisode) {
item.episodeLabel().orEmpty()
} else {
listOfNotNull(item.year?.toString(), item.genres.take(2).joinToString(", ").ifEmpty { null }).joinToString(" · ")
},
description = item.overview,
image = image(item.backdrop ?: item.landscape(), FEATURED_W, FEATURED_H),
progress = item.user.progress ?: 0f,
remaining = item.remaining(),
watch = watchTarget(item),
)
}
/**
* Where a card leads: a movie to its page, a series, season or episode to the series page (on that episode).
* Collections and folders still open the old prototype list until the library screen exists.
*/
fun target(item: ItemSummary): Screen = when (item.kind) {
ItemKind.Series -> Screen.Series(item.id)
ItemKind.Season -> item.seriesId?.let { Screen.Series(it) } ?: Screen.Items(item.id, item.name)
ItemKind.Episode -> item.seriesId?.let { Screen.Series(it, item.id) } ?: Screen.Detail(item.id)
ItemKind.BoxSet, ItemKind.Folder -> Screen.Items(item.id, item.name)
else -> Screen.Detail(item.id)
}
/** What the card's long press marks watched: the item itself (a single new episode too, though it shows its series). */
fun watchTarget(item: ItemSummary): WatchTarget? {
val part = item.kind == ItemKind.Episode || item.kind == ItemKind.Season
return when (item.kind) {
ItemKind.Movie, ItemKind.Video, ItemKind.Episode, ItemKind.Season, ItemKind.Series -> WatchTarget(
id = item.id,
kind = item.kind,
title = (if (part) item.seriesName else null) ?: item.name,
subtitle = when (item.kind) {
ItemKind.Episode -> listOfNotNull(
item.seasonNumber?.let { "S$it" },
item.episodeNumber?.let { "E$it" },
item.name.takeIf { item.seriesName != null },
).joinToString(" · ").ifEmpty { null }
ItemKind.Season -> item.name.takeIf { item.seriesName != null }
else -> item.year?.toString()
},
played = item.user.played,
progress = item.user.progress,
unplayed = item.user.unplayedCount.takeIf { item.kind == ItemKind.Series || item.kind == ItemKind.Season },
)
else -> null
}
}
fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? =
ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) }
private fun ItemSummary.episodeLabel(): String? =
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
/** "Новинка": added within the last 14 days. */
fun isNew(item: ItemSummary): Boolean = item.dateAddedAt?.let { System.currentTimeMillis() - it < NEW_MS } == true
private fun ItemSummary.formatBadge(): String? {
val w = videoWidth ?: return null
return when {
w >= 3200 -> "4K"
w >= 1800 -> "1080p"
w >= 1200 -> "720p"
else -> null
}
}
private companion object {
/** "Новинка": added within the last 14 days. */
const val NEW_MS = 14 * 24 * 3600_000L
const val FEATURED_W = 410f
const val FEATURED_H = 230.5f
}
}
/** Episode still, else the backdrop or thumb of the item or its series. Also the TV home screen's cards (`LauncherFeed`). */
fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
/** "осталось 31 мин" from the item's progress. */
fun ItemSummary.remaining(): String? {
val runtime = runtimeMs ?: return null
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
return "осталось ${left.roundToInt()} мин"
}

View File

@@ -0,0 +1,35 @@
package ru.stenfredd.sleepyowl.ui.model
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
/** Russian plural form for [n]: 1 серия, 2 серии, 5 серий. */
fun plural(n: Int, one: String, few: String, many: String): String {
val mod100 = n % 100
val mod10 = n % 10
return when {
mod100 in 11..14 -> many
mod10 == 1 -> one
mod10 in 2..4 -> few
else -> many
}
}
/** How long ago [at] (epoch ms) was, by calendar days: "сегодня", "вчера", "3 дня назад", "2 недели назад", "в прошлом году"… */
fun daysAgo(at: Long): String {
val zone = ZoneId.systemDefault()
val days = ChronoUnit.DAYS.between(Instant.ofEpochMilli(at).atZone(zone).toLocalDate(), LocalDate.now(zone)).toInt()
return when {
days <= 0 -> "сегодня"
days == 1 -> "вчера"
days < 7 -> "$days ${plural(days, "день", "дня", "дней")} назад"
days < 14 -> "неделю назад"
days < 30 -> "${days / 7} недели назад"
days < 60 -> "месяц назад"
days < 365 -> (days / 30).let { "$it ${plural(it, "месяц", "месяца", "месяцев")} назад" }
days < 730 -> "год назад"
else -> (days / 365).let { "$it ${plural(it, "год", "года", "лет")} назад" }
}
}

View File

@@ -0,0 +1,103 @@
package ru.stenfredd.sleepyowl.ui.person
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.model.plural
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@Immutable
data class PersonState(
val loading: Boolean = true,
val error: String? = null,
val name: String = "",
/** Initials when there is no photo. */
val letters: String = "",
val photo: RemoteImageSpec? = null,
/** "12 фильмов · 3 сериала". */
val summary: String = "",
val items: List<PosterModel> = emptyList(),
)
/** A person's page: who it is and the movies and series of the server they are in, newest first. */
class PersonViewModel(
private val personId: UUID,
private val library: LibraryRepository,
private val cards: CardMapper,
cache: SessionCache,
) : ViewModel() {
private val _state = MutableStateFlow(PersonState())
val state: StateFlow<PersonState> = _state.asStateFlow()
private val targets = ConcurrentHashMap<String, Screen>()
init {
viewModelScope.launch { load() }
viewModelScope.launch { cache.invalidations.collect { load() } }
}
fun target(key: String): Screen? = targets[key]
private suspend fun load() {
try {
coroutineScope {
val person = async { library.person(personId) }
// A guest role in an episode stands for its series, once, unless the series itself is in the list.
val all = library.filmography(personId)
val seriesIds = all.filter { it.kind == ItemKind.Series }.mapTo(HashSet()) { it.id }
val items = all.filter { it.kind != ItemKind.Episode || (it.seriesId != null && it.seriesId !in seriesIds) }
.distinctBy { if (it.kind == ItemKind.Episode) it.seriesId else it.id }
val p = person.await()
val posters = withContext(Dispatchers.Default) {
items.map { item ->
targets[cards.key(ROW, item)] = cards.target(item)
if (item.kind == ItemKind.Episode) cards.poster(item, ROW) else cards.kindPoster(item, ROW)
}
}
val movies = items.count { it.kind == ItemKind.Movie }
val series = items.size - movies
_state.value = PersonState(
loading = false,
name = p.name,
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
photo = cards.image(p.image, PersonLayout.PHOTO, PersonLayout.PHOTO),
summary = listOfNotNull(
movies.takeIf { it > 0 }?.let { "$it ${plural(it, "фильм", "фильма", "фильмов")}" },
series.takeIf { it > 0 }?.let { "$it ${plural(it, "сериал", "сериала", "сериалов")}" },
).joinToString(" · ").ifEmpty { "На сервере нет фильмов с этим человеком" },
items = posters,
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Person failed")
if (_state.value.loading) _state.value = PersonState(loading = false, error = e.message ?: e.toString())
}
}
private companion object {
const val ROW = "person"
}
}
object PersonLayout {
const val PHOTO = 64f
}

View File

@@ -0,0 +1,42 @@
package ru.stenfredd.sleepyowl.ui.player
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.ui.details.TrackOption
/** The player's side panels. */
enum class PlayerPanel { Audio, Subtitles, Quality, Info }
/** One choice in a panel list or chip row; the screen applies the pick. */
@Immutable
data class PanelChoice(val key: String, val label: String, val detail: String?, val selected: Boolean, val swatch: Color? = null)
@Immutable
data class PanelGroup(val key: String, val title: String, val choices: List<PanelChoice>)
/** Size, colour and background choices of text subtitles, for the player's panel and the series tray. */
object SubtitleChoices {
private val colors = listOf(SubtitleStyle.WHITE to "Белый", SubtitleStyle.YELLOW to "Жёлтый", SubtitleStyle.LIGHT_BLUE to "Голубой")
private val backgrounds = listOf(SubtitleStyle.Background.NONE to "Нет", SubtitleStyle.Background.SHADOW to "Тень", SubtitleStyle.Background.BOX to "Плашка")
fun sizes(style: SubtitleStyle) = SubtitleStyle.Size.entries.map { PanelChoice("size/$it", it.name, null, it == style.size) }
fun colors(style: SubtitleStyle) = colors.map { (argb, name) -> PanelChoice("color/$argb", name, null, argb == style.color, Color(argb)) }
fun backgrounds(style: SubtitleStyle) = backgrounds.map { (bg, name) -> PanelChoice("bg/$bg", name, null, bg == style.background) }
/** [style] with [choice] (one of the lists above) applied. */
fun apply(style: SubtitleStyle, choice: PanelChoice): SubtitleStyle {
val value = choice.key.substringAfter('/')
return when (choice.key.substringBefore('/')) {
"size" -> style.copy(size = SubtitleStyle.Size.valueOf(value))
"color" -> style.copy(color = value.toInt())
"bg" -> style.copy(background = SubtitleStyle.Background.valueOf(value))
else -> style
}
}
}
fun List<TrackOption>.toChoices(selected: Int?): List<PanelChoice> =
map { PanelChoice(it.key, it.label, it.detail, it.index == selected) }

View File

@@ -0,0 +1,166 @@
package ru.stenfredd.sleepyowl.ui.player
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import org.jellyfin.sdk.model.api.PlayMethod
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.core.data.model.VideoRange
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
import ru.stenfredd.sleepyowl.ui.details.TrackOption
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import java.util.Locale
/** Audio and subtitle choices of the file being played. Subtitles start with "Выкл" (index null). */
@Immutable
data class PlayerTracks(val audio: List<TrackOption>, val subtitles: List<TrackOption>) {
companion object {
fun of(media: MediaInfo?): PlayerTracks {
media ?: return PlayerTracks(emptyList(), emptyList())
return PlayerTracks(
audio = media.audio.map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) },
subtitles = listOf(TrackOption("sub/off", null, "Выкл", null)) +
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) },
)
}
}
}
/** One line of "Информация о потоке"; [color] is the dot: video blue, audio pink, subtitles yellow, the rest green. */
@Immutable
data class InfoRow(val key: String, val value: String, val color: Color)
/** The verdict at the top of "Информация о потоке". */
@Immutable
data class StreamMode(val direct: Boolean, val title: String, val reason: String)
object PlayerLabels {
/** "Русский 5.1"; a track without a language: its own title, else the format ("AC3 5.1"). */
fun audioChip(media: MediaInfo?, index: Int?): String {
val track = media?.audio?.firstOrNull { it.index == index } ?: return "Аудио"
val name = MediaLabels.language(track.language) ?: track.name ?: return MediaLabels.audioDetail(track) ?: "Аудио"
return listOfNotNull(name, channels(track.channels)).joinToString(" ")
}
/** "Русский" or "Субтитры выкл" */
fun subtitleChip(media: MediaInfo?, index: Int?): String {
val track = media?.subtitles?.firstOrNull { it.index == index } ?: return "Субтитры выкл"
return MediaLabels.language(track.language) ?: track.name ?: "Субтитры"
}
fun isDirect(state: PlaybackUiState) = state.playMethod == null || state.playMethod == PlayMethod.DIRECT_PLAY
fun mode(state: PlaybackUiState): StreamMode {
if (isDirect(state)) return StreamMode(true, "Прямое воспроизведение", "Файл воспроизводится без изменений")
val reasons = state.transcodeReasons?.split(',')?.mapNotNull { reason(it.trim()) }?.distinct().orEmpty()
val why = when {
reasons.isNotEmpty() -> reasons.joinToString(", ")
state.maxBitrate != null -> "ограничение качества"
else -> "выбрано вручную"
}
return StreamMode(false, "Транскодирование", "Причина: $why")
}
/** "ExoPlayer · видеослой ТВ", "VLC · вручную"; the DV decoder is only mentioned for a Dolby Vision file. */
fun engine(state: PlaybackUiState, engine: PlaybackEngine?, dolbyVisionFile: Boolean): String {
val name = when (val id = state.engine?.value) {
"exo" -> "ExoPlayer"
"vlc" -> "VLC"
null -> "—"
else -> id
}
val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "через GPU" else "видеослой ТВ" }
val dv = "DV-декодер".takeIf { dolbyVisionFile && (engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true }
return listOfNotNull(name, path, dv, "вручную".takeIf { state.manualEngine }).joinToString(" · ")
}
fun rows(state: PlaybackUiState, media: MediaInfo?, engine: PlaybackEngine?): List<InfoRow> = buildList {
val video = media?.video
val transcode = !isDirect(state)
media?.container?.let { add(InfoRow("Контейнер", it.uppercase() + if (transcode) " → HLS" else "", AppColors.Green)) }
if (video != null) {
val format = listOfNotNull(
MediaLabels.videoCodec(video.codec),
video.bitDepth?.takeIf { it > 8 }?.let { "$it бит" },
if (video.width != null && video.height != null) "${video.width}×${video.height}" else null,
range(video.range, video.dvProfile),
).joinToString(" · ")
add(InfoRow("Видео", format, AppColors.Blue))
video.frameRate?.takeIf { it > 0 }?.let { add(InfoRow("Частота", String.format(Locale.US, "%.3f к/с", it).replace(".000", ""), AppColors.Blue)) }
}
media?.bitrate?.takeIf { it > 0 }?.let {
val cap = state.maxBitrate?.let { c -> " → до " + mbps(c) }.orEmpty()
add(InfoRow("Битрейт", String.format(Locale.US, "%.1f Мбит/с", it / 1e6) + cap, AppColors.Green))
}
media?.audio?.firstOrNull { it.index == state.selection.audioStreamIndex }?.let {
add(InfoRow("Аудио", listOfNotNull(MediaLabels.audioDetail(it), MediaLabels.audioLabel(it)).joinToString(" · "), AppColors.Pink))
}
val subtitle = media?.subtitles?.firstOrNull { it.index == state.selection.subtitleStreamIndex }
add(InfoRow("Субтитры", subtitle?.let { listOfNotNull(MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)).joinToString(" · ") } ?: "выкл", AppColors.Yellow))
add(InfoRow("Движок", engine(state, engine, video?.range == VideoRange.DolbyVision), AppColors.Green))
state.stats.videoDecoder?.let { add(InfoRow("Декодер", it, AppColors.Blue)) }
state.stats.audioOutput?.let { add(InfoRow("Звук", it, AppColors.Pink)) }
if (state.bufferedMs > state.positionMs) add(InfoRow("Буфер", "${(state.bufferedMs - state.positionMs) / 1000} с", AppColors.Green))
add(InfoRow("Пропущено", "${state.stats.droppedFrames} кадров", AppColors.Blue))
}
/** "20 Мбит/с", "1.5 Мбит/с" */
fun mbps(bitrate: Int): String {
val m = bitrate / 1e6
return if (m >= 10) "${m.toInt()} Мбит/с" else String.format(Locale.US, "%.1f Мбит/с", m)
}
/** "1:24:25" */
fun time(ms: Long): String {
val total = (ms.coerceAtLeast(0) + 500) / 1000
return if (total >= 3600) "%d:%02d:%02d".format(total / 3600, total / 60 % 60, total % 60) else "%d:%02d".format(total / 60, total % 60)
}
private fun channels(n: Int?) = when (n) {
null, 0 -> null
1 -> "1.0"
2 -> "2.0"
6 -> "5.1"
8 -> "7.1"
else -> "${n}ch"
}
private fun range(range: VideoRange, dvProfile: Int?) = when (range) {
VideoRange.SDR -> null
VideoRange.HDR10 -> "HDR10"
VideoRange.HDR10Plus -> "HDR10+"
VideoRange.HLG -> "HLG"
VideoRange.DolbyVision -> dvProfile?.let { "DV P$it" } ?: "DV"
VideoRange.Other -> "HDR"
}
/** Jellyfin's TranscodeReason names in plain Russian; unknown ones are dropped. */
private fun reason(name: String): String? = when (name) {
"ContainerNotSupported" -> "контейнер не поддерживается"
"VideoCodecNotSupported", "VideoCodecTagNotSupported" -> "видеокодек не поддерживается"
"AudioCodecNotSupported" -> "аудиокодек не поддерживается"
"SubtitleCodecNotSupported" -> "субтитры нужно вшить"
"AudioIsExternal" -> "внешняя аудиодорожка"
"SecondaryAudioNotSupported" -> "вторая аудиодорожка"
"VideoProfileNotSupported" -> "профиль видео"
"VideoLevelNotSupported" -> "уровень видео"
"VideoResolutionNotSupported" -> "разрешение видео"
"VideoBitDepthNotSupported" -> "глубина цвета"
"VideoFramerateNotSupported" -> "частота кадров"
"RefFramesNotSupported" -> "опорные кадры"
"AnamorphicVideoNotSupported" -> "анаморфное видео"
"InterlacedVideoNotSupported" -> "чересстрочное видео"
"AudioChannelsNotSupported" -> "число каналов звука"
"AudioProfileNotSupported" -> "профиль звука"
"AudioSampleRateNotSupported" -> "частота дискретизации"
"AudioBitDepthNotSupported" -> "разрядность звука"
"ContainerBitrateExceedsLimit", "VideoBitrateNotSupported", "AudioBitrateNotSupported" -> "ограничение битрейта"
"VideoRangeTypeNotSupported" -> "HDR не поддерживается"
"DirectPlayError" -> "ошибка прямого воспроизведения"
"UnknownVideoStreamInfo", "UnknownAudioStreamInfo" -> "неизвестный формат потока"
else -> null
}
}

View File

@@ -0,0 +1,336 @@
package ru.stenfredd.sleepyowl.ui.player
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.PersonKind
import ru.stenfredd.sleepyowl.playback.api.EngineId
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.sleepyowl.playback.core.EngineSelector
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
import ru.stenfredd.sleepyowl.playback.core.PlaybackSessionFactory
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.details.CastModel
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.model.plural
import java.util.Locale
import java.util.UUID
/** "Качество": [bitrate] null = the original file. */
@Immutable
data class QualityOption(val key: String, val bitrate: Int?, val label: String, val detail: String?)
/** One episode in the player's drawer. */
@Immutable
data class EpisodeModel(
val key: String,
val id: UUID,
/** "E04" */
val number: String,
/** "4. Разлом" */
val title: String,
val duration: String?,
val image: RemoteImageSpec?,
val progress: Float?,
val watched: Boolean,
val isNew: Boolean,
)
/** The episode after the one being played: the "Следующая через N с" card. */
@Immutable
data class NextEpisode(
val id: UUID,
/** "S1 · E5 · Разлом" */
val title: String,
val image: RemoteImageSpec?,
)
/** What the controls and the drawer show about the item being played; display strings only. */
@Immutable
data class PlayerInfo(
val id: UUID,
val isEpisode: Boolean,
val seriesId: UUID?,
/** "ФИЛЬМ", "СЕРИАЛ" */
val label: String,
val title: String,
/** "2024 · Драма, Приключения" or "Сезон 2 · Серия 4 · Разлом" */
val subtitle: String?,
val chaptersMs: List<Long>,
val poster: RemoteImageSpec?,
val rating: String?,
val age: String?,
val year: String?,
val runtime: String?,
val genres: String?,
val description: String?,
val director: String?,
val cast: List<CastModel>,
val seasonNumber: Int?,
val qualities: List<QualityOption>,
)
/** The season of the episode being played, for the drawer and ⏮/⏭. */
@Immutable
data class SeasonEpisodes(
/** The episode being played: until the next one's season loads, this still describes the previous one. */
val currentId: UUID,
/** "СЕЗОН 2 · СЕРИИ" */
val title: String,
/** "10 серий · 3 просмотрено" */
val stat: String,
val episodes: List<EpisodeModel>,
val previous: UUID?,
val next: UUID?,
val nextEpisode: NextEpisode?,
)
class PlayerViewModel(
private val factory: PlaybackSessionFactory,
val selector: EngineSelector,
val probe: DeviceCapabilitiesProbe,
private val library: LibraryRepository,
private val cards: CardMapper,
private val prefs: PlaybackPreferences,
) : ViewModel() {
private val _session = MutableStateFlow<PlaybackSession?>(null)
val session: StateFlow<PlaybackSession?> = _session.asStateFlow()
private val _info = MutableStateFlow<PlayerInfo?>(null)
val info: StateFlow<PlayerInfo?> = _info.asStateFlow()
private val _season = MutableStateFlow<SeasonEpisodes?>(null)
val season: StateFlow<SeasonEpisodes?> = _season.asStateFlow()
private val _segments = MutableStateFlow<PlayerSegments?>(null)
/** Intro, credits …: skip buttons and the "next episode" card. */
val segments: StateFlow<PlayerSegments?> = _segments.asStateFlow()
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
/** For the skip settings. */
val defaults: StateFlow<PlaybackDefaults> = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
private var startedFor: Screen.Player? = null
private var infoJob: Job? = null
private var segmentsJob: Job? = null
/** Quality chosen in the player: kept for the next episode. Until then the default from the settings applies. */
private var maxBitrate: Int? = null
private var qualityChosen = false
/** Every episode of the series being played, in order. */
private var allEpisodes: List<ItemSummary> = emptyList()
fun start(screen: Screen.Player) {
if (screen == startedFor) return
startedFor = screen
play(screen.itemId, screen.engine, screen.startPositionMs, screen.streams, screen.startFraction)
}
/** Switches to another episode (drawer, ⏮/⏭, end of an episode): from its resume point, with the remembered tracks. */
fun playEpisode(id: UUID) {
val episode = allEpisodes.firstOrNull { it.id == id } ?: return
play(id, null, episode.resumeMs(), streams = null, startFraction = null)
}
private fun ItemSummary.resumeMs(): Long = user.progress?.takeIf { it < RESUME_LIMIT }?.let { user.positionMs } ?: 0
private fun play(itemId: UUID, engine: EngineId?, startMs: Long, streams: StreamSelection?, startFraction: Double?) {
val old = _session.value
val session = factory.create(viewModelScope)
if (qualityChosen) session.setMaxBitrate(maxBitrate)
_session.value = session
old?.release()
session.start(itemId, engine, startMs, streams, startFraction)
loadInfo(itemId)
}
fun setQuality(bitrate: Int?) {
maxBitrate = bitrate
qualityChosen = true
_session.value?.setMaxBitrate(bitrate)
}
fun setSubtitleStyle(style: SubtitleStyle) {
_session.value?.setSubtitleStyle(style)
viewModelScope.launch { prefs.setSubtitleStyle(style) }
}
/** Releases [target] (default: the current session); a stale screen disposing late can't kill a newer session. */
fun release(target: PlaybackSession? = _session.value) {
target?.release()
if (target === _session.value) {
_session.value = null
startedFor = null
}
}
override fun onCleared() = release()
private fun loadInfo(itemId: UUID) {
loadSegments(itemId)
infoJob?.cancel()
if (_info.value?.id != itemId) _info.value = null
infoJob = viewModelScope.launch {
try {
val details = library.details(itemId)
_info.value = withContext(Dispatchers.Default) { details.toInfo() }
val item = details.summary
val seriesId = item.seriesId
if (item.kind == ItemKind.Episode && seriesId != null) {
val all = library.episodes(seriesId)
allEpisodes = all
_season.value = withContext(Dispatchers.Default) { season(item, all) }
} else {
_season.value = null
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Player: info failed")
}
}
}
/** Apart from the info: a server without segments (or failing to give them) must not cost the drawer. */
private fun loadSegments(itemId: UUID) {
segmentsJob?.cancel()
if (_segments.value?.itemId != itemId) _segments.value = null
segmentsJob = viewModelScope.launch {
try {
_segments.value = PlayerSegments(itemId, library.segments(itemId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Player: segments failed")
}
}
}
private fun season(current: ItemSummary, all: List<ItemSummary>): SeasonEpisodes {
val index = all.indexOfFirst { it.id == current.id }
val inSeason = all.filter { it.seasonId == current.seasonId }
val watched = inSeason.count { it.user.played }
return SeasonEpisodes(
currentId = current.id,
title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "),
stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено",
episodes = inSeason.map { e ->
val progress = e.user.progress
EpisodeModel(
key = "ep/${e.id}",
id = e.id,
number = e.episodeNumber?.let { "E%02d".format(it) } ?: "",
title = listOfNotNull(e.episodeNumber?.let { "$it." }, e.name).joinToString(" "),
duration = e.runtimeMs?.let { "${it / 60_000} мин" },
image = cards.image(e.primary ?: e.thumb ?: e.backdrop, PlayerLayout.EPISODE_W, PlayerLayout.EPISODE_H),
progress = progress,
watched = e.user.played,
isNew = cards.isNew(e),
)
},
previous = all.getOrNull(index - 1)?.id?.takeIf { index > 0 },
next = all.getOrNull(index + 1)?.id?.takeIf { index >= 0 },
nextEpisode = all.getOrNull(index + 1)?.takeIf { index >= 0 }?.let { n ->
NextEpisode(
id = n.id,
title = listOfNotNull(n.seasonNumber?.let { "S$it" }, n.episodeNumber?.let { "E$it" }, n.name).joinToString(" · "),
image = cards.image(n.primary ?: n.thumb ?: n.backdrop, PlayerLayout.NEXT_W, PlayerLayout.NEXT_H),
)
},
)
}
private fun ItemDetails.toInfo(): PlayerInfo {
val item = summary
val episode = item.kind == ItemKind.Episode
val media = media
return PlayerInfo(
id = item.id,
isEpisode = episode,
seriesId = item.seriesId.takeIf { episode },
label = if (episode) "СЕРИАЛ" else "ФИЛЬМ",
title = if (episode) item.seriesName ?: item.name else item.name,
subtitle = if (episode) {
// Episodes without their own title are often named "Серия 3": don't say it twice.
val name = item.name.takeUnless { n -> item.episodeNumber != null && n.equals("Серия ${item.episodeNumber}", ignoreCase = true) }
listOfNotNull(item.seasonNumber?.let { "Сезон $it" }, item.episodeNumber?.let { "Серия $it" }, name).joinToString(" · ")
} else {
listOfNotNull(item.year?.toString(), item.genres.take(3).joinToString(", ").ifEmpty { null }).joinToString(" · ").ifEmpty { null }
},
chaptersMs = chapters.map { it.startMs }.filter { it > 0 },
poster = cards.image(if (episode) item.seriesPrimary ?: item.primary else item.primary, PlayerLayout.POSTER_W, PlayerLayout.POSTER_H),
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
year = item.year?.toString(),
runtime = MediaLabels.runtime(item.runtimeMs),
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
description = item.overview,
director = people.filter { it.kind == PersonKind.Director }.joinToString(", ") { it.name }.ifEmpty { null },
cast = people.filter { it.kind == PersonKind.Actor }.take(CAST_LIMIT).map { p ->
CastModel(
key = "cast/${p.id}",
name = p.name,
role = p.role,
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
image = cards.image(p.image, PlayerLayout.AVATAR, PlayerLayout.AVATAR),
)
},
seasonNumber = item.seasonNumber,
qualities = qualities(media?.bitrate, media?.video?.height),
)
}
/** "Оригинал" and the transcode caps below the file's own bitrate, labelled with the resulting resolution. */
private fun qualities(fileBitrate: Int?, height: Int?): List<QualityOption> {
val original = QualityOption("q/original", null, "Оригинал", fileBitrate?.let { PlayerLabels.mbps(it) })
val caps = QualityCaps.ALL.filter { (bitrate, _) -> fileBitrate == null || bitrate < fileBitrate * 0.9 }
return listOf(original) + caps.map { (bitrate, lines) ->
val shown = if (height != null && height in 1 until lines) height else lines
QualityOption("q/$bitrate", bitrate, "${shown}p", PlayerLabels.mbps(bitrate))
}
}
private companion object {
const val CAST_LIMIT = 4
/** An episode watched further than this starts from the beginning when picked in the drawer. */
const val RESUME_LIMIT = 0.9f
}
}
/** Transcode caps offered by "Качество" (player and settings). */
object QualityCaps {
/** Bitrate cap → resolution the server scales to at that bitrate (its own rule of thumb). */
val ALL = listOf(20_000_000 to 1080, 10_000_000 to 1080, 4_000_000 to 720, 1_500_000 to 480)
}
/** Sizes (dp) shared by the layout and the image requests. */
object PlayerLayout {
const val EPISODE_W = 180f
const val EPISODE_H = 101f
const val POSTER_W = 100f
const val POSTER_H = 150f
const val AVATAR = 24f
const val NEXT_W = 104f
const val NEXT_H = 58.5f
}

View File

@@ -0,0 +1,100 @@
package ru.stenfredd.sleepyowl.ui.player
import androidx.compose.runtime.Immutable
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
import ru.stenfredd.sleepyowl.core.data.model.SegmentKind
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
import ru.stenfredd.sleepyowl.playback.core.SkipMode
import java.util.UUID
/** The server's segments of one item (they arrive after the item starts playing). */
@Immutable
data class PlayerSegments(val itemId: UUID, val segments: List<MediaSegment>)
/** A segment the player offers to skip ("Пропустить заставку") or skips on its own ([auto]). */
@Immutable
data class Skippable(val segment: MediaSegment, val auto: Boolean)
/**
* What the player does with an item's segments under the settings.
* [credits]: the credits of an episode with a next one, from their start to the end of the file: the "next episode"
* card shows from their start instead of 30 s before the end ([creditsAuto]: the next episode starts right away).
*/
@Immutable
data class SkipPlan(val skips: List<Skippable>, val credits: MediaSegment?, val creditsAuto: Boolean) {
/** Parts drawn on the timeline. */
val spans: List<MediaSegment> = skips.map { it.segment } + listOfNotNull(credits)
/** The skippable part [positionMs] is in (not counting its last second: nothing left to skip there). */
fun skipAt(positionMs: Long): Skippable? = skips.firstOrNull { positionMs >= it.segment.startMs && positionMs < it.segment.endMs - SKIP_END_MARGIN_MS }
/** The part a seek to [positionMs] lands in: such a part is not skipped on its own, the user went there. */
fun spanAt(positionMs: Long): MediaSegment? = spans.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
companion object {
/**
* Credits (and previews after them) that run to the end of the file count as the end of the episode: with a
* next episode they bring the "next" card, without one (a movie, the last episode) there is nothing to skip to.
* Credits followed by more of the episode (an epilogue) get "Пропустить титры".
*/
fun of(segments: List<MediaSegment>, durationMs: Long, defaults: PlaybackDefaults, hasNext: Boolean): SkipPlan? {
if (durationMs <= 0) return null
val useful = segments.filter { it.endMs - it.startMs >= MIN_SEGMENT_MS && it.startMs < durationMs }
var tail: MediaSegment? = null
for (s in useful.filter { it.kind == SegmentKind.Outro || it.kind == SegmentKind.Preview }.sortedByDescending { it.startMs }) {
val reaches = tail?.let { s.endMs >= it.startMs - TAIL_GAP_MS } ?: (s.endMs >= durationMs - TAIL_MARGIN_MS)
if (!reaches) break
tail = MediaSegment(SegmentKind.Outro, s.startMs, durationMs)
}
val tailStart = tail?.startMs ?: Long.MAX_VALUE
val skips = useful.filter { it.startMs < tailStart }.mapNotNull { s ->
val mode = if (s.kind == SegmentKind.Outro) defaults.skipCredits else defaults.skipIntro
if (mode == SkipMode.Off) null else Skippable(s, auto = mode == SkipMode.Auto)
}
val credits = tail?.takeIf { hasNext && defaults.skipCredits != SkipMode.Off }
return SkipPlan(skips, credits, creditsAuto = defaults.skipCredits == SkipMode.Auto)
}
}
}
/** Segments of one item something already happened to; an item change forgets them. */
@Immutable
data class SegmentSet(val itemId: UUID? = null, val segments: Set<MediaSegment> = emptySet()) {
fun of(id: UUID?): Set<MediaSegment> = if (id == itemId) segments else emptySet()
fun plus(id: UUID?, segment: MediaSegment): SegmentSet = SegmentSet(id, of(id) + segment)
}
object SkipLabels {
fun button(kind: SegmentKind): String = when (kind) {
SegmentKind.Intro -> "Пропустить заставку"
SegmentKind.Recap -> "Пропустить пересказ"
SegmentKind.Preview -> "Пропустить анонс"
SegmentKind.Outro -> "Пропустить титры"
SegmentKind.Commercial -> "Пропустить рекламу"
}
fun skipped(kind: SegmentKind): String = when (kind) {
SegmentKind.Intro -> "Заставка пропущена"
SegmentKind.Recap -> "Пересказ пропущен"
SegmentKind.Preview -> "Анонс пропущен"
SegmentKind.Outro -> "Титры пропущены"
SegmentKind.Commercial -> "Реклама пропущена"
}
}
/** The button over the bare video hides after this long; it comes back with the controls. */
const val SKIP_PROMPT_MS = 10_000L
/** With known credits the "next episode" card counts down this long from their start. */
const val CREDITS_COUNTDOWN_MS = 15_000L
const val SKIP_NOTICE_MS = 3_000L
private const val SKIP_END_MARGIN_MS = 1_000L
/** Shorter segments are detection noise. */
private const val MIN_SEGMENT_MS = 2_000L
/** Credits ending this close to the end of the file run to the end (black frames after them, rounding). */
private const val TAIL_MARGIN_MS = 10_000L
/** Credits and a preview this far apart still make one tail. */
private const val TAIL_GAP_MS = 2_000L

View File

@@ -0,0 +1,169 @@
package ru.stenfredd.sleepyowl.ui.search
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SearchHistory
import ru.stenfredd.sleepyowl.core.data.model.Person
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import java.util.concurrent.ConcurrentHashMap
@Immutable
data class PersonCardModel(val key: String, val name: String, val letters: String, val photo: RemoteImageSpec?)
/** Results of one library ("Фильмы 3"), or people. */
@Immutable
data class SearchGroup(val key: String, val title: String, val posters: List<PosterModel>, val people: List<PersonCardModel>) {
val size: Int get() = posters.size + people.size
}
@Immutable
data class SearchState(
val query: String = "",
val latin: Boolean = false,
val recentQueries: List<String> = emptyList(),
val recentItems: List<PosterModel> = emptyList(),
/** Null while the query is empty: the screen shows recent queries and items instead. */
val results: List<SearchGroup>? = null,
val searching: Boolean = false,
val error: String? = null,
)
/**
* Search tab: the query typed on the on-screen keyboard (or a physical one), results grouped by library plus people,
* searched as the user types (after a short pause). Recent queries are kept on the device ([SearchHistory]):
* a query is remembered when one of its results is opened.
*/
class SearchViewModel(
private val library: LibraryRepository,
private val history: SearchHistory,
private val cards: CardMapper,
) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
private val targets = ConcurrentHashMap<String, Screen>()
private var searchJob: Job? = null
init {
viewModelScope.launch { history.recent().collect { list -> _state.update { it.copy(recentQueries = list) } } }
viewModelScope.launch {
runCatching { library.latest().take(RECENT_ITEMS) }
.onSuccess { items ->
val posters = withContext(Dispatchers.Default) {
items.map { item ->
targets[cards.key(RECENT_ROW, item)] = cards.target(item)
cards.poster(item, RECENT_ROW)
}
}
_state.update { it.copy(recentItems = posters) }
}
.onFailure { Log.w(it, "Recent items failed") }
}
}
/** Where a card leads; opening a search result remembers the query. */
fun open(key: String): Screen? {
val query = _state.value.query
if (_state.value.results != null && !key.startsWith(RECENT_ROW)) viewModelScope.launch { history.add(query) }
return targets[key]
}
fun type(text: String) = setQuery(_state.value.query + text)
fun backspace() = setQuery(_state.value.query.dropLast(1))
fun clear() = setQuery("")
fun pickRecent(query: String) = setQuery(query)
fun toggleLayout() = _state.update { it.copy(latin = !it.latin) }
private fun setQuery(query: String) {
val q = query.take(MAX_LENGTH)
if (q == _state.value.query) return
searchJob?.cancel()
if (q.isBlank()) {
_state.update { it.copy(query = q, results = null, searching = false, error = null) }
return
}
_state.update { it.copy(query = q, searching = true) }
searchJob = viewModelScope.launch {
delay(TYPING_PAUSE_MS)
search(q.trim())
}
}
private suspend fun search(term: String) {
try {
val groups = coroutineScope {
val people = async { library.searchPeople(term) }
val byLibrary = library.views().map { view -> async { view to library.search(term, view) } }.awaitAll()
withContext(Dispatchers.Default) {
byLibrary.filter { it.second.isNotEmpty() }.map { (view, items) ->
val row = "s/${view.id}"
SearchGroup(
row,
view.name,
items.map { item ->
targets[cards.key(row, item)] = cards.target(item)
cards.poster(item, row)
},
emptyList(),
)
} + listOfNotNull(people.await().takeIf { it.isNotEmpty() }?.let { list -> SearchGroup("s/people", "Люди", emptyList(), list.map(::personCard)) })
}
}
_state.update { it.copy(results = groups, searching = false, error = null) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Search failed")
_state.update { it.copy(results = emptyList(), searching = false, error = e.message ?: e.toString()) }
}
}
private fun personCard(p: Person): PersonCardModel {
val key = "s/people/${p.id}"
targets[key] = Screen.Person(p.id)
return PersonCardModel(
key = key,
name = p.name,
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
photo = cards.image(p.image, SearchLayout.PERSON_PHOTO, SearchLayout.PERSON_PHOTO),
)
}
private companion object {
const val RECENT_ROW = "recent"
const val RECENT_ITEMS = 8
const val TYPING_PAUSE_MS = 350L
const val MAX_LENGTH = 60
}
}
object SearchLayout {
const val PERSON_PHOTO = 80f
/** Left column: query field and keyboard. */
const val KEYBOARD_W = 280
/** The results column starts here, from the content's left edge. */
const val RESULTS_START = 372
}

View File

@@ -0,0 +1,409 @@
package ru.stenfredd.sleepyowl.ui.series
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.data.ImageUrls
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
import ru.stenfredd.sleepyowl.ui.details.TrackChoices
import ru.stenfredd.sleepyowl.ui.details.TrackOption
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.model.plural
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import kotlin.math.roundToInt
/** Where an episode is in watching: the season map colours and the card's corner badge. */
enum class EpisodeState { Watched, InProgress, Next, New, None }
@Immutable
data class SeriesEpisode(
val key: String,
val id: UUID,
/** Index of its season in [SeriesModel.seasons]. */
val season: Int,
/** Position in the episode row, which has a divider before every season. */
val rowIndex: Int,
/** "S2·E04" */
val code: String,
/** "СЕЗОН 2 · СЕРИЯ 4" */
val longCode: String,
val title: String,
/** "56 мин · 13.06.2023" */
val meta: String?,
val duration: String?,
val airDate: String?,
val overview: String?,
val image: RemoteImageSpec?,
val progress: Float?,
val state: EpisodeState,
val resumeMs: Long,
/** What a long press on the card marks watched. */
val watch: WatchTarget,
)
@Immutable
data class SeriesSeason(
val key: String,
/** "Сезон 2", or the server's name for specials. */
val label: String,
/** "✓", "новый", "3/10" */
val stat: String,
/** "S2 · 3/10" over the season map. */
val mapLabel: String,
/** Its episodes are [first] until [first] + [count] in [SeriesModel.episodes]. */
val first: Int,
val count: Int,
/** The episode ↓ from the season chip goes to: started, next, first unwatched, else the first. */
val target: Int,
/** Row index of the season's divider. */
val dividerIndex: Int,
/** The whole season, for a long press on its chip. */
val watch: WatchTarget,
)
@Immutable
data class SeriesModel(
val id: UUID,
/** "СЕРИАЛ · 3 СЕЗОНА · 28 СЕРИЙ" */
val label: String,
val title: String,
val rating: String?,
val age: String?,
val year: String?,
val genres: String?,
val poster: RemoteImageSpec?,
val backdropUrl: String?,
val favorite: Boolean,
/** The whole series, for the ✓ button; played when every episode is. */
val watch: WatchTarget,
val seasons: List<SeriesSeason>,
val episodes: List<SeriesEpisode>,
/** Index of the episode "Продолжить"/"Смотреть" plays. */
val nextUp: Int,
/** "Продолжить S2 E4 · ещё 32 мин" */
val playLabel: String,
)
@Immutable
data class SeriesState(
val loading: Boolean = true,
val error: String? = null,
val item: SeriesModel? = null,
/** Formats of the next episode's file: "4K", "HDR10", "Atmos". */
val badges: List<String> = emptyList(),
/** Tracks of the next episode's file, for the "Аудио и субтитры" tray. */
val audio: List<TrackOption> = emptyList(),
val subtitles: List<TrackOption> = emptyList(),
val selectedAudio: Int? = null,
val selectedSubtitle: Int? = null,
/** "Русский 5.1" */
val audioLabel: String? = null,
/** "Русский", "без субтитров" */
val subtitleLabel: String? = null,
)
/**
* Series page: header, season map and chips, every episode in one row, and the audio/subtitle choice for the series
* (remembered per series, see [PlaybackPreferences]; the player picks it up for every episode).
*/
class SeriesViewModel(
private val seriesId: UUID,
private val library: LibraryRepository,
private val watch: WatchRepository,
private val images: ImageUrls,
private val cards: CardMapper,
private val prefs: PlaybackPreferences,
cache: SessionCache,
) : ViewModel() {
private val _state = MutableStateFlow(SeriesState())
val state: StateFlow<SeriesState> = _state.asStateFlow()
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
/** File of the next episode: its tracks are what the tray offers. */
private var media: MediaInfo? = null
init {
viewModelScope.launch { load() }
// Back from the player (progress, next episode) or watched/favourite changed elsewhere: reload.
viewModelScope.launch { cache.invalidations.collect { load() } }
}
fun player(episode: SeriesEpisode): Screen.Player = Screen.Player(
itemId = episode.id,
engine = null,
// A watched episode starts over; the tracks come from the series' memory.
startPositionMs = if (episode.state == EpisodeState.Watched) 0 else episode.resumeMs,
)
fun selectAudio(index: Int?) {
_state.update { it.withTracks(media, index, it.selectedSubtitle) }
viewModelScope.launch { TrackChoices.rememberAudio(prefs, seriesId, media, index) }
}
fun selectSubtitle(index: Int?) {
_state.update { it.withTracks(media, it.selectedAudio, index) }
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, seriesId, media, index) }
}
fun setSubtitleStyle(style: SubtitleStyle) {
viewModelScope.launch { prefs.setSubtitleStyle(style) }
}
/** Shows the new value at once and reverts it if the server refuses. */
fun toggleFavorite() {
val item = _state.value.item ?: return
val value = !item.favorite
_state.update { s -> s.copy(item = s.item?.copy(favorite = value)) }
viewModelScope.launch {
try {
watch.setFavorite(seriesId, value)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Series: favourite failed")
_state.update { s -> s.copy(item = s.item?.copy(favorite = !value)) }
}
}
}
private suspend fun load() {
try {
coroutineScope {
val started = monotonicMs()
val seasons = async { library.seasons(seriesId) }
val episodes = async { library.episodes(seriesId) }
val details = library.details(seriesId)
val model = withContext(Dispatchers.Default) { model(details, seasons.await(), episodes.await()) }
_state.update { it.copy(loading = false, error = null, item = model) }
Log.i("Series loaded in %d ms (%d episodes)", monotonicMs() - started, model.episodes.size)
// The tray and the badges describe the file of the episode "Продолжить" plays.
model.episodes.getOrNull(model.nextUp)?.let { next ->
val media = runCatching { library.details(next.id).media }.getOrNull()
this@SeriesViewModel.media = media
val (audio, subtitle) = media?.let { TrackChoices.initial(prefs, seriesId, it) } ?: (null to null)
_state.update { s ->
s.copy(badges = MediaLabels.badges(media), audio = TrackChoices.audio(media), subtitles = TrackChoices.subtitles(media))
.withTracks(media, audio, subtitle)
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Series: load failed")
// Keep what is shown when a reload fails.
_state.update { if (it.item == null) it.copy(loading = false, error = e.message ?: e.toString()) else it }
}
}
private fun SeriesState.withTracks(media: MediaInfo?, audio: Int?, subtitle: Int?) = copy(
selectedAudio = audio,
selectedSubtitle = subtitle,
audioLabel = media?.takeIf { it.audio.isNotEmpty() }?.let { PlayerLabels.audioChip(it, audio) },
subtitleLabel = media?.takeIf { it.subtitles.isNotEmpty() }?.let { m ->
if (subtitle == null) "без субтитров" else PlayerLabels.subtitleChip(m, subtitle)
},
)
private fun model(details: ItemDetails, seasonItems: List<ItemSummary>, episodeItems: List<ItemSummary>): SeriesModel {
val item = details.summary
// Specials (season 0) go after the regular seasons; episodes of seasons the server didn't list are left out.
val ordered = seasonItems.sortedBy { if (it.seasonNumber == 0) 1 else 0 }
val bySeason = episodeItems.groupBy { it.seasonId }
val groups = ordered.mapNotNull { s -> bySeason[s.id]?.takeIf { it.isNotEmpty() }?.let { s to it } }
val all = groups.flatMap { it.second }
val nextUp = nextUp(all)
val episodes = ArrayList<SeriesEpisode>(all.size)
val seasons = ArrayList<SeriesSeason>(groups.size)
var row = 0
groups.forEachIndexed { si, (season, list) ->
val first = episodes.size
val dividerIndex = row++
list.forEachIndexed { i, e ->
episodes += episode(e, si, row++, i, season, item.name, isNextUp = e === all.getOrNull(nextUp))
}
val own = episodes.subList(first, episodes.size)
val watched = own.count { it.state == EpisodeState.Watched }
val number = season.seasonNumber
val label = if (number != null && number > 0) "Сезон $number" else season.name
seasons += SeriesSeason(
key = "season/${season.id}",
label = label,
stat = when {
watched == own.size -> "✓"
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
else -> "$watched/${own.size}"
},
mapLabel = "${if (number != null && number > 0) "S$number" else "SP"} · $watched/${own.size}",
first = first,
count = own.size,
target = first + (
own.indexOfFirst { it.state == EpisodeState.InProgress || it.state == EpisodeState.Next }.takeIf { it >= 0 }
?: own.indexOfFirst { it.state != EpisodeState.Watched }.takeIf { it >= 0 }
?: 0
),
dividerIndex = dividerIndex,
watch = groupTarget(season.id, ItemKind.Season, item.name, label, own),
)
}
val next = episodes.getOrNull(nextUp)
val regularSeasons = groups.count { (it.first.seasonNumber ?: 1) > 0 }
return SeriesModel(
id = item.id,
label = listOf(
"СЕРИАЛ",
"$regularSeasons ${plural(regularSeasons, "СЕЗОН", "СЕЗОНА", "СЕЗОНОВ")}",
"${all.size} ${plural(all.size, "СЕРИЯ", "СЕРИИ", "СЕРИЙ")}",
).joinToString(" · "),
title = item.name,
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
year = item.year?.toString(),
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H),
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
favorite = item.user.favorite,
watch = groupTarget(item.id, ItemKind.Series, item.name, null, episodes),
seasons = seasons,
episodes = episodes,
nextUp = nextUp.coerceAtLeast(0),
playLabel = next?.let { playLabel(it, all[nextUp]) } ?: "Смотреть",
)
}
private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, seriesName: String, isNextUp: Boolean): SeriesEpisode {
val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0
// Some files have no episode number: their place in the season stands in.
val number = e.episodeNumber ?: (position + 1)
val numberLabel = e.episodeNumberEnd?.takeIf { it > number }?.let { "$number–$it" } ?: "%02d".format(number)
val progress = e.user.progress?.takeIf { !e.user.played }
val state = when {
e.user.played -> EpisodeState.Watched
progress != null -> EpisodeState.InProgress
isNextUp -> EpisodeState.Next
cards.isNew(e) -> EpisodeState.New
else -> EpisodeState.None
}
val duration = e.runtimeMs?.let { "${(it / 60_000.0).roundToInt()} мин" }
val air = e.premiereAt?.let { DateFormat.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) }
return SeriesEpisode(
key = "ep/${e.id}",
id = e.id,
season = season,
rowIndex = rowIndex,
code = "S$seasonNumber·E$numberLabel",
longCode = "СЕЗОН $seasonNumber · СЕРИЯ ${e.episodeNumber ?: number}",
title = e.name,
meta = listOfNotNull(duration, air).joinToString(" · ").ifEmpty { null },
duration = duration,
airDate = air,
overview = e.overview,
image = cards.image(e.primary ?: e.thumb ?: e.backdrop, SeriesLayout.EPISODE_W, SeriesLayout.EPISODE_H),
progress = progress,
state = state,
resumeMs = if (progress != null) e.user.positionMs else 0,
watch = WatchTarget(
id = e.id,
kind = ItemKind.Episode,
title = seriesName,
subtitle = "S$seasonNumber · E${e.episodeNumber ?: number} · ${e.name}",
played = e.user.played,
progress = progress,
),
)
}
/** A season or the whole series: every episode of it, as the page counts them (specials too). */
private fun groupTarget(id: UUID, kind: ItemKind, title: String, subtitle: String?, episodes: List<SeriesEpisode>): WatchTarget {
val watched = episodes.count { it.state == EpisodeState.Watched }
return WatchTarget(
id = id,
kind = kind,
title = title,
subtitle = subtitle,
played = episodes.isNotEmpty() && watched == episodes.size,
episodes = episodes.size,
watchedEpisodes = watched,
started = episodes.any { it.state == EpisodeState.InProgress },
)
}
private fun playLabel(next: SeriesEpisode, item: ItemSummary): String {
val code = next.code.replace("·", " ").replace(Regex("""E0(\d)"""), "E$1")
return when (next.state) {
EpisodeState.InProgress -> {
val left = item.runtimeMs?.let { (it * (1 - (next.progress ?: 0f)) / 60_000).roundToInt() }
"Продолжить $code" + (left?.let { " · ещё $it мин" } ?: "")
}
else -> "Смотреть $code" + (next.duration?.let { " · $it" } ?: "")
}
}
/**
* The episode to continue with: the last one played if it isn't finished (also when it was only started for a few
* seconds and the server kept no position), else the first unwatched one after it; nothing played yet — the first
* unwatched. Specials only count when the series has nothing else.
*/
private fun nextUp(all: List<ItemSummary>): Int {
if (all.isEmpty()) return -1
val regular = all.indices.filter { (all[it].seasonNumber ?: 1) > 0 }.ifEmpty { all.indices.toList() }
val last = regular.filter { all[it].user.lastPlayedAt != null }.maxByOrNull { all[it].user.lastPlayedAt!! }
if (last != null) {
val e = all[last]
if (!e.user.played) return last
regular.firstOrNull { it > last && !all[it].user.played }?.let { return it }
}
return regular.firstOrNull { !all[it].user.played } ?: regular.first()
}
private companion object {
val DateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
}
}
/** Sizes (dp) shared by the layout, the image requests and the baked backdrop. */
object SeriesLayout {
const val PAGE_W = 960f
const val PAGE_H = 540f
const val POSTER_W = 90f
const val POSTER_H = 135f
const val EPISODE_W = 200f
const val EPISODE_H = 112.5f
/** The backdrop covers 70 % of the page width and height at the top right, as on the movie page. */
const val BACKDROP_W = 672f
const val BACKDROP_H = 378f
}

View File

@@ -0,0 +1,144 @@
package ru.stenfredd.sleepyowl.ui.settings
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
import ru.stenfredd.sleepyowl.core.data.ServerInfo
import ru.stenfredd.sleepyowl.core.data.UserSettingsRepository
import ru.stenfredd.sleepyowl.core.jellyfin.Account
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.ui.UiPlatform
/** Server-side language settings: loading, loaded or failed (with the reason). */
@Immutable
data class LanguagesState(val settings: LanguageSettings? = null, val error: String? = null, val saving: Boolean = false)
@Immutable
data class AccountsState(
val current: Account? = null,
val others: List<Account> = emptyList(),
/** LAN address the app talks to at home, null = through the public address. */
val localUrl: String? = null,
val server: ServerInfo? = null,
)
class SettingsViewModel(
platform: UiPlatform,
private val session: JellyfinSession,
private val prefs: PlaybackPreferences,
private val userSettings: UserSettingsRepository,
private val probe: DeviceCapabilitiesProbe,
) : ViewModel() {
val defaults: StateFlow<PlaybackDefaults> = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
private val _languages = MutableStateFlow(LanguagesState())
val languages: StateFlow<LanguagesState> = _languages.asStateFlow()
private val server = MutableStateFlow<ServerInfo?>(null)
val accounts: StateFlow<AccountsState> = combine(session.session, session.accounts, server) { s, all, info ->
AccountsState(
current = all.firstOrNull { it.id == s?.accountId },
others = all.filter { it.id != s?.accountId },
localUrl = session.localUrl,
server = info,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountsState())
/** The TV can show Dolby Vision through its own decoder: only then the choice is offered. */
var dolbyVisionCapable by mutableStateOf(false)
private set
val tourAvailable = platform.tourAvailable
val about = About(version = platform.version, device = platform.device)
init {
loadLanguages()
viewModelScope.launch {
server.value = runCatching { userSettings.server() }.onFailure { Log.w(it, "Settings: server info") }.getOrNull()
}
viewModelScope.launch {
dolbyVisionCapable = withContext(Dispatchers.Default) {
val device = probe.capabilities
device.hardwareVideoDecoders(DV_MIME).isNotEmpty() && device.displaySupportsHdr(HDR_TYPE_DOLBY_VISION)
}
}
}
fun loadLanguages() {
_languages.value = LanguagesState()
viewModelScope.launch {
_languages.value = try {
LanguagesState(userSettings.languages())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Settings: languages")
LanguagesState(error = e.message ?: e.javaClass.simpleName)
}
}
}
/** Shown at once, saved on the server in the background; on failure the old settings come back with the error. */
fun setLanguages(settings: LanguageSettings) {
val old = _languages.value.settings ?: return
if (settings == old) return
_languages.value = LanguagesState(settings, saving = true)
viewModelScope.launch {
_languages.value = try {
userSettings.setLanguages(settings)
LanguagesState(settings)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(e, "Settings: saving languages")
LanguagesState(old, error = "Не удалось сохранить: ${e.message ?: e.javaClass.simpleName}")
}
}
}
fun setDefaults(defaults: PlaybackDefaults) {
viewModelScope.launch { prefs.setDefaults(defaults) }
}
fun setSubtitleStyle(style: SubtitleStyle) {
viewModelScope.launch { prefs.setSubtitleStyle(style) }
}
suspend fun logout() = session.logout()
suspend fun switchTo(accountId: String) = session.switchTo(accountId)
fun forget(accountId: String) {
viewModelScope.launch { session.forget(accountId) }
}
private companion object {
const val DV_MIME = "video/dolby-vision"
/** Android's `Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION` (`DeviceCapabilities.displayHdrTypes`). */
const val HDR_TYPE_DOLBY_VISION = 1
}
}
@Immutable
data class About(val version: String, val device: String)

View File

@@ -0,0 +1,17 @@
package ru.stenfredd.sleepyowl.ui.shell
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import java.util.UUID
/** The root screens of the side rail. */
enum class Tab(val label: String, val icon: AppIcon) {
Search("Поиск", AppIcon.Search),
Home("Главная", AppIcon.Home),
Library("Медиатека", AppIcon.Library),
Favorites("Избранное", AppIcon.Heart),
/** Not in the mockup; at the bottom of the rail, above the profile. */
Settings("Настройки", AppIcon.Settings),
}
/** A library listed on the rail under the tabs; choosing it opens the library tab on it. */
data class RailLibrary(val id: UUID, val name: String, val icon: AppIcon)

View File

@@ -0,0 +1,111 @@
package ru.stenfredd.sleepyowl.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
/**
* Design tokens from `design/…/Jellyfin TV.dc.html`. The mockup is drawn at 1920×1080 px,
* the TV UI is 960×540 dp, so every size here is the mockup's px / 2.
*/
object AppColors {
val Background = Color(0xFF0A0B10)
val BackgroundDeep = Color(0xFF050609)
val DotGrid = Color(0xFF1B1E2B)
val Surface = Color(0xFF12141C)
val SurfaceRaised = Color(0xFF151824)
val SurfaceFocused = Color(0xFF1D2031)
val Rail = Color(0xB30A0B10)
val RailOpen = Color(0xFF0D0E16)
val Border = Color(0xFF2A2E40)
val BorderSubtle = Color(0xFF1D2030)
val Hairline = Color(0x14FFFFFF)
val Text = Color(0xFFECEEF6)
val TextSecondary = Color(0xFFC3C7DD)
val TextMuted = Color(0xFFA3A8C3)
val TextDim = Color(0xFF8B90AD)
val TextFaint = Color(0xFF5F6582)
val Accent = Color(0xFFA594FF)
val AccentStrong = Color(0xFF8F6BFF)
val AccentGlow = Color(0xFF7C5CFF)
val FocusFill = Color(0xFFECE9FF)
val OnFocusFill = Color(0xFF0B0C12)
val Selected = Color(0xFF1D1A3D)
val SelectedBorder = Color(0xFF5F55E6)
val Green = Color(0xFF7EE0A3)
val Pink = Color(0xFFFF9CF9)
val Yellow = Color(0xFFFFD166)
val Blue = Color(0xFF64B5F6)
val Orange = Color(0xFFFFB454)
val Red = Color(0xFFFF6E6E)
val NewBadge = Color(0xFF4D7CFF)
val PrimaryGradient = Brush.linearGradient(listOf(Color(0xFF4D7CFF), Color(0xFF8A5CFF)))
val ProgressGradient = Brush.horizontalGradient(listOf(Color(0xFF4D8BFF), Color(0xFF9B6BFF)))
val ProgressTrack = Color(0x24FFFFFF)
}
object AppFonts {
val Onest: FontFamily = onestFontFamily()
val Mono: FontFamily = monoFontFamily()
}
/** Onest 400–800: Android font resources on the TV, the same files from the classpath on the desktop. */
internal expect fun onestFontFamily(): FontFamily
/** JetBrains Mono 400–600. */
internal expect fun monoFontFamily(): FontFamily
/** Text styles used by the mockup, named by role. */
@Immutable
object AppType {
private val base = TextStyle(
fontFamily = AppFonts.Onest,
color = AppColors.Text,
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both),
)
private val mono = base.copy(fontFamily = AppFonts.Mono)
val Hero = base.copy(fontSize = 30.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.02).em)
val Display = base.copy(fontSize = 40.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.02).em)
val Section = base.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
val CardTitle = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold)
val PosterTitle = base.copy(fontSize = 11.5.sp, fontWeight = FontWeight.SemiBold)
val Body = base.copy(fontSize = 11.sp, lineHeight = 16.sp, color = AppColors.TextMuted)
val BodyLarge = base.copy(fontSize = 13.sp, color = Color(0xFFD9DCEC))
val Button = base.copy(fontSize = 11.5.sp, fontWeight = FontWeight.SemiBold)
val Rail = base.copy(fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold)
val MonoCount = mono.copy(fontSize = 10.sp, color = AppColors.TextFaint)
val MonoCaption = mono.copy(fontSize = 9.sp, color = AppColors.TextDim)
val MonoMeta = mono.copy(fontSize = 9.5.sp, color = AppColors.TextSecondary)
val MonoLabel = mono.copy(fontSize = 10.sp, letterSpacing = 0.12.em, color = AppColors.Accent)
val MonoBadge = mono.copy(fontSize = 8.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.06.em)
val Clock = mono.copy(fontSize = 13.sp, color = AppColors.TextMuted)
}
/** Layout constants (dp), mockup px / 2. */
object AppDimens {
const val RAIL_COLLAPSED = 56
const val RAIL_OPEN = 190
/** Content starts at 64 px + 120 px padding in the mockup. */
const val CONTENT_START = 60
const val CONTENT_TOP = 44
const val ROW_GAP = 14
const val SECTION_GAP = 22
const val POSTER_W = 110
const val POSTER_H = 165
const val WIDE_W = 190
const val WIDE_H = 107
}

View File

@@ -0,0 +1,33 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageInfo
actual fun argbImageBitmap(pixels: IntArray, width: Int, height: Int): ImageBitmap {
// BGRA in memory is an ARGB int in little-endian order: the ints go in as they are.
val bytes = ByteArray(pixels.size * 4)
for (i in pixels.indices) {
val p = pixels[i]
bytes[i * 4] = p.toByte()
bytes[i * 4 + 1] = (p shr 8).toByte()
bytes[i * 4 + 2] = (p shr 16).toByte()
bytes[i * 4 + 3] = (p ushr 24).toByte()
}
val bitmap = Bitmap()
bitmap.allocPixels(ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.UNPREMUL))
bitmap.installPixels(bytes)
bitmap.setImmutable()
return bitmap.asComposeImageBitmap()
}
/** A picture from the classpath (the TV's drawables are there, see build.gradle.kts). */
internal fun classpathImage(path: String): ImageBitmap {
val bytes = checkNotNull(Thread.currentThread().contextClassLoader.getResourceAsStream(path)) { "no resource $path" }.use { it.readBytes() }
return Image.makeFromEncoded(bytes).toComposeImageBitmap()
}

View File

@@ -0,0 +1,12 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
@Composable
internal actual fun owlPainter(): Painter = remember { BitmapPainter(classpathImage("drawable-xhdpi/logo_owl.png")) }
@Composable
internal actual fun wordmarkPainter(): Painter = remember { BitmapPainter(classpathImage("drawable-xhdpi/logo_wordmark.png")) }

View File

@@ -0,0 +1,38 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toComposeImageBitmap
import org.jetbrains.skia.FilterBlurMode
import org.jetbrains.skia.MaskFilter
import org.jetbrains.skia.Paint
import org.jetbrains.skia.RRect
import org.jetbrains.skia.Surface
import kotlin.math.ceil
private data class GlowKey(val w: Int, val h: Int, val corner: Int, val layers: List<GlowLayer>)
// A few dozen glows at most: elements of one kind share a size.
private val glows = object : LinkedHashMap<GlowKey, GlowCache.Glow>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<GlowKey, GlowCache.Glow>) = size > 64
}
/** Skia's own blur at full resolution, layers drawn one over another: the desktop has GPU and memory to spare. */
internal actual fun cachedGlow(w: Int, h: Int, corner: Int, layers: List<GlowLayer>): GlowCache.Glow = synchronized(glows) {
glows.getOrPut(GlowKey(w, h, corner, layers)) {
// A blurred edge fades out at roughly 1× the CSS blur radius beyond the spread.
val margin = layers.maxOf { it.blurPx + it.spreadPx }
val side = ceil(margin).toInt()
val surface = Surface.makeRasterN32Premul((w + 2 * side).coerceAtLeast(1), (h + 2 * side).coerceAtLeast(1))
for (layer in layers) {
val s = layer.spreadPx
val paint = Paint().apply {
isAntiAlias = true
color = layer.color.toArgb()
// CSS blur radius B is a Gaussian with sigma B/2.
if (layer.blurPx > 0f) maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, layer.blurPx / 2)
}
surface.canvas.drawRRect(RRect.makeXYWH(side - s, side - s, w + 2 * s, h + 2 * s, corner + s), paint)
}
GlowCache.Glow(surface.makeImageSnapshot().toComposeImageBitmap(), side.toFloat())
}
}

View File

@@ -0,0 +1,75 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import coil3.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import kotlin.math.ceil
/**
* The desktop's server image: clipped and shaded while drawing, which a desktop GPU doesn't notice. The BlurHash is
* decoded small and stretched: its colour waves are defined over the whole picture, so stretching loses nothing.
*/
@Composable
actual fun RemoteImage(image: RemoteImageSpec?, corner: Dp, modifier: Modifier, shade: ImageShade?, contentScale: ContentScale) {
val url = image?.url
var loaded by remember(url) { mutableStateOf(false) }
val shape = remember(corner) { RoundedCornerShape(corner) }
val placeholder by produceState<ImageBitmap?>(null, image?.blurHash) {
val hash = image?.blurHash
value = if (hash == null) null else withContext(Dispatchers.Default) {
BlurHash.decode(hash, PLACEHOLDER_SIZE, PLACEHOLDER_SIZE)?.let { argbImageBitmap(it, PLACEHOLDER_SIZE, PLACEHOLDER_SIZE) }
}
}
val background = when {
loaded -> Modifier
placeholder != null -> Modifier.drawBehind {
placeholder?.let { drawImage(it, dstSize = IntSize(ceil(size.width).toInt(), ceil(size.height).toInt())) }
}
else -> Modifier.background(AppColors.SurfaceRaised)
}
Box(modifier.clip(shape).then(background)) {
if (url != null) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale,
onSuccess = { loaded = true },
)
}
shade?.let { s ->
Spacer(
Modifier.fillMaxSize().drawBehind {
val top = size.height * (1f - s.height)
drawRect(
Brush.verticalGradient(*s.stops.toTypedArray(), startY = size.height, endY = top),
topLeft = androidx.compose.ui.geometry.Offset(0f, top),
size = androidx.compose.ui.geometry.Size(size.width, size.height - top),
)
},
)
}
}
}
private const val PLACEHOLDER_SIZE = 32

View File

@@ -0,0 +1,20 @@
package ru.stenfredd.sleepyowl.ui.theme
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.platform.Font
// The TV's font resources (src/androidMain/res/font), on the classpath as `font/…`.
internal actual fun onestFontFamily() = FontFamily(
Font("font/onest_400.ttf", FontWeight.Normal),
Font("font/onest_500.ttf", FontWeight.Medium),
Font("font/onest_600.ttf", FontWeight.SemiBold),
Font("font/onest_700.ttf", FontWeight.Bold),
Font("font/onest_800.ttf", FontWeight.ExtraBold),
)
internal actual fun monoFontFamily() = FontFamily(
Font("font/jetbrains_mono_400.ttf", FontWeight.Normal),
Font("font/jetbrains_mono_500.ttf", FontWeight.Medium),
Font("font/jetbrains_mono_600.ttf", FontWeight.SemiBold),
)

View File

@@ -0,0 +1,42 @@
package ru.stenfredd.sleepyowl.ui
import okhttp3.OkHttpClient
import org.junit.Test
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import org.koin.test.verify.verify
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.common.desktopAppDirs
import ru.stenfredd.sleepyowl.core.data.dataModule
import ru.stenfredd.sleepyowl.core.jellyfin.DesktopJellyfinPlatform
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinPlatform
import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.sleepyowl.playback.core.playbackModule
import java.io.File
import java.util.UUID
/**
* The shared view models on the desktop: everything they need comes from the shared modules plus the few platform
* definitions the desktop app will add (L4); the engine and its probe come with L6.
*/
class DesktopKoinGraphTest {
@OptIn(KoinExperimentalAPI::class)
@Test
fun graphIsComplete() {
val desktop = module {
single { desktopAppDirs() }
singleOf(::PreferenceStores)
single<JellyfinPlatform> { DesktopJellyfinPlatform() }
single<DeviceCapabilitiesProbe> { error("L6") }
single<UiPlatform> { error("L4") }
}
module { includes(jellyfinModule, dataModule, playbackModule, uiModule, desktop) }.verify(
// UUID is the id view models get through parametersOf; File and OkHttpClient.Builder are used inside lambdas;
// EngineProvider: EngineSelector takes all of them (getAll); the desktop has none until mpv (L6).
extraTypes = listOf(UUID::class, OkHttpClient.Builder::class, File::class, EngineProvider::class),
)
}
}

View File

@@ -0,0 +1,122 @@
package ru.stenfredd.sleepyowl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import org.jetbrains.skia.EncodedImageFormat
import org.junit.Assert.assertTrue
import org.junit.Test
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.FavoriteBadge
import ru.stenfredd.sleepyowl.ui.components.FormatBadge
import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.components.ImageShade
import ru.stenfredd.sleepyowl.ui.components.NewBadge
import ru.stenfredd.sleepyowl.ui.components.OwlMark
import ru.stenfredd.sleepyowl.ui.components.PosterCard
import ru.stenfredd.sleepyowl.ui.components.PosterModel
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.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
import ru.stenfredd.sleepyowl.ui.components.StatusDot
import ru.stenfredd.sleepyowl.ui.components.WatchedBadge
import ru.stenfredd.sleepyowl.ui.components.Wordmark
import ru.stenfredd.sleepyowl.ui.components.dashedBorder
import ru.stenfredd.sleepyowl.ui.login.qrBitmap
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
import java.io.File
/**
* Draws the shared components with their desktop implementations (fonts and pictures from the classpath, Skia glow,
* BlurHash, QR) at the TV's density, and saves the picture to `build/reports/ui-shared/components.png` to look at.
*/
class DesktopRenderTest {
@Test
fun componentsRender() {
val scene = ImageComposeScene(960 * 2, 540 * 2, Density(2f)) {
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
Column(Modifier.fillMaxSize().background(AppColors.Background).padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OwlMark()
Wordmark()
BasicText("Главная · Медиатека", style = AppType.Display)
BasicText("01:23:45 · HEVC 10 бит", style = AppType.MonoMeta)
StatusDot(AppColors.Green, pulsing = false)
Avatar("Claude", "seed", 32.dp, AppType.CardTitle)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AppIcon.entries.forEach { Icon(it, AppColors.TextSecondary, Modifier.size(18.dp)) }
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
// Focused: its glow and ring are the desktop GlowCache.
PrimaryButton("Смотреть", onClick = {}, modifier = Modifier.focusRequester(focus))
SecondaryButton("С начала", onClick = {})
CircleButton(AppIcon.Heart, onClick = {})
Chip("Русский", selected = true, onClick = {}, detail = "AC3 5.1")
Chip("English", selected = false, onClick = {})
FormatBadge("4K")
FormatBadge("HDR10")
FormatBadge("Atmos")
NewBadge()
WatchedBadge()
FavoriteBadge()
}
ProgressLine(0.4f, Modifier.width(300.dp).height(3.dp))
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
// No image loader for the URL here: the BlurHash placeholder stays, with the shade over it.
RemoteImage(
RemoteImageSpec("test://none", "LEHV6nWB2yk8pyo0adR*.7kCMdnj"),
8.dp,
Modifier.size(190.dp, 107.dp),
shade = ImageShade(0.7f, listOf(0f to AppColors.Background, 1f to AppColors.Background.copy(alpha = 0f))),
)
PosterCard(PosterModel("p", "Бойцовский клуб", "1999 · ★ 8.4", null, badge = "1080p", progress = 0.3f, isNew = true), onClick = {})
val qr = remember { qrBitmap("https://jellyfin.example.com/web/#/quickconnect?code=123456") }
Spacer(
Modifier.size(100.dp).drawBehind {
drawImage(qr, dstSize = IntSize(size.width.toInt(), size.height.toInt()), filterQuality = FilterQuality.None)
},
)
Box(Modifier.size(150.dp, 80.dp).dashedBorder(AppColors.Border, 2.dp, 10.dp))
}
}
}
var frame = 0L
repeat(30) { scene.render(frame); frame += 16_000_000L }
val image = scene.render(frame)
val png = checkNotNull(image.encodeToData(EncodedImageFormat.PNG)).bytes
val out = File("build/reports/ui-shared/components.png")
out.parentFile.mkdirs()
out.writeBytes(png)
scene.close()
assertTrue(png.size > 10_000)
}
}