Shared KMP core and UI, switch to Koin
This commit is contained in:
40
ui/shared/build.gradle.kts
Normal file
40
ui/shared/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
BIN
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png
Normal file
BIN
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png
Normal file
BIN
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
ui/shared/src/androidMain/res/font/jetbrains_mono_400.ttf
Normal file
BIN
ui/shared/src/androidMain/res/font/jetbrains_mono_400.ttf
Normal file
Binary file not shown.
BIN
ui/shared/src/androidMain/res/font/jetbrains_mono_500.ttf
Normal file
BIN
ui/shared/src/androidMain/res/font/jetbrains_mono_500.ttf
Normal file
Binary file not shown.
BIN
ui/shared/src/androidMain/res/font/jetbrains_mono_600.ttf
Normal file
BIN
ui/shared/src/androidMain/res/font/jetbrains_mono_600.ttf
Normal file
Binary file not shown.
BIN
ui/shared/src/androidMain/res/font/onest_400.ttf
Normal file
BIN
ui/shared/src/androidMain/res/font/onest_400.ttf
Normal file
Binary file not shown.
BIN
ui/shared/src/androidMain/res/font/onest_500.ttf
Normal file
BIN
ui/shared/src/androidMain/res/font/onest_500.ttf
Normal file
Binary file not shown.
BIN
ui/shared/src/androidMain/res/font/onest_600.ttf
Normal file
BIN
ui/shared/src/androidMain/res/font/onest_600.ttf
Normal file
Binary file not shown.
BIN
ui/shared/src/androidMain/res/font/onest_700.ttf
Normal file
BIN
ui/shared/src/androidMain/res/font/onest_700.ttf
Normal file
Binary file not shown.
BIN
ui/shared/src/androidMain/res/font/onest_800.ttf
Normal file
BIN
ui/shared/src/androidMain/res/font/onest_800.ttf
Normal file
Binary file not shown.
@@ -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(" · ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 непросмотренным", "Отметки и места остановки всех серий сбросятся"))
|
||||
}
|
||||
}
|
||||
@@ -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()} мин"
|
||||
}
|
||||
@@ -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, "год", "года", "лет")} назад" }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")) }
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user