Data layer and baseline profile

This commit is contained in:
2026-10-14 11:52:33 +03:00
parent b86da05fee
commit c7d29e94ea
30 changed files with 51025 additions and 231 deletions

View File

@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.androidx.baselineprofile)
}
android {
@@ -49,6 +50,7 @@ android {
dependencies {
implementation(project(":core:jellyfin"))
implementation(project(":core:data"))
implementation(project(":playback:core"))
implementation(project(":playback:engine-exo"))
implementation(project(":playback:engine-vlc"))
@@ -73,4 +75,5 @@ dependencies {
ksp(libs.hilt.compiler)
implementation(libs.timber)
implementation(libs.androidx.profileinstaller)
baselineProfile(project(":baselineprofile"))
}

View File

@@ -7,13 +7,19 @@ import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.request.crossfade
import dagger.hilt.android.HiltAndroidApp
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp
class MoviesApp : Application(), SingletonImageLoader.Factory {
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
@Inject lateinit var httpClient: OkHttpClient
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
@@ -21,11 +27,13 @@ class MoviesApp : Application(), SingletonImageLoader.Factory {
/**
* Images: posters and stills are opaque, so RGB_565 halves their memory; the memory cache is capped
* well below the heap (the video buffer needs it); the disk cache keeps posters across launches.
* well below the heap (the video buffer needs it); the disk cache keeps posters across launches. Cache keys are the
* URLs on the server's public address, whichever address the image actually came from.
*/
override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context)
.memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.15).build() }
.diskCache { DiskCache.Builder().directory(context.cacheDir.resolve("images")).maxSizeBytes(200L * 1024 * 1024).build() }
.components { add(OkHttpNetworkFetcherFactory(callFactory = { httpClient })) }
.allowRgb565(true)
.crossfade(false)
.build()

View File

@@ -0,0 +1,113 @@
package ru.stenfredd.movies.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.cos
import kotlin.math.pow
import kotlin.math.roundToInt
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), 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.
*/
object BlurHash {
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 small = decode(hash, DECODE_SIZE, (DECODE_SIZE * heightPx / widthPx.toFloat()).roundToInt().coerceAtLeast(1)) ?: 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
}
/** Standard BlurHash decoding (https://blurha.sh), punch 1. */
fun decode(hash: String, width: Int, height: Int): Bitmap? {
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 Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
}
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)
private const val DECODE_SIZE = 16
/** The placeholder is a quarter of the card: it is a blur, upscaling it costs nothing visible. */
private const val SCALE = 4
private const val CACHE_ENTRIES = 300
}

View File

@@ -52,19 +52,22 @@ class CardImageTransformation(private val radiusPx: Float, private val shade: Im
)
}
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), radiusPx, radiusPx, paint)
if (shade != null) {
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)
canvas.drawRect(0f, top, w.toFloat(), h.toFloat(), gradient)
}
shade?.let { canvas.drawShade(it, w, h) }
return output
}
}
/** Draws [shade] over the bottom of a [w]×[h] image already on this canvas, only over its own (non-transparent) pixels. */
internal fun Canvas.drawShade(shade: ImageShade, w: Int, h: Int) {
val top = h * (1f - shade.height)
val gradient = Paint()
gradient.shader = LinearGradient(
0f, h.toFloat(), 0f, top,
shade.stops.map { it.second.toArgb() }.toIntArray(),
shade.stops.map { it.first }.toFloatArray(),
Shader.TileMode.CLAMP,
)
// Only over the image's own pixels, so the rounded corners stay transparent.
gradient.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
drawRect(0f, top, w.toFloat(), h.toFloat(), gradient)
}

View File

@@ -38,7 +38,7 @@ data class PosterModel(
val key: String,
val title: String,
val caption: String?,
val imageUrl: String?,
val image: RemoteImageSpec?,
/** Format badge at the bottom left: "4K", "1080p". */
val badge: String? = null,
/** Watch progress 0..1, or null. */
@@ -53,7 +53,7 @@ data class WideModel(
val key: String,
val title: String,
val subtitle: String,
val imageUrl: String?,
val image: RemoteImageSpec?,
val progress: Float?,
)
@@ -73,7 +73,7 @@ fun PosterCard(model: PosterModel, onClick: () -> Unit, modifier: Modifier = Mod
.size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp)
.clickable(interactionSource = null, indication = null, onClick = onClick),
) {
RemoteImage(model.imageUrl, PosterCorner, Modifier.fillMaxSize())
RemoteImage(model.image, PosterCorner, Modifier.fillMaxSize())
if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp))
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.
@@ -116,7 +116,7 @@ fun WideCard(model: WideModel, onClick: () -> Unit, modifier: Modifier = Modifie
.size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp)
.clickable(interactionSource = null, indication = null, onClick = onClick),
) {
RemoteImage(model.imageUrl, WideCorner, Modifier.fillMaxSize(), shade = WideShade)
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(
@@ -141,7 +141,7 @@ data class FeaturedModel(
val title: String,
val subtitle: String,
val description: String?,
val imageUrl: String?,
val image: RemoteImageSpec?,
val progress: Float,
val remaining: String?,
)
@@ -154,7 +154,7 @@ fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier =
.size(410.dp, 230.5.dp)
.clickable(interactionSource = null, indication = null, onClick = onClick),
) {
RemoteImage(model.imageUrl, FeaturedCorner, Modifier.fillMaxSize(), shade = FeaturedShade)
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(

View File

@@ -5,36 +5,63 @@ 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.Immutable
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.movies.ui.theme.AppColors
/** 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)
/**
* Server image with rounded corners.
*
* Corners and the optional text [shade] are baked into the bitmap when it is decoded ([CardImageTransformation]),
* instead of clipping and blending every frame; the rounded placeholder is drawn only until the image arrives.
* The image size comes from the URL (the view model asks the server for exactly the displayed size),
* so Coil only decodes. No crossfade: it would redraw every visible card for its duration.
* 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
fun RemoteImage(url: String?, corner: Dp, modifier: Modifier = Modifier, shade: ImageShade? = null, contentScale: ContentScale = ContentScale.Crop) {
fun RemoteImage(image: RemoteImageSpec?, corner: Dp, modifier: Modifier = Modifier, shade: ImageShade? = null, contentScale: ContentScale = ContentScale.Crop) {
val url = image?.url
var loaded by remember(url) { mutableStateOf(false) }
val shape = remember(corner) { RoundedCornerShape(corner) }
Box(if (loaded) modifier else modifier.background(AppColors.SurfaceRaised, shape)) {
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) { BlurHash.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 radius = with(LocalDensity.current) { corner.toPx() }
val request = remember(url, radius, shade) {
ImageRequest.Builder(context).data(url).transformations(CardImageTransformation(radius, shade)).build()
}

View File

@@ -1,34 +1,30 @@
package ru.stenfredd.movies.ui.home
import android.content.Context
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
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 org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import ru.stenfredd.movies.ui.Library
import ru.stenfredd.movies.core.data.HomeContent
import ru.stenfredd.movies.core.data.HomeRepository
import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.components.FeaturedModel
import ru.stenfredd.movies.ui.components.PosterModel
import ru.stenfredd.movies.ui.components.WideModel
import ru.stenfredd.movies.ui.theme.AppDimens
import ru.stenfredd.movies.ui.model.CardMapper
import timber.log.Timber
import java.time.LocalDateTime
import javax.inject.Inject
import kotlin.math.roundToInt
@Immutable
data class HomeRow(val key: String, val title: String, val count: Int, val items: List<PosterModel>)
data class HomeRow(val key: String, val title: String, val count: Int?, val items: List<PosterModel>)
@Immutable
data class HomeState(
@@ -40,155 +36,66 @@ data class HomeState(
val rows: List<HomeRow> = emptyList(),
)
/**
* Home screen data. Stage 1 version straight on top of [Library]; stage 2 moves loading into repositories.
*/
/** Home screen: [HomeRepository] content (shown at once from the last snapshot, then refreshed) as card models. */
@HiltViewModel
class HomeViewModel @Inject constructor(
private val library: Library,
@ApplicationContext context: Context,
private val home: HomeRepository,
private val cards: CardMapper,
) : ViewModel() {
private val density = context.resources.displayMetrics.density
private val _state = MutableStateFlow(HomeState())
val state: StateFlow<HomeState> = _state.asStateFlow()
/** Card key → where it leads. */
private val targets = HashMap<String, Screen>()
/** Card key → where it leads; replaced with every update. */
private var targets: Map<String, Screen> = emptyMap()
init { load() }
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 {
Timber.w(it, "Home load failed")
_state.value = HomeState(loading = false, error = it.message ?: it.toString())
}
.collect { (state, newTargets) ->
if (first) Timber.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 load() = viewModelScope.launch {
val started = System.nanoTime()
runCatching {
coroutineScope {
val resume = async { library.resume() }
val latest = async { library.latest() }
val views = library.views()
val perView = views.map { v -> async { v to library.libraryItems(v.id) } }
val resumeItems = resume.await()
val rows = buildList {
val l = latest.await()
if (l.isNotEmpty()) add(HomeRow("latest", "Недавно добавлено", l.size, l.map { poster(it, "latest") }))
perView.awaitAll().forEach { (v, r) ->
val (items, total) = r
if (items.isNotEmpty()) add(HomeRow("lib-${v.id}", v.name.orEmpty(), total, items.map { poster(it, "lib-${v.id}") }))
}
}
HomeState(
loading = false,
featured = resumeItems.firstOrNull()?.let(::featured),
continueItems = resumeItems.drop(1).take(4).map(::wide),
continueCount = resumeItems.size,
rows = rows,
)
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) }))
}
}.onSuccess {
_state.value = it
Timber.i("Home loaded in %d ms", (System.nanoTime() - started) / 1_000_000)
}.onFailure {
Timber.w(it, "Home load failed")
_state.value = HomeState(loading = false, error = it.message ?: it.toString())
}
}
private fun px(dp: Float) = (dp * density).roundToInt()
private fun poster(item: BaseItemDto, row: String): PosterModel {
val key = "$row/${item.id}"
targets[key] = item.target()
// A single new episode comes as the episode itself: show it as its series, poster included.
val episode = item.type == BaseItemKind.EPISODE
val imageId = if (episode) item.seriesId else item.id
val tag = if (episode) item.seriesPrimaryImageTag else item.imageTags?.get(ImageType.PRIMARY)
return PosterModel(
key = key,
title = (if (episode) item.seriesName else null) ?: item.name.orEmpty(),
caption = if (episode) {
listOfNotNull(item.parentIndexNumber?.let { "S$it" }, item.indexNumber?.let { "E$it" }).joinToString(" · ").ifEmpty { null }
} else {
listOfNotNull(item.productionYear?.toString(), item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null }
},
imageUrl = if (imageId != null && tag != null) library.imageUrl(imageId, ImageType.PRIMARY, tag, px(AppDimens.POSTER_W.toFloat()), px(AppDimens.POSTER_H.toFloat())) else null,
badge = item.formatBadge(),
progress = item.userData?.playedPercentage?.takeIf { it > 0 && item.type != BaseItemKind.SERIES }?.let { (it / 100).toFloat() },
isNew = item.dateCreated?.isAfter(LocalDateTime.now().minusDays(NEW_DAYS)) == true,
watched = item.userData?.played == true,
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,
)
}
private fun wide(item: BaseItemDto): WideModel {
val key = "continue/${item.id}"
targets[key] = Screen.Detail(item.id)
return WideModel(
key = key,
title = item.seriesName ?: item.name.orEmpty(),
subtitle = listOfNotNull(item.episodeLabel() ?: item.productionYear?.toString(), item.remaining()).joinToString(" · "),
imageUrl = item.landscapeImage(px(AppDimens.WIDE_W.toFloat()), px(AppDimens.WIDE_H.toFloat())),
progress = item.progress(),
)
}
private fun featured(item: BaseItemDto): FeaturedModel {
val key = "continue/${item.id}"
targets[key] = Screen.Detail(item.id)
val isEpisode = item.type == BaseItemKind.EPISODE
return FeaturedModel(
key = key,
label = if (isEpisode) "СЕРИАЛ" else "ФИЛЬМ",
title = item.seriesName ?: item.name.orEmpty(),
subtitle = if (isEpisode) {
item.episodeLabel().orEmpty()
} else {
listOfNotNull(item.productionYear?.toString(), item.genres?.take(2)?.joinToString(", ")?.ifEmpty { null }).joinToString(" · ")
},
description = item.overview,
imageUrl = item.backdrop(px(410f), px(230.5f)) ?: item.landscapeImage(px(410f), px(230.5f)),
progress = item.progress() ?: 0f,
remaining = item.remaining(),
)
}
private fun BaseItemDto.progress() = userData?.playedPercentage?.let { (it / 100).toFloat() }
private fun BaseItemDto.remaining(): String? {
val ticks = runTimeTicks ?: return null
val left = ticks * (1 - (progress() ?: 0f)) / 600_000_000
return "осталось ${left.roundToInt()} мин"
}
private fun BaseItemDto.episodeLabel(): String? =
if (type == BaseItemKind.EPISODE && parentIndexNumber != null && indexNumber != null) "S$parentIndexNumber · E$indexNumber · $name" else null
private fun BaseItemDto.backdrop(w: Int, h: Int): String? {
backdropImageTags?.firstOrNull()?.let { return library.imageUrl(id, ImageType.BACKDROP, it, w, h) }
val parent = parentBackdropItemId ?: return null
return parentBackdropImageTags?.firstOrNull()?.let { library.imageUrl(parent, ImageType.BACKDROP, it, w, h) }
}
/** Episode still, else the item's or series' backdrop. */
private fun BaseItemDto.landscapeImage(w: Int, h: Int): String? {
if (type == BaseItemKind.EPISODE) imageTags?.get(ImageType.PRIMARY)?.let { return library.imageUrl(id, ImageType.PRIMARY, it, w, h) }
return backdrop(w, h)
}
private fun BaseItemDto.formatBadge(): String? {
val w = width ?: return null
return when {
w >= 3200 -> "4K"
w >= 1800 -> "1080p"
w >= 1200 -> "720p"
else -> null
}
}
private fun BaseItemDto.target(): Screen = when (type) {
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.BOX_SET, BaseItemKind.FOLDER, BaseItemKind.COLLECTION_FOLDER -> Screen.Items(id, name.orEmpty())
else -> Screen.Detail(id)
return state to newTargets
}
private companion object {
const val NEW_DAYS = 14L
const val CONTINUE = "continue"
}
}

View File

@@ -0,0 +1,116 @@
package ru.stenfredd.movies.ui.model
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import ru.stenfredd.movies.core.data.ImageUrls
import ru.stenfredd.movies.core.data.model.ImageRef
import ru.stenfredd.movies.core.data.model.ItemKind
import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.components.FeaturedModel
import ru.stenfredd.movies.ui.components.PosterModel
import ru.stenfredd.movies.ui.components.RemoteImageSpec
import ru.stenfredd.movies.ui.components.WideModel
import ru.stenfredd.movies.ui.theme.AppDimens
import javax.inject.Inject
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 @Inject constructor(
private val images: ImageUrls,
@ApplicationContext context: Context,
) {
private val density = context.resources.displayMetrics.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 = item.isNew(),
watched = item.user.played,
)
}
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,
)
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(),
)
}
/** Where a card leads. Series and folders still open the old prototype list until their screens exist. */
fun target(item: ItemSummary): Screen = when (item.kind) {
ItemKind.Series, ItemKind.Season, ItemKind.BoxSet, ItemKind.Folder -> Screen.Items(item.id, item.name)
else -> Screen.Detail(item.id)
}
private fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? =
ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) }
/** Episode still, else the backdrop or thumb of the item or its series. */
private fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
private fun ItemSummary.remaining(): String? {
val runtime = runtimeMs ?: return null
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
return "осталось ${left.roundToInt()} мин"
}
private fun ItemSummary.episodeLabel(): String? =
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
private fun ItemSummary.isNew(): Boolean = 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
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff