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
|
## 0.2.67 — 2026-08-16
|
||||||
- Improved: Movie and show pages.
|
- 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"
|
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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t 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, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t 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, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t 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, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t 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, "Couldn’t open the trailer", Toast.LENGTH_SHORT).show()
|
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,
|
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 @@
|
|||||||
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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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