Data layer and baseline profile
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
116
app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt
Normal file
116
app/src/main/kotlin/ru/stenfredd/movies/ui/model/CardMapper.kt
Normal 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
|
||||
}
|
||||
}
|
||||
24644
app/src/release/generated/baselineProfiles/baseline-prof.txt
Normal file
24644
app/src/release/generated/baselineProfiles/baseline-prof.txt
Normal file
File diff suppressed because it is too large
Load Diff
24644
app/src/release/generated/baselineProfiles/startup-prof.txt
Normal file
24644
app/src/release/generated/baselineProfiles/startup-prof.txt
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user