0.2.69 - Homepage loading improvements pass
This commit is contained in:
@@ -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
|
||||
- Improved: Movie and show pages.
|
||||
|
||||
|
||||
-12
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+12
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
||||
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"
|
||||
/>
|
||||
<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="stylesheet" crossorigin href="/admin/assets/index-Dyz7s7hT.css">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-CASotpHk.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { NotificationBell } from './NotificationBell';
|
||||
import { nav } from '../nav';
|
||||
import { useNotifications } from '../lib/notifications';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { time } from '../lib/format';
|
||||
import { Confirm } from './ui';
|
||||
|
||||
/* 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() {
|
||||
const { version, currentUser, online, checkedAt, loading, status, setMaintenance } = useGateway();
|
||||
const { version, currentUser, online, loading, status, setMaintenance } = useGateway();
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [changingAvailability, setChangingAvailability] = useState(false);
|
||||
@@ -143,7 +142,6 @@ export function Layout() {
|
||||
const account = useRef<HTMLDivElement>(null);
|
||||
const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A';
|
||||
const statusTone = status && online && !offline ? 'ok' : status || !loading ? 'bad' : 'checking';
|
||||
const statusLabel = offline ? 'offline' : status && online ? 'online' : loading ? 'checking' : 'not responding';
|
||||
|
||||
const toggleAvailability = async () => {
|
||||
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'}
|
||||
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
|
||||
>
|
||||
<span className="dot" />
|
||||
<b>{statusLabel}</b>
|
||||
<span>{offline ? 'click to bring back online' : checkedAt ? `updated ${time(checkedAt)}` : ''}</span>
|
||||
<span className="dot" aria-hidden="true" />
|
||||
</button>
|
||||
<NotificationBell />
|
||||
<div className="account-menu" data-open={accountOpen || undefined} ref={account}>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function OmniSearch() {
|
||||
.filter((entry) => entry.rank > 0)
|
||||
.sort((a, b) => b.rank - a.rank || a.index - b.index)
|
||||
.map(({ item, rank }) => ({ item, rank }));
|
||||
}, [query]);
|
||||
}, [query, status]);
|
||||
|
||||
// Something is always selected, so Enter has an answer without an arrow press first.
|
||||
useEffect(() => setCursor(0), [query]);
|
||||
|
||||
+69
-80
@@ -208,18 +208,17 @@ a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 38px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: background .16s ease, border-color .16s ease, color .16s ease;
|
||||
}
|
||||
.topbar-status:hover:not(:disabled),
|
||||
@@ -227,37 +226,32 @@ a {
|
||||
background: var(--surface-lift);
|
||||
}
|
||||
.topbar-status:disabled {
|
||||
cursor: wait;
|
||||
opacity: .65;
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
.topbar-status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--quiet);
|
||||
flex: 0 0 8px;
|
||||
box-shadow: 0 0 0 3px rgba(125, 133, 144, .12);
|
||||
}
|
||||
.topbar-status[data-tone="ok"] .dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-wash), 0 0 12px rgba(86, 211, 100, .34);
|
||||
background: var(--accent-ink);
|
||||
box-shadow: 0 0 0 3px var(--accent-wash), 0 0 9px rgba(86, 211, 100, .28);
|
||||
}
|
||||
.topbar-status[data-tone="bad"] .dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
.topbar-status b {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.topbar-status span {
|
||||
font-size: 11.5px;
|
||||
color: var(--quiet);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 0 0 3px var(--danger-wash);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.account-menu {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.account-trigger {
|
||||
@@ -360,8 +354,7 @@ a {
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar-version,
|
||||
.topbar-status span {
|
||||
.topbar-version {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -419,9 +412,10 @@ a {
|
||||
.omni-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: min(520px, 94vw);
|
||||
max-height: min(60vh, 520px);
|
||||
right: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-height: min(50vh, 420px);
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -475,13 +469,16 @@ a {
|
||||
|
||||
.bell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
}
|
||||
.bell-button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
@@ -1165,20 +1162,8 @@ table {
|
||||
max-width: 640px;
|
||||
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,
|
||||
.topbar-version,
|
||||
.topbar-status span {
|
||||
.topbar-version {
|
||||
display: none;
|
||||
}
|
||||
.topbar-tools {
|
||||
@@ -1190,21 +1175,19 @@ table {
|
||||
height: 40px;
|
||||
}
|
||||
.topbar-status {
|
||||
width: 40px;
|
||||
flex-basis: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
.topbar-status[data-tone="ok"] {
|
||||
border-color: rgba(86, 211, 100, .38);
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.topbar-status[data-tone="bad"] {
|
||||
border-color: rgba(229, 83, 75, .34);
|
||||
background: var(--danger-wash);
|
||||
color: var(--danger-ink);
|
||||
.omni-input,
|
||||
.bell,
|
||||
.bell-button,
|
||||
.account-menu,
|
||||
.account-trigger {
|
||||
height: 40px;
|
||||
}
|
||||
.page {
|
||||
width: 100%;
|
||||
@@ -1236,8 +1219,7 @@ table {
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.brand-word,
|
||||
.topbar-status b {
|
||||
.brand-word {
|
||||
display: none;
|
||||
}
|
||||
.topbar-status {
|
||||
@@ -1266,40 +1248,43 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
/* A phone gets two deliberate rows: navigation and live controls above, search below.
|
||||
Compressing all six controls into one line made the search unusable at exactly the
|
||||
widths where it is the quickest way around a long navigation drawer. */
|
||||
/* A phone keeps every global control on one row. Search is the only flexible item: the
|
||||
navigation and identity controls retain useful targets while the field gives up width
|
||||
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) {
|
||||
:root {
|
||||
--top: calc(108px + var(--safe-top));
|
||||
--top: calc(60px + var(--safe-top));
|
||||
}
|
||||
.topbar {
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: calc(var(--safe-top) + 8px) max(12px, var(--safe-right)) 8px max(12px, var(--safe-left));
|
||||
flex-wrap: nowrap;
|
||||
gap: clamp(4px, 1.5vw, 8px);
|
||||
padding: calc(var(--safe-top) + 8px) max(8px, var(--safe-right)) 8px max(8px, var(--safe-left));
|
||||
}
|
||||
.rail-toggle { order: 1; }
|
||||
.topbar-brand { order: 2; }
|
||||
.topbar-status {
|
||||
order: 3;
|
||||
margin-left: auto;
|
||||
.topbar-brand {
|
||||
order: 2;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.bell { order: 4; }
|
||||
.account-menu { order: 5; }
|
||||
.topbar .omni {
|
||||
order: 6;
|
||||
flex: 1 0 100%;
|
||||
order: 3;
|
||||
flex: 1 1 96px;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-basis: 32px;
|
||||
.topbar-status {
|
||||
order: 4;
|
||||
margin-left: 0;
|
||||
}
|
||||
.omni-input {
|
||||
height: 38px;
|
||||
.bell { order: 5; }
|
||||
.account-menu { order: 6; }
|
||||
.brand-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-basis: 30px;
|
||||
}
|
||||
.account-panel,
|
||||
.bell-panel {
|
||||
@@ -3000,8 +2985,12 @@ details summary {
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
}
|
||||
.topbar-status b {
|
||||
margin-left: 1px;
|
||||
.topbar .bell,
|
||||
.topbar .account-menu {
|
||||
height: 44px;
|
||||
}
|
||||
.topbar .omni-input {
|
||||
height: 44px;
|
||||
}
|
||||
.rail a {
|
||||
min-height: 46px;
|
||||
|
||||
@@ -46,7 +46,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.68"
|
||||
val defaultVersionName = "0.2.69"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -56,18 +56,40 @@ internal class DiagnosticNetworkInterceptor(private val backend: String) : Inter
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val started = android.os.SystemClock.elapsedRealtime()
|
||||
MembyDiagnostics.trace("http_started", "backend" to backend, "method" to request.method, "url" to MembyDiagnostics.safeUrl(request.url))
|
||||
if (MembyDiagnostics.traceEnabled) {
|
||||
MembyDiagnostics.trace(
|
||||
"http_started",
|
||||
"backend" to backend,
|
||||
"method" to request.method,
|
||||
"url" to MembyDiagnostics.safeUrl(request.url),
|
||||
)
|
||||
}
|
||||
return try {
|
||||
chain.proceed(request).also { response ->
|
||||
MembyDiagnostics.debug("http_finished", "backend" to backend, "method" to request.method,
|
||||
"url" to MembyDiagnostics.safeUrl(request.url), "status" to response.code,
|
||||
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"))
|
||||
"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,19 +365,10 @@ 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) {
|
||||
launch {
|
||||
val details = runCatching {
|
||||
repository.getItemDetails(item.id)
|
||||
}.getOrNull() ?: return@launch
|
||||
@@ -409,17 +379,17 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -2030,20 +2060,26 @@ private fun HomeScreen(
|
||||
scope.launch {
|
||||
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
|
||||
}
|
||||
launch {
|
||||
runCatching { repo.getNotifications() }
|
||||
.onSuccess { notificationState = it; notificationsError = null }
|
||||
.onFailure {
|
||||
if (it is kotlinx.coroutines.CancellationException) throw it
|
||||
notificationsError = friendlyEmbyError(it)
|
||||
}
|
||||
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,21 +2099,27 @@ private fun HomeScreen(
|
||||
LaunchedEffect(settings.userId) {
|
||||
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
|
||||
}
|
||||
launch {
|
||||
runCatching { repo.getNotifications() }
|
||||
.onSuccess { notificationState = it; notificationsError = null }
|
||||
.onFailure {
|
||||
if (it is kotlinx.coroutines.CancellationException) throw it
|
||||
notificationsError = friendlyEmbyError(it)
|
||||
}
|
||||
myShowsLoading = false
|
||||
notificationsLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(liveMaintenance) {
|
||||
if (liveMaintenance != null) {
|
||||
@@ -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,12 +4094,14 @@ 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.
|
||||
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
|
||||
@@ -4077,6 +4128,7 @@ private fun HomeArtworkPreloader(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,23 @@ class HomeBenchmark {
|
||||
@Test
|
||||
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(
|
||||
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()),
|
||||
compilationMode = mode,
|
||||
startupMode = StartupMode.COLD,
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.48
|
||||
0.1.49
|
||||
|
||||
@@ -79,10 +79,13 @@ viewers is enough only when both rolled into the next episode, which is unambigu
|
||||
they left.
|
||||
|
||||
**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
|
||||
frame score, which is the one modelling decision worth defending: under a weighted sum a
|
||||
night exterior reaches the credit-like floor on darkness and flatness alone, which is exactly
|
||||
how a final scene comes to be reported as a credits roll. A unit test pins that case.
|
||||
text, and staying that way for about half a minute. Darkness is *multiplied* rather than added
|
||||
into the frame score, which is the one modelling decision worth defending: under a weighted
|
||||
sum a night exterior reaches the credit-like floor on darkness and flatness alone, which is
|
||||
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
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -20,9 +20,12 @@ import (
|
||||
// proportion to a feature whose fallback is simply not showing a button.
|
||||
|
||||
const (
|
||||
// sustainSeconds is how long the credit-like state has to persist to count. Shorter than
|
||||
// this and a dark establishing shot at the end of an act qualifies.
|
||||
sustainSeconds = 45
|
||||
// sustainSeconds is how long the credit-like state has to persist to count. Thirty
|
||||
// seconds still rejects an ordinary fade or end-of-act beat, but admits the compact
|
||||
// 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
|
||||
// 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.
|
||||
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
|
||||
// primary guard against answering on noise, and it is set high because the cost of a
|
||||
// wrong marker is somebody losing the end of an episode.
|
||||
minSeparation = 0.18
|
||||
// primary guard against answering on noise. A dark programme can make the absolute gap
|
||||
// modest even at its real credits; the stay-credit-like guard below is what makes this
|
||||
// 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,
|
||||
// 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 {
|
||||
darkness := frame.DarkFraction
|
||||
|
||||
// Text produces a narrow *band* of edge density: a flat black frame has almost none, and
|
||||
// a detailed photograph has far more than titles do. Scoring the band rather than the
|
||||
// magnitude is what stops a bright, busy scene outscoring the credits.
|
||||
const idealEdges = 0.030
|
||||
// Text produces a band of edge density: a flat black frame has almost none, and a
|
||||
// detailed photograph has far more than titles do. The useful band is deliberately
|
||||
// broad. A sparse title card and a dense cast roll are both credits; the old triangle
|
||||
// reached zero at twice one narrow ideal and rejected the latter outright.
|
||||
const (
|
||||
idealEdges = 0.020
|
||||
maxEdges = 0.120
|
||||
)
|
||||
text := frame.EdgeDensity / idealEdges
|
||||
if text > 1 {
|
||||
text = 2 - text
|
||||
text = 1 - (frame.EdgeDensity-idealEdges)/(maxEdges-idealEdges)
|
||||
}
|
||||
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
|
||||
// free the moment somebody widens the window.
|
||||
prefix := make([]float64, len(scores)+1)
|
||||
qualifying := make([]int, len(scores)+1)
|
||||
for index, score := range scores {
|
||||
prefix[index+1] = prefix[index] + score
|
||||
qualifying[index+1] = qualifying[index]
|
||||
if score >= creditLikeFloor {
|
||||
qualifying[index+1]++
|
||||
}
|
||||
}
|
||||
segmentMean := func(from, to int) float64 {
|
||||
if to <= from {
|
||||
@@ -202,6 +227,9 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
|
||||
|
||||
bestIndex, bestSeparation, found := 0, 0.0, false
|
||||
for split := lead; split+sustain <= len(frames); split++ {
|
||||
if frames[split].Mean > onsetMeanCeiling {
|
||||
continue
|
||||
}
|
||||
head := segmentMean(0, split)
|
||||
tail := segmentMean(split, len(frames))
|
||||
// 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 {
|
||||
continue
|
||||
}
|
||||
tailCount := len(frames) - split
|
||||
creditLikeTail := qualifying[len(scores)] - qualifying[split]
|
||||
if float64(creditLikeTail)/float64(tailCount) < minCreditLikeTailFraction {
|
||||
continue
|
||||
}
|
||||
separation := tail - head
|
||||
if separation < minSeparation || separation <= bestSeparation {
|
||||
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
|
||||
// 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.
|
||||
return backOffToOnset(scores, bestIndex, segmentMean(bestIndex, min(bestIndex+sustain, len(scores))), interval),
|
||||
bestSeparation, true
|
||||
onset := backOffToOnset(
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
frames := make([]frameStats, 0, len(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.
|
||||
func TestDarkSceneIsNotCredits(t *testing.T) {
|
||||
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) {
|
||||
frames := window(41*time.Minute, repeatFrames(4, creditsFrame)...)
|
||||
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.
|
||||
func TestVisualConfidenceIsCapped(t *testing.T) {
|
||||
if score := visualConfidence(10); score >= 1 {
|
||||
|
||||
@@ -122,12 +122,42 @@ func (s *Sampler) Sample(
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
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
|
||||
// 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.
|
||||
args := []string{
|
||||
"-hide_banner", "-loglevel", "error", "-nostdin",
|
||||
"-ss", formatSeconds(from),
|
||||
}
|
||||
if conservative {
|
||||
args = append(args,
|
||||
"-threads", "1",
|
||||
"-fflags", "+discardcorrupt",
|
||||
"-err_detect", "ignore_err",
|
||||
)
|
||||
}
|
||||
args = append(args,
|
||||
"-i", url,
|
||||
"-t", formatSeconds(to-from),
|
||||
"-an", "-sn", "-dn",
|
||||
@@ -136,7 +166,7 @@ func (s *Sampler) Sample(
|
||||
"-frames:v", strconv.Itoa(maxFrames),
|
||||
"-f", "rawvideo", "-pix_fmt", "gray",
|
||||
"pipe:1",
|
||||
}
|
||||
)
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.binary(), args...)
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user