Data layer and baseline profile
This commit is contained in:
@@ -3,6 +3,7 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
|
alias(libs.plugins.androidx.baselineprofile)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -49,6 +50,7 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:jellyfin"))
|
implementation(project(":core:jellyfin"))
|
||||||
|
implementation(project(":core:data"))
|
||||||
implementation(project(":playback:core"))
|
implementation(project(":playback:core"))
|
||||||
implementation(project(":playback:engine-exo"))
|
implementation(project(":playback:engine-exo"))
|
||||||
implementation(project(":playback:engine-vlc"))
|
implementation(project(":playback:engine-vlc"))
|
||||||
@@ -73,4 +75,5 @@ dependencies {
|
|||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
implementation(libs.androidx.profileinstaller)
|
implementation(libs.androidx.profileinstaller)
|
||||||
|
baselineProfile(project(":baselineprofile"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,19 @@ import coil3.SingletonImageLoader
|
|||||||
import coil3.disk.DiskCache
|
import coil3.disk.DiskCache
|
||||||
import coil3.disk.directory
|
import coil3.disk.directory
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.allowRgb565
|
import coil3.request.allowRgb565
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class MoviesApp : Application(), SingletonImageLoader.Factory {
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Timber.plant(Timber.DebugTree())
|
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
|
* 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)
|
override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context)
|
||||||
.memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.15).build() }
|
.memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.15).build() }
|
||||||
.diskCache { DiskCache.Builder().directory(context.cacheDir.resolve("images")).maxSizeBytes(200L * 1024 * 1024).build() }
|
.diskCache { DiskCache.Builder().directory(context.cacheDir.resolve("images")).maxSizeBytes(200L * 1024 * 1024).build() }
|
||||||
|
.components { add(OkHttpNetworkFetcherFactory(callFactory = { httpClient })) }
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.crossfade(false)
|
.crossfade(false)
|
||||||
.build()
|
.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)
|
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), radiusPx, radiusPx, paint)
|
||||||
if (shade != null) {
|
shade?.let { canvas.drawShade(it, w, h) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
return output
|
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 key: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val caption: String?,
|
val caption: String?,
|
||||||
val imageUrl: String?,
|
val image: RemoteImageSpec?,
|
||||||
/** Format badge at the bottom left: "4K", "1080p". */
|
/** Format badge at the bottom left: "4K", "1080p". */
|
||||||
val badge: String? = null,
|
val badge: String? = null,
|
||||||
/** Watch progress 0..1, or null. */
|
/** Watch progress 0..1, or null. */
|
||||||
@@ -53,7 +53,7 @@ data class WideModel(
|
|||||||
val key: String,
|
val key: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val imageUrl: String?,
|
val image: RemoteImageSpec?,
|
||||||
val progress: Float?,
|
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)
|
.size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp)
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
.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.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp))
|
||||||
if (model.watched) WatchedBadge(Modifier.align(Alignment.TopEnd).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.
|
// 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)
|
.size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp)
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
.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)) {
|
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.title, style = AppType.CardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
BasicText(
|
BasicText(
|
||||||
@@ -141,7 +141,7 @@ data class FeaturedModel(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val imageUrl: String?,
|
val image: RemoteImageSpec?,
|
||||||
val progress: Float,
|
val progress: Float,
|
||||||
val remaining: String?,
|
val remaining: String?,
|
||||||
)
|
)
|
||||||
@@ -154,7 +154,7 @@ fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier =
|
|||||||
.size(410.dp, 230.5.dp)
|
.size(410.dp, 230.5.dp)
|
||||||
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
.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)) {
|
Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) {
|
||||||
BasicText(model.label, style = AppType.MonoLabel)
|
BasicText(model.label, style = AppType.MonoLabel)
|
||||||
BasicText(
|
BasicText(
|
||||||
|
|||||||
@@ -5,36 +5,63 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.ContentScale
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.transformations
|
import coil3.request.transformations
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import ru.stenfredd.movies.ui.theme.AppColors
|
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.
|
* Server image with rounded corners.
|
||||||
*
|
*
|
||||||
* Corners and the optional text [shade] are baked into the bitmap when it is decoded ([CardImageTransformation]),
|
* 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.
|
* instead of clipping and blending every frame. Until the image arrives the card shows its [BlurHash] placeholder
|
||||||
* The image size comes from the URL (the view model asks the server for exactly the displayed size),
|
* (decoded off the main thread), or a plain rounded rectangle. The image size comes from the URL, so Coil only
|
||||||
* so Coil only decodes. No crossfade: it would redraw every visible card for its duration.
|
* decodes. No crossfade: it would redraw every visible card for its duration.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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) }
|
var loaded by remember(url) { mutableStateOf(false) }
|
||||||
val shape = remember(corner) { RoundedCornerShape(corner) }
|
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) {
|
if (url != null) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val radius = with(LocalDensity.current) { corner.toPx() }
|
|
||||||
val request = remember(url, radius, shade) {
|
val request = remember(url, radius, shade) {
|
||||||
ImageRequest.Builder(context).data(url).transformations(CardImageTransformation(radius, shade)).build()
|
ImageRequest.Builder(context).data(url).transformations(CardImageTransformation(radius, shade)).build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
package ru.stenfredd.movies.ui.home
|
package ru.stenfredd.movies.ui.home
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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 kotlinx.coroutines.launch
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import ru.stenfredd.movies.core.data.HomeContent
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import ru.stenfredd.movies.core.data.HomeRepository
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
import ru.stenfredd.movies.ui.Library
|
|
||||||
import ru.stenfredd.movies.ui.Screen
|
import ru.stenfredd.movies.ui.Screen
|
||||||
import ru.stenfredd.movies.ui.components.FeaturedModel
|
import ru.stenfredd.movies.ui.components.FeaturedModel
|
||||||
import ru.stenfredd.movies.ui.components.PosterModel
|
import ru.stenfredd.movies.ui.components.PosterModel
|
||||||
import ru.stenfredd.movies.ui.components.WideModel
|
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 timber.log.Timber
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@Immutable
|
@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
|
@Immutable
|
||||||
data class HomeState(
|
data class HomeState(
|
||||||
@@ -40,155 +36,66 @@ data class HomeState(
|
|||||||
val rows: List<HomeRow> = emptyList(),
|
val rows: List<HomeRow> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Home screen: [HomeRepository] content (shown at once from the last snapshot, then refreshed) as card models. */
|
||||||
* Home screen data. Stage 1 version straight on top of [Library]; stage 2 moves loading into repositories.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeViewModel @Inject constructor(
|
class HomeViewModel @Inject constructor(
|
||||||
private val library: Library,
|
private val home: HomeRepository,
|
||||||
@ApplicationContext context: Context,
|
private val cards: CardMapper,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val density = context.resources.displayMetrics.density
|
|
||||||
private val _state = MutableStateFlow(HomeState())
|
private val _state = MutableStateFlow(HomeState())
|
||||||
val state: StateFlow<HomeState> = _state.asStateFlow()
|
val state: StateFlow<HomeState> = _state.asStateFlow()
|
||||||
|
|
||||||
/** Card key → where it leads. */
|
/** Card key → where it leads; replaced with every update. */
|
||||||
private val targets = HashMap<String, Screen>()
|
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]
|
fun target(key: String): Screen? = targets[key]
|
||||||
|
|
||||||
private fun load() = viewModelScope.launch {
|
private fun build(content: HomeContent): Pair<HomeState, Map<String, Screen>> {
|
||||||
val started = System.nanoTime()
|
val newTargets = HashMap<String, Screen>()
|
||||||
runCatching {
|
fun <T> ItemSummary.card(row: String, map: (ItemSummary, String) -> T): T {
|
||||||
coroutineScope {
|
newTargets[cards.key(row, this)] = cards.target(this)
|
||||||
val resume = async { library.resume() }
|
return map(this, row)
|
||||||
val latest = async { library.latest() }
|
}
|
||||||
val views = library.views()
|
val rows = buildList {
|
||||||
val perView = views.map { v -> async { v to library.libraryItems(v.id) } }
|
if (content.latest.isNotEmpty()) add(HomeRow("latest", "Недавно добавлено", null, content.latest.map { it.card("latest", cards::poster) }))
|
||||||
val resumeItems = resume.await()
|
content.libraries.forEach { lib ->
|
||||||
val rows = buildList {
|
val key = "lib-${lib.library.id}"
|
||||||
val l = latest.await()
|
if (lib.items.isNotEmpty()) add(HomeRow(key, lib.library.name, lib.total, lib.items.map { it.card(key, cards::poster) }))
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}.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())
|
|
||||||
}
|
}
|
||||||
}
|
val cont = content.continueWatching
|
||||||
|
val state = HomeState(
|
||||||
private fun px(dp: Float) = (dp * density).roundToInt()
|
loading = false,
|
||||||
|
featured = cont.firstOrNull()?.card(CONTINUE, cards::featured),
|
||||||
private fun poster(item: BaseItemDto, row: String): PosterModel {
|
continueItems = cont.drop(1).take(4).map { it.card(CONTINUE, cards::wide) },
|
||||||
val key = "$row/${item.id}"
|
continueCount = cont.size,
|
||||||
targets[key] = item.target()
|
rows = rows,
|
||||||
// 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,
|
|
||||||
)
|
)
|
||||||
}
|
return state to newTargets
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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
31
baselineprofile/build.gradle.kts
Normal file
31
baselineprofile/build.gradle.kts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.test)
|
||||||
|
alias(libs.plugins.androidx.baselineprofile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates the app's baseline profile on a connected device (the test TV, logged in):
|
||||||
|
// ./gradlew :app:generateReleaseBaselineProfile
|
||||||
|
android {
|
||||||
|
namespace = "ru.stenfredd.movies.baselineprofile"
|
||||||
|
compileSdk = 37
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 28
|
||||||
|
targetSdk = 36
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
targetProjectPath = ":app"
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baselineProfile {
|
||||||
|
useConnectedDevices = true
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.test.junit)
|
||||||
|
implementation(libs.androidx.uiautomator)
|
||||||
|
implementation(libs.androidx.benchmark.macro.junit4)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package ru.stenfredd.movies.baselineprofile
|
||||||
|
|
||||||
|
import androidx.benchmark.macro.junit4.BaselineProfileRule
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.uiautomator.By
|
||||||
|
import androidx.test.uiautomator.Until
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records which code the app runs on its main path, so it is compiled ahead of time on install instead of being
|
||||||
|
* interpreted and JIT-compiled while the user scrolls (first passes over a screen used to drop frames).
|
||||||
|
*
|
||||||
|
* Path: cold start → home (snapshot, then fresh data) → along "Continue watching" → down through the rows, along
|
||||||
|
* each → open a card → back. The device must be logged in.
|
||||||
|
*
|
||||||
|
* Run: `./gradlew :app:generateReleaseBaselineProfile` with the TV connected over adb. The result is written to
|
||||||
|
* `app/src/release/generated/baselineProfiles/` and goes into the release APK.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class BaselineProfileGenerator {
|
||||||
|
@get:Rule
|
||||||
|
val rule = BaselineProfileRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun generate() = rule.collect(packageName = PACKAGE, includeInStartupProfile = true) {
|
||||||
|
pressHome()
|
||||||
|
startActivityAndWait()
|
||||||
|
check(device.wait(Until.hasObject(By.text("Продолжить просмотр")), 15_000)) { "Home did not show: is the TV logged in?" }
|
||||||
|
// Let the fresh data replace the snapshot.
|
||||||
|
device.waitForIdle()
|
||||||
|
Thread.sleep(2_000)
|
||||||
|
|
||||||
|
repeat(3) { press { device.pressDPadRight() } }
|
||||||
|
repeat(3) { press { device.pressDPadLeft() } }
|
||||||
|
repeat(ROWS) {
|
||||||
|
press { device.pressDPadDown() }
|
||||||
|
repeat(6) { press { device.pressDPadRight() } }
|
||||||
|
}
|
||||||
|
repeat(ROWS) { press { device.pressDPadUp() } }
|
||||||
|
|
||||||
|
press { device.pressDPadCenter() }
|
||||||
|
Thread.sleep(2_000)
|
||||||
|
press { device.pressBack() }
|
||||||
|
Thread.sleep(1_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun press(action: () -> Unit) {
|
||||||
|
action()
|
||||||
|
Thread.sleep(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PACKAGE = "ru.stenfredd.movies"
|
||||||
|
const val ROWS = 6
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.android.test) apply false
|
||||||
|
alias(libs.plugins.androidx.baselineprofile) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
alias(libs.plugins.hilt) apply false
|
alias(libs.plugins.hilt) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
24
core/data/build.gradle.kts
Normal file
24
core/data/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ru.stenfredd.movies.core.data"
|
||||||
|
compileSdk = 37
|
||||||
|
defaultConfig { minSdk = 26 }
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(project(":core:jellyfin"))
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
implementation(libs.timber)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package ru.stenfredd.movies.core.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.SystemClock
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemQuery
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
|
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||||
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HomeContent(
|
||||||
|
val continueWatching: List<ItemSummary>,
|
||||||
|
val latest: List<ItemSummary>,
|
||||||
|
val libraries: List<LibraryRow>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A library's row on home: its newest items and, once counted, how many movies and series it has. */
|
||||||
|
@Serializable
|
||||||
|
data class LibraryRow(val library: LibraryView, val items: List<ItemSummary>, val total: Int? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home screen content.
|
||||||
|
*
|
||||||
|
* Shown at once from what is known: the last content of this run, else the snapshot saved on disk at the previous
|
||||||
|
* run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов")
|
||||||
|
* cost the server ~0.2 s each, so they come last, in a separate update.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class HomeRepository @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
private val session: JellyfinSession,
|
||||||
|
private val cache: SessionCache,
|
||||||
|
private val library: LibraryRepository,
|
||||||
|
private val watch: WatchRepository,
|
||||||
|
) {
|
||||||
|
private class Remembered(val content: HomeContent, val fetchedAt: Long)
|
||||||
|
|
||||||
|
private val remembered = ConcurrentHashMap<String, Remembered>()
|
||||||
|
private val dir = File(context.filesDir, "home")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Watch state changed: what we have is still worth showing at once, but it must be fetched again.
|
||||||
|
scope.launch { cache.invalidations.collect { remembered.replaceAll { _, r -> Remembered(r.content, 0) } } }
|
||||||
|
// A signed-out account's home must not stay on the device.
|
||||||
|
scope.launch {
|
||||||
|
var previous = emptySet<String>()
|
||||||
|
session.accounts.collect { list ->
|
||||||
|
val current = list.mapTo(HashSet()) { it.id }
|
||||||
|
(previous - current).forEach { gone ->
|
||||||
|
remembered.remove(gone)
|
||||||
|
File(dir, "${fileName(gone)}.json").delete()
|
||||||
|
}
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Home content, updated again whenever watch state changes on the server (e.g. after playback). */
|
||||||
|
fun home(): Flow<HomeContent> = flow {
|
||||||
|
val account = session.requireSession().accountId
|
||||||
|
val known = remembered[account]
|
||||||
|
var shown = known?.content ?: readSnapshot(account)
|
||||||
|
if (shown != null) emit(shown)
|
||||||
|
if (known == null || SystemClock.elapsedRealtime() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
||||||
|
cache.invalidations.collect { shown = refresh(account, shown) }
|
||||||
|
}.flowOn(Dispatchers.Default)
|
||||||
|
|
||||||
|
/** With something already on screen a failed refresh only gets logged; with nothing to show it is the error. */
|
||||||
|
private suspend fun FlowCollector<HomeContent>.refresh(account: String, shown: HomeContent?): HomeContent = try {
|
||||||
|
fetch(account, shown)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (shown == null) throw e
|
||||||
|
Timber.w(e, "Home refresh failed, keeping what is shown")
|
||||||
|
shown
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent {
|
||||||
|
val started = SystemClock.elapsedRealtime()
|
||||||
|
val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
|
||||||
|
val fresh = coroutineScope {
|
||||||
|
val continueWatching = async { watch.continueWatching() }
|
||||||
|
val latest = async { library.latest() }
|
||||||
|
val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } }
|
||||||
|
HomeContent(continueWatching.await(), latest.await(), rows.awaitAll())
|
||||||
|
}
|
||||||
|
Timber.i("Home fetched in %d ms", SystemClock.elapsedRealtime() - started)
|
||||||
|
emit(fresh)
|
||||||
|
remember(account, fresh)
|
||||||
|
|
||||||
|
val counted = fresh.copy(
|
||||||
|
libraries = coroutineScope {
|
||||||
|
fresh.libraries.map { row ->
|
||||||
|
async { row.copy(total = runCatching { library.count(ItemQuery(parentId = row.library.id)) }.getOrNull() ?: row.total) }
|
||||||
|
}.awaitAll()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (counted == fresh) return fresh
|
||||||
|
emit(counted)
|
||||||
|
remember(account, counted)
|
||||||
|
return counted
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun remember(account: String, content: HomeContent) {
|
||||||
|
remembered[account] = Remembered(content, SystemClock.elapsedRealtime())
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
dir.mkdirs()
|
||||||
|
val tmp = File(dir, "${fileName(account)}.tmp")
|
||||||
|
tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
|
||||||
|
tmp.renameTo(File(dir, "${fileName(account)}.json"))
|
||||||
|
}.onFailure { Timber.w(it, "Home snapshot not saved") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) {
|
||||||
|
val file = File(dir, "${fileName(account)}.json")
|
||||||
|
if (!file.exists()) return@withContext null
|
||||||
|
val started = SystemClock.elapsedRealtime()
|
||||||
|
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
|
||||||
|
.onSuccess { Timber.i("Home snapshot read in %d ms (%d KB)", SystemClock.elapsedRealtime() - started, file.length() / 1024) }
|
||||||
|
.onFailure { Timber.w(it, "Home snapshot unreadable") }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileName(account: String) = account.replace(Regex("[^A-Za-z0-9-]"), "_")
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Coming back to home within this time shows what was loaded without asking the server again. */
|
||||||
|
const val FRESH_MS = 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package ru.stenfredd.movies.core.data
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import ru.stenfredd.movies.core.data.model.ImageKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.ImageRef
|
||||||
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps).
|
||||||
|
*
|
||||||
|
* URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the
|
||||||
|
* request itself goes to the LAN address (`ServerRouting`).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ImageUrls @Inject constructor(private val session: JellyfinSession) {
|
||||||
|
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
|
||||||
|
session.api.imageApi.getItemImageUrl(
|
||||||
|
itemId = ref.itemId,
|
||||||
|
imageType = ref.kind.toImageType(),
|
||||||
|
fillWidth = widthPx,
|
||||||
|
fillHeight = heightPx,
|
||||||
|
quality = quality,
|
||||||
|
tag = ref.tag,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ImageKind.toImageType() = when (this) {
|
||||||
|
ImageKind.Primary -> ImageType.PRIMARY
|
||||||
|
ImageKind.Backdrop -> ImageType.BACKDROP
|
||||||
|
ImageKind.Thumb -> ImageType.THUMB
|
||||||
|
ImageKind.Logo -> ImageType.LOGO
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val QUALITY = 90
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package ru.stenfredd.movies.core.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.personApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.showApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewApi
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFilter
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import ru.stenfredd.movies.core.data.SessionCache.Kind
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemDetails
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemQuery
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemSort
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
|
import ru.stenfredd.movies.core.data.model.LibraryType
|
||||||
|
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||||
|
import ru.stenfredd.movies.core.data.model.Page
|
||||||
|
import ru.stenfredd.movies.core.data.model.SearchResults
|
||||||
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
|
||||||
|
@Singleton
|
||||||
|
class LibraryRepository @Inject constructor(
|
||||||
|
private val session: JellyfinSession,
|
||||||
|
private val cache: SessionCache,
|
||||||
|
) {
|
||||||
|
private val userId get() = session.requireSession().userId
|
||||||
|
|
||||||
|
/** Video libraries of the user, in the server's order (music, photos, live TV are left out). */
|
||||||
|
suspend fun views(): List<LibraryView> = cache.get("views", Kind.Stable) {
|
||||||
|
session.call { userViewApi.getUserViews(userId = userId).content.items }
|
||||||
|
.map { it.toLibraryView() }
|
||||||
|
.filter { it.type != LibraryType.Other }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest items, episodes grouped into their series (a series with new episodes comes as the series, or as the
|
||||||
|
* episode itself when it is the only new one). Cheap on the server, unlike [items] sorted by date.
|
||||||
|
*/
|
||||||
|
suspend fun latest(parentId: UUID? = null, limit: Int = 20): List<ItemSummary> = cache.get("latest:$parentId:$limit", Kind.UserData) {
|
||||||
|
session.call {
|
||||||
|
libraryApi.getLatestMedia(userId = userId, parentId = parentId, limit = limit, groupItems = true, fields = CARD_FIELDS).content
|
||||||
|
}.map { it.toSummary() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of a filtered, sorted list. The total is only counted when [withTotal] (the server pays ~0.2 s for it). */
|
||||||
|
suspend fun items(query: ItemQuery, start: Int, limit: Int, withTotal: Boolean = true): Page<ItemSummary> =
|
||||||
|
cache.get("items:$query:$start:$limit:$withTotal", Kind.UserData) {
|
||||||
|
val result = session.call {
|
||||||
|
libraryApi.getItems(
|
||||||
|
userId = userId,
|
||||||
|
parentId = query.parentId,
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = query.kinds.mapNotNull { it.toBaseKind() },
|
||||||
|
sortBy = query.sort.toSortBy(),
|
||||||
|
sortOrder = listOf(if (query.descending) SortOrder.DESCENDING else SortOrder.ASCENDING),
|
||||||
|
filters = buildList {
|
||||||
|
if (query.unplayedOnly) add(ItemFilter.IS_UNPLAYED)
|
||||||
|
if (query.playedOnly) add(ItemFilter.IS_PLAYED)
|
||||||
|
if (query.favoritesOnly) add(ItemFilter.IS_FAVORITE)
|
||||||
|
},
|
||||||
|
genres = query.genre?.let { listOf(it) },
|
||||||
|
personIds = query.personId?.let { listOf(it) },
|
||||||
|
startIndex = start,
|
||||||
|
limit = limit,
|
||||||
|
fields = CARD_FIELDS,
|
||||||
|
enableTotalRecordCount = withTotal,
|
||||||
|
).content
|
||||||
|
}
|
||||||
|
Page(result.items.map { it.toSummary() }, start, if (withTotal) result.totalRecordCount else start + result.items.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of items matching [query]; kept longer than the lists, it changes rarely. */
|
||||||
|
suspend fun count(query: ItemQuery): Int = cache.get("count:$query", Kind.Stable) {
|
||||||
|
session.call {
|
||||||
|
libraryApi.getItems(
|
||||||
|
userId = userId,
|
||||||
|
parentId = query.parentId,
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = query.kinds.mapNotNull { it.toBaseKind() },
|
||||||
|
filters = buildList {
|
||||||
|
if (query.unplayedOnly) add(ItemFilter.IS_UNPLAYED)
|
||||||
|
if (query.favoritesOnly) add(ItemFilter.IS_FAVORITE)
|
||||||
|
},
|
||||||
|
limit = 0,
|
||||||
|
enableTotalRecordCount = true,
|
||||||
|
).content.totalRecordCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun details(id: UUID): ItemDetails = cache.get("details:$id", Kind.UserData) {
|
||||||
|
session.call { libraryApi.getItem(itemId = id, userId = userId).content }.toDetails()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun seasons(seriesId: UUID): List<ItemSummary> = cache.get("seasons:$seriesId", Kind.UserData) {
|
||||||
|
session.call { showApi.getSeasons(seriesId = seriesId, userId = userId, fields = CARD_FIELDS).content.items }.map { it.toSummary() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episodes of one season, or of the whole series when [seasonId] is null. */
|
||||||
|
suspend fun episodes(seriesId: UUID, seasonId: UUID? = null): List<ItemSummary> = cache.get("episodes:$seriesId:$seasonId", Kind.UserData) {
|
||||||
|
session.call {
|
||||||
|
showApi.getEpisodes(seriesId = seriesId, userId = userId, seasonId = seasonId, fields = FEATURED_FIELDS).content.items
|
||||||
|
}.map { it.toSummary() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun similar(id: UUID, limit: Int = 12): List<ItemSummary> = cache.get("similar:$id:$limit", Kind.UserData) {
|
||||||
|
session.call { libraryApi.getSimilarItems(itemId = id, userId = userId, limit = limit, fields = CARD_FIELDS).content.items }
|
||||||
|
.map { it.toSummary() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Movies, series and episodes matching [term], and people. Not cached: every keystroke is a new term. */
|
||||||
|
suspend fun search(term: String, limit: Int = 60): SearchResults = coroutineScope {
|
||||||
|
val items = async {
|
||||||
|
session.call {
|
||||||
|
libraryApi.getItems(
|
||||||
|
userId = userId,
|
||||||
|
searchTerm = term,
|
||||||
|
recursive = true,
|
||||||
|
includeItemTypes = listOf(ItemKind.Movie, ItemKind.Series, ItemKind.Episode).mapNotNull { it.toBaseKind() },
|
||||||
|
limit = limit,
|
||||||
|
fields = CARD_FIELDS,
|
||||||
|
enableTotalRecordCount = false,
|
||||||
|
).content.items
|
||||||
|
}.map { it.toSummary() }
|
||||||
|
}
|
||||||
|
val people = async {
|
||||||
|
session.call { personApi.getPersons(searchTerm = term, userId = userId, limit = PEOPLE_LIMIT).content.items }.map { it.toPerson() }
|
||||||
|
}
|
||||||
|
SearchResults(items.await(), people.await())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ItemSort.toSortBy(): List<ItemSortBy> = when (this) {
|
||||||
|
ItemSort.Name -> listOf(ItemSortBy.SORT_NAME)
|
||||||
|
ItemSort.DateAdded -> listOf(ItemSortBy.DATE_CREATED, ItemSortBy.SORT_NAME)
|
||||||
|
ItemSort.Year -> listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME)
|
||||||
|
ItemSort.Rating -> listOf(ItemSortBy.COMMUNITY_RATING, ItemSortBy.SORT_NAME)
|
||||||
|
ItemSort.DatePlayed -> listOf(ItemSortBy.DATE_PLAYED)
|
||||||
|
ItemSort.PremiereDate -> listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.SORT_NAME)
|
||||||
|
ItemSort.Random -> listOf(ItemSortBy.RANDOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PEOPLE_LIMIT = 20
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package ru.stenfredd.movies.core.data
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.api.AudioSpatialFormat
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||||
|
import ru.stenfredd.movies.core.data.model.AudioTrack
|
||||||
|
import ru.stenfredd.movies.core.data.model.Chapter
|
||||||
|
import ru.stenfredd.movies.core.data.model.ImageKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.ImageRef
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemDetails
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
|
import ru.stenfredd.movies.core.data.model.LibraryType
|
||||||
|
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||||
|
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||||
|
import ru.stenfredd.movies.core.data.model.Person
|
||||||
|
import ru.stenfredd.movies.core.data.model.PersonKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.SubtitleTrack
|
||||||
|
import ru.stenfredd.movies.core.data.model.UserState
|
||||||
|
import ru.stenfredd.movies.core.data.model.VideoRange
|
||||||
|
import ru.stenfredd.movies.core.data.model.VideoTrack
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Fields every list asks for on top of the defaults: the format badge, "new" and episode counts. */
|
||||||
|
internal val CARD_FIELDS = listOf(ItemFields.WIDTH, ItemFields.DATE_CREATED, ItemFields.CHILD_COUNT)
|
||||||
|
|
||||||
|
/** Lists whose first card is shown big (continue watching) also need the description and genres. */
|
||||||
|
internal val FEATURED_FIELDS = CARD_FIELDS + listOf(ItemFields.OVERVIEW, ItemFields.GENRES)
|
||||||
|
|
||||||
|
internal val DETAIL_FIELDS = FEATURED_FIELDS + listOf(
|
||||||
|
ItemFields.ORIGINAL_TITLE, ItemFields.TAGLINES, ItemFields.STUDIOS, ItemFields.PEOPLE, ItemFields.MEDIA_SOURCES,
|
||||||
|
ItemFields.MEDIA_STREAMS, ItemFields.CHAPTERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val TICKS_PER_MS = 10_000L
|
||||||
|
|
||||||
|
internal fun BaseItemDto.toSummary(): ItemSummary {
|
||||||
|
val kind = type.toKind()
|
||||||
|
val primaryTag = imageTags?.get(ImageType.PRIMARY)
|
||||||
|
return ItemSummary(
|
||||||
|
id = id,
|
||||||
|
kind = kind,
|
||||||
|
name = name.orEmpty(),
|
||||||
|
year = productionYear,
|
||||||
|
communityRating = communityRating,
|
||||||
|
officialRating = officialRating,
|
||||||
|
runtimeMs = runTimeTicks?.div(TICKS_PER_MS),
|
||||||
|
overview = overview,
|
||||||
|
genres = genres.orEmpty(),
|
||||||
|
videoWidth = width,
|
||||||
|
dateAddedAt = dateCreated?.epochMs(),
|
||||||
|
premiereAt = premiereDate?.epochMs(),
|
||||||
|
seriesId = seriesId,
|
||||||
|
seriesName = seriesName,
|
||||||
|
seasonId = seasonId,
|
||||||
|
seasonNumber = if (kind == ItemKind.Season) indexNumber else parentIndexNumber,
|
||||||
|
episodeNumber = if (kind == ItemKind.Episode) indexNumber else null,
|
||||||
|
episodeNumberEnd = if (kind == ItemKind.Episode) indexNumberEnd else null,
|
||||||
|
childCount = childCount,
|
||||||
|
user = userData?.let { u ->
|
||||||
|
UserState(
|
||||||
|
played = u.played,
|
||||||
|
favorite = u.isFavorite,
|
||||||
|
progress = u.playedPercentage?.takeIf { it > 0 && kind != ItemKind.Series && kind != ItemKind.Season }?.let { (it / 100).toFloat() },
|
||||||
|
positionMs = u.playbackPositionTicks / TICKS_PER_MS,
|
||||||
|
unplayedCount = u.unplayedItemCount,
|
||||||
|
lastPlayedAt = u.lastPlayedDate?.epochMs(),
|
||||||
|
)
|
||||||
|
} ?: UserState(),
|
||||||
|
primary = primaryTag?.let { image(id, ImageKind.Primary, it) },
|
||||||
|
backdrop = backdropImageTags?.firstOrNull()?.let { image(id, ImageKind.Backdrop, it) }
|
||||||
|
?: parentBackdropItemId?.let { pid -> parentBackdropImageTags?.firstOrNull()?.let { image(pid, ImageKind.Backdrop, it) } },
|
||||||
|
thumb = imageTags?.get(ImageType.THUMB)?.let { image(id, ImageKind.Thumb, it) }
|
||||||
|
?: parentThumbItemId?.let { pid -> parentThumbImageTag?.let { image(pid, ImageKind.Thumb, it) } }
|
||||||
|
?: seriesId?.let { sid -> seriesThumbImageTag?.let { image(sid, ImageKind.Thumb, it) } },
|
||||||
|
seriesPrimary = seriesId?.let { sid -> seriesPrimaryImageTag?.let { image(sid, ImageKind.Primary, it) } }
|
||||||
|
?: if (kind == ItemKind.Season) parentPrimaryImageItemId?.let { pid -> parentPrimaryImageTag?.let { image(pid, ImageKind.Primary, it) } } else null,
|
||||||
|
logo = imageTags?.get(ImageType.LOGO)?.let { image(id, ImageKind.Logo, it) }
|
||||||
|
?: parentLogoItemId?.let { pid -> parentLogoImageTag?.let { image(pid, ImageKind.Logo, it) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BaseItemDto.toDetails(): ItemDetails = ItemDetails(
|
||||||
|
summary = toSummary(),
|
||||||
|
originalTitle = originalTitle?.takeIf { it != name },
|
||||||
|
tagline = taglines?.firstOrNull(),
|
||||||
|
studios = studios.orEmpty().mapNotNull { it.name },
|
||||||
|
people = people.orEmpty().map { it.toPerson() },
|
||||||
|
media = mediaSources?.firstOrNull()?.toMediaInfo(),
|
||||||
|
chapters = chapters.orEmpty().map { Chapter(it.name.orEmpty(), it.startPositionTicks / TICKS_PER_MS) },
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun BaseItemDto.toLibraryView(): LibraryView = LibraryView(
|
||||||
|
id = id,
|
||||||
|
name = name.orEmpty(),
|
||||||
|
type = when (collectionType) {
|
||||||
|
CollectionType.MOVIES -> LibraryType.Movies
|
||||||
|
CollectionType.TVSHOWS -> LibraryType.Shows
|
||||||
|
null -> LibraryType.Mixed
|
||||||
|
else -> LibraryType.Other
|
||||||
|
},
|
||||||
|
image = imageTags?.get(ImageType.PRIMARY)?.let { image(id, ImageKind.Primary, it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun BaseItemPerson.toPerson() = Person(
|
||||||
|
id = id,
|
||||||
|
name = name.orEmpty(),
|
||||||
|
role = role?.takeIf { it.isNotBlank() },
|
||||||
|
kind = when (type.serialName) {
|
||||||
|
"Actor", "GuestStar" -> PersonKind.Actor
|
||||||
|
"Director" -> PersonKind.Director
|
||||||
|
"Writer" -> PersonKind.Writer
|
||||||
|
"Producer" -> PersonKind.Producer
|
||||||
|
else -> PersonKind.Other
|
||||||
|
},
|
||||||
|
image = primaryImageTag?.let { ImageRef(id, ImageKind.Primary, it, imageBlurHashes?.get(ImageType.PRIMARY)?.get(it)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun BaseItemDto.toPerson() = Person(
|
||||||
|
id = id,
|
||||||
|
name = name.orEmpty(),
|
||||||
|
role = null,
|
||||||
|
kind = PersonKind.Other,
|
||||||
|
image = imageTags?.get(ImageType.PRIMARY)?.let { image(id, ImageKind.Primary, it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MediaSourceInfo.toMediaInfo(): MediaInfo {
|
||||||
|
val streams = mediaStreams.orEmpty()
|
||||||
|
return MediaInfo(
|
||||||
|
sourceId = id.orEmpty(),
|
||||||
|
container = container,
|
||||||
|
sizeBytes = size,
|
||||||
|
bitrate = bitrate,
|
||||||
|
video = streams.firstOrNull { it.type == MediaStreamType.VIDEO }?.toVideo(),
|
||||||
|
audio = streams.filter { it.type == MediaStreamType.AUDIO }.map { it.toAudio(defaultAudioStreamIndex) },
|
||||||
|
subtitles = streams.filter { it.type == MediaStreamType.SUBTITLE }.map { it.toSubtitle(defaultSubtitleStreamIndex) },
|
||||||
|
defaultAudioIndex = defaultAudioStreamIndex,
|
||||||
|
defaultSubtitleIndex = defaultSubtitleStreamIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaStream.toVideo() = VideoTrack(
|
||||||
|
codec = codec,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
range = videoRangeType.toRange(),
|
||||||
|
dvProfile = dvProfile,
|
||||||
|
bitDepth = bitDepth,
|
||||||
|
frameRate = realFrameRate ?: averageFrameRate,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun VideoRangeType.toRange(): VideoRange = when {
|
||||||
|
this == VideoRangeType.SDR -> VideoRange.SDR
|
||||||
|
this == VideoRangeType.HLG -> VideoRange.HLG
|
||||||
|
serialName.startsWith("DOVI") -> VideoRange.DolbyVision
|
||||||
|
serialName == "HDR10Plus" -> VideoRange.HDR10Plus
|
||||||
|
serialName == "HDR10" -> VideoRange.HDR10
|
||||||
|
else -> VideoRange.Other
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaStream.toAudio(defaultIndex: Int?) = AudioTrack(
|
||||||
|
index = index,
|
||||||
|
title = displayTitle ?: title ?: codec.orEmpty(),
|
||||||
|
language = language,
|
||||||
|
codec = codec,
|
||||||
|
channels = channels,
|
||||||
|
atmos = audioSpatialFormat == AudioSpatialFormat.DOLBY_ATMOS || profile?.contains("Atmos", ignoreCase = true) == true,
|
||||||
|
isDefault = index == defaultIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MediaStream.toSubtitle(defaultIndex: Int?) = SubtitleTrack(
|
||||||
|
index = index,
|
||||||
|
title = displayTitle ?: title ?: codec.orEmpty(),
|
||||||
|
language = language,
|
||||||
|
codec = codec,
|
||||||
|
external = isExternal,
|
||||||
|
forced = isForced,
|
||||||
|
isDefault = index == defaultIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun BaseItemDto.image(itemId: UUID, kind: ImageKind, tag: String): ImageRef {
|
||||||
|
val type = when (kind) {
|
||||||
|
ImageKind.Primary -> ImageType.PRIMARY
|
||||||
|
ImageKind.Backdrop -> ImageType.BACKDROP
|
||||||
|
ImageKind.Thumb -> ImageType.THUMB
|
||||||
|
ImageKind.Logo -> ImageType.LOGO
|
||||||
|
}
|
||||||
|
return ImageRef(itemId, kind, tag, imageBlurHashes?.get(type)?.get(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun BaseItemKind.toKind(): ItemKind = when (this) {
|
||||||
|
BaseItemKind.MOVIE -> ItemKind.Movie
|
||||||
|
BaseItemKind.SERIES -> ItemKind.Series
|
||||||
|
BaseItemKind.SEASON -> ItemKind.Season
|
||||||
|
BaseItemKind.EPISODE -> ItemKind.Episode
|
||||||
|
BaseItemKind.BOX_SET -> ItemKind.BoxSet
|
||||||
|
BaseItemKind.FOLDER, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.USER_VIEW -> ItemKind.Folder
|
||||||
|
BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO -> ItemKind.Video
|
||||||
|
else -> ItemKind.Other
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ItemKind.toBaseKind(): BaseItemKind? = when (this) {
|
||||||
|
ItemKind.Movie -> BaseItemKind.MOVIE
|
||||||
|
ItemKind.Series -> BaseItemKind.SERIES
|
||||||
|
ItemKind.Season -> BaseItemKind.SEASON
|
||||||
|
ItemKind.Episode -> BaseItemKind.EPISODE
|
||||||
|
ItemKind.BoxSet -> BaseItemKind.BOX_SET
|
||||||
|
ItemKind.Folder -> BaseItemKind.FOLDER
|
||||||
|
ItemKind.Video -> BaseItemKind.VIDEO
|
||||||
|
ItemKind.Other -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SDK converts server dates to the device's time zone. */
|
||||||
|
private fun LocalDateTime.epochMs(): Long = atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package ru.stenfredd.movies.core.data
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait
|
||||||
|
* for the network again. Identical requests in flight share one call.
|
||||||
|
*
|
||||||
|
* Everything that depends on watch state is dropped when the server reports a change ([JellyfinSession.userDataChanges]:
|
||||||
|
* playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and
|
||||||
|
* counts ([Kind.Stable]) only expire with time. Switching the account drops everything.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||||
|
enum class Kind(val ttlMs: Long) {
|
||||||
|
/** Lists with watch state: resume, next up, latest, details. */
|
||||||
|
UserData(10 * 60_000L),
|
||||||
|
/** Libraries, item counts. */
|
||||||
|
Stable(30 * 60_000L),
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Entry(val kind: Kind, val createdAt: Long, val value: CompletableDeferred<Any?>)
|
||||||
|
|
||||||
|
private val entries = ConcurrentHashMap<String, Entry>()
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val _invalidations = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
|
/** Emits after user-data entries were dropped: screens showing such data should reload. */
|
||||||
|
val invalidations: SharedFlow<Unit> = _invalidations.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
session.userDataChanges.collect {
|
||||||
|
drop(Kind.UserData)
|
||||||
|
_invalidations.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
session.session.distinctUntilChangedBy { it?.accountId }.drop(1).collect { entries.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
suspend fun <T> get(key: String, kind: Kind, load: suspend () -> T): T {
|
||||||
|
// Per account, so a call of the previous account finishing after a switch can't land in the new one's cache.
|
||||||
|
val scoped = "${session.session.value?.accountId}:$key"
|
||||||
|
while (true) {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val mine = Entry(kind, now, CompletableDeferred())
|
||||||
|
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
|
||||||
|
if (entry !== mine) {
|
||||||
|
try {
|
||||||
|
return entry.value.await() as T
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// The caller that was loading it went away; load it ourselves unless we are cancelled too.
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
// Off the main thread: turning DTOs into models of a big response takes a few ms.
|
||||||
|
withContext(Dispatchers.Default) { load() }.also { mine.value.complete(it) }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
entries.remove(scoped, mine)
|
||||||
|
mine.value.completeExceptionally(e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drop(kind: Kind) {
|
||||||
|
val before = entries.size
|
||||||
|
entries.entries.removeAll { it.value.kind == kind }
|
||||||
|
Timber.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package ru.stenfredd.movies.core.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.showApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userDataApi
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import ru.stenfredd.movies.core.data.SessionCache.Kind
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemQuery
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemSort
|
||||||
|
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||||
|
import ru.stenfredd.movies.core.data.model.Page
|
||||||
|
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */
|
||||||
|
@Singleton
|
||||||
|
class WatchRepository @Inject constructor(
|
||||||
|
private val session: JellyfinSession,
|
||||||
|
private val cache: SessionCache,
|
||||||
|
private val library: LibraryRepository,
|
||||||
|
) {
|
||||||
|
private val userId get() = session.requireSession().userId
|
||||||
|
|
||||||
|
/** Started movies and episodes, most recently watched first. */
|
||||||
|
suspend fun resume(limit: Int = 20): List<ItemSummary> = cache.get("resume:$limit", Kind.UserData) {
|
||||||
|
session.call {
|
||||||
|
libraryApi.getResumeItems(userId = userId, limit = limit, mediaTypes = listOf(MediaType.VIDEO), fields = FEATURED_FIELDS).content.items
|
||||||
|
}.map { it.toSummary() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next unwatched episode of each series in progress. */
|
||||||
|
suspend fun nextUp(limit: Int = 20): List<ItemSummary> = cache.get("nextup:$limit", Kind.UserData) {
|
||||||
|
session.call {
|
||||||
|
showApi.getNextUp(userId = userId, limit = limit, fields = FEATURED_FIELDS, enableResumable = false, enableRewatching = false).content.items
|
||||||
|
}.map { it.toSummary() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Continue watching": started items first (most recent first), then the next episode of each series in progress
|
||||||
|
* that isn't already there.
|
||||||
|
*/
|
||||||
|
suspend fun continueWatching(limit: Int = 20): List<ItemSummary> = coroutineScope {
|
||||||
|
val resume = async { resume(limit) }
|
||||||
|
val nextUp = async { nextUp(limit) }
|
||||||
|
val started = resume.await()
|
||||||
|
val seriesStarted = started.mapNotNullTo(HashSet()) { it.seriesId }
|
||||||
|
(started + nextUp.await().filter { it.seriesId !in seriesStarted && started.none { s -> s.id == it.id } }).take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Watched movies and episodes, most recent first. */
|
||||||
|
suspend fun history(start: Int, limit: Int): Page<ItemSummary> = library.items(
|
||||||
|
ItemQuery(kinds = setOf(ItemKind.Movie, ItemKind.Episode), sort = ItemSort.DatePlayed, descending = true, playedOnly = true),
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun favorites(start: Int, limit: Int, kinds: Set<ItemKind> = setOf(ItemKind.Movie, ItemKind.Series)): Page<ItemSummary> =
|
||||||
|
library.items(ItemQuery(kinds = kinds, favoritesOnly = true, sort = ItemSort.Name), start, limit)
|
||||||
|
|
||||||
|
suspend fun setPlayed(id: UUID, played: Boolean) {
|
||||||
|
session.call { if (played) userDataApi.markPlayedItem(itemId = id, userId = userId) else userDataApi.markUnplayedItem(itemId = id, userId = userId) }
|
||||||
|
session.notifyUserDataChanged(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setFavorite(id: UUID, favorite: Boolean) {
|
||||||
|
session.call { if (favorite) userDataApi.markFavoriteItem(itemId = id, userId = userId) else userDataApi.unmarkFavoriteItem(itemId = id, userId = userId) }
|
||||||
|
session.notifyUserDataChanged(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
@file:UseSerializers(UUIDSerializer::class)
|
||||||
|
|
||||||
|
package ru.stenfredd.movies.core.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.UseSerializers
|
||||||
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/*
|
||||||
|
* App-side models of server data. Screens and view models work with these, never with SDK DTOs: they hold only what
|
||||||
|
* the app shows, are immutable (the UI module marks this package stable for Compose) and serializable (the home
|
||||||
|
* snapshot on disk).
|
||||||
|
*/
|
||||||
|
|
||||||
|
enum class ItemKind { Movie, Series, Season, Episode, BoxSet, Folder, Video, Other }
|
||||||
|
|
||||||
|
enum class ImageKind { Primary, Backdrop, Thumb, Logo }
|
||||||
|
|
||||||
|
/** One server image; the URL with the needed size comes from `ImageUrls`. */
|
||||||
|
@Serializable
|
||||||
|
data class ImageRef(val itemId: UUID, val kind: ImageKind, val tag: String, val blurHash: String? = null)
|
||||||
|
|
||||||
|
/** Per-user state of an item. */
|
||||||
|
@Serializable
|
||||||
|
data class UserState(
|
||||||
|
val played: Boolean = false,
|
||||||
|
val favorite: Boolean = false,
|
||||||
|
/** 0..1 for a started item, else null. */
|
||||||
|
val progress: Float? = null,
|
||||||
|
val positionMs: Long = 0,
|
||||||
|
/** Unwatched episodes of a series or season. */
|
||||||
|
val unplayedCount: Int? = null,
|
||||||
|
val lastPlayedAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** An item as cards and lists show it: movie, series, season or episode. */
|
||||||
|
@Serializable
|
||||||
|
data class ItemSummary(
|
||||||
|
val id: UUID,
|
||||||
|
val kind: ItemKind,
|
||||||
|
val name: String,
|
||||||
|
val year: Int? = null,
|
||||||
|
val communityRating: Float? = null,
|
||||||
|
val officialRating: String? = null,
|
||||||
|
val runtimeMs: Long? = null,
|
||||||
|
val overview: String? = null,
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
/** Width of the main video stream, px: the "4K" badge. */
|
||||||
|
val videoWidth: Int? = null,
|
||||||
|
val dateAddedAt: Long? = null,
|
||||||
|
val premiereAt: Long? = null,
|
||||||
|
val seriesId: UUID? = null,
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seasonId: UUID? = null,
|
||||||
|
/** Season number of an episode, or of the season itself. */
|
||||||
|
val seasonNumber: Int? = null,
|
||||||
|
val episodeNumber: Int? = null,
|
||||||
|
val episodeNumberEnd: Int? = null,
|
||||||
|
val childCount: Int? = null,
|
||||||
|
val user: UserState = UserState(),
|
||||||
|
/** Poster (movie, series, season) or still (episode). */
|
||||||
|
val primary: ImageRef? = null,
|
||||||
|
/** The item's own backdrop, else its parent's (series for an episode). */
|
||||||
|
val backdrop: ImageRef? = null,
|
||||||
|
/** Landscape thumb of the item or its series. */
|
||||||
|
val thumb: ImageRef? = null,
|
||||||
|
/** Poster of the series an episode or season belongs to. */
|
||||||
|
val seriesPrimary: ImageRef? = null,
|
||||||
|
val logo: ImageRef? = null,
|
||||||
|
) {
|
||||||
|
val isPlayable: Boolean get() = kind == ItemKind.Movie || kind == ItemKind.Episode || kind == ItemKind.Video
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A user view (library) on the server. */
|
||||||
|
@Serializable
|
||||||
|
data class LibraryView(val id: UUID, val name: String, val type: LibraryType, val image: ImageRef? = null)
|
||||||
|
|
||||||
|
enum class LibraryType { Movies, Shows, Mixed, Other }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Person(val id: UUID, val name: String, val role: String?, val kind: PersonKind, val image: ImageRef?)
|
||||||
|
|
||||||
|
enum class PersonKind { Actor, Director, Writer, Producer, Other }
|
||||||
|
|
||||||
|
/** One page of a long list: [items] start at [start] of [total]. */
|
||||||
|
data class Page<T>(val items: List<T>, val start: Int, val total: Int) {
|
||||||
|
val end: Int get() = start + items.size
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ItemSort { Name, DateAdded, Year, Rating, DatePlayed, PremiereDate, Random }
|
||||||
|
|
||||||
|
/** Filter and order of an item list (library grid, favourites, history). */
|
||||||
|
data class ItemQuery(
|
||||||
|
val parentId: UUID? = null,
|
||||||
|
val kinds: Set<ItemKind> = setOf(ItemKind.Movie, ItemKind.Series),
|
||||||
|
val sort: ItemSort = ItemSort.Name,
|
||||||
|
val descending: Boolean = false,
|
||||||
|
val unplayedOnly: Boolean = false,
|
||||||
|
val playedOnly: Boolean = false,
|
||||||
|
val favoritesOnly: Boolean = false,
|
||||||
|
val genre: String? = null,
|
||||||
|
val personId: UUID? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Everything the movie/episode/series screens show about one item. */
|
||||||
|
data class ItemDetails(
|
||||||
|
val summary: ItemSummary,
|
||||||
|
val originalTitle: String?,
|
||||||
|
val tagline: String?,
|
||||||
|
val studios: List<String>,
|
||||||
|
val people: List<Person>,
|
||||||
|
val media: MediaInfo?,
|
||||||
|
val chapters: List<Chapter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Chapter(val name: String, val startMs: Long)
|
||||||
|
|
||||||
|
/** The file of a playable item: what the format badges and the "file" line show, and the track choices. */
|
||||||
|
data class MediaInfo(
|
||||||
|
val sourceId: String,
|
||||||
|
val container: String?,
|
||||||
|
val sizeBytes: Long?,
|
||||||
|
val bitrate: Int?,
|
||||||
|
val video: VideoTrack?,
|
||||||
|
val audio: List<AudioTrack>,
|
||||||
|
val subtitles: List<SubtitleTrack>,
|
||||||
|
val defaultAudioIndex: Int?,
|
||||||
|
val defaultSubtitleIndex: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class VideoTrack(
|
||||||
|
val codec: String?,
|
||||||
|
val width: Int?,
|
||||||
|
val height: Int?,
|
||||||
|
val range: VideoRange,
|
||||||
|
val dvProfile: Int?,
|
||||||
|
val bitDepth: Int?,
|
||||||
|
val frameRate: Float?,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class VideoRange { SDR, HDR10, HDR10Plus, HLG, DolbyVision, Other }
|
||||||
|
|
||||||
|
data class AudioTrack(
|
||||||
|
val index: Int,
|
||||||
|
val title: String,
|
||||||
|
val language: String?,
|
||||||
|
val codec: String?,
|
||||||
|
val channels: Int?,
|
||||||
|
/** Dolby Atmos (TrueHD or E-AC-3 JOC). */
|
||||||
|
val atmos: Boolean,
|
||||||
|
val isDefault: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SubtitleTrack(
|
||||||
|
val index: Int,
|
||||||
|
val title: String,
|
||||||
|
val language: String?,
|
||||||
|
val codec: String?,
|
||||||
|
val external: Boolean,
|
||||||
|
val forced: Boolean,
|
||||||
|
val isDefault: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Search results: items (grouped by kind on screen) and people. */
|
||||||
|
data class SearchResults(val items: List<ItemSummary>, val people: List<Person>)
|
||||||
@@ -17,6 +17,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
api(libs.jellyfin.sdk)
|
api(libs.jellyfin.sdk)
|
||||||
api(libs.kotlinx.coroutines.android)
|
api(libs.kotlinx.coroutines.android)
|
||||||
|
api(libs.okhttp)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package ru.stenfredd.movies.core.jellyfin
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */
|
||||||
|
data class Account(
|
||||||
|
val serverId: String,
|
||||||
|
val serverName: String,
|
||||||
|
/** Address the user entered (usually the public HTTPS name); API calls are addressed to it. */
|
||||||
|
val serverUrl: String,
|
||||||
|
val userId: UUID,
|
||||||
|
val userName: String,
|
||||||
|
val accessToken: String,
|
||||||
|
/** Direct LAN address of the same server found last time, see [LocalServerDiscovery]; tried first on the next start. */
|
||||||
|
val localUrl: String? = null,
|
||||||
|
val lastUsedAt: Long = 0,
|
||||||
|
) {
|
||||||
|
val id: String get() = accountId(serverId, userId)
|
||||||
|
|
||||||
|
internal fun toJson(): JSONObject = JSONObject()
|
||||||
|
.put("serverId", serverId)
|
||||||
|
.put("serverName", serverName)
|
||||||
|
.put("serverUrl", serverUrl)
|
||||||
|
.put("userId", userId.toString())
|
||||||
|
.put("userName", userName)
|
||||||
|
.put("token", accessToken)
|
||||||
|
.put("localUrl", localUrl ?: JSONObject.NULL)
|
||||||
|
.put("lastUsedAt", lastUsedAt)
|
||||||
|
|
||||||
|
internal companion object {
|
||||||
|
fun fromJson(o: JSONObject) = Account(
|
||||||
|
serverId = o.getString("serverId"),
|
||||||
|
serverName = o.optString("serverName"),
|
||||||
|
serverUrl = o.getString("serverUrl"),
|
||||||
|
userId = UUID.fromString(o.getString("userId")),
|
||||||
|
userName = o.optString("userName"),
|
||||||
|
accessToken = o.getString("token"),
|
||||||
|
localUrl = o.optString("localUrl").takeIf { it.isNotEmpty() && it != "null" },
|
||||||
|
lastUsedAt = o.optLong("lastUsedAt"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun accountId(serverId: String, userId: UUID) = "$serverId/$userId"
|
||||||
|
|
||||||
|
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
|
||||||
|
|
||||||
|
/** Saved accounts and the device id, in DataStore; the list is a small JSON array. */
|
||||||
|
internal class AccountStore(private val context: Context) {
|
||||||
|
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
|
||||||
|
|
||||||
|
suspend fun read(): Saved {
|
||||||
|
val prefs = context.sessionStore.data.first()
|
||||||
|
return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDeviceId(id: String) {
|
||||||
|
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies [change] to the saved list and sets the current account; returns the new list. */
|
||||||
|
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
|
||||||
|
var result = emptyList<Account>()
|
||||||
|
context.sessionStore.edit { prefs ->
|
||||||
|
result = change(parse(prefs))
|
||||||
|
prefs[KEY_ACCOUNTS] = JSONArray(result.map { it.toJson() }).toString()
|
||||||
|
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single session saved before accounts existed (prototype); null when there is none. */
|
||||||
|
suspend fun legacySession(): LegacySession? {
|
||||||
|
val prefs = context.sessionStore.data.first()
|
||||||
|
return LegacySession(
|
||||||
|
url = prefs[LEGACY_URL] ?: return null,
|
||||||
|
token = prefs[LEGACY_TOKEN] ?: return null,
|
||||||
|
userId = prefs[LEGACY_USER_ID]?.let(UUID::fromString) ?: return null,
|
||||||
|
userName = prefs[LEGACY_USER_NAME].orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun dropLegacySession() {
|
||||||
|
context.sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String)
|
||||||
|
|
||||||
|
private fun parse(prefs: Preferences): List<Account> {
|
||||||
|
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
|
||||||
|
val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList()
|
||||||
|
return (0 until array.length()).mapNotNull { runCatching { Account.fromJson(array.getJSONObject(it)) }.getOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY_ACCOUNTS = stringPreferencesKey("accounts")
|
||||||
|
val KEY_CURRENT = stringPreferencesKey("current_account")
|
||||||
|
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
||||||
|
val LEGACY_URL = stringPreferencesKey("server_url")
|
||||||
|
val LEGACY_TOKEN = stringPreferencesKey("access_token")
|
||||||
|
val LEGACY_USER_ID = stringPreferencesKey("user_id")
|
||||||
|
val LEGACY_USER_NAME = stringPreferencesKey("user_name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package ru.stenfredd.movies.core.jellyfin
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting]. */
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object HttpModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun serverRouting(): ServerRouting = ServerRouting()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun okHttpClient(routing: ServerRouting): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(routing)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -3,22 +3,26 @@ package ru.stenfredd.movies.core.jellyfin
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||||
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||||
|
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||||
import org.jellyfin.sdk.createJellyfin
|
import org.jellyfin.sdk.createJellyfin
|
||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
@@ -27,95 +31,133 @@ import java.util.UUID
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
|
|
||||||
|
|
||||||
data class SessionInfo(
|
data class SessionInfo(
|
||||||
|
val accountId: String,
|
||||||
|
val serverId: String,
|
||||||
val serverUrl: String,
|
val serverUrl: String,
|
||||||
val userId: UUID,
|
val userId: UUID,
|
||||||
val userName: String,
|
val userName: String,
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Holds the single authenticated [ApiClient]; persists the session across restarts. */
|
/**
|
||||||
|
* Holds the authenticated [ApiClient] of the current account and the accounts saved on the device.
|
||||||
|
*
|
||||||
|
* All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public
|
||||||
|
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class JellyfinSession @Inject constructor(
|
class JellyfinSession @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
private val routing: ServerRouting,
|
||||||
) {
|
) {
|
||||||
|
private val store = AccountStore(context)
|
||||||
private val jellyfin = createJellyfin {
|
private val jellyfin = createJellyfin {
|
||||||
context = this@JellyfinSession.context
|
context = this@JellyfinSession.context
|
||||||
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
|
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
|
||||||
|
apiClientFactory = OkHttpFactory(httpClient)
|
||||||
}
|
}
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
private val _session = MutableStateFlow<SessionInfo?>(null)
|
private val _session = MutableStateFlow<SessionInfo?>(null)
|
||||||
val session: StateFlow<SessionInfo?> = _session.asStateFlow()
|
val session: StateFlow<SessionInfo?> = _session.asStateFlow()
|
||||||
|
|
||||||
|
private val _accounts = MutableStateFlow<List<Account>>(emptyList())
|
||||||
|
/** Saved accounts, most recently used first. */
|
||||||
|
val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
|
||||||
|
|
||||||
|
private val _userDataChanges = MutableSharedFlow<UUID>(extraBufferCapacity = 16)
|
||||||
|
/** Items whose watch state, progress or favourite flag just changed on the server (playback stopped, marked …). */
|
||||||
|
val userDataChanges: SharedFlow<UUID> = _userDataChanges.asSharedFlow()
|
||||||
|
|
||||||
lateinit var api: ApiClient
|
lateinit var api: ApiClient
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val deviceId: String get() = api.deviceInfo.id
|
val deviceId: String get() = api.deviceInfo.id
|
||||||
|
|
||||||
/** Direct LAN address of the same server for media streams, see [LocalServerDiscovery]; null away from home. */
|
|
||||||
@Volatile
|
|
||||||
private var localUrl: String? = null
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var localCheckedAt = 0L
|
private var localCheckedAt = 0L
|
||||||
private val discoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
init {
|
||||||
|
routing.onLocalFailed = {
|
||||||
|
localCheckedAt = 0
|
||||||
|
refreshLocalAddress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct LAN address of the current server, or null (away from home, or not found yet). */
|
||||||
|
val localUrl: String? get() = routing.localUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Looks for the server on the local network in the background (at most every [LOCAL_RECHECK_MS]); playback uses
|
* Looks for the server on the local network in the background (at most every [LOCAL_RECHECK_MS]). Requests use
|
||||||
* whatever is known at that moment and never waits for it.
|
* whatever is known at that moment and never wait for it; the address found is remembered for the next start.
|
||||||
*/
|
*/
|
||||||
fun refreshLocalAddress() {
|
fun refreshLocalAddress() {
|
||||||
val s = _session.value ?: return
|
val s = _session.value ?: return
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = SystemClock.elapsedRealtime()
|
||||||
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
|
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
|
||||||
localCheckedAt = now
|
localCheckedAt = now
|
||||||
discoveryScope.launch {
|
backgroundScope.launch {
|
||||||
val id = LocalServerDiscovery.serverId(s.serverUrl)
|
val found = LocalServerDiscovery.find(s.serverId)
|
||||||
val found = id?.let { LocalServerDiscovery.find(it) }
|
if (_session.value?.accountId != s.accountId) return@launch
|
||||||
if (found != localUrl) Timber.i("Media streams: %s", found ?: "через ${s.serverUrl}")
|
if (found != routing.localUrl) Timber.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
|
||||||
localUrl = found
|
routing.set(s.serverUrl, found)
|
||||||
|
mutex.withLock {
|
||||||
|
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
|
||||||
|
_accounts.value = store.update(s.accountId) { list -> list.map { if (it.id == s.accountId) it.copy(localUrl = found) else it } }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restores the saved session. Returns false when the user has to log in. */
|
/** Restores the current account. Returns false when the user has to log in. */
|
||||||
suspend fun restore(): Boolean {
|
suspend fun restore(): Boolean = mutex.withLock {
|
||||||
val prefs = context.sessionStore.data.first()
|
val saved = store.read()
|
||||||
api = jellyfin.createApi(deviceInfo = deviceInfo(prefs[KEY_DEVICE_ID] ?: newDeviceId()))
|
api = jellyfin.createApi(deviceInfo = deviceInfo(saved.deviceId ?: newDeviceId()))
|
||||||
val url = prefs[KEY_URL] ?: return false
|
var accounts = saved.accounts
|
||||||
val token = prefs[KEY_TOKEN] ?: return false
|
var currentId = saved.currentId
|
||||||
val userId = prefs[KEY_USER_ID]?.let(UUID::fromString) ?: return false
|
if (accounts.isEmpty()) migrateLegacy()?.let { account ->
|
||||||
api.update(baseUrl = url, accessToken = token)
|
accounts = listOf(account)
|
||||||
_session.value = SessionInfo(url, userId, prefs[KEY_USER_NAME].orEmpty(), token)
|
currentId = account.id
|
||||||
refreshLocalAddress()
|
}
|
||||||
return true
|
_accounts.value = accounts.sortedByDescending { it.lastUsedAt }
|
||||||
|
val current = accounts.firstOrNull { it.id == currentId } ?: return false
|
||||||
|
activate(current)
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(serverUrl: String, userName: String, password: String) {
|
suspend fun login(serverUrl: String, userName: String, password: String) {
|
||||||
val url = serverUrl.trim().trimEnd('/')
|
val url = serverUrl.trim().trimEnd('/')
|
||||||
api.update(baseUrl = url, accessToken = null)
|
mutex.withLock {
|
||||||
val result = call { authenticationApi.authenticateUserByName(userName, password).content }
|
routing.set(null, null)
|
||||||
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
api.update(baseUrl = url, accessToken = null)
|
||||||
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
val result = call { authenticationApi.authenticateUserByName(userName, password).content }
|
||||||
api.update(accessToken = token)
|
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
||||||
context.sessionStore.edit {
|
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
||||||
it[KEY_URL] = url
|
api.update(accessToken = token)
|
||||||
it[KEY_TOKEN] = token
|
val serverId = result.serverId ?: user.serverId ?: url
|
||||||
it[KEY_USER_ID] = user.id.toString()
|
val serverName = runCatching { call { systemApi.getPublicSystemInfo().content.serverName } }.getOrNull().orEmpty()
|
||||||
it[KEY_USER_NAME] = user.name.orEmpty()
|
val account = Account(serverId, serverName, url, user.id, user.name.orEmpty(), token, lastUsedAt = System.currentTimeMillis())
|
||||||
|
save(account)
|
||||||
|
activate(account)
|
||||||
}
|
}
|
||||||
_session.value = SessionInfo(url, user.id, user.name.orEmpty(), token)
|
|
||||||
localUrl = null
|
|
||||||
localCheckedAt = 0
|
|
||||||
refreshLocalAddress()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
/** Makes a saved account current (profile switch); its token is reused, no password needed. */
|
||||||
context.sessionStore.edit {
|
suspend fun switchTo(accountId: String) = mutex.withLock {
|
||||||
it.remove(KEY_TOKEN)
|
val account = _accounts.value.first { it.id == accountId }.copy(lastUsedAt = System.currentTimeMillis())
|
||||||
it.remove(KEY_USER_ID)
|
save(account)
|
||||||
}
|
activate(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs out of the current account and forgets it on this device. */
|
||||||
|
suspend fun logout() = mutex.withLock {
|
||||||
|
val current = _session.value ?: return@withLock
|
||||||
|
_accounts.value = store.update(currentId = null) { list -> list.filterNot { it.id == current.accountId } }
|
||||||
api.update(accessToken = null)
|
api.update(accessToken = null)
|
||||||
|
routing.set(null, null)
|
||||||
_session.value = null
|
_session.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,13 +166,19 @@ class JellyfinSession @Inject constructor(
|
|||||||
|
|
||||||
fun requireSession(): SessionInfo = checkNotNull(_session.value) { "Нет активной сессии" }
|
fun requireSession(): SessionInfo = checkNotNull(_session.value) { "Нет активной сессии" }
|
||||||
|
|
||||||
|
/** Tells screens that [itemId]'s user data changed on the server, so cached lists (resume, next up …) reload. */
|
||||||
|
fun notifyUserDataChanged(itemId: UUID) {
|
||||||
|
_userDataChanges.tryEmit(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Absolute URL for a server-relative path or a URL on the server, with the token as `ApiKey` (engines can't send
|
* Absolute URL for a server-relative path or a URL on the server, with the token as `ApiKey` (engines can't send
|
||||||
* headers). Media goes to the LAN address when one was found; API calls keep using [SessionInfo.serverUrl].
|
* headers). Media goes to the LAN address when one is known; engines use their own HTTP stacks, so the address is
|
||||||
|
* substituted here rather than by [ServerRouting].
|
||||||
*/
|
*/
|
||||||
fun authorizedUrl(pathOrUrl: String): String {
|
fun authorizedUrl(pathOrUrl: String): String {
|
||||||
val s = requireSession()
|
val s = requireSession()
|
||||||
val base = localUrl ?: s.serverUrl
|
val base = routing.localUrl ?: s.serverUrl
|
||||||
val absolute = when {
|
val absolute = when {
|
||||||
pathOrUrl.startsWith(s.serverUrl) -> base + pathOrUrl.removePrefix(s.serverUrl)
|
pathOrUrl.startsWith(s.serverUrl) -> base + pathOrUrl.removePrefix(s.serverUrl)
|
||||||
pathOrUrl.startsWith("http") -> pathOrUrl
|
pathOrUrl.startsWith("http") -> pathOrUrl
|
||||||
@@ -141,9 +189,34 @@ class JellyfinSession @Inject constructor(
|
|||||||
return "$absolute${separator}${ApiClient.QUERY_ACCESS_TOKEN}=${s.accessToken}"
|
return "$absolute${separator}${ApiClient.QUERY_ACCESS_TOKEN}=${s.accessToken}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun activate(account: Account) {
|
||||||
|
api.update(baseUrl = account.serverUrl, accessToken = account.accessToken)
|
||||||
|
// The LAN address from last time is used right away; a wrong one costs one fast failed connect.
|
||||||
|
routing.set(account.serverUrl, account.localUrl)
|
||||||
|
_session.value = SessionInfo(account.id, account.serverId, account.serverUrl, account.userId, account.userName, account.accessToken)
|
||||||
|
localCheckedAt = 0
|
||||||
|
refreshLocalAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun save(account: Account) {
|
||||||
|
_accounts.value = store.update(account.id) { list -> (listOf(account) + list.filterNot { it.id == account.id }) }
|
||||||
|
.sortedByDescending { it.lastUsedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The prototype kept one session in separate keys; turn it into the first saved account. */
|
||||||
|
private suspend fun migrateLegacy(): Account? {
|
||||||
|
val legacy = store.legacySession() ?: return null
|
||||||
|
val serverId = LocalServerDiscovery.serverId(legacy.url) ?: legacy.url
|
||||||
|
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
|
||||||
|
store.update(account.id) { listOf(account) }
|
||||||
|
store.dropLegacySession()
|
||||||
|
Timber.i("Migrated the saved session to account %s", account.id)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun newDeviceId(): String {
|
private suspend fun newDeviceId(): String {
|
||||||
val id = UUID.randomUUID().toString()
|
val id = UUID.randomUUID().toString()
|
||||||
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
|
store.setDeviceId(id)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,11 +225,6 @@ class JellyfinSession @Inject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val CLIENT_NAME = "Movies (prototype)"
|
const val CLIENT_NAME = "Movies (prototype)"
|
||||||
const val CLIENT_VERSION = "0.1.0"
|
const val CLIENT_VERSION = "0.1.0"
|
||||||
val KEY_URL = stringPreferencesKey("server_url")
|
|
||||||
val KEY_TOKEN = stringPreferencesKey("access_token")
|
|
||||||
val KEY_USER_ID = stringPreferencesKey("user_id")
|
|
||||||
val KEY_USER_NAME = stringPreferencesKey("user_name")
|
|
||||||
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
|
||||||
const val LOCAL_RECHECK_MS = 10 * 60_000L
|
const val LOCAL_RECHECK_MS = 10 * 60_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package ru.stenfredd.movies.core.jellyfin
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends requests for the server's public address to its LAN address when one is known.
|
||||||
|
*
|
||||||
|
* API calls and images are built on the public address (it is also the image cache key, so the cache stays valid
|
||||||
|
* at home and away); at home this interceptor swaps the origin to the plain-HTTP LAN address, which skips the router
|
||||||
|
* hairpin, the reverse proxy and TLS on the TV's weak CPU. If the LAN address can't be connected to, the request goes
|
||||||
|
* to the public address and [onLocalFailed] lets the session look for the server again.
|
||||||
|
*/
|
||||||
|
class ServerRouting internal constructor() : Interceptor {
|
||||||
|
private class Route(val public: HttpUrl, val local: HttpUrl)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var route: Route? = null
|
||||||
|
|
||||||
|
internal var onLocalFailed: (() -> Unit)? = null
|
||||||
|
|
||||||
|
val localUrl: String? get() = route?.local?.toString()?.trimEnd('/')
|
||||||
|
|
||||||
|
internal fun set(publicUrl: String?, localUrl: String?) {
|
||||||
|
val public = publicUrl?.toHttpUrlOrNull()
|
||||||
|
val local = localUrl?.toHttpUrlOrNull()
|
||||||
|
route = if (public != null && local != null) Route(public, local) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val r = route ?: return chain.proceed(request)
|
||||||
|
val local = rewrite(request.url, r) ?: return chain.proceed(request)
|
||||||
|
return try {
|
||||||
|
// A LAN address that isn't there (away from home) must fail fast, not after the default 10 s.
|
||||||
|
chain.withConnectTimeout(LOCAL_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS).proceed(request.newBuilder().url(local).build())
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// Only failures to connect are safe to repeat: the request never reached the server.
|
||||||
|
if (!e.isConnectFailure() || chain.call().isCanceled()) throw e
|
||||||
|
Timber.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
|
||||||
|
if (route === r) route = null
|
||||||
|
onLocalFailed?.invoke()
|
||||||
|
chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rewrite(url: HttpUrl, r: Route): HttpUrl? {
|
||||||
|
if (url.host != r.public.host || url.port != r.public.port || url.scheme != r.public.scheme) return null
|
||||||
|
val prefix = r.public.encodedPath.trimEnd('/')
|
||||||
|
val path = url.encodedPath
|
||||||
|
if (!path.startsWith(prefix)) return null
|
||||||
|
val tail = path.removePrefix(prefix)
|
||||||
|
val base = r.local.toString().trimEnd('/')
|
||||||
|
val query = url.encodedQuery?.let { "?$it" }.orEmpty()
|
||||||
|
return "$base$tail$query".toHttpUrlOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IOException.isConnectFailure() = this is ConnectException || this is NoRouteToHostException ||
|
||||||
|
this is UnknownHostException || (this is SocketTimeoutException && message?.contains("connect", ignoreCase = true) == true)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LOCAL_CONNECT_TIMEOUT_MS = 1500
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ profileinstaller = "1.4.1"
|
|||||||
navigation3 = "1.2.0"
|
navigation3 = "1.2.0"
|
||||||
okhttp = "5.5.0"
|
okhttp = "5.5.0"
|
||||||
slf4j = "2.0.20"
|
slf4j = "2.0.20"
|
||||||
|
serialization = "1.11.0"
|
||||||
|
benchmark = "1.5.0"
|
||||||
|
uiautomator = "2.4.0"
|
||||||
|
androidx-test-junit = "1.3.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
||||||
@@ -55,11 +59,18 @@ navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", ver
|
|||||||
navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
|
navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
|
||||||
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
|
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
|
||||||
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||||
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
|
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" }
|
||||||
|
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" }
|
||||||
|
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
android-test = { id = "com.android.test", version.ref = "agp" }
|
||||||
|
androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" }
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ class PlaybackSession(
|
|||||||
failed = failed,
|
failed = failed,
|
||||||
),
|
),
|
||||||
) }
|
) }
|
||||||
|
session.notifyUserDataChanged(item.id)
|
||||||
if (method == PlayMethod.TRANSCODE && sessionId != null) {
|
if (method == PlayMethod.TRANSCODE && sessionId != null) {
|
||||||
// HLS endpoints are hidden from the 12.x OpenAPI, so the SDK has no method for this.
|
// HLS endpoints are hidden from the 12.x OpenAPI, so the SDK has no method for this.
|
||||||
session.call {
|
session.call {
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "movies"
|
rootProject.name = "movies"
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":baselineprofile")
|
||||||
include(":core:jellyfin")
|
include(":core:jellyfin")
|
||||||
|
include(":core:data")
|
||||||
include(":playback:api")
|
include(":playback:api")
|
||||||
include(":playback:core")
|
include(":playback:core")
|
||||||
include(":playback:engine-exo")
|
include(":playback:engine-exo")
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ play() {
|
|||||||
# Журнал решений сессии воспроизведения
|
# Журнал решений сессии воспроизведения
|
||||||
plog() { $A logcat -d | grep -E " (I|W|E) PlaybackSession" | sed 's/^.*PlaybackSession: //' | cut -c1-300; }
|
plog() { $A logcat -d | grep -E " (I|W|E) PlaybackSession" | sed 's/^.*PlaybackSession: //' | cut -c1-300; }
|
||||||
# Токен с ТВ (только debug-сборка: run-as читает DataStore приложения)
|
# Токен с ТВ (только debug-сборка: run-as читает DataStore приложения)
|
||||||
tv_token() { $A exec-out run-as ru.stenfredd.movies cat files/datastore/jellyfin_session.preferences_pb | strings -n 30 | grep -oE '[0-9a-f]{32}' | head -1; }
|
# (текущий аккаунт: первый в списке — последний использованный)
|
||||||
|
tv_token() { $A exec-out run-as ru.stenfredd.movies cat files/datastore/jellyfin_session.preferences_pb | grep -aoE '"token":"[0-9a-f]{32}"' | head -1 | grep -oE '[0-9a-f]{32}'; }
|
||||||
# Что играет на ТВ по данным сервера: название, позиция (с), PlayMethod. X-Emby-Token сервер 12.x не принимает.
|
# Что играет на ТВ по данным сервера: название, позиция (с), PlayMethod. X-Emby-Token сервер 12.x не принимает.
|
||||||
sess() {
|
sess() {
|
||||||
curl -s -H "Authorization: MediaBrowser Token=\"${TV_TOKEN:=$(tv_token)}\"" "http://192.168.1.10:8096/Sessions?activeWithinSeconds=60" |
|
curl -s -H "Authorization: MediaBrowser Token=\"${TV_TOKEN:=$(tv_token)}\"" "http://192.168.1.10:8096/Sessions?activeWithinSeconds=60" |
|
||||||
@@ -40,4 +41,20 @@ ui_bench() {
|
|||||||
$A shell dumpsys gfxinfo $P | grep -E 'Janky frames:|50th percentile|90th percentile|99th percentile|50th gpu|90th gpu'
|
$A shell dumpsys gfxinfo $P | grep -E 'Janky frames:|50th percentile|90th percentile|99th percentile|50th gpu|90th gpu'
|
||||||
$A shell dumpsys gfxinfo $P framestats | python3 tools/tv/framestats.py
|
$A shell dumpsys gfxinfo $P framestats | python3 tools/tv/framestats.py
|
||||||
}
|
}
|
||||||
install_release() { ./gradlew :app:assembleRelease -q && $A install -r app/build/outputs/apk/release/app-release.apk; }
|
# Release + сразу компиляция по baseline profile (иначе ТВ скомпилирует его только при фоновом обслуживании).
|
||||||
|
install_release() {
|
||||||
|
./gradlew :app:assembleRelease -q && $A install -r app/build/outputs/apk/release/app-release.apk || return
|
||||||
|
$A shell am broadcast -a androidx.profileinstaller.action.INSTALL_PROFILE ru.stenfredd.movies/androidx.profileinstaller.ProfileInstallReceiver >/dev/null
|
||||||
|
$A shell cmd package compile -f -m speed-profile ru.stenfredd.movies
|
||||||
|
}
|
||||||
|
# Снять baseline profile заново на ТВ (~8 мин, ТВ должен быть с выполненным входом) и положить в app/src/release.
|
||||||
|
# Gradle может закончить с «failing tests» при успешном прогоне — тогда профиль копируется вручную.
|
||||||
|
gen_profile() {
|
||||||
|
ANDROID_SERIAL=$TV_ADB ./gradlew :app:generateReleaseBaselineProfile -Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true
|
||||||
|
local src="baselineprofile/build/outputs/connected_android_test_additional_output/nonMinifiedRelease/connected"
|
||||||
|
src=$(find "$src" -name 'BaselineProfileGenerator_generate-startup-prof.txt' | head -1)
|
||||||
|
[ -n "$src" ] || { echo "профиль не найден"; return 1; }
|
||||||
|
cp "$src" app/src/release/generated/baselineProfiles/baseline-prof.txt
|
||||||
|
cp "$src" app/src/release/generated/baselineProfiles/startup-prof.txt
|
||||||
|
echo "профиль: $(wc -l < "$src") правил"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user