0.2.69 - Homepage loading improvements pass
This commit is contained in:
@@ -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, "Couldn’t 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, "Couldn’t 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, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -2211,6 +2257,7 @@ private fun HomeScreen(
|
||||
Toast.makeText(context, "Couldn’t 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, "Couldn’t 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)),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user