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
+9
View File
@@ -1,3 +1,12 @@
## 0.2.69 - 2026-08-16
- Fixed: Cached recommendation shelves remain visible while fresh recommendations rebuild, avoiding row flicker and recomposition.
- Fixed: “For You” content now loads only when opened, rather than competing with homepage requests.
- Fixed: Detail, related-title, episode and trailer prefetching waits until focus settles, preventing request storms during D-pad navigation.
- Fixed: Homepage artwork preloading is smaller and lifecycle-aware.
- Fixed: My Shows and notifications refresh concurrently.
- Fixed: Diagnostic logging avoids unnecessary production-path work.
- Fixed: Added time-to-interactive and improved frame/jank measurements, including a warm-start benchmark.
## 0.2.67 — 2026-08-16 ## 0.2.67 — 2026-08-16
- Improved: Movie and show pages. - Improved: Movie and show pages.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon" rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/> />
<script type="module" crossorigin src="/admin/assets/index-C4RyKtIU.js"></script> <script type="module" crossorigin src="/admin/assets/index-CSt3yVnU.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js"> <link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-Dyz7s7hT.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-CASotpHk.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -6
View File
@@ -6,7 +6,6 @@ import { NotificationBell } from './NotificationBell';
import { nav } from '../nav'; import { nav } from '../nav';
import { useNotifications } from '../lib/notifications'; import { useNotifications } from '../lib/notifications';
import { useGateway } from '../lib/gateway'; import { useGateway } from '../lib/gateway';
import { time } from '../lib/format';
import { Confirm } from './ui'; import { Confirm } from './ui';
/* The shell: a top bar spanning the width, a rail down the left, and the page. /* The shell: a top bar spanning the width, a rail down the left, and the page.
@@ -133,7 +132,7 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
} }
export function Layout() { export function Layout() {
const { version, currentUser, online, checkedAt, loading, status, setMaintenance } = useGateway(); const { version, currentUser, online, loading, status, setMaintenance } = useGateway();
const [railOpen, setRailOpen] = useState(false); const [railOpen, setRailOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false); const [accountOpen, setAccountOpen] = useState(false);
const [changingAvailability, setChangingAvailability] = useState(false); const [changingAvailability, setChangingAvailability] = useState(false);
@@ -143,7 +142,6 @@ export function Layout() {
const account = useRef<HTMLDivElement>(null); const account = useRef<HTMLDivElement>(null);
const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A'; const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A';
const statusTone = status && online && !offline ? 'ok' : status || !loading ? 'bad' : 'checking'; const statusTone = status && online && !offline ? 'ok' : status || !loading ? 'bad' : 'checking';
const statusLabel = offline ? 'offline' : status && online ? 'online' : loading ? 'checking' : 'not responding';
const toggleAvailability = async () => { const toggleAvailability = async () => {
if (changingAvailability || !status) return; if (changingAvailability || !status) return;
@@ -234,9 +232,7 @@ export function Layout() {
aria-label={offline ? 'Memby is offline. Bring it online' : status && online ? 'Memby is online. Take it offline' : loading ? 'Checking Memby status' : 'Memby is not responding'} aria-label={offline ? 'Memby is offline. Bring it online' : status && online ? 'Memby is online. Take it offline' : loading ? 'Checking Memby status' : 'Memby is not responding'}
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)} onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
> >
<span className="dot" /> <span className="dot" aria-hidden="true" />
<b>{statusLabel}</b>
<span>{offline ? 'click to bring back online' : checkedAt ? `updated ${time(checkedAt)}` : ''}</span>
</button> </button>
<NotificationBell /> <NotificationBell />
<div className="account-menu" data-open={accountOpen || undefined} ref={account}> <div className="account-menu" data-open={accountOpen || undefined} ref={account}>
+1 -1
View File
@@ -74,7 +74,7 @@ export function OmniSearch() {
.filter((entry) => entry.rank > 0) .filter((entry) => entry.rank > 0)
.sort((a, b) => b.rank - a.rank || a.index - b.index) .sort((a, b) => b.rank - a.rank || a.index - b.index)
.map(({ item, rank }) => ({ item, rank })); .map(({ item, rank }) => ({ item, rank }));
}, [query]); }, [query, status]);
// Something is always selected, so Enter has an answer without an arrow press first. // Something is always selected, so Enter has an answer without an arrow press first.
useEffect(() => setCursor(0), [query]); useEffect(() => setCursor(0), [query]);
+69 -80
View File
@@ -208,18 +208,17 @@ a {
white-space: nowrap; white-space: nowrap;
} }
.topbar-status { .topbar-status {
display: flex; display: grid;
align-items: center; place-items: center;
gap: 8px; width: 38px;
min-width: 0; height: 38px;
padding: 4px 6px; flex: 0 0 38px;
border: 0; padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: transparent; background: transparent;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
font: inherit;
text-align: left;
transition: background .16s ease, border-color .16s ease, color .16s ease; transition: background .16s ease, border-color .16s ease, color .16s ease;
} }
.topbar-status:hover:not(:disabled), .topbar-status:hover:not(:disabled),
@@ -227,37 +226,32 @@ a {
background: var(--surface-lift); background: var(--surface-lift);
} }
.topbar-status:disabled { .topbar-status:disabled {
cursor: wait; cursor: default;
opacity: .65; opacity: 1;
} }
.topbar-status .dot { .topbar-status .dot {
width: 8px; width: 9px;
height: 8px; height: 9px;
border-radius: 50%; border-radius: 50%;
background: var(--quiet); background: var(--quiet);
flex: 0 0 8px; box-shadow: 0 0 0 3px rgba(125, 133, 144, .12);
} }
.topbar-status[data-tone="ok"] .dot { .topbar-status[data-tone="ok"] .dot {
background: var(--accent); background: var(--accent-ink);
box-shadow: 0 0 0 4px var(--accent-wash), 0 0 12px rgba(86, 211, 100, .34); box-shadow: 0 0 0 3px var(--accent-wash), 0 0 9px rgba(86, 211, 100, .28);
} }
.topbar-status[data-tone="bad"] .dot { .topbar-status[data-tone="bad"] .dot {
background: var(--danger); background: var(--danger);
} box-shadow: 0 0 0 3px var(--danger-wash);
.topbar-status b {
font-size: 12.5px;
font-weight: 600;
}
.topbar-status span {
font-size: 11.5px;
color: var(--quiet);
white-space: nowrap;
} }
/* The account is an identity first and an exit second. Keeping sign-out in this menu /* The account is an identity first and an exit second. Keeping sign-out in this menu
prevents an unexplained door icon from competing with the operational controls. */ prevents an unexplained door icon from competing with the operational controls. */
.account-menu { .account-menu {
position: relative; position: relative;
display: flex;
align-items: center;
height: 38px;
flex: 0 0 auto; flex: 0 0 auto;
} }
.account-trigger { .account-trigger {
@@ -360,8 +354,7 @@ a {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.topbar-version, .topbar-version {
.topbar-status span {
display: none; display: none;
} }
} }
@@ -419,9 +412,10 @@ a {
.omni-panel { .omni-panel {
position: absolute; position: absolute;
top: calc(100% + 6px); top: calc(100% + 6px);
right: 0; right: auto;
width: min(520px, 94vw); left: 0;
max-height: min(60vh, 520px); width: 100%;
max-height: min(50vh, 420px);
overflow-y: auto; overflow-y: auto;
padding: 6px; padding: 6px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -475,13 +469,16 @@ a {
.bell { .bell {
position: relative; position: relative;
display: flex;
align-items: center;
height: 38px;
} }
.bell-button { .bell-button {
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
width: 34px; width: 38px;
height: 34px; height: 38px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
@@ -1165,20 +1162,8 @@ table {
max-width: 640px; max-width: 640px;
min-width: 0; min-width: 0;
} }
.omni-input {
height: 36px;
}
.omni-panel {
position: fixed;
top: calc(var(--top) + 6px);
right: 12px;
left: 12px;
width: auto;
max-height: calc(100dvh - var(--top) - 18px);
}
.omni-key, .omni-key,
.topbar-version, .topbar-version {
.topbar-status span {
display: none; display: none;
} }
.topbar-tools { .topbar-tools {
@@ -1190,21 +1175,19 @@ table {
height: 40px; height: 40px;
} }
.topbar-status { .topbar-status {
width: 40px;
flex-basis: 40px;
min-width: 40px; min-width: 40px;
height: 40px; height: 40px;
padding: 0 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--surface); background: var(--surface);
} }
.topbar-status[data-tone="ok"] { .omni-input,
border-color: rgba(86, 211, 100, .38); .bell,
background: var(--accent-wash); .bell-button,
color: var(--accent-ink); .account-menu,
} .account-trigger {
.topbar-status[data-tone="bad"] { height: 40px;
border-color: rgba(229, 83, 75, .34);
background: var(--danger-wash);
color: var(--danger-ink);
} }
.page { .page {
width: 100%; width: 100%;
@@ -1236,8 +1219,7 @@ table {
} }
@media (max-width: 820px) { @media (max-width: 820px) {
.brand-word, .brand-word {
.topbar-status b {
display: none; display: none;
} }
.topbar-status { .topbar-status {
@@ -1266,40 +1248,43 @@ table {
} }
} }
/* A phone gets two deliberate rows: navigation and live controls above, search below. /* A phone keeps every global control on one row. Search is the only flexible item: the
Compressing all six controls into one line made the search unusable at exactly the navigation and identity controls retain useful targets while the field gives up width
widths where it is the quickest way around a long navigation drawer. */ before the header can wrap. Its relative wrapper still anchors the results directly
beneath the field at exactly the width available here. */
@media (max-width: 680px) { @media (max-width: 680px) {
:root { :root {
--top: calc(108px + var(--safe-top)); --top: calc(60px + var(--safe-top));
} }
.topbar { .topbar {
align-content: center;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 8px; gap: clamp(4px, 1.5vw, 8px);
padding: calc(var(--safe-top) + 8px) max(12px, var(--safe-right)) 8px max(12px, var(--safe-left)); padding: calc(var(--safe-top) + 8px) max(8px, var(--safe-right)) 8px max(8px, var(--safe-left));
} }
.rail-toggle { order: 1; } .rail-toggle { order: 1; }
.topbar-brand { order: 2; } .topbar-brand {
.topbar-status { order: 2;
order: 3; height: 40px;
margin-left: auto; flex: 0 0 auto;
} }
.bell { order: 4; }
.account-menu { order: 5; }
.topbar .omni { .topbar .omni {
order: 6; order: 3;
flex: 1 0 100%; flex: 1 1 96px;
width: 0;
min-width: 0;
max-width: none; max-width: none;
} }
.brand-mark { .topbar-status {
width: 32px; order: 4;
height: 32px; margin-left: 0;
flex-basis: 32px;
} }
.omni-input { .bell { order: 5; }
height: 38px; .account-menu { order: 6; }
.brand-mark {
width: 30px;
height: 30px;
flex-basis: 30px;
} }
.account-panel, .account-panel,
.bell-panel { .bell-panel {
@@ -3000,8 +2985,12 @@ details summary {
height: 44px; height: 44px;
padding: 0; padding: 0;
} }
.topbar-status b { .topbar .bell,
margin-left: 1px; .topbar .account-menu {
height: 44px;
}
.topbar .omni-input {
height: 44px;
} }
.rail a { .rail a {
min-height: 46px; min-height: 46px;
+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 // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.68" val defaultVersionName = "0.2.69"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -56,18 +56,40 @@ internal class DiagnosticNetworkInterceptor(private val backend: String) : Inter
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
val started = android.os.SystemClock.elapsedRealtime() 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 { return try {
chain.proceed(request).also { response -> chain.proceed(request).also { response ->
MembyDiagnostics.debug("http_finished", "backend" to backend, "method" to request.method, if (MembyDiagnostics.debugEnabled) {
"url" to MembyDiagnostics.safeUrl(request.url), "status" to response.code, MembyDiagnostics.debug(
"duration_ms" to (android.os.SystemClock.elapsedRealtime() - started), "http_finished",
"correlation" to response.header("X-Memby-Correlation")) "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) { } catch (error: Exception) {
MembyDiagnostics.debug("http_failed", "backend" to backend, "method" to request.method, if (MembyDiagnostics.debugEnabled) {
"url" to MembyDiagnostics.safeUrl(request.url), "duration_ms" to (android.os.SystemClock.elapsedRealtime() - started), MembyDiagnostics.debug(
"exception" to error.javaClass.simpleName, "detail" to error.message) "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 throw error
} }
} }
@@ -10,6 +10,15 @@ object MembyDiagnostics {
private val rank = mapOf("TRACE" to 0, "DEBUG" to 1, "INFO" to 2) private val rank = mapOf("TRACE" to 0, "DEBUG" to 1, "INFO" to 2)
private val configured = rank[BuildConfig.DIAGNOSTIC_LOG_LEVEL] ?: 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 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 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) 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. */ /** Debug-only frame telemetry. It does not alter rendering or app state. */
object PerformanceMonitor { object PerformanceMonitor {
private const val TAG = "EmbyClientPerf" 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 stats: JankStats? = null
private var frameCount = 0 private var frameCount = 0
private var jankCount = 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 private var windowStartedAt = 0L
fun start(activity: Activity) { fun start(activity: Activity) {
@@ -20,10 +27,18 @@ object PerformanceMonitor {
if (stats != null) return@post if (stats != null) return@post
windowStartedAt = SystemClock.elapsedRealtime() windowStartedAt = SystemClock.elapsedRealtime()
stats = JankStats.createAndTrack(activity.window) { frameData -> stats = JankStats.createAndTrack(activity.window) { frameData ->
val duration = frameData.frameDurationUiNanos
frameDurations[frameCount] = duration
frameCount++ 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 (frameData.isJank) jankCount++
if (frameCount % 120 == 0) report("window") if (frameCount == WINDOW_FRAMES) {
report("window")
resetWindow()
}
} }
Log.i(TAG, "tracking started") Log.i(TAG, "tracking started")
} }
@@ -32,18 +47,50 @@ object PerformanceMonitor {
fun mark(name: String) { fun mark(name: String) {
if (stats == null) return if (stats == null) return
report(name) report(name)
resetWindow()
}
private fun resetWindow() {
frameCount = 0 frameCount = 0
jankCount = 0 jankCount = 0
totalFrameMs = 0 missedFrameBudgetCount = 0
missedTwoFrameBudgetCount = 0
totalFrameNanos = 0L
maxFrameNanos = 0L
windowStartedAt = SystemClock.elapsedRealtime() windowStartedAt = SystemClock.elapsedRealtime()
} }
private fun report(name: String) { private fun report(name: String) {
if (frameCount == 0) return if (frameCount == 0) return
val elapsed = SystemClock.elapsedRealtime() - windowStartedAt 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( Log.i(
TAG, 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()) private val _forYou = MutableStateFlow(ForYouUiState())
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow() val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
private var metadataJob: Job? = null private var metadataJob: Job? = null
private var detailPrefetchJob: Job? = null
private var refreshJob: Job? = null private var refreshJob: Job? = null
private var forYouJob: Job? = null private var forYouJob: Job? = null
private var forYouRequestId = 0L private var forYouRequestId = 0L
@@ -170,6 +171,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
/** Row engagement, buffered here and uploaded in batches. */ /** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics() private val analytics = RowAnalytics()
private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty()) private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty())
@Volatile private var analyticsPausedForPlayback = false
init { init {
refreshAll() refreshAll()
@@ -200,11 +202,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* screen stops, so time spent sitting on one row is not lost. * screen stops, so time spent sitting on one row is not lost.
*/ */
fun flushAnalytics() { fun flushAnalytics() {
if (analyticsPausedForPlayback) return
analytics.endFocus() analytics.endFocus()
repository.reportRowEvents(analytics.drain()) repository.reportRowEvents(analytics.drain())
repository.reportJourneyEvents(journey.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( fun trackJourney(
category: String, action: String, screen: String = "", feature: String = "", category: String, action: String, screen: String = "", feature: String = "",
source: String = "", target: String = "", itemName: String = "", itemType: String = "", source: String = "", target: String = "", itemName: String = "", itemType: String = "",
@@ -227,16 +246,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
requestForYou(availableMinutes, clearFocus = true) 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) { private fun requestForYou(availableMinutes: Int, clearFocus: Boolean) {
val minutes = availableMinutes.coerceIn(0, 360) val minutes = availableMinutes.coerceIn(0, 360)
if (forYouJob?.isActive == true && _forYou.value.availableMinutes == minutes) { 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, // Recommendation rows are built in the background by the gateway,
// so an early response can arrive without them. Keeping the rows // so an early response can arrive without them. Keeping the rows
// we already had stops the strip flickering out and back in. // 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(), loading = emptySet(),
hasRefreshError = taggedHome.partial, hasRefreshError = taggedHome.partial,
statusMessage = null, statusMessage = null,
@@ -332,11 +341,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
if (_focusedItem.value == null) { if (_focusedItem.value == null) {
initialFocusedItem(_state.value)?.let(::focusItem) 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 -> .onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw 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. * Updates local metadata immediately, then enriches it only after focus settles.
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation. * 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) val focused = focusedItemWithMetadata(item, cached)
_focusedItem.value = focused _focusedItem.value = focused
metadataJob?.cancel() metadataJob?.cancel()
detailPrefetchJob?.cancel()
metadataJob = viewModelScope.launch(Dispatchers.IO) { metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS) delay(FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope { if (cached == null && !item.isSchedule) {
// Do not negotiate playback on focus. Emby creates a playback session as val details = runCatching {
// part of PlaybackInfo, so warming a stream here made merely browsing a repository.getItemDetails(item.id)
// shelf appear in server history as something the viewer had played. }.getOrNull() ?: return@launch
// Warm the explanation and franchise siblings while the card is already val taggedDetails = focusedItemWithMetadata(item, details)
// focused, so opening Details does not add a reason line a frame later. synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
if (!item.isSchedule && (item.isMovie || item.isSeries)) { if (_focusedItem.value?.id == item.id) {
launch { runCatching { repository.getRelated(focused) } } _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. * 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 * The item record is warmed separately for the launcher's metadata panel. The larger
* record and its "why you might enjoy it" are warmed above but * detail-only requests wait here, so a series page does not open with an empty
* the episode list and the trailer were not, so a series page opened with an empty
* Episodes pane, no progress, no next episode and no estimated finish, and every page * 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 * 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 * 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) { private suspend fun warmDetailPage(item: BaseItem) {
if (item.isSchedule) return if (item.isSchedule) return
delay(DETAIL_PREFETCH_DELAY_MS - FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope { 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 // 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. // exactly what its own detail page will ask for.
val seriesId = when { val seriesId = when {
@@ -623,12 +598,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
override fun onCleared() { override fun onCleared() {
analyticsPausedForPlayback = false
flushAnalytics() flushAnalytics()
super.onCleared() super.onCleared()
} }
companion object { 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 * 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 * this one can be a thousand episode records and should only follow a viewer who
* has stopped. See [warmDetailPage]. * 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 ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private const val HOME_RETRY_INITIAL_MS = 2_000L private const val HOME_RETRY_INITIAL_MS = 2_000L
private const val HOME_RETRY_MAX_MS = 60_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, 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 { internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys() val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this 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.saveable.listSaver
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.withFrameNanos
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
@@ -109,6 +110,8 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.imageLoader import coil.imageLoader
@@ -251,17 +254,32 @@ private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>,
) )
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var homeInteractiveReported = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Captured before composition and never observed as state: a downloaded document // 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. // is for the next process, never a label change under the viewer's focus.
val remoteConfig = ServiceLocator.remoteConfig.active val remoteConfig = ServiceLocator.remoteConfig.active
setContent { setContent {
MembyTheme { AppRoot(remoteConfig = remoteConfig, onCloseSettings = ::finish) } MembyTheme {
AppRoot(
remoteConfig = remoteConfig,
onCloseSettings = ::finish,
onHomeInteractive = ::reportHomeInteractive,
)
}
} }
PerformanceMonitor.start(this) PerformanceMonitor.start(this)
} }
private fun reportHomeInteractive() {
if (homeInteractiveReported) return
homeInteractiveReported = true
reportFullyDrawn()
PerformanceMonitor.mark("home_interactive")
}
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
// Also runs when playback returns to Home. A used preroll player is parked while // 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 private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
@Composable @Composable
private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit) { private fun AppRoot(
remoteConfig: MembyRemoteConfig,
onCloseSettings: () -> Unit,
onHomeInteractive: () -> Unit,
) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val context = LocalContext.current val context = LocalContext.current
// This client intentionally has no token provider and no dependency on the active // 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) { key(loaded.userId, loaded.serverUrl) {
HomeScreen(settings = loaded, remoteConfig = remoteConfig) 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 // 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 // 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 // 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", category = "playback", action = "stop", screen = "player",
feature = "playback", target = selectedDestination.name.lowercase(), feature = "playback", target = selectedDestination.name.lowercase(),
) )
homeViewModel.resumeAnalyticsAfterPlayback()
// PlayerActivity has finished and this activity owns the window again. Compose // 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 // 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. // 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. // routes into the player, including the one that hands over without waiting.
launchingItem = null launchingItem = null
scope.launch { scope.launch {
myShowsLoading = true myShowsLoading = true
notificationsLoading = true notificationsLoading = true
runCatching { repo.getMyShows() } kotlinx.coroutines.coroutineScope {
.onSuccess { myShows = it; myShowsError = null } launch {
.onFailure { runCatching { repo.getMyShows() }
if (it is kotlinx.coroutines.CancellationException) throw it .onSuccess { myShows = it; myShowsError = null }
myShowsError = friendlyEmbyError(it) .onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
}
myShowsLoading = false
} }
runCatching { repo.getNotifications() } launch {
.onSuccess { notificationState = it; notificationsError = null } runCatching { repo.getNotifications() }
.onFailure { .onSuccess { notificationState = it; notificationsError = null }
if (it is kotlinx.coroutines.CancellationException) throw it .onFailure {
notificationsError = friendlyEmbyError(it) if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
}
notificationsLoading = false
} }
myShowsLoading = false }
notificationsLoading = false
kotlinx.coroutines.delay(32L) kotlinx.coroutines.delay(32L)
// Trailer playback leaves its detail page composed. Let Compose restore the // Trailer playback leaves its detail page composed. Let Compose restore the
// exact hero action instead of moving focus to the home card behind it. // exact hero action instead of moving focus to the home card behind it.
@@ -2063,20 +2099,26 @@ private fun HomeScreen(
LaunchedEffect(settings.userId) { LaunchedEffect(settings.userId) {
myShowsLoading = true myShowsLoading = true
notificationsLoading = true notificationsLoading = true
runCatching { repo.getMyShows() } kotlinx.coroutines.coroutineScope {
.onSuccess { myShows = it; myShowsError = null } launch {
.onFailure { runCatching { repo.getMyShows() }
if (it is kotlinx.coroutines.CancellationException) throw it .onSuccess { myShows = it; myShowsError = null }
myShowsError = friendlyEmbyError(it) .onFailure {
if (it is kotlinx.coroutines.CancellationException) throw it
myShowsError = friendlyEmbyError(it)
}
myShowsLoading = false
} }
runCatching { repo.getNotifications() } launch {
.onSuccess { notificationState = it; notificationsError = null } runCatching { repo.getNotifications() }
.onFailure { .onSuccess { notificationState = it; notificationsError = null }
if (it is kotlinx.coroutines.CancellationException) throw it .onFailure {
notificationsError = friendlyEmbyError(it) if (it is kotlinx.coroutines.CancellationException) throw it
notificationsError = friendlyEmbyError(it)
}
notificationsLoading = false
} }
myShowsLoading = false }
notificationsLoading = false
} }
LaunchedEffect(liveMaintenance) { LaunchedEffect(liveMaintenance) {
@@ -2106,6 +2148,7 @@ private fun HomeScreen(
resolveJob = null resolveJob = null
resolvingItem = null resolvingItem = null
launchingItem = null launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
} }
val playItem: (BaseItem) -> Unit = playItem@{ item -> val playItem: (BaseItem) -> Unit = playItem@{ item ->
if (launchingItem != null || !item.membyPlayable) return@playItem if (launchingItem != null || !item.membyPlayable) return@playItem
@@ -2114,7 +2157,7 @@ private fun HomeScreen(
feature = "playback", source = returnRowId.orEmpty(), target = "player", feature = "playback", source = returnRowId.orEmpty(), target = "player",
itemName = item.name, itemType = item.type, itemName = item.name, itemType = item.type,
) )
homeViewModel.flushAnalytics() homeViewModel.pauseAnalyticsForPlayback()
launchingItem = item launchingItem = item
val playbackRequestedAtMs = SystemClock.elapsedRealtime() val playbackRequestedAtMs = SystemClock.elapsedRealtime()
// Resuming: open the player now and let it resolve the stream while it starts. // Resuming: open the player now and let it resolve the stream while it starts.
@@ -2133,6 +2176,7 @@ private fun HomeScreen(
}.getOrElse { }.getOrElse {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
return@playItem return@playItem
} }
val request = prepared.first val request = prepared.first
@@ -2151,6 +2195,7 @@ private fun HomeScreen(
if (launched.isFailure) { if (launched.isFailure) {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
} }
return@playItem return@playItem
} }
@@ -2202,6 +2247,7 @@ private fun HomeScreen(
if (launched.isFailure) { if (launched.isFailure) {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
} }
} }
.onFailure { error -> .onFailure { error ->
@@ -2211,6 +2257,7 @@ private fun HomeScreen(
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
// Nothing was launched, so nothing will come back to reopen the gate. // Nothing was launched, so nothing will come back to reopen the gate.
launchingItem = null launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
} }
} finally { } finally {
resolvingItem = null resolvingItem = null
@@ -2226,7 +2273,7 @@ private fun HomeScreen(
feature = "trailer", source = "details", target = "player", feature = "trailer", source = "details", target = "player",
itemName = item.name, itemType = item.type, itemName = item.name, itemType = item.type,
) )
homeViewModel.flushAnalytics() homeViewModel.pauseAnalyticsForPlayback()
val launched = runCatching { val launched = runCatching {
playbackLauncher.launch( playbackLauncher.launch(
PlayerActivity.trailerIntent( PlayerActivity.trailerIntent(
@@ -2242,6 +2289,7 @@ private fun HomeScreen(
} }
if (launched.isFailure) { if (launched.isFailure) {
launchingItem = null launchingItem = null
homeViewModel.resumeAnalyticsAfterPlayback()
Toast.makeText(context, "Couldnt open the trailer", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Couldnt open the trailer", Toast.LENGTH_SHORT).show()
} }
} }
@@ -4027,14 +4075,15 @@ private fun HomeArtworkPreloader(
availableWidth: androidx.compose.ui.unit.Dp, availableWidth: androidx.compose.ui.unit.Dp,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
val density = LocalDensity.current val density = LocalDensity.current
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val discovered = remember(rows) { val discovered = remember(rows) {
// Warm the leading posters across several rows instead of exhausting the // Warm the leading posters across several rows instead of exhausting the
// budget on the first shelf. Vertical navigation is then far less likely to // 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. // compete with image fetch/decode as the next row enters the viewport.
val perRow = rows.map { row -> val perRow = rows.take(6).map { row ->
row.items.take(4).map { row.kind to it } row.items.take(2).map { row.kind to it }
} }
buildList { buildList {
val depth = perRow.maxOfOrNull { it.size } ?: 0 val depth = perRow.maxOfOrNull { it.size } ?: 0
@@ -4045,35 +4094,38 @@ private fun HomeArtworkPreloader(
} }
} }
.distinctBy { it.second.id } .distinctBy { it.second.id }
.take(24) .take(12)
} }
val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } } val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } }
LaunchedEffect(signature, availableWidth) { LaunchedEffect(signature, availableWidth, lifecycleOwner) {
// Let visible cards win the first network/decode slots, then warm everything lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// else that this home response discovered into Coil's memory and disk caches. // Let visible cards win the first network/decode slots. Limiting the warm to
kotlinx.coroutines.delay(350L) // the leading pair from the next six shelves covers the likely D-pad path
discovered.forEach { (kind, item) -> // without decoding a second screenful nobody may visit.
val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode kotlinx.coroutines.delay(350L)
val width = if (landscape) { discovered.forEach { (kind, item) ->
(availableWidth / 4.25f).coerceIn(184.dp, 316.dp) val landscape = kind == MediaRowKind.CONTINUE || item.isEpisode
} else { val width = if (landscape) {
(availableWidth / 6.8f).coerceIn(116.dp, 184.dp) (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.HomeCache
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -41,4 +42,66 @@ class HomeUiStateTest {
state.continueWatching.map(BaseItem::id), 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)),
)
} }
@@ -27,8 +27,23 @@ class HomeBenchmark {
@Test @Test
fun coldStartToHomeWithProfile() = measureColdStart(CompilationMode.Partial()) fun coldStartToHomeWithProfile() = measureColdStart(CompilationMode.Partial())
/** Returning to Memby while its process is still resident on the television. */
@Test
fun warmStartToHomeWithProfile() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
compilationMode = CompilationMode.Partial(),
startupMode = StartupMode.WARM,
iterations = 5,
setupBlock = { pressHome() },
measureBlock = { startActivityAndWait() },
)
private fun measureColdStart(mode: CompilationMode) = benchmarkRule.measureRepeated( private fun measureColdStart(mode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME, packageName = PACKAGE_NAME,
// MainActivity calls reportFullyDrawn after Home has submitted its first D-pad-ready
// frame. StartupTimingMetric therefore reports both the opening surface and actual
// time-to-interactive instead of treating the branded intro as a finished launch.
metrics = listOf(StartupTimingMetric()), metrics = listOf(StartupTimingMetric()),
compilationMode = mode, compilationMode = mode,
startupMode = StartupMode.COLD, startupMode = StartupMode.COLD,
+1 -1
View File
@@ -1 +1 @@
0.1.48 0.1.49
+10 -4
View File
@@ -79,10 +79,13 @@ viewers is enough only when both rolled into the next episode, which is unambigu
they left. they left.
**Visual** scanning finds a sustained structural transition — dark, flat, textured with thin **Visual** scanning finds a sustained structural transition — dark, flat, textured with thin
text, and staying that way for a minute. Darkness is *multiplied* rather than added into the text, and staying that way for about half a minute. Darkness is *multiplied* rather than added
frame score, which is the one modelling decision worth defending: under a weighted sum a into the frame score, which is the one modelling decision worth defending: under a weighted
night exterior reaches the credit-like floor on darkness and flatness alone, which is exactly sum a night exterior reaches the credit-like floor on darkness and flatness alone, which is
how a final scene comes to be reported as a credits roll. A unit test pins that case. exactly how a final scene comes to be reported as a credits roll. The candidate frame itself
must also be genuinely dark, and at least 65% of the remaining frames must stay credit-like;
those two guards stop a dim final scene or a temporary title card becoming an early marker.
Unit tests pin both cases.
The two fail in unrelated ways, so agreement between them is worth far more than either The two fail in unrelated ways, so agreement between them is worth far more than either
alone — hence a probabilistic union rather than an average. Disagreement beyond 20 seconds alone — hence a probabilistic union rather than an average. Disagreement beyond 20 seconds
@@ -163,3 +166,6 @@ in which demand-driven narrowing is doing nothing, and the design would need rev
on every playback.** Readings wobble by seconds; a ±12s difference is not news. on every playback.** Readings wobble by seconds; a ±12s difference is not news.
- **The detector never learns why an episode was chosen.** That boundary is what stops it - **The detector never learns why an episode was chosen.** That boundary is what stops it
being tuned to agree with the predictor rather than with the media. being tuned to agree with the predictor rather than with the media.
- **An `ffmpeg` decoder crash gets one conservative retry.** The retry is single-threaded and
discards corrupt packets; ordinary network, authentication and timeout failures are not
retried by the sampler.
+53 -13
View File
@@ -20,9 +20,12 @@ import (
// proportion to a feature whose fallback is simply not showing a button. // proportion to a feature whose fallback is simply not showing a button.
const ( const (
// sustainSeconds is how long the credit-like state has to persist to count. Shorter than // sustainSeconds is how long the credit-like state has to persist to count. Thirty
// this and a dark establishing shot at the end of an act qualifies. // seconds still rejects an ordinary fade or end-of-act beat, but admits the compact
sustainSeconds = 45 // closing rolls common to half-hour and network television. It also fits inside the
// thirty-second forward half of the fine pass; the old forty-five-second requirement
// made refinement around a correctly centred transition impossible.
sustainSeconds = 30
// leadSeconds is how much ordinary programme has to precede the transition. Without it // leadSeconds is how much ordinary programme has to precede the transition. Without it
// the changepoint can sit at the very first sampled frame, which is not evidence of a // the changepoint can sit at the very first sampled frame, which is not evidence of a
@@ -34,10 +37,23 @@ const (
// "slightly more dark" in the middle of a night scene. // "slightly more dark" in the middle of a night scene.
creditLikeFloor = 0.45 creditLikeFloor = 0.45
// onsetMeanCeiling requires the candidate cut itself to be genuinely dark. A dim final
// scene can share the roll's edge density and be followed by real credits soon enough to
// lift the sustain average; accepting it clips the scene. The detector deliberately does
// not claim bright or picture-backed credits without behavioural corroboration.
onsetMeanCeiling = 0.10
// minSeparation is how much better the tail has to score than the head. This is the // minSeparation is how much better the tail has to score than the head. This is the
// primary guard against answering on noise, and it is set high because the cost of a // primary guard against answering on noise. A dark programme can make the absolute gap
// wrong marker is somebody losing the end of an episode. // modest even at its real credits; the stay-credit-like guard below is what makes this
minSeparation = 0.18 // lower bar safe.
minSeparation = 0.10
// minCreditLikeTailFraction says that credits continue to the end of the file. A dark
// scene may satisfy the short sustain window, but ordinary programme resumes afterwards;
// a real roll leaves most remaining samples credit-like even with black gaps, logos and
// production cards mixed through it.
minCreditLikeTailFraction = 0.65
// onsetFraction is how credit-like a frame has to be, relative to the established roll, // onsetFraction is how credit-like a frame has to be, relative to the established roll,
// to count as already part of it. // to count as already part of it.
@@ -142,13 +158,17 @@ func (d *VisualDetector) Detect(ctx context.Context, media MediaInfo) (Detection
func creditScore(frame frameStats) float64 { func creditScore(frame frameStats) float64 {
darkness := frame.DarkFraction darkness := frame.DarkFraction
// Text produces a narrow *band* of edge density: a flat black frame has almost none, and // Text produces a band of edge density: a flat black frame has almost none, and a
// a detailed photograph has far more than titles do. Scoring the band rather than the // detailed photograph has far more than titles do. The useful band is deliberately
// magnitude is what stops a bright, busy scene outscoring the credits. // broad. A sparse title card and a dense cast roll are both credits; the old triangle
const idealEdges = 0.030 // reached zero at twice one narrow ideal and rejected the latter outright.
const (
idealEdges = 0.020
maxEdges = 0.120
)
text := frame.EdgeDensity / idealEdges text := frame.EdgeDensity / idealEdges
if text > 1 { if text > 1 {
text = 2 - text text = 1 - (frame.EdgeDensity-idealEdges)/(maxEdges-idealEdges)
} }
text = clamp01(text) text = clamp01(text)
@@ -190,8 +210,13 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
// a few hundred frames, but the quadratic version is the kind of thing that stops being // a few hundred frames, but the quadratic version is the kind of thing that stops being
// free the moment somebody widens the window. // free the moment somebody widens the window.
prefix := make([]float64, len(scores)+1) prefix := make([]float64, len(scores)+1)
qualifying := make([]int, len(scores)+1)
for index, score := range scores { for index, score := range scores {
prefix[index+1] = prefix[index] + score prefix[index+1] = prefix[index] + score
qualifying[index+1] = qualifying[index]
if score >= creditLikeFloor {
qualifying[index+1]++
}
} }
segmentMean := func(from, to int) float64 { segmentMean := func(from, to int) float64 {
if to <= from { if to <= from {
@@ -202,6 +227,9 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
bestIndex, bestSeparation, found := 0, 0.0, false bestIndex, bestSeparation, found := 0, 0.0, false
for split := lead; split+sustain <= len(frames); split++ { for split := lead; split+sustain <= len(frames); split++ {
if frames[split].Mean > onsetMeanCeiling {
continue
}
head := segmentMean(0, split) head := segmentMean(0, split)
tail := segmentMean(split, len(frames)) tail := segmentMean(split, len(frames))
// The sustained window immediately after the split has to qualify on its own, not // The sustained window immediately after the split has to qualify on its own, not
@@ -210,6 +238,11 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
if segmentMean(split, split+sustain) < creditLikeFloor { if segmentMean(split, split+sustain) < creditLikeFloor {
continue continue
} }
tailCount := len(frames) - split
creditLikeTail := qualifying[len(scores)] - qualifying[split]
if float64(creditLikeTail)/float64(tailCount) < minCreditLikeTailFraction {
continue
}
separation := tail - head separation := tail - head
if separation < minSeparation || separation <= bestSeparation { if separation < minSeparation || separation <= bestSeparation {
continue continue
@@ -222,8 +255,15 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
// The separation is reported for the split that earned it, never for the walked-back // The separation is reported for the split that earned it, never for the walked-back
// frame: confidence is a statement about how clearly the transition was found, and // frame: confidence is a statement about how clearly the transition was found, and
// recomputing it over a fade would report the answer as weaker for having been improved. // recomputing it over a fade would report the answer as weaker for having been improved.
return backOffToOnset(scores, bestIndex, segmentMean(bestIndex, min(bestIndex+sustain, len(scores))), interval), onset := backOffToOnset(
bestSeparation, true scores, bestIndex, segmentMean(bestIndex, min(bestIndex+sustain, len(scores))), interval,
)
// The score-only walk can step back onto a dim final picture whose texture resembles
// text. Preserve the same luminance guard that qualified the split itself.
for onset < bestIndex && frames[onset].Mean > onsetMeanCeiling {
onset++
}
return onset, bestSeparation, true
} }
// backOffToOnset walks a split backwards through the credits' fade-in. // backOffToOnset walks a split backwards through the credits' fade-in.
+75
View File
@@ -49,6 +49,19 @@ func darkSceneFrame(position time.Duration) frameStats {
} }
} }
// A dim, textured final scene: close enough to the roll's structural score that what comes
// after it could lift the sustain average, but visibly still programme.
func dimTexturedSceneFrame(position time.Duration) frameStats {
return frameStats{
PositionMs: position.Milliseconds(),
Mean: 0.13,
Variance: 0.006,
DarkFraction: 0.78,
EdgeDensity: 0.015,
Diff: 0.08,
}
}
func window(start time.Duration, kinds ...func(time.Duration) frameStats) []frameStats { func window(start time.Duration, kinds ...func(time.Duration) frameStats) []frameStats {
frames := make([]frameStats, 0, len(kinds)) frames := make([]frameStats, 0, len(kinds))
for index, build := range kinds { for index, build := range kinds {
@@ -85,6 +98,32 @@ func TestFindsACleanTransition(t *testing.T) {
} }
} }
// Television rolls are commonly only half a minute long. This is also the amount of media
// available after a correctly centred transition in the fine pass, so requiring more would
// make the refinement structurally unable to confirm the coarse answer.
func TestFindsACompactTelevisionCreditsRoll(t *testing.T) {
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, creditsFrame)...)
frames := window(20*time.Minute, kinds...)
if index, _, found := findTransition(frames, coarseInterval); !found || index != 20 {
t.Fatalf("compact credits transition = %d, found %t; want frame 20", index, found)
}
}
func TestFinePassCanConfirmACentredTransition(t *testing.T) {
frames := make([]frameStats, 0, 80)
for index := 0; index < 40; index++ {
frames = append(frames, programmeFrame(time.Duration(index)*fineInterval))
}
for index := 40; index < 80; index++ {
frames = append(frames, creditsFrame(time.Duration(index)*fineInterval))
}
if index, _, found := findTransition(frames, fineInterval); !found || index != 40 {
t.Fatalf("fine transition = %d, found %t; want frame 40", index, found)
}
}
// The whole point of not answering on darkness alone. // The whole point of not answering on darkness alone.
func TestDarkSceneIsNotCredits(t *testing.T) { func TestDarkSceneIsNotCredits(t *testing.T) {
kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, darkSceneFrame)...) kinds := append(repeatFrames(30, programmeFrame), repeatFrames(30, darkSceneFrame)...)
@@ -122,6 +161,34 @@ func TestBriefDarkBeatIsIgnored(t *testing.T) {
} }
} }
// A locally convincing title-like sequence in the middle of the tail is not closing
// credits when ordinary programme resumes after it. This is the real-media failure shape:
// looking only at the immediate sustain window marked Westworld and Blue Bloods several
// minutes before their genuine rolls.
func TestCreditLikeSequenceThatDoesNotReachTheEndIsIgnored(t *testing.T) {
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, creditsFrame)...)
kinds = append(kinds, repeatFrames(20, programmeFrame)...)
frames := window(35*time.Minute, kinds...)
if _, _, found := findTransition(frames, coarseInterval); found {
t.Fatal("a temporary credit-like sequence was reported as closing credits")
}
}
func TestDimFinalSceneBeforeCreditsDoesNotMoveTheMarkerEarly(t *testing.T) {
kinds := append(repeatFrames(20, programmeFrame), repeatFrames(8, dimTexturedSceneFrame)...)
kinds = append(kinds, repeatFrames(12, creditsFrame)...)
frames := window(35*time.Minute, kinds...)
index, _, found := findTransition(frames, coarseInterval)
if !found {
t.Fatal("the genuine credits after the dim scene were not found")
}
if index != 28 {
t.Fatalf("transition at frame %d, want the credits at frame 28", index)
}
}
func TestTooFewFramesAnswerNothing(t *testing.T) { func TestTooFewFramesAnswerNothing(t *testing.T) {
frames := window(41*time.Minute, repeatFrames(4, creditsFrame)...) frames := window(41*time.Minute, repeatFrames(4, creditsFrame)...)
if _, _, found := findTransition(frames, coarseInterval); found { if _, _, found := findTransition(frames, coarseInterval); found {
@@ -151,6 +218,14 @@ func TestCreditScoreSeparatesTheThreeCases(t *testing.T) {
} }
} }
func TestDenseCreditsStillLookLikeCredits(t *testing.T) {
frame := creditsFrame(0)
frame.EdgeDensity = 0.075
if score := creditScore(frame); score < creditLikeFloor {
t.Fatalf("dense credits scored %.2f, below the floor %.2f", score, creditLikeFloor)
}
}
// Confidence from one detector agreeing with itself is not corroboration. // Confidence from one detector agreeing with itself is not corroboration.
func TestVisualConfidenceIsCapped(t *testing.T) { func TestVisualConfidenceIsCapped(t *testing.T) {
if score := visualConfidence(10); score >= 1 { if score := visualConfidence(10); score >= 1 {
+50 -2
View File
@@ -122,21 +122,51 @@ func (s *Sampler) Sample(
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
stats, err := s.samplePass(ctx, url, from, to, interval, false)
if err == nil || !decoderCrashed(err) || ctx.Err() != nil {
return stats, err
}
// A decoder crash is local to the ffmpeg process, not evidence that the media is
// unreadable. Retry once with conservative decoder settings: single-threaded decoding
// avoids the most common native-code race, while corrupt packets are discarded rather
// than handed back through the failing path. Ordinary HTTP and authentication failures
// are never retried here.
stats, retryErr := s.samplePass(ctx, url, from, to, interval, true)
if retryErr != nil {
return stats, fmt.Errorf("credits: conservative ffmpeg retry: %w", retryErr)
}
return stats, nil
}
func (s *Sampler) samplePass(
ctx context.Context, url string, from, to time.Duration, interval time.Duration,
conservative bool,
) ([]frameStats, error) {
// -ss ahead of -i is the whole optimisation: it seeks in the container before opening a // -ss ahead of -i is the whole optimisation: it seeks in the container before opening a
// decoder, so the input starts near the credits. Behind -i it would decode from zero and // decoder, so the input starts near the credits. Behind -i it would decode from zero and
// discard, which is the full read this package exists to avoid. // discard, which is the full read this package exists to avoid.
args := []string{ args := []string{
"-hide_banner", "-loglevel", "error", "-nostdin", "-hide_banner", "-loglevel", "error", "-nostdin",
"-ss", formatSeconds(from), "-ss", formatSeconds(from),
}
if conservative {
args = append(args,
"-threads", "1",
"-fflags", "+discardcorrupt",
"-err_detect", "ignore_err",
)
}
args = append(args,
"-i", url, "-i", url,
"-t", formatSeconds(to - from), "-t", formatSeconds(to-from),
"-an", "-sn", "-dn", "-an", "-sn", "-dn",
"-vf", fmt.Sprintf("fps=%s,scale=%d:%d,format=gray", "-vf", fmt.Sprintf("fps=%s,scale=%d:%d,format=gray",
formatRate(interval), sampleWidth, sampleHeight), formatRate(interval), sampleWidth, sampleHeight),
"-frames:v", strconv.Itoa(maxFrames), "-frames:v", strconv.Itoa(maxFrames),
"-f", "rawvideo", "-pix_fmt", "gray", "-f", "rawvideo", "-pix_fmt", "gray",
"pipe:1", "pipe:1",
} )
cmd := exec.CommandContext(ctx, s.binary(), args...) cmd := exec.CommandContext(ctx, s.binary(), args...)
// Cancel and WaitDelay together are what stop an orphan. CommandContext's default is to // Cancel and WaitDelay together are what stop an orphan. CommandContext's default is to
@@ -181,6 +211,24 @@ func (s *Sampler) Sample(
return stats, nil return stats, nil
} }
func decoderCrashed(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, signature := range []string{
"segmentation fault",
"signal: aborted",
"signal: bus error",
"access violation",
} {
if strings.Contains(message, signature) {
return true
}
}
return false
}
// readFrames pulls fixed-size grayscale frames off the pipe and reduces each one as it // readFrames pulls fixed-size grayscale frames off the pipe and reduces each one as it
// arrives. The frame buffer is allocated once and reused, so a four-hundred-frame pass // arrives. The frame buffer is allocated once and reused, so a four-hundred-frame pass
// allocates fourteen kilobytes rather than five and a half megabytes. // allocates fourteen kilobytes rather than five and a half megabytes.
+28
View File
@@ -0,0 +1,28 @@
package credits
import (
"errors"
"testing"
)
func TestDecoderCrashClassification(t *testing.T) {
for _, message := range []string{
"credits: ffmpeg: signal: segmentation fault",
"credits: ffmpeg: signal: aborted",
"credits: ffmpeg: signal: bus error",
"credits: ffmpeg: access violation reading location",
} {
if !decoderCrashed(errors.New(message)) {
t.Errorf("%q was not classified as a decoder crash", message)
}
}
for _, message := range []string{
"credits: ffmpeg: exit status 1: HTTP error 401 Unauthorized",
"context deadline exceeded",
"credits: ffmpeg is not available",
} {
if decoderCrashed(errors.New(message)) {
t.Errorf("%q was classified as a decoder crash", message)
}
}
}