Data layer and baseline profile

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

View File

@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.compose) alias(libs.plugins.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"))
} }

View File

@@ -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()

View File

@@ -0,0 +1,113 @@
package ru.stenfredd.movies.ui.components
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Shader
import android.util.LruCache
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.withSign
/**
* Placeholder from the image's BlurHash (sent by the server with every item): a blurred colour sketch of the picture
* while it loads.
*
* Decoded tiny (the hash holds only a few colour components anyway), then upscaled to a quarter of the card with
* the rounded corners baked in, like the real images ([CardImageTransformation]); drawn stretched to the card it is a
* single cheap bitmap draw. Decoding runs off the main thread; results are cached by hash and size.
*/
object BlurHash {
private val cache = LruCache<String, Bitmap>(CACHE_ENTRIES)
/** A placeholder bitmap for a [widthPx]×[heightPx] card with [radiusPx] corners and [shade], or null for a broken hash. */
fun placeholder(hash: String, widthPx: Int, heightPx: Int, radiusPx: Float, shade: ImageShade?): Bitmap? {
val w = (widthPx / SCALE).coerceAtLeast(1)
val h = (heightPx / SCALE).coerceAtLeast(1)
val key = "$hash:$w:$h:$radiusPx:${shade?.cacheKey}"
cache.get(key)?.let { return it }
val small = decode(hash, DECODE_SIZE, (DECODE_SIZE * heightPx / widthPx.toFloat()).roundToInt().coerceAtLeast(1)) ?: return null
val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
paint.shader = BitmapShader(small, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
setLocalMatrix(Matrix().apply { setScale(w / small.width.toFloat(), h / small.height.toFloat()) })
}
val r = radiusPx / SCALE
val canvas = Canvas(out)
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), r, r, paint)
// The same shade as the real image, or the text over a bright blur would be unreadable.
shade?.let { canvas.drawShade(it, w, h) }
small.recycle()
cache.put(key, out)
return out
}
/** Standard BlurHash decoding (https://blurha.sh), punch 1. */
fun decode(hash: String, width: Int, height: Int): Bitmap? {
if (hash.length < 6) return null
val sizeFlag = decode83(hash, 0, 1)
val numX = sizeFlag % 9 + 1
val numY = sizeFlag / 9 + 1
if (hash.length != 4 + 2 * numX * numY) return null
val maxAc = (decode83(hash, 1, 2) + 1) / 166f
val colors = Array(numX * numY) { i ->
if (i == 0) {
val v = decode83(hash, 2, 6)
floatArrayOf(srgbToLinear(v shr 16), srgbToLinear((v shr 8) and 255), srgbToLinear(v and 255))
} else {
val v = decode83(hash, 4 + i * 2, 6 + i * 2)
floatArrayOf(signedPow2((v / (19 * 19) - 9) / 9f) * maxAc, signedPow2(((v / 19) % 19 - 9) / 9f) * maxAc, signedPow2((v % 19 - 9) / 9f) * maxAc)
}
}
val cosX = FloatArray(width * numX) { cos(Math.PI * (it / numX) * (it % numX) / width).toFloat() }
val cosY = FloatArray(height * numY) { cos(Math.PI * (it / numY) * (it % numY) / height).toFloat() }
val pixels = IntArray(width * height)
for (y in 0 until height) for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numY) for (i in 0 until numX) {
val basis = cosX[x * numX + i] * cosY[y * numY + j]
val c = colors[j * numX + i]
r += c[0] * basis
g += c[1] * basis
b += c[2] * basis
}
pixels[y * width + x] = (0xFF shl 24) or (linearToSrgb(r) shl 16) or (linearToSrgb(g) shl 8) or linearToSrgb(b)
}
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
}
private const val CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~"
private fun decode83(s: String, from: Int, to: Int): Int {
var v = 0
for (i in from until to) {
val d = CHARS.indexOf(s[i])
if (d < 0) return 0
v = v * 83 + d
}
return v
}
private fun srgbToLinear(v: Int): Float {
val f = v / 255f
return if (f <= 0.04045f) f / 12.92f else ((f + 0.055f) / 1.055f).pow(2.4f)
}
private fun linearToSrgb(v: Float): Int {
val f = v.coerceIn(0f, 1f)
return if (f <= 0.0031308f) (f * 12.92f * 255 + 0.5f).toInt() else ((1.055f * f.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
private fun signedPow2(v: Float) = (v * v).withSign(v)
private const val DECODE_SIZE = 16
/** The placeholder is a quarter of the card: it is a blur, upscaling it costs nothing visible. */
private const val SCALE = 4
private const val CACHE_ENTRIES = 300
}

View File

@@ -52,19 +52,22 @@ class CardImageTransformation(private val radiusPx: Float, private val shade: Im
) )
} }
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), radiusPx, radiusPx, paint) 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)
}

View File

@@ -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(

View File

@@ -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()
} }

View File

@@ -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"
} }
} }

View File

@@ -0,0 +1,116 @@
package ru.stenfredd.movies.ui.model
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import ru.stenfredd.movies.core.data.ImageUrls
import ru.stenfredd.movies.core.data.model.ImageRef
import ru.stenfredd.movies.core.data.model.ItemKind
import ru.stenfredd.movies.core.data.model.ItemSummary
import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.ui.components.FeaturedModel
import ru.stenfredd.movies.ui.components.PosterModel
import ru.stenfredd.movies.ui.components.RemoteImageSpec
import ru.stenfredd.movies.ui.components.WideModel
import ru.stenfredd.movies.ui.theme.AppDimens
import javax.inject.Inject
import kotlin.math.roundToInt
/**
* Turns server items into card models. Image URLs ask the server for exactly the card's size in pixels.
* Card keys are "<row>/<item id>": the same item can appear in several rows, and focus is remembered per key.
*/
class CardMapper @Inject constructor(
private val images: ImageUrls,
@ApplicationContext context: Context,
) {
private val density = context.resources.displayMetrics.density
fun px(dp: Float) = (dp * density).roundToInt()
fun key(row: String, item: ItemSummary) = "$row/${item.id}"
fun poster(item: ItemSummary, row: String): PosterModel {
// A single new episode comes as the episode itself: show it as its series, poster included.
val episode = item.kind == ItemKind.Episode
return PosterModel(
key = key(row, item),
title = (if (episode) item.seriesName else null) ?: item.name,
caption = if (episode) {
listOfNotNull(item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" · ").ifEmpty { null }
} else {
listOfNotNull(item.year?.toString(), item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null }
},
image = image(if (episode) item.seriesPrimary ?: item.primary else item.primary, AppDimens.POSTER_W.toFloat(), AppDimens.POSTER_H.toFloat()),
badge = item.formatBadge(),
progress = item.user.progress,
isNew = item.isNew(),
watched = item.user.played,
)
}
fun wide(item: ItemSummary, row: String): WideModel = WideModel(
key = key(row, item),
title = item.seriesName ?: item.name,
subtitle = listOfNotNull(item.episodeLabel() ?: item.year?.toString(), item.remaining()).joinToString(" · "),
image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()),
progress = item.user.progress,
)
fun featured(item: ItemSummary, row: String): FeaturedModel {
val isEpisode = item.kind == ItemKind.Episode
return FeaturedModel(
key = key(row, item),
label = if (isEpisode) "СЕРИАЛ" else "ФИЛЬМ",
title = item.seriesName ?: item.name,
subtitle = if (isEpisode) {
item.episodeLabel().orEmpty()
} else {
listOfNotNull(item.year?.toString(), item.genres.take(2).joinToString(", ").ifEmpty { null }).joinToString(" · ")
},
description = item.overview,
image = image(item.backdrop ?: item.landscape(), FEATURED_W, FEATURED_H),
progress = item.user.progress ?: 0f,
remaining = item.remaining(),
)
}
/** Where a card leads. Series and folders still open the old prototype list until their screens exist. */
fun target(item: ItemSummary): Screen = when (item.kind) {
ItemKind.Series, ItemKind.Season, ItemKind.BoxSet, ItemKind.Folder -> Screen.Items(item.id, item.name)
else -> Screen.Detail(item.id)
}
private fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? =
ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) }
/** Episode still, else the backdrop or thumb of the item or its series. */
private fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
private fun ItemSummary.remaining(): String? {
val runtime = runtimeMs ?: return null
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
return "осталось ${left.roundToInt()} мин"
}
private fun ItemSummary.episodeLabel(): String? =
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
private fun ItemSummary.isNew(): Boolean = dateAddedAt?.let { System.currentTimeMillis() - it < NEW_MS } == true
private fun ItemSummary.formatBadge(): String? {
val w = videoWidth ?: return null
return when {
w >= 3200 -> "4K"
w >= 1800 -> "1080p"
w >= 1200 -> "720p"
else -> null
}
}
private companion object {
/** "Новинка": added within the last 14 days. */
const val NEW_MS = 14 * 24 * 3600_000L
const val FEATURED_W = 410f
const val FEATURED_H = 230.5f
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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)
}

View File

@@ -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
}
}

View File

@@ -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
} }

View 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)
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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>)

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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()
}

View File

@@ -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
} }
} }

View File

@@ -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
}
}

View File

@@ -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" }

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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") правил"
}