0.2.69 - Homepage loading improvements pass

This commit is contained in:
ponzischeme89
2026-08-16 21:20:55 +12:00
parent b9374baaf1
commit 845fa349e4
23 changed files with 663 additions and 262 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.68"
val defaultVersionName = "0.2.69"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -56,18 +56,40 @@ internal class DiagnosticNetworkInterceptor(private val backend: String) : Inter
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val started = android.os.SystemClock.elapsedRealtime()
MembyDiagnostics.trace("http_started", "backend" to backend, "method" to request.method, "url" to MembyDiagnostics.safeUrl(request.url))
if (MembyDiagnostics.traceEnabled) {
MembyDiagnostics.trace(
"http_started",
"backend" to backend,
"method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url),
)
}
return try {
chain.proceed(request).also { response ->
MembyDiagnostics.debug("http_finished", "backend" to backend, "method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url), "status" to response.code,
"duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
"correlation" to response.header("X-Memby-Correlation"))
if (MembyDiagnostics.debugEnabled) {
MembyDiagnostics.debug(
"http_finished",
"backend" to backend,
"method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url),
"status" to response.code,
"duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
"correlation" to response.header("X-Memby-Correlation"),
)
}
}
} catch (error: Exception) {
MembyDiagnostics.debug("http_failed", "backend" to backend, "method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url), "duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
"exception" to error.javaClass.simpleName, "detail" to error.message)
if (MembyDiagnostics.debugEnabled) {
MembyDiagnostics.debug(
"http_failed",
"backend" to backend,
"method" to request.method,
"url" to MembyDiagnostics.safeUrl(request.url),
"duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
"exception" to error.javaClass.simpleName,
"detail" to error.message,
)
}
throw error
}
}
@@ -10,6 +10,15 @@ object MembyDiagnostics {
private val rank = mapOf("TRACE" to 0, "DEBUG" to 1, "INFO" to 2)
private val configured = rank[BuildConfig.DIAGNOSTIC_LOG_LEVEL] ?: 2
/**
* Lets hot call sites avoid constructing vararg pairs and sanitised URLs when their
* configured level would discard the event. Network interceptors run for every API
* request, including playback progress, so doing that work before [write] can reject it
* turns disabled diagnostics into steady allocation pressure.
*/
val debugEnabled: Boolean get() = configured <= (rank["DEBUG"] ?: 1)
val traceEnabled: Boolean get() = configured <= (rank["TRACE"] ?: 0)
fun debug(event: String, vararg fields: Pair<String, Any?>) = write("DEBUG", event, fields)
fun trace(event: String, vararg fields: Pair<String, Any?>) = write("TRACE", event, fields)
fun info(event: String, vararg fields: Pair<String, Any?>) = write("INFO", event, fields)
@@ -8,10 +8,17 @@ import androidx.metrics.performance.JankStats
/** Debug-only frame telemetry. It does not alter rendering or app state. */
object PerformanceMonitor {
private const val TAG = "EmbyClientPerf"
private const val WINDOW_FRAMES = 120
private const val FRAME_60_FPS_NS = 16_666_667L
private const val TWO_FRAMES_60_FPS_NS = FRAME_60_FPS_NS * 2
private var stats: JankStats? = null
private var frameCount = 0
private var jankCount = 0
private var totalFrameMs = 0L
private var missedFrameBudgetCount = 0
private var missedTwoFrameBudgetCount = 0
private var totalFrameNanos = 0L
private var maxFrameNanos = 0L
private val frameDurations = LongArray(WINDOW_FRAMES)
private var windowStartedAt = 0L
fun start(activity: Activity) {
@@ -20,10 +27,18 @@ object PerformanceMonitor {
if (stats != null) return@post
windowStartedAt = SystemClock.elapsedRealtime()
stats = JankStats.createAndTrack(activity.window) { frameData ->
val duration = frameData.frameDurationUiNanos
frameDurations[frameCount] = duration
frameCount++
totalFrameMs += frameData.frameDurationUiNanos / 1_000_000L
totalFrameNanos += duration
maxFrameNanos = maxOf(maxFrameNanos, duration)
if (duration > FRAME_60_FPS_NS) missedFrameBudgetCount++
if (duration > TWO_FRAMES_60_FPS_NS) missedTwoFrameBudgetCount++
if (frameData.isJank) jankCount++
if (frameCount % 120 == 0) report("window")
if (frameCount == WINDOW_FRAMES) {
report("window")
resetWindow()
}
}
Log.i(TAG, "tracking started")
}
@@ -32,18 +47,50 @@ object PerformanceMonitor {
fun mark(name: String) {
if (stats == null) return
report(name)
resetWindow()
}
private fun resetWindow() {
frameCount = 0
jankCount = 0
totalFrameMs = 0
missedFrameBudgetCount = 0
missedTwoFrameBudgetCount = 0
totalFrameNanos = 0L
maxFrameNanos = 0L
windowStartedAt = SystemClock.elapsedRealtime()
}
private fun report(name: String) {
if (frameCount == 0) return
val elapsed = SystemClock.elapsedRealtime() - windowStartedAt
// One small copy every 120 debug frames is preferable to allocating or maintaining
// an ordered collection inside the frame callback itself.
val ordered = frameDurations.copyOf(frameCount).apply { sort() }
val p50 = ordered.percentile(50)
val p95 = ordered.percentile(95)
val p99 = ordered.percentile(99)
Log.i(
TAG,
"$name frames=$frameCount jank=$jankCount avgUiMs=${totalFrameMs / frameCount} elapsedMs=$elapsed",
"$name frames=$frameCount jank=$jankCount " +
"jankPct=${percent(jankCount, frameCount)} " +
"over16ms=$missedFrameBudgetCount over33ms=$missedTwoFrameBudgetCount " +
"avgUiMs=${nanosToTenths(totalFrameNanos / frameCount)} " +
"p50UiMs=${nanosToTenths(p50)} p95UiMs=${nanosToTenths(p95)} " +
"p99UiMs=${nanosToTenths(p99)} maxUiMs=${nanosToTenths(maxFrameNanos)} " +
"elapsedMs=$elapsed",
)
}
private fun LongArray.percentile(percentile: Int): Long {
val index = (((size - 1) * percentile) / 100).coerceIn(indices)
return this[index]
}
private fun nanosToTenths(nanos: Long): String =
"${nanos / 1_000_000}.${(nanos % 1_000_000) / 100_000}"
private fun percent(count: Int, total: Int): String {
val tenths = count * 1_000 / total
return "${tenths / 10}.${tenths % 10}"
}
}
@@ -160,6 +160,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
private var metadataJob: Job? = null
private var detailPrefetchJob: Job? = null
private var refreshJob: Job? = null
private var forYouJob: Job? = null
private var forYouRequestId = 0L
@@ -170,6 +171,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
/** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics()
private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty())
@Volatile private var analyticsPausedForPlayback = false
init {
refreshAll()
@@ -200,11 +202,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* screen stops, so time spent sitting on one row is not lost.
*/
fun flushAnalytics() {
if (analyticsPausedForPlayback) return
analytics.endFocus()
repository.reportRowEvents(analytics.drain())
repository.reportJourneyEvents(journey.drain())
}
/**
* Closes the current dwell measurement without starting telemetry requests beside the
* player's decoder and first media reads. The buffered events are uploaded after the
* player returns; losing them if the process dies meanwhile is an acceptable telemetry
* trade, and is preferable to delaying the first picture.
*/
fun pauseAnalyticsForPlayback() {
analytics.endFocus()
analyticsPausedForPlayback = true
}
fun resumeAnalyticsAfterPlayback() {
analyticsPausedForPlayback = false
flushAnalytics()
}
fun trackJourney(
category: String, action: String, screen: String = "", feature: String = "",
source: String = "", target: String = "", itemName: String = "", itemType: String = "",
@@ -227,16 +246,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
requestForYou(availableMinutes, clearFocus = true)
}
/**
* Warms the dedicated destination once Home has its essential rows. Starting this
* before the viewer opens For You hides the gateway round trip without making the
* launcher's first response compete with another request.
*/
private fun preloadForYou(availableMinutes: Int) {
if (_forYou.value.rows.isNotEmpty()) return
requestForYou(availableMinutes, clearFocus = false)
}
private fun requestForYou(availableMinutes: Int, clearFocus: Boolean) {
val minutes = availableMinutes.coerceIn(0, 360)
if (forYouJob?.isActive == true && _forYou.value.availableMinutes == minutes) {
@@ -320,7 +329,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
// Recommendation rows are built in the background by the gateway,
// so an early response can arrive without them. Keeping the rows
// we already had stops the strip flickering out and back in.
rows = taggedHome.rows.sanitisedRows().ifEmpty { current.rows },
rows = mergeFreshHomeRows(current.rows, taggedHome.rows),
loading = emptySet(),
hasRefreshError = taggedHome.partial,
statusMessage = null,
@@ -332,11 +341,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (_focusedItem.value == null) {
initialFocusedItem(_state.value)?.let(::focusItem)
}
preloadForYou(repository.currentSettings.forYouMinutes)
// Home may have been cached just before the background recommendation
// build completed. Pull the dedicated endpoint after the fast home draw
// so personalized Shows shelves appear on this visit, not a minute later.
refreshRecommendationRows()
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
@@ -352,31 +356,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
private suspend fun refreshRecommendationRows() {
val fresh = runCatching { repository.getRecommendations() }
.getOrElse { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
return
}
_state.update { state ->
val airingTodayKeys = state.rows.airingTodayShowKeys()
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
// The three id shapes are what the recommendation build is *expected* to
// replace. Anything else it returns under an id the launcher already holds is
// still a replacement — and, left alone, would be a second LazyColumn item
// under one key, which is a crash rather than a duplicate row. So the arriving
// set wins by id, and the concatenation is sanitised regardless.
val freshIds = taggedFresh.mapTo(mutableSetOf(), HomeRow::id)
val fixedRows = state.rows.filterNot { row ->
row.id == "recommended" ||
row.id.startsWith("similar:") ||
row.id.startsWith("curated:") ||
row.id in freshIds
}
state.copy(rows = (fixedRows + taggedFresh).sanitisedRows())
}
}
/**
* Updates local metadata immediately, then enriches it only after focus settles.
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
@@ -386,40 +365,31 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val focused = focusedItemWithMetadata(item, cached)
_focusedItem.value = focused
metadataJob?.cancel()
detailPrefetchJob?.cancel()
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope {
// Do not negotiate playback on focus. Emby creates a playback session as
// part of PlaybackInfo, so warming a stream here made merely browsing a
// shelf appear in server history as something the viewer had played.
// Warm the explanation and franchise siblings while the card is already
// focused, so opening Details does not add a reason line a frame later.
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
launch { runCatching { repository.getRelated(focused) } }
if (cached == null && !item.isSchedule) {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
val taggedDetails = focusedItemWithMetadata(item, details)
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = taggedDetails
}
if (cached == null && !item.isSchedule) {
launch {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
val taggedDetails = focusedItemWithMetadata(item, details)
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = taggedDetails
}
}
}
launch { warmDetailPage(item) }
}
}
detailPrefetchJob = viewModelScope.launch(Dispatchers.IO) {
delay(DETAIL_PREFETCH_DELAY_MS)
warmDetailPage(focused)
}
}
/**
* The two requests a detail page still opened cold, warmed while the card is focused.
*
* Everything else the page needs is already in hand by the time it opens — the item
* record and its "why you might enjoy it" are warmed above — but
* the episode list and the trailer were not, so a series page opened with an empty
* The item record is warmed separately for the launcher's metadata panel. The larger
* detail-only requests wait here, so a series page does not open with an empty
* Episodes pane, no progress, no next episode and no estimated finish, and every page
* opened with its trailer button missing until the network answered. Continue Watching
* is the case that matters most: every card on the launcher's busiest row is an
@@ -436,8 +406,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/
private suspend fun warmDetailPage(item: BaseItem) {
if (item.isSchedule) return
delay(DETAIL_PREFETCH_DELAY_MS - FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope {
// Related, episodes and trailers are detail-page work. Waiting until focus has
// genuinely settled prevents a held D-pad from starting long-lived requests
// for every card it crosses; a press still shares the resulting single flight.
if (item.isMovie || item.isSeries) {
launch { runCatching { repository.getRelated(item) } }
}
// A series is keyed on itself, an episode on the show it belongs to — which is
// exactly what its own detail page will ask for.
val seriesId = when {
@@ -623,12 +598,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
override fun onCleared() {
analyticsPausedForPlayback = false
flushAnalytics()
super.onCleared()
}
companion object {
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
private const val FOCUS_METADATA_DEBOUNCE_MS = 200L
/**
* How long focus must rest on a card before its detail page is warmed, measured
@@ -638,7 +614,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* this one can be a thousand episode records and should only follow a viewer who
* has stopped. See [warmDetailPage].
*/
private const val DETAIL_PREFETCH_DELAY_MS = 450L
private const val DETAIL_PREFETCH_DELAY_MS = 500L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private const val HOME_RETRY_INITIAL_MS = 2_000L
private const val HOME_RETRY_MAX_MS = 60_000L
@@ -667,6 +643,32 @@ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseI
membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility,
)
/**
* Applies a live launcher response without making cached recommendation shelves disappear
* while the gateway rebuilds its recommendation cache in the background.
*
* A fresh engine set is authoritative and replaces the old one as a group. Prepared
* `for-you:` rows are intentionally not used as that signal: they are assembled separately
* by the gateway and may be present while the engine cache is still cold.
*/
internal fun mergeFreshHomeRows(
previous: List<HomeRow>,
incoming: List<HomeRow>,
): List<HomeRow> {
val fresh = incoming.sanitisedRows()
if (fresh.isEmpty()) return previous.sanitisedRows()
if (fresh.any(HomeRow::isEngineRecommendationRow)) return fresh
val freshIds = fresh.mapTo(mutableSetOf(), HomeRow::id)
val retainedRecommendations = previous.filter { row ->
row.isEngineRecommendationRow() && row.id !in freshIds
}
return (fresh + retainedRecommendations).sanitisedRows()
}
private fun HomeRow.isEngineRecommendationRow(): Boolean =
id == "recommended" || id.startsWith("similar:") || id.startsWith("curated:")
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this
@@ -56,6 +56,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.key
import androidx.compose.runtime.withFrameNanos
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
@@ -109,6 +110,8 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.imageLoader
@@ -251,17 +254,32 @@ private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>,
)
class MainActivity : ComponentActivity() {
private var homeInteractiveReported = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Captured before composition and never observed as state: a downloaded document
// is for the next process, never a label change under the viewer's focus.
val remoteConfig = ServiceLocator.remoteConfig.active
setContent {
MembyTheme { AppRoot(remoteConfig = remoteConfig, onCloseSettings = ::finish) }
MembyTheme {
AppRoot(
remoteConfig = remoteConfig,
onCloseSettings = ::finish,
onHomeInteractive = ::reportHomeInteractive,
)
}
}
PerformanceMonitor.start(this)
}
private fun reportHomeInteractive() {
if (homeInteractiveReported) return
homeInteractiveReported = true
reportFullyDrawn()
PerformanceMonitor.mark("home_interactive")
}
override fun onStart() {
super.onStart()
// Also runs when playback returns to Home. A used preroll player is parked while
@@ -308,7 +326,11 @@ private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4
private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
@Composable
private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit) {
private fun AppRoot(
remoteConfig: MembyRemoteConfig,
onCloseSettings: () -> Unit,
onHomeInteractive: () -> Unit,
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
// This client intentionally has no token provider and no dependency on the active
@@ -655,6 +677,13 @@ private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit
key(loaded.userId, loaded.serverUrl) {
HomeScreen(settings = loaded, remoteConfig = remoteConfig)
}
LaunchedEffect(loaded.userId, loaded.serverUrl) {
// Report after the launcher has submitted a frame, not merely when its
// composable was entered. StartupTimingMetric can now distinguish the
// quick opening surface from the point at which D-pad content is live.
withFrameNanos { }
onHomeInteractive()
}
// Snow, bats or blossom for the few days a year a season is on, over the
// launcher and nowhere else. Not over playback — a film is the one thing
// nothing may drift across — and not over the settings sheet, which is a
@@ -2021,6 +2050,7 @@ private fun HomeScreen(
category = "playback", action = "stop", screen = "player",
feature = "playback", target = selectedDestination.name.lowercase(),
)
homeViewModel.resumeAnalyticsAfterPlayback()
// PlayerActivity has finished and this activity owns the window again. Compose
// needs one frame to reattach the saved card's focus node before it can receive
// focus, especially when playback progress refreshed the row behind the player.
@@ -2028,22 +2058,28 @@ private fun HomeScreen(
// routes into the player, including the one that hands over without waiting.
launchingItem = null
scope.launch {
myShowsLoading = true
notificationsLoading = true
runCatching { repo.getMyShows() }
.onSuccess { myShows = it; myShowsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
myShowsLoading = true
notificationsLoading = true
kotlinx.coroutines.coroutineScope {
launch {
runCatching { repo.getMyShows() }
.onSuccess { myShows = it; myShowsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
}
myShowsLoading = false
}
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it; notificationsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
launch {
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it; notificationsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
}
notificationsLoading = false
}
myShowsLoading = false
notificationsLoading = false
}
kotlinx.coroutines.delay(32L)
// Trailer playback leaves its detail page composed. Let Compose restore the
// exact hero action instead of moving focus to the home card behind it.
@@ -2063,20 +2099,26 @@ private fun HomeScreen(
LaunchedEffect(settings.userId) {
myShowsLoading = true
notificationsLoading = true
runCatching { repo.getMyShows() }
.onSuccess { myShows = it; myShowsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
kotlinx.coroutines.coroutineScope {
launch {
runCatching { repo.getMyShows() }
.onSuccess { myShows = it; myShowsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
}
myShowsLoading = false
}
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it; notificationsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
launch {
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it; notificationsError = null }
.onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
}
notificationsLoading = false
}
myShowsLoading = false
notificationsLoading = false
}
}
LaunchedEffect(liveMaintenance) {
@@ -2106,6 +2148,7 @@ private fun HomeScreen(
resolveJob = null
resolvingItem = null
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
val playItem: (BaseItem) -> Unit = playItem@{ item ->
if (launchingItem != null || !item.membyPlayable) return@playItem
@@ -2114,7 +2157,7 @@ private fun HomeScreen(
feature = "playback", source = returnRowId.orEmpty(), target = "player",
itemName = item.name, itemType = item.type,
)
homeViewModel.flushAnalytics()
homeViewModel.pauseAnalyticsForPlayback()
launchingItem = item
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
// Resuming: open the player now and let it resolve the stream while it starts.
@@ -2133,6 +2176,7 @@ private fun HomeScreen(
}.getOrElse {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
return@playItem
}
val request = prepared.first
@@ -2151,6 +2195,7 @@ private fun HomeScreen(
if (launched.isFailure) {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
return@playItem
}
@@ -2202,6 +2247,7 @@ private fun HomeScreen(
if (launched.isFailure) {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
}
.onFailure { error ->
@@ -2211,6 +2257,7 @@ private fun HomeScreen(
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
// Nothing was launched, so nothing will come back to reopen the gate.
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
}
} finally {
resolvingItem = null
@@ -2226,7 +2273,7 @@ private fun HomeScreen(
feature = "trailer", source = "details", target = "player",
itemName = item.name, itemType = item.type,
)
homeViewModel.flushAnalytics()
homeViewModel.pauseAnalyticsForPlayback()
val launched = runCatching {
playbackLauncher.launch(
PlayerActivity.trailerIntent(
@@ -2242,6 +2289,7 @@ private fun HomeScreen(
}
if (launched.isFailure) {
launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
Toast.makeText(context, "Couldnt open the trailer", Toast.LENGTH_SHORT).show()
}
}
@@ -4027,14 +4075,15 @@ private fun HomeArtworkPreloader(
availableWidth: androidx.compose.ui.unit.Dp,
) {
val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
val density = LocalDensity.current
val repo = ServiceLocator.repository
val discovered = remember(rows) {
// Warm the leading posters across several rows instead of exhausting the
// budget on the first shelf. Vertical navigation is then far less likely to
// compete with image fetch/decode as the next row enters the viewport.
val perRow = rows.map { row ->
row.items.take(4).map { row.kind to it }
val perRow = rows.take(6).map { row ->
row.items.take(2).map { row.kind to it }
}
buildList {
val depth = perRow.maxOfOrNull { it.size } ?: 0
@@ -4045,35 +4094,38 @@ private fun HomeArtworkPreloader(
}
}
.distinctBy { it.second.id }
.take(24)
.take(12)
}
val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } }
LaunchedEffect(signature, availableWidth) {
// Let visible cards win the first network/decode slots, then warm everything
// else that this home response discovered into Coil's memory and disk caches.
kotlinx.coroutines.delay(350L)
discovered.forEach { (kind, item) ->
val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode
val width = if (landscape) {
(availableWidth / 4.25f).coerceIn(184.dp, 316.dp)
} else {
(availableWidth / 6.8f).coerceIn(116.dp, 184.dp)
LaunchedEffect(signature, availableWidth, lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Let visible cards win the first network/decode slots. Limiting the warm to
// the leading pair from the next six shelves covers the likely D-pad path
// without decoding a second screenful nobody may visit.
kotlinx.coroutines.delay(350L)
discovered.forEach { (kind, item) ->
val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode
val width = if (landscape) {
(availableWidth / 4.25f).coerceIn(184.dp, 316.dp)
} else {
(availableWidth / 6.8f).coerceIn(116.dp, 184.dp)
}
val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
val heightPx = if (landscape) (widthPx * 9f / 16f).toInt() else (widthPx * 3f / 2f).toInt()
val url = if (landscape) {
repo.backdropUrl(item, widthPx) ?: repo.primaryUrl(item, widthPx)
} else {
repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx)
} ?: return@forEach
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(url)
.size(widthPx, heightPx)
.allowHardware(true)
.crossfade(false)
.build(),
)
}
val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
val heightPx = if (landscape) (widthPx * 9f / 16f).toInt() else (widthPx * 3f / 2f).toInt()
val url = if (landscape) {
repo.backdropUrl(item, widthPx) ?: repo.primaryUrl(item, widthPx)
} else {
repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx)
} ?: return@forEach
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(url)
.size(widthPx, heightPx)
.allowHardware(true)
.crossfade(false)
.build(),
)
}
}
}
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -41,4 +42,66 @@ class HomeUiStateTest {
state.continueWatching.map(BaseItem::id),
)
}
@Test
fun coldRecommendationResponseKeepsCachedShelves() {
val previous = listOf(
row("continue", "resume"),
row("recommended", "old-pick"),
row("curated:comedy", "old-comedy"),
)
val incoming = listOf(
row("continue", "fresh-resume"),
row("latest", "fresh-film"),
)
val merged = mergeFreshHomeRows(previous, incoming)
assertEquals(
listOf("continue", "latest", "recommended", "curated:comedy"),
merged.map(HomeRow::id),
)
assertEquals("fresh-resume", merged.first().items.single().id)
}
@Test
fun freshRecommendationSetReplacesCachedSet() {
val previous = listOf(
row("continue", "resume"),
row("recommended", "old-pick"),
row("curated:comedy", "old-comedy"),
)
val incoming = listOf(
row("continue", "fresh-resume"),
row("recommended", "fresh-pick"),
)
val merged = mergeFreshHomeRows(previous, incoming)
assertEquals(listOf("continue", "recommended"), merged.map(HomeRow::id))
assertEquals("fresh-pick", merged.last().items.single().id)
}
@Test
fun preparedForYouRowDoesNotDiscardCachedEngineShelves() {
val previous = listOf(row("recommended", "old-pick"))
val incoming = listOf(
row("continue", "resume"),
row("for-you:quick", "quick-pick"),
)
val merged = mergeFreshHomeRows(previous, incoming)
assertEquals(
listOf("continue", "for-you:quick", "recommended"),
merged.map(HomeRow::id),
)
}
private fun row(id: String, itemId: String) = HomeRow(
id = id,
title = id,
kind = id.substringBefore(':'),
items = listOf(BaseItem(id = itemId)),
)
}