This commit is contained in:
ponzischeme89
2026-08-24 22:56:46 +12:00
parent 4f95767e2f
commit 396d35e2f5
48 changed files with 1541 additions and 672 deletions
+44 -1
View File
@@ -2130,7 +2130,7 @@ gained a duplicate on a copy-paste, looks identical to one that did not. The gre
assembled separately so the authenticated name is trimmed once; the player also draws from
the same tone-specific pool while it prepares a programme.
**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up
**Next up / auto-advance.** Inside the last minute of an episode, `PlayerActivity` fades in
`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings
→ Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from
`repository.nextEpisode`, dual-path like everything else: `/v1/items/{id}/next` on the
@@ -2144,6 +2144,49 @@ inside the running player instead of relaunching the activity, so `itemId`/`play
/`stopReported` must all be reset together or the outgoing episode is never reported
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
**The bar is compact because the credits are the programme.** It was a 420dp card against
the end wall that shrank the picture to 58% and slid it left to make room for itself — so
the one part of an episode the overlay covered was its closing minute, which is the thing
the viewer is still watching. It is now a short bar in the bottom-left safe area: the
show's own title treatment, a hairline, `NEXT EPISODE` over the countdown, and a draining
ring. Nothing else — no episode thumbnail, no synopsis, no panel. Things to preserve:
- **Nothing on it is focusable and it takes no focus**, which is what lets every gate it
used to appear in be *removed* rather than tightened. `seekControlsActive`,
`centrePausesPlayback` and `skipIntroCanShow` no longer mention it, so the centre key
still pauses and Left/Right still skip while it is up — the remote goes on meaning what
it meant a moment before the bar appeared. It is the only overlay in this player that
owns nothing, and `nextUpCanShow` is the other half of that bargain: wherever something
that *does* own the screen is up — the transport, which shares the same bottom edge, the
drop-up, the cast panel, the credits pane, the loading or error surfaces — the bar stands
down rather than being drawn underneath it, and comes back when they go.
- **The two actions survive without buttons.** Back dismisses, the same one-press-per-level
contract every other overlay here has; Play now is the transport's own Next Episode
control, which `shouldOfferNextEpisodeButton` offers under exactly the conditions this
bar appears under. A pair of pills on the bar would need focus, and focus is the thing
being given back.
- **The identity is bound when the episode is *resolved*, not when the bar is drawn.**
That is a minute or more of lead time, so the logo is already in the slot when the bar
fades in and the viewer never sees the name swapped for artwork — the swap
`setUpPlaybackIdentity` takes such care to avoid in the opposite corner. `nextUpBoundItemId`
is what keeps it to one bind per episode rather than one per 250 ms tick, and the text
fallback is shown meanwhile because a bar held back waiting on a logo is a bar that is
late for the thing it was announcing.
- **The ring is `CountdownRingView`**, shared with the skip-intro offer, which now differs
from it only in taking its colours from its own drawable state (that button inverts on
focus; this bar cannot be focused). Both are advanced from the *playhead* — a pause holds
the ring and a seek moves it — and both redraw only past a degree of movement, which over
a minute at 250 ms is most of the ticks skipped on a box that has a decoder to feed.
- **The figure and the line under the eyebrow are the same value**, from `formatRemaining`.
Two countdowns on one bar reading "60s" and "1:00" is a bar that looks broken.
- **`NEXT_UP_ACCENT` is written out rather than built with `Color.rgb`.** `PlayerActivity`'s
companion is initialised by plain JUnit tests with no Android framework under them, and
one android.graphics call from there fails every test in the file.
- Screenshots are `NextUpOverlayScreenshotTest``build/screenshots/next-up/`, over a
deliberately bright stand-in frame and captured at the full screen size rather than
cropped to the bar: whether the programme is still visible around it is the whole claim
the redesign makes, and it is not a thing a unit test can check.
**One next-item pipeline.** `ui/player/NextUpPipeline.kt` owns the answer to "what plays
after this?", and everything that wants to know reads it from there: the manual Next Episode
button, the next-up banner, the credits pane, the countdown and the ended frame. Before it,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon"
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-D2yWw-VA.js"></script>
<script type="module" crossorigin src="/admin/assets/index-BNwLRlCd.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-CtoC2zbP.css">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -4,6 +4,7 @@ import { GatewayProvider } from './lib/gateway';
import { NotificationProvider } from './lib/notifications';
import { ToastProvider } from './lib/toast';
import { PageHead } from './components/ui';
import { ReconnectOverlay } from './components/ReconnectOverlay';
import { OverviewPage } from './pages/Overview';
import { ActivityPage } from './pages/Activity';
@@ -125,6 +126,7 @@ export function App() {
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
<ReconnectOverlay />
</ToastProvider>
</NotificationProvider>
</GatewayProvider>
+27 -1
View File
@@ -7,17 +7,28 @@
* by reloading into the gateway's own sign-in form rather than by anything here.
*/
import { reportTemporaryAvailabilityFailure } from '../lib/availability';
/** ApiError carries the status so a caller can tell "not configured" (404) from "broken". */
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly temporaryAvailability = false,
) {
super(message);
this.name = 'ApiError';
}
}
function isTemporaryStatus(status: number): boolean {
return status === 502 || status === 503 || status === 504;
}
export function isTemporaryAvailabilityError(error: unknown): boolean {
return error instanceof ApiError && error.temporaryAvailability;
}
/* The sign-in behind this console lasts twelve hours and slides forward only for requests
an operator actually caused, so the poll of a tab nobody is reading cannot keep it
alive. Anything the console does while somebody is working it says so with this header;
@@ -48,7 +59,9 @@ function reauthenticate(): boolean {
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
let response: Response;
try {
response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
@@ -56,6 +69,15 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
...(options.headers ?? {}),
},
});
} catch (error) {
// Fetch reports a refused connection, DNS failure, or broken proxy as TypeError.
// Keep that implementation detail out of the UI and begin the one global recovery loop.
if (error instanceof TypeError) {
reportTemporaryAvailabilityFailure();
throw new ApiError('The server is temporarily unavailable.', 0, true);
}
throw error;
}
if (response.status === 401) {
throw new ApiError(
reauthenticate()
@@ -65,6 +87,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
);
}
if (!response.ok) {
if (isTemporaryStatus(response.status)) {
reportTemporaryAvailabilityFailure();
throw new ApiError('The server is temporarily unavailable.', response.status, true);
}
const body = (await response.json().catch(() => ({}))) as { error?: string };
throw new ApiError(body.error ?? `Request failed (${response.status})`, response.status);
}
@@ -0,0 +1,17 @@
import { useAvailability } from '../lib/availability';
export function ReconnectOverlay() {
const { reconnecting, attempt } = useAvailability();
if (!reconnecting) return null;
return (
<div className="reconnect-overlay" role="status" aria-live="polite" aria-label="Server updating">
<div className="reconnect-panel">
<span className="reconnect-spinner" aria-hidden="true" />
<h1>Server Updating...</h1>
<p>The backend is restarting or temporarily unavailable. Your Admin UI will reconnect automatically.</p>
<small>Attempting connection... Attempt {attempt}</small>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
export interface AvailabilityState {
reconnecting: boolean;
attempt: number;
}
type StateListener = (state: AvailabilityState) => void;
type RecoveryListener = () => void;
const RETRY_MS = 2500;
const HEALTH_TIMEOUT_MS = 2000;
const listeners = new Set<StateListener>();
const recoveryListeners = new Set<RecoveryListener>();
let state: AvailabilityState = { reconnecting: false, attempt: 0 };
let loopRunning = false;
let retryTimer: number | undefined;
let healthRequest: AbortController | undefined;
function publish(next: AvailabilityState) {
state = next;
listeners.forEach((listener) => listener(state));
}
function waitForRetry(): Promise<void> {
return new Promise((resolve) => {
retryTimer = window.setTimeout(() => {
retryTimer = undefined;
resolve();
}, RETRY_MS);
});
}
async function reconnect() {
if (loopRunning) return;
loopRunning = true;
try {
while (state.reconnecting) {
const attempt = state.attempt + 1;
publish({ reconnecting: true, attempt });
healthRequest = new AbortController();
const timeout = window.setTimeout(() => healthRequest?.abort(), HEALTH_TIMEOUT_MS);
try {
const response = await fetch('/healthz', {
cache: 'no-store',
credentials: 'same-origin',
signal: healthRequest.signal,
});
if (response.ok) {
publish({ reconnecting: false, attempt: 0 });
recoveryListeners.forEach((listener) => listener());
break;
}
} catch {
// The next attempt is the recovery path. Health failures are deliberately silent.
} finally {
window.clearTimeout(timeout);
healthRequest = undefined;
}
if (state.reconnecting) await waitForRetry();
}
} finally {
loopRunning = false;
window.clearTimeout(retryTimer);
retryTimer = undefined;
healthRequest = undefined;
}
}
export function reportTemporaryAvailabilityFailure(): void {
if (!state.reconnecting) publish({ reconnecting: true, attempt: 0 });
void reconnect();
}
export function subscribeAvailability(listener: StateListener): () => void {
listeners.add(listener);
listener(state);
return () => listeners.delete(listener);
}
export function subscribeToRecovery(listener: RecoveryListener): () => void {
recoveryListeners.add(listener);
return () => recoveryListeners.delete(listener);
}
export function useAvailability(): AvailabilityState {
const [current, setCurrent] = useState(state);
useEffect(() => subscribeAvailability(setCurrent), []);
return current;
}
+3
View File
@@ -10,6 +10,7 @@ import {
} from 'react';
import { api } from '../api/client';
import type { AdminStatus } from '../api/types';
import { subscribeToRecovery } from './availability';
/* /admin/api/status is the console's shared heartbeat.
*
@@ -101,6 +102,8 @@ export function GatewayProvider({ children }: { children: ReactNode }) {
};
}, [reload]);
useEffect(() => subscribeToRecovery(() => void reload()), [reload]);
const value = useMemo<GatewayState>(
() => ({
status,
+3
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { subscribeToRecovery } from './availability';
/* The two hooks every page is built from.
*
@@ -77,6 +78,8 @@ export function useQuery<T>(path: string, options: QueryOptions = {}): Loadable<
};
}, [run]);
useEffect(() => subscribeToRecovery(() => void run()), [run]);
useEffect(() => {
if (!pollMs || !enabled) return;
let timer: number | undefined;
+3
View File
@@ -11,6 +11,7 @@ import {
import { api } from '../api/client';
import type { Tone } from './format';
import type { IconName } from '../components/Icon';
import { subscribeToRecovery } from './availability';
/* The administrative feed, shared by the bell in the top bar and the activity page.
*
@@ -117,6 +118,8 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
void reload();
}, [reload]);
useEffect(() => subscribeToRecovery(() => void reload()), [reload]);
useEffect(() => {
let source: EventSource | null = null;
let fallback: number | undefined;
+2
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react';
import { Icon } from '../components/Icon';
import type { Tone } from './format';
import { isTemporaryAvailabilityError } from '../api/client';
/* Toasts say what a mutation did.
*
@@ -51,6 +52,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
if (success) show(success, 'ok');
return result;
} catch (err) {
if (isTemporaryAvailabilityError(err)) return undefined;
show(err instanceof Error ? err.message : String(err), 'bad');
return undefined;
}
+59 -196
View File
@@ -1,216 +1,79 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, initials, num, presence, recent, watchTime, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Loading,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { KnownClient } from '../api/types';
/* A directory, and only a directory. Everything you can *do* to a person lives on their own
page: this list used to render a full seventeen-control settings editor for every account
at once, which meant the page grew with the household and an operator scrolled past four
other people's preferences to reach the one they came for. */
/* Watch time comes from Tracearr, and `matched` is the field that matters: a household
running no Tracearr, and a person Tracearr has never seen, both arrive as zeroes. Drawing
those as "0 min this week" would have an operator asking why somebody stopped watching
when the real answer is that nothing was asked. */
interface WatchTime {
matched: boolean;
tracearrUsername?: string;
weekMs: number;
monthMs: number;
totalMs: number;
weekSessions: number;
monthSessions: number;
lastWatchedAt?: string;
}
import { Banner, Button, Card, Confirm, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles, Toggle } from '../components/ui';
interface Device { id: string; name: string; version: string; lastSeen: string; lastIp?: string; }
interface WatchTime { matched: boolean; weekMs: number; monthMs: number; }
interface Account {
id: string;
username: string;
initials: string;
shortName: string;
lastSeen: string;
devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean };
watchTime?: WatchTime;
id: string; username: string; initials: string; shortName: string; lastSeen: string; devices: Device[] | null;
enabled: boolean; lastIp?: string; notifications: { enabled: boolean; [key: string]: unknown }; watchTime?: WatchTime;
}
interface AccountsResponse { accounts: Account[] | null; }
interface StatusResponse { updatePolicy?: { latestVersion?: string }; }
interface AccountsResponse {
accounts: Account[] | null;
}
/** seenAt is a timestamp as a number, with anything unreadable sorting last rather than
* first an invalid date yields NaN, and NaN comparisons would scatter those rows. */
function seenAt(value: string | undefined): number {
const at = value ? new Date(value).getTime() : 0;
return Number.isFinite(at) ? at : 0;
}
/** A dash, and it says why on hover. Watch time comes from Tracearr; a household running
* none and a person it has never matched are both "not measured", never "none". */
function NotMeasured() {
return (
<span className="muted" title="No Tracearr sessions matched to this person">
</span>
);
}
function seenAt(value: string | undefined): number { const at = value ? new Date(value).getTime() : 0; return Number.isFinite(at) ? at : 0; }
function NotMeasured() { return <span className="muted" title="No Tracearr sessions matched to this person"></span>; }
export function AccountsPage() {
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
pollMs: 60_000,
});
const { data, error, loading, reload } = useQuery<AccountsResponse>('/admin/api/accounts', { pollMs: 60_000 });
const { data: status } = useQuery<StatusResponse>('/admin/api/status', { pollMs: 60_000 });
const { busy, run } = useAction();
const { wrap } = useToast();
const [expanded, setExpanded] = useState<string | null>(null);
const [confirm, setConfirm] = useState<Account | null>(null);
const accounts = data?.accounts ?? [];
const devices = accounts.flatMap((account) => account.devices ?? []);
const completed = accounts.filter((account) => account.recommendations?.completed).length;
const queued = accounts.filter(
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
).length;
/* Summed from the same rows the list draws, so the tile and the column underneath it can
never disagree a separate total query is how those two come apart. */
const latest = status?.updatePolicy?.latestVersion ?? '';
const current = (version: string) => Boolean(version && latest && version === latest);
const needsUpdate = devices.filter((device) => device.version && latest && device.version !== latest).length;
const tracked = accounts.filter((account) => account.watchTime?.matched);
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
const rows = [...accounts].sort((a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen));
const path = (id: string, suffix: string) => `/admin/api/accounts/${encodeURIComponent(id)}${suffix}`;
/* Most recently seen first, so the table means something without being sorted. Whoever
is using Memby right now is the row an operator opening this page is looking for, and a
person who has never signed in from a device sorts to the bottom rather than the top. */
const rows = [...accounts].sort(
(a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen),
);
const saveNotifications = async (account: Account, enabled: boolean) => run(`notifications-${account.id}`, () => wrap(() => api.put(path(account.id, '/notifications'), { ...account.notifications, enabled }), `Notifications ${enabled ? 'enabled' : 'disabled'} for ${account.username}`).then(() => reload()));
const saveEnabled = async (account: Account) => { const enabled = !account.enabled; await run(`enabled-${account.id}`, () => wrap(() => api.put(path(account.id, '/enabled'), { enabled }), `${account.username} ${enabled ? 'enabled' : 'disabled'}`).then(() => { setConfirm(null); return reload(); })); };
const forceUpdate = async (account: Account) => run(`update-${account.id}`, () => wrap(() => api.post(path(account.id, '/force-update')), `Update request queued for ${account.username}`));
return (
<>
<PageHead title="Users" intro="Who uses Memby, and which devices they are signed in to." />
return <>
<PageHead title="Users" intro="Manage Memby users and signed-in devices at a glance." />
<Banner message={error} />
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{loading ? <Loading /> : <>
<Tiles tiles={[
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' },
{
label: 'active in the last 15 mins',
value: num(devices.filter((device) => recent(device.lastSeen)).length),
icon: 'pulse',
tone: 'ok',
},
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
{ label: 'setup prompts queued', value: num(queued), icon: 'sparkle', tone: 'note' },
...(tracked.length
? [
{
label: 'watch time this week',
value: watchTime(weekMs),
icon: 'pulse' as const,
tone: 'data' as const,
},
]
: []),
]}
/>
<Card title="Users" icon="people" tone="note">
<TableWrap>
<table>
<thead>
<tr>
<th>User</th>
<th>Short name</th>
<th className="num">Devices</th>
<th className="num">This week</th>
<th className="num">This month</th>
<th>Recommendations</th>
<th>Last seen</th>
{ label: 'active in the last 15 mins', value: num(devices.filter((device) => recent(device.lastSeen)).length), icon: 'pulse', tone: 'ok' },
{ label: 'devices up to date', value: num(devices.filter((device) => current(device.version)).length), icon: 'check', tone: 'ok' },
{ label: 'devices requiring update', value: num(needsUpdate), icon: 'alert', tone: needsUpdate ? 'warn' : 'note' },
...(tracked.length ? [{ label: 'watch time this week', value: watchTime(weekMs), icon: 'pulse' as const, tone: 'data' as const }] : []),
]} />
<Card title="Users" icon="people" tone="note"><TableWrap><table><thead><tr>
<th>User</th><th className="num">Devices</th><th>Version</th><th>Remote IP</th><th className="num">This week</th><th className="num">This month</th><th>Last seen</th><th>Notifications</th><th>Enabled</th><th>Actions</th>
</tr></thead><tbody>
{rows.length === 0 ? <EmptyRow columns={10}>No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.</EmptyRow> : rows.map((account) => {
const list = account.devices ?? []; const seen = presence(account.lastSeen); const watched = account.watchTime; const open = expanded === account.id; const first = list[0];
return <>
<tr key={account.id} className={!account.enabled ? 'is-disabled' : undefined}>
<td><span className="row tight"><span className="dot-state" data-tone={seen.tone} title={seen.label} /><span className="avatar">{account.initials || initials(account.username)}</span><Link className="table-row-link" to={`/admin/accounts/${encodeURIComponent(account.id)}`}>{account.username || 'Unnamed user'}</Link></span></td>
<td className="num"><button type="button" className="link-button" onClick={() => setExpanded(open ? null : account.id)}>{num(list.length)}</button></td>
<td><span title={first?.version ? `Latest recorded version on ${first.name}` : 'Version not reported'}>{first?.version || <span className="muted">Unknown</span>}</span>{first?.version && current(first.version) ? <Tag tone="ok">current</Tag> : first?.version && latest ? <Tag tone="warn">update</Tag> : null}{list.length > 1 ? <span className="table-sub">{list.length - 1} more device{list.length === 2 ? '' : 's'}</span> : null}</td>
<td className="mono" title="Last recorded IP address">{first?.lastIp || account.lastIp || <span className="muted"></span>}</td>
<td className="num">{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}</td><td className="num muted">{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}</td>
<td className="nowrap muted" title={when(account.lastSeen)}>{ago(account.lastSeen)}</td>
<td><Toggle label={account.notifications?.enabled ? 'ON' : 'OFF'} checked={Boolean(account.notifications?.enabled)} disabled={busy === `notifications-${account.id}`} onChange={(next) => void saveNotifications(account, next)} /></td>
<td><Toggle label={account.enabled ? 'Enabled' : 'Disabled'} checked={account.enabled} disabled={busy === `enabled-${account.id}`} onChange={() => setConfirm(account)} /></td>
<td><Button size="sm" variant="quiet" busy={busy === `update-${account.id}`} onClick={() => void forceUpdate(account)}>Force update</Button></td>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={7}>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</EmptyRow>
) : (
rows.map((account) => {
const list = account.devices ?? [];
const active = list.filter((device) => recent(device.lastSeen)).length;
const state = account.recommendations?.completed
? { label: 'personalised', tone: 'ok' as const }
: account.recommendations?.prompted
? { label: 'prompt queued', tone: 'warn' as const }
: { label: 'not invited', tone: undefined };
const seen = presence(account.lastSeen);
const watched = account.watchTime;
return (
<tr key={account.id}>
<td>
<span className="row tight">
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
<span className="avatar">{account.initials || initials(account.username)}</span>
<Link
className="table-row-link"
to={`/admin/accounts/${encodeURIComponent(account.id)}`}
>
{account.username || 'Unnamed user'}
</Link>
</span>
</td>
{/* Blank is the ordinary state and not a gap: the launcher greets
somebody by their account name unless an operator has given
Memby a friendlier one, and saying so beats a bare dash. */}
<td>
{account.shortName || (
<span className="muted" title="Memby greets them by their account name">
account name
</span>
)}
</td>
<td className="num">
{num(list.length)}
{/* Only where there is something to say. A sub-line under every
row reading "0 active now" is a column of noise. */}
{active ? <span className="table-sub">{num(active)} active now</span> : null}
</td>
{/* The month sits beside the week because a quiet week only means
something next to the month around it. Both are a dash rather
than a zero for somebody Tracearr has never seen: "0 min" would
have an operator investigating a person when the real answer is
that nothing was ever asked. */}
<td className="num">
{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}
</td>
<td className="num muted">
{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}
</td>
<td>
<Tag tone={state.tone}>{state.label}</Tag>
</td>
<td className="nowrap muted" title={when(account.lastSeen)}>
{ago(account.lastSeen)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
{open ? <tr key={`${account.id}-devices`} className="account-device-detail"><td colSpan={10}><div className="device-breakdown">{list.map((device) => <div className="device-breakdown-row" key={device.id}><b>{device.name || 'Unnamed device'}</b><span>{device.version || 'Version unknown'}</span><span className="mono" title="Last recorded IP address">{device.lastIp || 'IP not recorded'}</span><span className="muted">last seen {ago(device.lastSeen)}</span></div>)}</div></td></tr> : null}
</>;
})}
</tbody></table></TableWrap></Card>
</>}
{confirm ? <Confirm title={`${confirm.enabled ? 'Disable' : 'Enable'} ${confirm.username}?`} body={confirm.enabled ? 'This will stop their Memby sessions from accessing the gateway. Their Emby account is not changed.' : 'This will restore their access to Memby.'} confirmLabel={confirm.enabled ? 'Disable user' : 'Enable user'} destructive={confirm.enabled} busy={busy === `enabled-${confirm.id}`} onCancel={() => setConfirm(null)} onConfirm={() => void saveEnabled(confirm)} /> : null}
</>;
}
/** The account page reads the same list to find the person it is about. */
export type { Account, AccountsResponse };
+19 -3
View File
@@ -2,9 +2,9 @@ import { useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { when } from '../lib/format';
import { num, when } from '../lib/format';
import { Icon } from '../components/Icon';
import { Banner, Card, Loading, PageHead, Tag } from '../components/ui';
import { Banner, Card, Loading, PageHead, TableWrap, Tag } from '../components/ui';
interface JourneyEvent {
journeyId: string;
@@ -25,12 +25,20 @@ interface JourneyEvent {
interface JourneyResponse {
users: { userId: string; username: string }[] | null;
featureUsage: { feature: string; users: number; uses: number; journeys: number; activeUserRate: number; lastUsedAt: string }[] | null;
featureBreakdown: { feature: string; subFeature: string; action: string; users: number; uses: number; journeys: number; lastUsedAt: string }[] | null;
events: JourneyEvent[] | null;
}
const label = (value: string | undefined | null) => {
const text = String(value || '—').replaceAll('_', ' ');
return text === 'favorites' ? 'Favourites' : text;
const labels: Record<string, string> = {
favorites: 'Favourites', continue_watching: 'Continue Watching', recommendation: 'Recommendations',
for_you: 'Recommendations', screen_view: 'Viewed', open: 'Opened', close: 'Closed', select: 'Selected',
request: 'Requested', start: 'Started', stop: 'Stopped', complete: 'Completed', text: 'Text Search',
voice: 'Voice Search', journey_start: 'Started Session', journey_end: 'Ended Session',
};
return labels[String(value || '')] ?? text.replace(/\b\w/g, (character) => character.toUpperCase());
};
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
@@ -104,10 +112,18 @@ export function JourneyViewerPage() {
.sort((left, right) => (right[0]?.occurredAt ?? '').localeCompare(left[0]?.occurredAt ?? ''));
}, [data?.events]);
const journeys = sessions.flatMap((session) => splitViewingJourneys(session).map((events, index) => ({ events, key: `${session[0]?.journeyId}:${index}` })));
const featureUsage = data?.featureUsage ?? [];
const featureBreakdown = data?.featureBreakdown ?? [];
return <>
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." icon="journey" crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
<Banner message={error} />
{!loading && <Card title="Feature usage summary" intro="This viewers feature use before the detailed journeys below." icon="chart" tone="data">
<TableWrap><table><thead><tr><th>Feature</th><th className="num">Uses</th><th className="num">Visits</th><th>Last used</th></tr></thead><tbody>
{featureUsage.length === 0 ? <tr><td colSpan={4} className="empty">No feature usage recorded for this viewer.</td></tr> : featureUsage.map((feature) => <tr key={feature.feature}><td><b>{label(feature.feature)}</b><span className="table-sub">{num(feature.users)} viewer</span></td><td className="num">{num(feature.uses)}</td><td className="num">{num(feature.journeys)}</td><td>{when(feature.lastUsedAt)}</td></tr>)}
</tbody></table></TableWrap>
{featureBreakdown.length > 0 && <p className="table-sub">Breakdown: {featureBreakdown.slice(0, 8).map((entry) => `${label(entry.feature)} · ${label(entry.subFeature)} · ${label(entry.action)} (${num(entry.uses)})`).join(' · ')}</p>}
</Card>}
{loading ? <Loading /> : <Card title="Viewing journeys" intro={`${sessions.length} app session${sessions.length === 1 ? '' : 's'} · ${journeys.length} viewing journey${journeys.length === 1 ? '' : 's'} in the last 90 days.`} icon="journey" tone="info">
<div className="visits">
{journeys.length === 0 ? <p className="empty">No journeys recorded for this viewer.</p> : journeys.map((journey, index) => {
+52 -83
View File
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { duration, num, percent, when } from '../lib/format';
import { duration, num, percent } from '../lib/format';
import {
Banner,
Card,
@@ -13,30 +13,24 @@ import {
Meter,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
/* Memby's major feature catalogue, in the order it is presented.
*
* It is a list here rather than derived from what has been used, and that is the whole
* point of the table it feeds: a feature nobody has touched has no row in the analytics
* and would simply be absent, which is indistinguishable from a feature that does not
* exist. Listing it and showing a zero is what makes "not used" an answer. */
const FEATURE_CATALOGUE = [
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
'latest', 'my_shows', 'details', 'playback', 'magic_movie', 'notifications',
'profiles', 'settings',
];
/** The wire values are ids; this is the render, so it is spelled the same boundary the
* launcher's own row titles observe. */
function label(value: string | undefined | null): string {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
const labels: Record<string, string> = {
favorites: 'Favourites', continue: 'Continue Watching', continue_watching: 'Continue Watching',
recent_searches: 'Recent Searches', genre_browse: 'Genre Browse', for_you: 'Recommendations',
recommendation: 'Recommendations', for_you_time: 'Recommendation Timing', my_shows: 'My Shows',
magic_movie: 'Magic Movie', detail_page: 'Details', home_hero: 'Home Hero',
screen_view: 'Viewed', open: 'Opened', close: 'Closed', select: 'Selected', request: 'Requested',
start: 'Started', stop: 'Stopped', complete: 'Completed', change: 'Changed', toggle: 'Toggled',
submit: 'Submitted', journey_start: 'Started Session', journey_end: 'Ended Session',
abandoned: 'Abandoned / interrupted', text: 'Text Search', voice: 'Voice Search',
};
return labels[String(value || '')] ?? text.replace(/\b\w/g, (character) => character.toUpperCase());
}
interface JourneysResponse {
@@ -55,6 +49,8 @@ interface JourneysResponse {
};
users: { userId: string; username: string }[] | null;
features: { feature: string; uses: number; lastUsedAt: string }[] | null;
featureUsage: { feature: string; users: number; uses: number; journeys: number; activeUserRate: number; lastUsedAt: string }[] | null;
featureBreakdown: { feature: string; subFeature: string; action: string; users: number; uses: number; journeys: number; lastUsedAt: string }[] | null;
actions: { category: string; action: string; events: number; journeys: number }[] | null;
paths: { from: string; to: string; count: number }[] | null;
}
@@ -68,25 +64,11 @@ export function JourneysPage() {
const stats = data?.stats;
const users = data?.users ?? [];
const actions = data?.actions ?? [];
const featureUsage = data?.featureUsage ?? [];
const featureBreakdown = data?.featureBreakdown ?? [];
const paths = data?.paths ?? [];
const topPath = paths[0];
const features = useMemo(() => {
const used = new Map((data?.features ?? []).map((feature) => [feature.feature, feature]));
const position = new Map(FEATURE_CATALOGUE.map((name, index) => [name, index]));
return [...new Set([...FEATURE_CATALOGUE, ...used.keys()])]
.map((name) => ({ name, stat: used.get(name) }))
.sort((left, right) => {
const byUse = (right.stat?.uses ?? 0) - (left.stat?.uses ?? 0);
if (byUse) return byUse;
return (
(position.get(left.name) ?? Number.MAX_SAFE_INTEGER) -
(position.get(right.name) ?? Number.MAX_SAFE_INTEGER)
);
});
}, [data?.features]);
// Individual visits are shown only for one person: across a household they are a wall
// of cards with nothing to compare against, and the question they answer is always
// "what did *they* do".
@@ -182,8 +164,8 @@ export function JourneysPage() {
<Grid cols="2">
<Card
title="What people do"
intro="Actions show total use and how many separate visits included them."
title="Feature usage"
intro="Distinct viewers, total uses and visits containing each feature."
icon="chart"
tone="info"
>
@@ -191,23 +173,24 @@ export function JourneysPage() {
<table>
<thead>
<tr>
<th>Action</th>
<th>Feature</th>
<th className="num">Users</th>
<th className="num">Uses</th>
<th className="num">Visits</th>
<th className="num">Active users</th>
</tr>
</thead>
<tbody>
{actions.length === 0 ? (
<EmptyRow columns={3}>No significant actions in this window.</EmptyRow>
{featureUsage.length === 0 ? (
<EmptyRow columns={5}>No feature usage in this window.</EmptyRow>
) : (
actions.map((action) => (
<tr key={`${action.category}:${action.action}`}>
<td>
<b>{label(action.action)}</b>
<span className="table-sub">{label(action.category)}</span>
</td>
<td className="num">{num(action.events)}</td>
<td className="num">{num(action.journeys)}</td>
featureUsage.map((feature) => (
<tr key={feature.feature}>
<td><b>{label(feature.feature)}</b></td>
<td className="num">{num(feature.users)}</td>
<td className="num">{num(feature.uses)}</td>
<td className="num">{num(feature.journeys)}</td>
<td className="num">{percent(feature.activeUserRate)}</td>
</tr>
))
)}
@@ -217,8 +200,8 @@ export function JourneysPage() {
</Card>
<Card
title="Where people go"
intro="The most common steps between screens, including where quiet visits ended."
title="Feature and action detail"
intro="Structured sub-features show which parts of a feature were used."
icon="list"
tone="note"
>
@@ -226,20 +209,24 @@ export function JourneysPage() {
<table>
<thead>
<tr>
<th>Route</th>
<th>Feature / sub-feature</th>
<th>Action</th>
<th className="num">Users</th>
<th className="num">Times</th>
<th className="num">Visits</th>
</tr>
</thead>
<tbody>
{paths.length === 0 ? (
<EmptyRow columns={2}>No repeated paths in this window.</EmptyRow>
{featureBreakdown.length === 0 ? (
<EmptyRow columns={5}>No feature details in this window.</EmptyRow>
) : (
paths.map((entry, index) => (
<tr key={`${entry.from}:${entry.to}:${index}`}>
<td>
{label(entry.from)} <span className="route-arrow"></span> {label(entry.to)}
</td>
<td className="num">{num(entry.count)}</td>
featureBreakdown.slice(0, 30).map((entry, index) => (
<tr key={`${entry.feature}:${entry.subFeature}:${entry.action}:${index}`}>
<td><b>{label(entry.feature)}</b><span className="table-sub">{label(entry.subFeature)}</span></td>
<td>{label(entry.action)}</td>
<td className="num">{num(entry.users)}</td>
<td className="num">{num(entry.uses)}</td>
<td className="num">{num(entry.journeys)}</td>
</tr>
))
)}
@@ -250,8 +237,8 @@ export function JourneysPage() {
</Grid>
<Card
title="Feature use"
intro="Rare and unused features are shown against Memby's major feature catalogue."
title="Most common routes"
intro="The most common steps between screens, including where quiet visits ended."
icon="pulse"
tone="data"
>
@@ -259,32 +246,14 @@ export function JourneysPage() {
<table>
<thead>
<tr>
<th>Feature</th>
<th className="num">Uses</th>
<th>Last used</th>
<th>Status</th>
<th>Route</th>
<th className="num">Times</th>
</tr>
</thead>
<tbody>
{features.map(({ name, stat }) => {
const uses = stat?.uses ?? 0;
return (
<tr key={name}>
<td>{label(name)}</td>
<td className="num">{num(uses)}</td>
<td className="muted nowrap">{stat ? when(stat.lastUsedAt) : '—'}</td>
<td>
{uses === 0 ? (
<Tag tone="warn">not used</Tag>
) : uses < 3 ? (
<Tag tone="note">rare</Tag>
) : (
<Tag tone="ok">used</Tag>
)}
</td>
</tr>
);
})}
{paths.length === 0 ? <EmptyRow columns={2}>No repeated paths in this window.</EmptyRow> : paths.map((entry, index) => (
<tr key={`${entry.from}:${entry.to}:${index}`}><td>{label(entry.from)} <span className="route-arrow"></span> {label(entry.to)}</td><td className="num">{num(entry.count)}</td></tr>
))}
</tbody>
</table>
</TableWrap>
+69
View File
@@ -882,6 +882,59 @@ a {
font: inherit;
}
/* A gateway restart is a console-wide state, not a page error. Keep the page mounted so
filters and navigation survive the short outage, while making it impossible for a proxy
response or a collection of stale banners to become the operator's view. */
.reconnect-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: 24px;
background: rgba(3, 6, 10, .86);
backdrop-filter: blur(5px);
}
.reconnect-panel {
display: grid;
justify-items: center;
width: min(440px, 100%);
padding: 34px 32px 30px;
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(145deg, var(--surface-lift), var(--surface));
box-shadow: 0 24px 80px rgba(0, 0, 0, .4);
text-align: center;
}
.reconnect-spinner {
width: 30px;
height: 30px;
margin-bottom: 18px;
border: 3px solid var(--line);
border-top-color: var(--accent-ink);
border-radius: 50%;
animation: reconnect-spin .9s linear infinite;
}
.reconnect-panel h1 {
margin: 0;
font-size: 21px;
letter-spacing: -.02em;
}
.reconnect-panel p {
max-width: 35ch;
margin: 10px 0 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.reconnect-panel small {
color: var(--quiet);
font: 11.5px/1.4 var(--mono);
}
@keyframes reconnect-spin {
to { transform: rotate(360deg); }
}
/* ---------- cards ---------- */
.card {
@@ -1384,6 +1437,22 @@ td.mono {
font: 12px/1.5 var(--mono);
color: var(--muted);
}
.link-button {
border: 0;
padding: 0;
background: none;
color: var(--accent-ink);
font: inherit;
text-decoration: underline;
text-decoration-color: var(--line);
text-underline-offset: 3px;
cursor: pointer;
}
.is-disabled td { opacity: .62; }
.account-device-detail > td { padding: 0 0 12px; background: var(--surface-sunken, transparent); }
.device-breakdown { display: grid; gap: 6px; padding: 10px 12px 10px 46px; border-top: 1px solid var(--line-soft); }
.device-breakdown-row { display: grid; grid-template-columns: minmax(150px, 1.4fr) minmax(90px, .7fr) minmax(130px, 1fr) minmax(100px, 1fr); gap: 12px; align-items: center; padding: 8px 10px; border: 1px solid var(--line-soft); border-radius: var(--radius-sm); background: var(--surface-lift); font-size: 12px; }
@media (max-width: 1100px) { .device-breakdown-row { grid-template-columns: 1fr 1fr; } }
td.nowrap,
th.nowrap {
white-space: nowrap;
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.20"
val defaultVersionName = "0.3.21"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
+3 -3
View File
@@ -68,9 +68,9 @@
android:name=".MembyApp"
android:allowBackup="true"
android:banner="@drawable/app_banner"
android:icon="@drawable/emby_logo"
android:icon="@drawable/memby_mark"
android:label="@string/app_name"
android:roundIcon="@drawable/emby_logo"
android:roundIcon="@drawable/memby_mark"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Memby">
@@ -145,7 +145,7 @@
<service
android:name=".screensaver.MembyDreamService"
android:exported="true"
android:icon="@drawable/emby_logo"
android:icon="@drawable/memby_mark"
android:label="@string/screensaver_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
@@ -0,0 +1,12 @@
package com.ponzischeme89.memby.data
/**
* The one legal non-default value. Anything else missing, blank, a value a future variant
* introduces that this build does not know about must fall back to "v1" rather than be
* handed to the UI to guess about.
*/
private const val DETAIL_EXPERIENCE_V2 = "v2"
const val DETAIL_EXPERIENCE_DEFAULT = "v1"
fun detailExperienceOrDefault(raw: String): String =
raw.takeIf { it == DETAIL_EXPERIENCE_V2 } ?: DETAIL_EXPERIENCE_DEFAULT
@@ -294,7 +294,10 @@ class EmbyRepository internal constructor(
* layout and must not collect a flow to answer a question that changes once a year.
*/
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
// Carries whether the stopped item finished, computed at the report itself rather than
// read back from the [playbackPositions] side channel afterwards — the two are separate
// flows collected by separate coroutines, and nothing orders one against the other.
private val _playbackStops = MutableSharedFlow<PlaybackPosition>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow()
/**
@@ -2715,7 +2718,9 @@ class EmbyRepository internal constructor(
clearPlayableCache()
// Episode progress and watched badges may have changed during playback.
clearSeriesEpisodeCache()
_playbackStops.tryEmit(session.itemId)
_playbackStops.tryEmit(
PlaybackPosition(session.itemId, positionMs.coerceAtLeast(0L), durationMs.coerceAtLeast(0L)),
)
}
}
@@ -105,6 +105,7 @@ class MaintenanceMonitor(
private val _requestsAllowed = MutableStateFlow(false)
private val _gatewayVersion = MutableStateFlow("")
private val _embyVersion = MutableStateFlow("")
private val _detailExperience = MutableStateFlow(DETAIL_EXPERIENCE_DEFAULT)
/**
* Which colour scheme this viewer's televisions should be painted, as an id and a
@@ -129,6 +130,14 @@ class MaintenanceMonitor(
*/
val heroRevision: StateFlow<String> = _heroRevision.asStateFlow()
/**
* The v2 detail-page experiment for this viewer/device, already validated by
* [detailExperienceOrDefault] against garbage or a future value this build does not
* understand. "v1" whenever the server has not said otherwise signed out, mid-outage,
* or on a gateway that predates the field which is the existing detail page, unchanged.
*/
val detailExperience: StateFlow<String> = _detailExperience.asStateFlow()
/**
* The viewer's server-held settings revision, as of the last successful poll. This is
* how an operator's push reaches a television: the number changes, [PreferencesSync]
@@ -300,6 +309,7 @@ class MaintenanceMonitor(
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_detailExperience.value = DETAIL_EXPERIENCE_DEFAULT
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -348,6 +358,7 @@ class MaintenanceMonitor(
?: METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = status.theme
_heroRevision.value = status.hero.revision
_detailExperience.value = detailExperienceOrDefault(status.detailExperience)
_installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
@@ -396,6 +407,7 @@ class MaintenanceMonitor(
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_detailExperience.value = DETAIL_EXPERIENCE_DEFAULT
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -275,6 +275,13 @@ data class GatewayServiceStatus(
* exactly how the launcher behaved before this existed.
*/
val hero: GatewayHeroStatus = GatewayHeroStatus(),
/**
* The v2 detail-page experiment. A plain string rather than a boolean feature flag,
* scoped per user/device like [theme]. Missing (a gateway that predates it) or anything
* other than exactly "v2" decodes to the compiled default of "v1" see
* [com.ponzischeme89.memby.data.detailExperienceOrDefault].
*/
val detailExperience: String = "v1",
)
@Serializable
@@ -322,6 +322,14 @@ internal fun FocusedDetailsOverlay(
homeViewModel: HomeViewModel,
selected: BaseItem,
seriesStatusRevision: Long = 0,
/**
* The server-controlled detail-page experiment, already validated by
* [com.ponzischeme89.memby.data.detailExperienceOrDefault] to "v1" or "v2". Only Home's
* movie/series posters offer the seamless "v2" reveal an episode (reached from
* Continue Watching, never from a poster) and a Radarr-only card (no shelf to reveal
* from) always take the ordinary v1 overlay.
*/
detailExperience: String = com.ponzischeme89.memby.data.DETAIL_EXPERIENCE_DEFAULT,
restorePosition: Boolean,
onPlay: (BaseItem) -> Unit,
onPlayTrailer: (BaseItem) -> Unit,
@@ -360,6 +368,39 @@ internal fun FocusedDetailsOverlay(
}
}
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
// The seamless "v2" reveal only makes sense where there was a shelf to reveal from: a
// Radarr-only card has no Emby item behind it (its own page, see [RadarrMovieDetailsOverlay])
// and an episode is reached from Continue Watching rather than a poster press, so both
// keep the ordinary v1 overlay regardless of the operator's setting.
val isSeamless = com.ponzischeme89.memby.data.detailExperienceOrDefault(detailExperience) == "v2" &&
!item.isRadarrOnly && !item.isEpisode
var playPressed by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(item.id, isSeamless) {
homeViewModel.trackJourney(
category = "details", action = "opened", screen = "details",
feature = if (isSeamless) "detail_v2" else "detail_v1",
itemName = item.name, itemType = item.type,
)
}
val trackedOnPlay: (BaseItem) -> Unit = { playedItem ->
playPressed = true
homeViewModel.trackJourney(
category = "details", action = "play_pressed", screen = "details",
feature = if (isSeamless) "detail_v2" else "detail_v1",
itemName = playedItem.name, itemType = playedItem.type,
)
onPlay(playedItem)
}
val trackedOnClose: () -> Unit = {
if (!playPressed) {
homeViewModel.trackJourney(
category = "details", action = "closed_without_playback", screen = "details",
feature = if (isSeamless) "detail_v2" else "detail_v1",
itemName = item.name, itemType = item.type,
)
}
onClose()
}
if (item.isRadarrOnly) {
// A film Radarr is tracking that Emby has never imported. It is the one card with a
// page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why
@@ -367,13 +408,21 @@ internal fun FocusedDetailsOverlay(
RadarrMovieDetailsOverlay(
card = item,
onPlayTrailer = onPlayTrailer,
onClose = onClose,
onClose = trackedOnClose,
onOpenEmbyItem = onOpenEmbyItem,
)
} else if (item.isSeries) {
// Seamless "v2" entry is a fast fade-in over what was already warmed on focus —
// the item, its logo, backdrop and ratings are already resident, and Coil already
// holds the decoded bitmaps, so this costs no re-fetch. v1 gets no wrapper at all,
// so its existing screenshot tests and behaviour are untouched.
AnimatedVisibility(
visible = true,
enter = if (isSeamless) fadeIn(tween(SEAMLESS_REVEAL_DURATION_MS)) else fadeIn(tween(0)),
) {
SeriesDetailsOverlay(
item = item,
onPlay = onPlay,
onPlay = trackedOnPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite,
isMyShow = isMyShow,
@@ -387,37 +436,51 @@ internal fun FocusedDetailsOverlay(
homeViewModel.applySeriesPlayed(series, played)
},
onSeriesPlayedSettled = homeViewModel::refreshContinueWatching,
onClose = onClose,
onClose = trackedOnClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
airingNotice = airingNotice,
)
}
} else if (item.isEpisode) {
// An episode arrives here from Continue Watching, where it *is* the thing the
// viewer chose. It used to open the movie page, which named the
// episode with no way to tell which one it was or where in the show it sat.
// Never eligible for the seamless reveal — see [isSeamless].
EpisodeDetailsOverlay(
item = item,
onPlay = onPlay,
onPlay = trackedOnPlay,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onClose = onClose,
onClose = trackedOnClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
)
} else {
AnimatedVisibility(
visible = true,
enter = if (isSeamless) fadeIn(tween(SEAMLESS_REVEAL_DURATION_MS)) else fadeIn(tween(0)),
) {
MediaDetailsOverlay(
item = item,
onPlay = onPlay,
onPlay = trackedOnPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onClose = onClose,
onClose = trackedOnClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
)
}
}
}
/**
* How long the v2 seamless reveal fades in the detail hero over the shelf it replaced. Kept
* short and restrained, per the experiment's own "things not moving" premise this is not
* a cinematic transition, just enough to soften an otherwise instant cut.
*/
private const val SEAMLESS_REVEAL_DURATION_MS = 120
/**
* Combines the three owners involved in a detail page without confusing their lifetimes:
@@ -266,6 +266,9 @@ internal fun HomeScreen(
ServiceLocator.maintenance.metadataHeroTimeRemainingColour.collectAsStateWithLifecycle()
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
// Server-controlled experiment: which detail-page layout Home opens. Already validated
// to "v1" or "v2" by MaintenanceMonitor, so this is safe to branch on directly.
val detailExperience by ServiceLocator.maintenance.detailExperience.collectAsStateWithLifecycle()
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
val continueWatchingEnabled by
ServiceLocator.maintenance.continueWatchingEnabled.collectAsStateWithLifecycle()
@@ -2201,6 +2204,7 @@ internal fun HomeScreen(
homeViewModel = homeViewModel,
selected = selected,
seriesStatusRevision = seriesStatusRevision,
detailExperience = detailExperience,
restorePosition = restoreDetailPosition,
airingNotice = detailsAiringNotice,
onOpenItem = { related ->
@@ -220,7 +220,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
repository.playbackPositions.collect(::applyPlaybackPosition)
}
viewModelScope.launch {
repository.playbackStops.collect { refreshWatching(authoritative = false) }
repository.playbackStops.collect { stop ->
if (stop.completed) {
// The optimistic prune in applyPlaybackPosition has already removed the
// finished episode; only the gateway's own answer can put the next one in
// its place, and the stop report just invalidated its cached rows.
refreshAfterCompletion(stop.itemId)
} else {
refreshWatching(authoritative = false)
}
}
}
viewModelScope.launch {
// A D-pad produces focus changes far faster than anything should produce
@@ -862,6 +871,29 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* Follows a completed episode's stop report with the gateway's own answer, which is what
* actually puts the next episode in the finished one's place the optimistic prune in
* [applyPlaybackPosition] only ever removes a card, it never adds one. The stop report
* that precedes this is what invalidates the gateway's cached rows, but Emby's own
* bookkeeping can lag behind it by a beat, so an authoritative refresh that comes back
* with the same Continue Watching membership it already had is retried a small, bounded
* number of times rather than trusted on the first try or polled without end.
*/
private suspend fun refreshAfterCompletion(itemId: String) {
val before = continueWatchingIds()
repeat(COMPLETION_REFRESH_ATTEMPTS) { attempt ->
refreshWatching(authoritative = true)
val after = continueWatchingIds()
val settled = (itemId !in after && after != before) || attempt == COMPLETION_REFRESH_ATTEMPTS - 1
if (settled) return
delay(COMPLETION_REFRESH_RETRY_DELAY_MS)
}
}
private fun continueWatchingIds(): Set<String> =
_state.value.continueWatching.mapTo(mutableSetOf(), BaseItem::id)
private suspend fun loadContinueWatching(clearLoading: Boolean = true) =
load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items ->
state.copy(
@@ -924,6 +956,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private const val FOCUS_METADATA_DEBOUNCE_MS = 200L
private const val HERO_UPDATE_DEBOUNCE_MS = 120L
/** Bounded retries for [refreshAfterCompletion] a race with Emby's own bookkeeping,
* not a poll, so this stays small and stops on its own. */
private const val COMPLETION_REFRESH_ATTEMPTS = 3
private const val COMPLETION_REFRESH_RETRY_DELAY_MS = 600L
/**
* How long focus must rest on a card before lightweight detail work is warmed, measured
* from the press that focused it. Deliberately well past
@@ -0,0 +1,143 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
import com.ponzischeme89.memby.ui.theme.membyTypeface
import kotlin.math.ceil
import kotlin.math.min
/**
* A draining arc with the figure left inside it.
*
* Two things in the player count something down against the playhead the skip-intro
* offer and the next-up overlay and both want the same picture. This is that picture and
* nothing else: it runs no animator, holds no timer and knows nothing about what it is
* measuring. The caller advances it from media time, which is what keeps it honest a
* pause holds the ring where it is and a seek moves it, neither of which a ring counting
* wall-clock seconds could do.
*
* Colours are set rather than derived, because the two callers want different answers: the
* skip-intro ring lives inside a state-list pill and has to invert with it, while the
* next-up ring sits on somebody's programme and is the same white and green wherever the
* picture behind it happens to be light.
*/
open class CountdownRingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val density = resources.displayMetrics.density
private val ringBounds = RectF()
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = 2.5f * density
}
private val progressPaint = Paint(trackPaint)
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = context.membyTypeface(Typeface.BOLD)
color = Color.WHITE
}
private var figure = ""
private var progress = 1f
init {
setInk(track = Color.argb(72, 255, 255, 255), arc = Color.WHITE, figure = Color.WHITE)
}
/** How thick the arc is drawn. The default suits a ring of about 3040dp. */
fun setRingWidthDp(widthDp: Float) {
trackPaint.strokeWidth = widthDp * density
progressPaint.strokeWidth = trackPaint.strokeWidth
invalidate()
}
/** The three colours the ring is made of. Invalidates, so it is safe mid-countdown. */
fun setInk(track: Int, arc: Int, figure: Int) {
trackPaint.color = track
progressPaint.color = arc
figurePaint.color = figure
invalidate()
}
/**
* How much is left, and how long the whole thing runs for.
*
* Redraws only when the drawn result would actually differ. This is advanced several
* times a second for a minute or two at a stretch, and a long ring moves by a fraction
* of a degree per tick invalidating on every one of them would be a couple of hundred
* pointless draws per episode on a box that has a decoder to feed.
*/
fun setRemaining(remainingMs: Long, totalMs: Long) {
val remaining = remainingMs.coerceAtLeast(0L)
val nextFigure = formatRemaining(remaining)
val nextProgress = if (totalMs > 0L) {
(remaining.toFloat() / totalMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
// A degree is about the smallest movement worth a redraw; below that the arc lands
// on the same pixels.
val moved = kotlin.math.abs(nextProgress - progress) * 360f >= 1f
if (nextFigure == figure && !moved) return
figure = nextFigure
progress = nextProgress
describe(nextFigure)?.let { contentDescription = it }
invalidate()
}
/** What a screen reader should make of the figure. Nothing, unless a subclass says. */
protected open fun describe(figure: String): CharSequence? = null
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val strokeInset = trackPaint.strokeWidth / 2f
val diameter = min(width, height).toFloat()
val left = (width - diameter) / 2f + strokeInset
val top = (height - diameter) / 2f + strokeInset
ringBounds.set(
left,
top,
left + diameter - trackPaint.strokeWidth,
top + diameter - trackPaint.strokeWidth,
)
canvas.drawOval(ringBounds, trackPaint)
if (progress > 0f) {
// Anticlockwise from the top, so the ring empties the way a clock hand would
// sweep back rather than filling up as the thing it measures runs out.
canvas.drawArc(ringBounds, -90f, -360f * progress, false, progressPaint)
}
if (figure.isEmpty()) return
figurePaint.textSize = figureTextSize(diameter, figure.length)
val baseline = height / 2f - (figurePaint.ascent() + figurePaint.descent()) / 2f
canvas.drawText(figure, width / 2f, baseline, figurePaint)
}
/**
* The figure has to fit inside the ring, and "1:58" is twice the width of "12". Sizing
* from the string's own length is what stops a two-minute opening printing over its own
* arc an intro is commonly long enough to be counted in minutes, so this is the
* ordinary case rather than the edge one.
*/
private fun figureTextSize(diameter: Float, characters: Int): Float =
diameter * if (characters >= 4) 0.30f else 0.42f
}
/**
* "1:58" over a minute, "58" under it. Never "0:58": a bare figure is read at a glance in a
* ring this size, and the colon is only worth its width once there are minutes to separate.
*/
internal fun formatRemaining(remainingMs: Long): String {
val seconds = ceil(remainingMs.coerceAtLeast(0L) / 1_000.0).toInt()
if (seconds < 60) return seconds.toString()
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}
@@ -399,6 +399,17 @@ class PlayerActivity : ComponentActivity() {
private var currentMediaSubtitles: List<PlayableSubtitle> = emptyList()
private var nextUpBanner: View? = null
private var nextUpCountdown: TextView? = null
private var nextUpRing: CountdownRingView? = null
private var nextUpLogo: ImageView? = null
private var nextUpSeries: TextView? = null
/**
* The logo the bar is currently wearing, so the identity is bound once per episode
* rather than on every 250ms tick of the countdown. Coil would answer the repeats from
* its memory cache, but each one is still a request built, a listener attached and a
* drawable re-tinted while a decoder is being fed.
*/
private var nextUpBoundLogoUrl: String? = null
private var nextUpBoundItemId: String? = null
private var nextUpDismissed = false
private var advancing = false
@@ -2710,7 +2721,6 @@ class PlayerActivity : ComponentActivity() {
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
@@ -3046,7 +3056,6 @@ class PlayerActivity : ComponentActivity() {
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
@@ -3202,11 +3211,18 @@ class PlayerActivity : ComponentActivity() {
val banner = findViewById<View>(R.id.player_next_up)
nextUpBanner = banner
nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown)
banner.findViewById<View>(R.id.player_next_up_play).setOnClickListener {
startNextEpisode()
}
banner.findViewById<View>(R.id.player_next_up_dismiss).setOnClickListener {
dismissNextUp()
nextUpLogo = banner.findViewById(R.id.player_next_up_logo)
nextUpSeries = banner.findViewById(R.id.player_next_up_series)
nextUpRing = banner.findViewById<CountdownRingView>(R.id.player_next_up_ring)?.apply {
// Fixed rather than taken from a drawable state, because nothing here is
// focusable: the ring sits on somebody's programme and has to read against
// whatever the picture behind it happens to be doing.
setRingWidthDp(2.5f)
setInk(
track = Color.argb(56, 255, 255, 255),
arc = NEXT_UP_ACCENT,
figure = Color.WHITE,
)
}
}
@@ -3232,6 +3248,10 @@ class PlayerActivity : ComponentActivity() {
// Gating the lookup on it is what left the credits pane, the next-up banner and
// the ended frame reading a field that was permanently null with the setting off.
val resolved = nextUpResolver.resolve(id)
// Binds the bar's logo a minute or more before it is drawn, and warms Coil with
// it in the same movement. Safe to do for an episode nobody reaches the end of:
// the bar is hidden, and the next `begin` clears what was bound.
resolved?.let(::bindNextUpIdentity)
resolved?.imageUrl?.let { imageUrl ->
imageLoader.enqueue(
ImageRequest.Builder(this@PlayerActivity)
@@ -3563,16 +3583,25 @@ class PlayerActivity : ComponentActivity() {
}
nextUpDismissed -> Unit
remainingMs == 0L -> if (autoAdvance) startNextEpisode()
// Something that owns the screen is up, or the transport is occupying the same
// bottom edge. The bar has nothing to say that is worth being drawn under
// either, and it comes straight back when they go.
!nextUpCanShow() -> hideNextUp()
else -> {
showNextUp(next)
nextUpCountdown?.apply {
// The countdown and its ring are a promise that something will happen by
// itself, so they are drawn only where that is true. With automatic advance
// off the bar still says what is next and the transport's Next Episode
// button still starts it — the offer is not the transition.
nextUpCountdown?.visibility = if (autoAdvance) View.VISIBLE else View.GONE
nextUpRing?.visibility = if (autoAdvance) View.VISIBLE else View.GONE
if (autoAdvance) {
val seconds = ceil(remainingMs / 1_000.0).toInt()
text = getString(R.string.next_up_starting_in, seconds)
visibility = View.VISIBLE
} else {
visibility = View.GONE
}
nextUpCountdown?.text =
getString(R.string.next_up_starting_in, formatRemaining(remainingMs))
// Advanced from the playhead, not from a timer: pausing inside the last
// minute holds the ring where it is and seeking back out of the window
// takes the bar away entirely.
nextUpRing?.setRemaining(remainingMs, NEXT_UP_LEAD_MS)
}
}
}
@@ -3714,37 +3743,76 @@ class PlayerActivity : ComponentActivity() {
}
}
@OptIn(UnstableApi::class)
/**
* Whether the bar has anything to say and anywhere to say it.
*
* Every other overlay in this player *owns* the screen while it is up the drop-up,
* the cast panel, the credits pane, the error and loading surfaces and this one owns
* nothing at all, so wherever one of those is up this simply stands down rather than
* being drawn underneath it. The transport is in the list for the plainest reason:
* it is a full-width strip along the same bottom edge, and the two would overlap.
*/
private fun nextUpCanShow(): Boolean =
!prerollActive &&
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
skipIntroView?.isVisible != true &&
creditsView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
/**
* Puts the show's own title treatment on the bar, with its name as the fallback.
*
* Called when the next episode is *resolved* rather than when the bar appears, which
* is a minute or more of lead time on the ordinary path so by the time the bar fades
* in the logo is already in place and the viewer never sees the name swapped for the
* artwork. The fallback is shown meanwhile, because a bar held back waiting on a logo
* is a bar that is late for the thing it was announcing.
*/
private fun bindNextUpIdentity(next: NextEpisode) {
val logo = nextUpLogo ?: return
val fallback = nextUpSeries ?: return
val url = next.logoUrl?.takeIf { it.isNotBlank() }
if (nextUpBoundItemId == next.itemId && nextUpBoundLogoUrl == url) return
nextUpBoundItemId = next.itemId
nextUpBoundLogoUrl = url
fallback.text = next.seriesName.ifBlank { next.title }
logo.clearColorFilter()
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
if (url == null) return
logo.load(url) {
crossfade(false)
listener(
onSuccess = { _, result ->
// An Emby title treatment is commonly black-on-transparent, which on
// this wash is an invisible heading. Same judgement, same helper, as
// the ident in the opposite corner.
makeLogoVisibleOnDarkBackground(logo, result.drawable)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
},
)
}
}
private fun showNextUp(next: NextEpisode) {
val banner = nextUpBanner ?: return
bindNextUpIdentity(next)
if (banner.isVisible) return
banner.findViewById<TextView>(R.id.player_next_up_title).text =
next.title.ifBlank { next.seriesName }
val meta = listOfNotNull(
next.episodeCode,
next.seriesName.takeIf { it.isNotBlank() && next.title.isNotBlank() },
).joinToString(" · ")
banner.findViewById<TextView>(R.id.player_next_up_meta).apply {
text = meta
visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
}
banner.findViewById<ImageView>(R.id.player_next_up_image).load(next.imageUrl) {
crossfade(true)
}
banner.alpha = 0f
banner.visibility = View.VISIBLE
banner.post {
zoomVideoForNextUp()
banner.animate()
.alpha(1f)
.setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
playerView?.hideController()
banner.findViewById<View>(R.id.player_next_up_play).requestFocus()
}
}
private fun hideNextUp() {
@@ -3758,37 +3826,17 @@ class PlayerActivity : ComponentActivity() {
banner.alpha = 1f
}
.start()
restoreVideoAfterNextUp()
}
/**
* Refuses the offer. Back is how it is reached, which is the same contract every other
* overlay in this player has one press per level, and the key that would otherwise
* leave the programme spends itself on the thing that appeared over it instead.
*/
private fun dismissNextUp() {
nextUpDismissed = true
nextUpJob?.cancel()
hideNextUp()
playerView?.requestFocus()
}
private fun zoomVideoForNextUp() {
val view = playerView ?: return
view.animate()
.scaleX(NEXT_UP_VIDEO_SCALE)
.scaleY(NEXT_UP_VIDEO_SCALE)
.translationX(-view.width * NEXT_UP_VIDEO_SHIFT_X)
.translationY(-view.height * NEXT_UP_VIDEO_SHIFT_Y)
.setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun restoreVideoAfterNextUp() {
playerView?.animate()
?.scaleX(1f)
?.scaleY(1f)
?.translationX(0f)
?.translationY(0f)
?.setDuration(NEXT_UP_ANIMATION_MS)
?.setInterpolator(DecelerateInterpolator())
?.start()
}
// --- Closing credits ----------------------------------------------------------------
@@ -4512,7 +4560,10 @@ class PlayerActivity : ComponentActivity() {
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
// The next-up bar is deliberately absent from this list. It takes no focus and
// holds no button, so the centre key still means pause while it is up — the
// whole point of the compact bar is that the remote goes on meaning what it
// meant a moment before it appeared.
// The pane's Play button holds focus while it is up, and the centre key is how a
// remote presses what it is focused on.
creditsView?.isVisible != true &&
@@ -6142,9 +6193,15 @@ class PlayerActivity : ComponentActivity() {
private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L
private const val NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L
private const val NEXT_UP_ANIMATION_MS = 260L
private const val NEXT_UP_VIDEO_SCALE = 0.58f
private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f
private const val NEXT_UP_VIDEO_SHIFT_Y = 0.15f
/**
* The green the bar's eyebrow and countdown arc share with the rest of Memby.
*
* Written out rather than built with `Color.rgb`, because this companion is
* initialised by plain JUnit tests that have no Android framework under them and
* every android.graphics call from one throws.
*/
private val NEXT_UP_ACCENT = 0xFF69CD61.toInt()
private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640
private const val NEXT_UP_IMAGE_PREFETCH_HEIGHT = 360
@@ -1,80 +1,28 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.theme.membyTypeface
import kotlin.math.ceil
import kotlin.math.min
/**
* The ring on the skip-intro button: how long is left to press it, drawn as a draining arc
* with the figure inside.
* The ring on the skip-intro button: how long is left to press it.
*
* Like [PrerollCountdownView] it runs no animator of its own. PlayerActivity advances it
* from the playhead, which is what keeps it honest pausing during the titles holds the
* ring where it is, and seeking moves it to wherever the film now is, neither of which a
* timer counting wall-clock seconds could do.
*
* It takes its colours from its own drawable state rather than from a setter. The button
* around it is a state-list pill green with white text, white with dark text once
* focused so the ring has to change with it or it disappears into the fill the moment
* somebody's remote reaches it. `duplicateParentState` in the layout is what feeds that
* state down; without it this draws focused colours never.
* The drawing is [CountdownRingView]'s, shared with the next-up overlay. What is this
* class's own is the one thing that differs it takes its colours from its own drawable
* state rather than from a setter. The button around it is a state-list pill green with
* white text, white with dark text once focused so the ring has to change with it or it
* disappears into the fill the moment somebody's remote reaches it. `duplicateParentState`
* in the layout is what feeds that state down; without it this draws focused colours never.
*/
class SkipIntroCountdownView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
) : CountdownRingView(context, attrs, defStyleAttr) {
private val density = resources.displayMetrics.density
private val ringBounds = RectF()
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = 2.5f * density
}
private val progressPaint = Paint(trackPaint)
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = context.membyTypeface(Typeface.BOLD)
}
private var figure = ""
private var progress = 1f
/**
* How much of the offer is left, and how long it ran for.
*
* Redraws only when the drawn result would actually differ. This is advanced several
* times a second for two minutes at a stretch, and a two-minute ring moves by a
* fraction of a degree per tick invalidating on every one of them would be a couple
* of hundred pointless draws per episode on a box that has a decoder to feed.
*/
fun setRemaining(remainingMs: Long, totalMs: Long) {
val remaining = remainingMs.coerceAtLeast(0L)
val nextFigure = formatRemaining(remaining)
val nextProgress = if (totalMs > 0L) {
(remaining.toFloat() / totalMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
// A degree is about the smallest movement worth a redraw; below that the arc lands
// on the same pixels.
val moved = kotlin.math.abs(nextProgress - progress) * 360f >= 1f
if (nextFigure == figure && !moved) return
figure = nextFigure
progress = nextProgress
contentDescription = context.getString(R.string.player_skip_intro_countdown, nextFigure)
invalidate()
}
override fun describe(figure: String): CharSequence =
context.getString(R.string.player_skip_intro_countdown, figure)
override fun drawableStateChanged() {
super.drawableStateChanged()
@@ -85,61 +33,19 @@ class SkipIntroCountdownView @JvmOverloads constructor(
// The unspent part of the ring has to stay visible without competing with the arc.
// Dark ink on the focused white pill needs more of itself than white does on green,
// where the fill is already doing half the separating.
trackPaint.color = Color.argb(
setInk(
track = Color.argb(
if (focused) 92 else 72,
Color.red(ink),
Color.green(ink),
Color.blue(ink),
),
arc = ink,
figure = ink,
)
progressPaint.color = ink
figurePaint.color = ink
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val strokeInset = trackPaint.strokeWidth / 2f
val diameter = min(width, height).toFloat()
val left = (width - diameter) / 2f + strokeInset
val top = (height - diameter) / 2f + strokeInset
ringBounds.set(
left,
top,
left + diameter - trackPaint.strokeWidth,
top + diameter - trackPaint.strokeWidth,
)
canvas.drawOval(ringBounds, trackPaint)
if (progress > 0f) {
// Anticlockwise from the top, so the ring empties the way a clock hand would
// sweep back rather than filling up as the thing it measures runs out.
canvas.drawArc(ringBounds, -90f, -360f * progress, false, progressPaint)
}
if (figure.isEmpty()) return
figurePaint.textSize = figureTextSize(diameter, figure.length)
val baseline = height / 2f - (figurePaint.ascent() + figurePaint.descent()) / 2f
canvas.drawText(figure, width / 2f, baseline, figurePaint)
}
/**
* The figure has to fit inside the ring, and "1:58" is twice the width of "12". Sizing
* from the string's own length is what stops a two-minute opening printing over its own
* arc an intro is commonly long enough to be counted in minutes, so this is the
* ordinary case rather than the edge one.
*/
private fun figureTextSize(diameter: Float, characters: Int): Float =
diameter * if (characters >= 4) 0.30f else 0.42f
private companion object {
val FOCUSED_INK = Color.rgb(11, 14, 17)
}
}
/**
* "1:58" over a minute, "58" under it. Never "0:58": a bare figure is read at a glance in a
* ring this size, and the colon is only worth its width once there are minutes to separate.
*/
internal fun formatRemaining(remainingMs: Long): String {
val seconds = ceil(remainingMs.coerceAtLeast(0L) / 1_000.0).toInt()
if (seconds < 60) return seconds.toString()
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}
@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A wash, not a panel. This sits on a programme somebody is still watching, so it has no
stroke and no flat fill: it is darkest under the logo, where the type needs the
contrast, and thins towards the countdown so the bar reads as part of the picture
rather than as a card laid over it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F2101418" />
<corners android:radius="14dp" />
<stroke
android:width="1dp"
android:color="#26FFFFFF" />
<gradient
android:angle="0"
android:centerColor="#D0070A0C"
android:endColor="#8C070A0C"
android:startColor="#F0070A0C"
android:type="linear" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#3DFFFFFF" />
</shape>
@@ -1,6 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A shallow native end-of-episode overlay. The outgoing PlayerView is scaled into the
open left side while this single prefetched episode card fades in. -->
<!-- What is on next, said in one short bar in the bottom-left corner while the episode the
viewer is watching keeps playing behind it.
It replaced a 420dp card that shrank the picture to 58% to make room for itself, which
is the whole complaint: the credits are the last thing an episode has to say and the
overlay was covering them. Nothing here is focusable — the bar takes no part in the
remote's world, so the transport, the seek keys and the centre button all keep meaning
what they meant a moment before it appeared. Play now is the transport's own Next
Episode button, which is offered whenever this bar is, and Back dismisses.
Everything is in dp and nothing is measured against the screen, so it is the same size
on a 720p set and a 4K one; the safe-area margins keep it clear of overscan. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_next_up"
@@ -10,102 +20,89 @@
android:visibility="gone">
<LinearLayout
android:layout_width="420dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginEnd="50dp"
android:layout_gravity="bottom|start"
android:layout_marginStart="48dp"
android:layout_marginBottom="48dp"
android:background="@drawable/next_up_banner_background"
android:orientation="vertical"
android:padding="20dp">
android:baselineAligned="false"
android:focusable="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="22dp"
android:paddingTop="13dp"
android:paddingEnd="22dp"
android:paddingBottom="13dp">
<!-- The show's own title treatment, with its name as the fallback underneath. Both
live in the same slot at the same height, so which one arrives cannot change
the shape of the bar. -->
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="34dp">
<ImageView
android:id="@+id/player_next_up_image"
android:layout_width="match_parent"
android:layout_height="214dp"
android:background="#FF1B2026"
android:id="@+id/player_next_up_logo"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:contentDescription="@null"
android:scaleType="centerCrop" />
android:maxWidth="168dp"
android:scaleType="fitStart"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/player_next_up_series"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:maxWidth="220dp"
android:textColor="#FFFFFFFF"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Lioness" />
</FrameLayout>
<View
android:layout_width="1dp"
android:layout_height="26dp"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:background="@drawable/next_up_divider" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:letterSpacing="0.14"
android:text="@string/next_up_label"
android:text="@string/next_up_next_episode"
android:textColor="#FF69CD61"
android:textSize="11sp" />
<TextView
android:id="@+id/player_next_up_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#FFFFFFFF"
android:textSize="24sp"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_next_up_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#BFFFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/player_next_up_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="#FF69CD61"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal"
tools:ignore="ButtonStyle">
<Button
android:id="@+id/player_next_up_play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/next_up_primary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="22dp"
android:paddingTop="9dp"
android:paddingEnd="22dp"
android:paddingBottom="9dp"
android:stateListAnimator="@null"
android:text="@string/next_up_play_now"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="15sp" />
<Button
android:id="@+id/player_next_up_dismiss"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:background="@drawable/next_up_secondary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="22dp"
android:paddingTop="9dp"
android:paddingEnd="22dp"
android:paddingBottom="9dp"
android:stateListAnimator="@null"
android:text="@string/next_up_dismiss"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="15sp" />
android:layout_marginTop="3dp"
android:maxLines="1"
android:textColor="#D9FFFFFF"
android:textSize="13sp"
tools:text="Starting in 15s" />
</LinearLayout>
<com.ponzischeme89.memby.ui.player.CountdownRingView
android:id="@+id/player_next_up_ring"
android:layout_width="38dp"
android:layout_height="38dp"
android:layout_marginStart="20dp" />
</LinearLayout>
</FrameLayout>
+8 -2
View File
@@ -87,8 +87,14 @@
<string name="player_skip_intro_countdown">%1$s left</string>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
<string name="next_up_starting_in">Starting in %1$ds</string>
<!-- The eyebrow on the compact next-up bar. "NEXT EPISODE" rather than "NEXT UP":
with the show's own logo beside it there is room to say which of the two the bar
is promising, and the credits pane already uses the longer wording. -->
<string name="next_up_next_episode">NEXT EPISODE</string>
<!-- The line under the eyebrow, given the same figure the ring beside it is drawing:
a clock value over a minute, a bare count of seconds under one. Two figures on one
bar that disagree - "60s" beside "1:00" - read as a countdown that has gone wrong. -->
<string name="next_up_starting_in">Starting in %1$s</string>
<!-- The way back to the credits at normal size and normal speed. Worded as wanting the
credits rather than as dismissing a panel: it is the only thing this button does,
and somebody pressing it is asking to watch them. -->
@@ -0,0 +1,26 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The one boundary this feature has: anything other than exactly "v2" is "v1", never a
* screen the app cannot draw. A missing field, a blank string and a future value this build
* has never heard of must all be indistinguishable from an operator who left it alone.
*/
class DetailExperienceTest {
@Test
fun `v2 is the only value that opts in`() {
assertEquals("v2", detailExperienceOrDefault("v2"))
}
@Test
fun `missing, blank and unknown values all fall back to v1`() {
assertEquals("v1", detailExperienceOrDefault(""))
assertEquals("v1", detailExperienceOrDefault("v1"))
assertEquals("v1", detailExperienceOrDefault("V2"))
assertEquals("v1", detailExperienceOrDefault("v3"))
assertEquals("v1", detailExperienceOrDefault("unknown"))
}
}
@@ -0,0 +1,194 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The compact next-up bar over a stand-in for the closing minute of an episode, captured
* from the real player XML at TV resolution: `build/screenshots/next-up/`.
*
* This is the only test that can judge the thing the redesign is actually about. A unit test
* can check that the countdown says "15"; it cannot check that the bar leaves the programme
* visible, which is the entire complaint the old 420dp card produced. So the background is a
* deliberately *lit* stand-in for a frame with the light pooled where the bar sits, and the
* capture is of the whole 960x540 area rather than of the bar alone: if the picture is not
* still readable around it, this is where that shows.
*
* Both identity cases are captured because they are the two halves of the logo rule - a show
* whose title treatment Emby holds, and one where the name is all there is. The bar must be
* the same height either way, or a household whose library is half decorated gets a bar that
* changes shape between episodes.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class NextUpOverlayScreenshotTest {
@Test
fun `what is on next, wearing the show's own title treatment`() {
capture(name = "next-up-logo", seriesName = "Lioness", withLogo = true, remainingMs = 15_000L)
}
@Test
fun `a show with no logo falls back to its name`() {
capture(name = "next-up-text-fallback", seriesName = "Lioness", withLogo = false, remainingMs = 15_000L)
}
@Test
fun `the bar as it opens, with the whole minute still to run`() {
// The ring is full and the figure is at its widest here, which is where it either
// fits inside the arc or prints over it.
capture(name = "next-up-opening", seriesName = "Slow Horses", withLogo = false, remainingMs = 60_000L)
}
@Test
fun `the last seconds before the handover`() {
capture(name = "next-up-running-out", seriesName = "Slow Horses", withLogo = false, remainingMs = 3_000L)
}
@Test
fun `with automatic advance off there is no countdown to draw`() {
// The bar still says what is next - the offer is not the transition - but nothing on
// it promises the episode will start by itself, because it will not.
capture(
name = "next-up-manual",
seriesName = "The Bear",
withLogo = false,
remainingMs = 0L,
autoAdvance = false,
)
}
private fun capture(
name: String,
seriesName: String,
withLogo: Boolean,
remainingMs: Long,
autoAdvance: Boolean = true,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply { background = fakeScene() }
val bar = LayoutInflater.from(activity).inflate(R.layout.player_next_up_banner, root, false)
bar.visibility = View.VISIBLE
val logo = bar.findViewById<ImageView>(R.id.player_next_up_logo)
val fallback = bar.findViewById<TextView>(R.id.player_next_up_series).apply {
text = seriesName
}
// Stands in for the fetched title treatment: a wide, short wordmark rather than a
// photograph, because what matters is that a piece of artwork of that shape sits in
// the slot at the same height the name would have taken.
logo.setImageDrawable(if (withLogo) fakeTitleTreatment(seriesName) else null)
logo.isVisible = withLogo
fallback.isVisible = !withLogo
bar.findViewById<TextView>(R.id.player_next_up_countdown).apply {
isVisible = autoAdvance
text = activity.getString(R.string.next_up_starting_in, formatRemaining(remainingMs))
}
bar.findViewById<CountdownRingView>(R.id.player_next_up_ring).apply {
isVisible = autoAdvance
// The same three colours PlayerActivity sets, so this is the real ring.
setInk(
track = Color.argb(56, 255, 255, 255),
arc = Color.rgb(0x69, 0xCD, 0x61),
figure = Color.WHITE,
)
setRemaining(remainingMs, LEAD_MS)
}
root.addView(bar)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/next-up/$name.png")
}
/** A wide, short wordmark: the shape an Emby title treatment actually is. */
private fun fakeTitleTreatment(text: String): Drawable = object : Drawable() {
override fun draw(canvas: Canvas) {
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = bounds.height() * 0.66f
letterSpacing = 0.20f
isFakeBoldText = true
}
val baseline = bounds.height() / 2f - (paint.ascent() + paint.descent()) / 2f
canvas.drawText(text.uppercase(), 0f, baseline, paint)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
@Deprecated("Deprecated in Java")
override fun getOpacity() = PixelFormat.TRANSLUCENT
override fun getIntrinsicWidth() = 420
override fun getIntrinsicHeight() = 96
}
/**
* Stands in for a frame of an episode's closing minute, lit where the bar sits. The bar
* carries a wash rather than a panel, so the question this answers is whether that wash
* is enough for the type over a bright picture - and whether the picture is still there,
* which is the whole point of the redesign.
*/
private fun fakeScene(): Drawable = object : GradientDrawable(
Orientation.TL_BR,
intArrayOf(Color.rgb(28, 46, 66), Color.rgb(74, 62, 44), Color.rgb(150, 132, 96)),
) {
override fun draw(canvas: Canvas) {
super.draw(canvas)
val width = bounds.width().toFloat()
val height = bounds.height().toFloat()
canvas.drawPaint(
Paint().apply {
shader = RadialGradient(
width * 0.24f, height * 0.78f, width * 0.40f,
intArrayOf(Color.argb(205, 255, 238, 205), Color.TRANSPARENT),
null,
Shader.TileMode.CLAMP,
)
},
)
canvas.drawRect(
0f, height * 0.62f, width, height,
Paint().apply {
shader = LinearGradient(
0f, height * 0.62f, 0f, height,
Color.argb(110, 12, 16, 22), Color.argb(200, 6, 8, 12),
Shader.TileMode.CLAMP,
)
},
)
}
}
private companion object {
/** The final minute, which is the window the bar and its ring both measure. */
const val LEAD_MS = 60_000L
}
}
+11 -3
View File
@@ -40,6 +40,8 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
mux.Handle("PUT /admin/api/accounts/{userID}/notifications", s.adminAuth(s.handleAdminNotificationPreferences))
mux.Handle("POST /admin/api/accounts/{userID}/force-update", s.adminAuth(s.handleAdminForceUpdate))
mux.Handle("PUT /admin/api/accounts/{userID}/enabled", s.adminAuth(s.handleAdminUserEnabled))
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
s.adminAuth(s.handleAdminRestorePreferences))
@@ -857,16 +859,22 @@ func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) {
}
stats, statsErr := s.store.JourneyStats(r.Context(), userID, since)
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
featureUsage, usageErr := s.store.JourneyFeatureUsage(r.Context(), userID, since)
featureBreakdown, breakdownErr := s.store.JourneyFeatureBreakdown(r.Context(), userID, since)
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
actions, actionErr := s.store.JourneyActionStats(r.Context(), userID, since)
if statsErr != nil || featureErr != nil || pathErr != nil || actionErr != nil {
s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID)
if statsErr != nil || featureErr != nil || usageErr != nil || breakdownErr != nil || pathErr != nil || actionErr != nil {
s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID,
"stats_error", statsErr, "feature_error", featureErr,
"feature_usage_error", usageErr, "feature_breakdown_error", breakdownErr,
"paths_error", pathErr, "actions_error", actionErr)
writeError(w, http.StatusInternalServerError, "could not read journeys")
return
}
payload := map[string]any{
"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)),
"users": users, "stats": stats, "features": features, "paths": paths, "actions": actions,
"users": users, "stats": stats, "features": features, "featureUsage": featureUsage,
"featureBreakdown": featureBreakdown, "paths": paths, "actions": actions,
}
if userID != "" {
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
+54 -42
View File
@@ -3,7 +3,6 @@ package api
import (
"encoding/json"
"net/http"
"sort"
"strings"
"time"
@@ -42,7 +41,8 @@ type adminMembyAccount struct {
CreatedAt time.Time `json:"createdAt"`
LastSeen time.Time `json:"lastSeen"`
Devices []store.MembyDevice `json:"devices"`
Recommendations adminOnboardingPreferences `json:"recommendations"`
Enabled bool `json:"enabled"`
LastIP string `json:"lastIp"`
// Settings is the same document the television reads, normalised the same way, so
// the console is editing what the TV will actually receive rather than a projection
// of it. Saved is false for someone who has never synced — the values shown are then
@@ -77,23 +77,6 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
return
}
allRatingIDs := []string{}
preferences := make(map[string]recommend.OnboardingPreferences, len(accounts))
for _, account := range accounts {
var pref recommend.OnboardingPreferences
_ = json.Unmarshal(account.RecommendationPreferences, &pref)
preferences[account.ID] = pref
for id := range pref.Ratings {
allRatingIDs = append(allRatingIDs, id)
}
}
titles := map[string]string{}
if raws, loadErr := s.store.LibraryItemsByID(r.Context(), allRatingIDs); loadErr == nil {
for _, item := range recommend.Decode(raws) {
titles[item.ID] = item.Name
}
}
settings, err := s.store.AllUserPreferences(r.Context())
if err != nil {
// A settings read failure must not cost the operator the account list; the
@@ -126,21 +109,6 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts {
pref := preferences[account.ID]
ratings := make([]adminOnboardingRating, 0, len(pref.Ratings))
for itemID, rating := range pref.Ratings {
title := titles[itemID]
if title == "" {
title = itemID
}
ratings = append(ratings, adminOnboardingRating{ItemID: itemID, Title: title, Rating: rating})
}
sort.Slice(ratings, func(i, j int) bool {
if ratings[i].Rating != ratings[j].Rating {
return ratings[i].Rating > ratings[j].Rating
}
return strings.ToLower(ratings[i].Title) < strings.ToLower(ratings[j].Title)
})
stored, saved := settings[account.ID]
accountSettings := adminAccountSettings{
Saved: saved, Revision: stored.Revision, Source: stored.Source,
@@ -162,14 +130,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
Themes: nonNilStrings(themes[account.ID]),
Notifications: notificationPrefs,
Recommendations: adminOnboardingPreferences{
Completed: pref.Completed, Prompted: pref.Prompted,
Updated: len(account.RecommendationPreferences) > 2,
Ratings: ratings, Genres: nonNilStrings(pref.Genres),
Studios: nonNilStrings(pref.Studios), Actors: nonNilStrings(pref.Actors),
Actresses: nonNilStrings(pref.Actresses),
Directors: nonNilStrings(pref.Directors), ContentTypes: nonNilStrings(pref.ContentTypes),
},
Enabled: account.Enabled, LastIP: account.LastIP,
})
}
// The catalogue rides along so the console builds its editor from the server's own
@@ -218,6 +179,57 @@ func (s *Server) handleAdminNotificationPreferences(w http.ResponseWriter, r *ht
writeJSON(w, http.StatusOK, prefs)
}
func (s *Server) handleAdminUserEnabled(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
var req struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil || req.Enabled == nil {
writeError(w, http.StatusBadRequest, "enabled is required")
return
}
if err := s.store.SetUserEnabled(r.Context(), userID, *req.Enabled); err != nil {
writeError(w, http.StatusInternalServerError, "could not change user access")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"enabled": *req.Enabled})
}
// The gateway's update check is policy-driven. This is the operator-facing queue point;
// the device sees the request on its next status poll.
func (s *Server) handleAdminForceUpdate(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
accounts, err := s.store.MembyAccounts(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load Memby account")
return
}
for _, account := range accounts {
if account.ID == userID {
version := strings.TrimSpace(s.updatePolicy.get().LatestVersion)
if version == "" {
writeError(w, http.StatusConflict, "no current release is configured")
return
}
if err := s.store.SetForcedUpdate(r.Context(), userID, version); err != nil {
writeError(w, http.StatusInternalServerError, "could not queue update")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued"})
return
}
}
writeError(w, http.StatusNotFound, "Memby account not found")
}
func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
+3
View File
@@ -701,6 +701,9 @@ func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, e
if raw, err := s.cache.Get(ctx, key); err == nil {
var cs cachedSession
if json.Unmarshal(raw, &cs) == nil {
if enabled, err := s.store.UserEnabled(ctx, cs.EmbyUserID); err != nil || !enabled {
return store.Session{}, store.ErrNotFound
}
return store.Session{
TokenHash: hash,
EmbyUserID: cs.EmbyUserID,
+17
View File
@@ -62,6 +62,7 @@ var configurationCatalogue = []configurationDefinition{
{Key: "continueWatching.enabled", Name: "Continue Watching", Description: "Show the Continue Watching row.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "continueWatching.showNextUp", Name: "Continue Watching: Next Up", Description: "Include an unstarted next episode in Continue Watching.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "continueWatching.progressColour", Name: "Progress bar colour", Description: "Choose the progress bar treatment.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "emby", Options: []string{"emby", "white"}},
{Key: "detailExperience", Name: "Detail page experience", Description: "Which detail-page layout a viewer sees.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "v1", Options: []string{"v1", "v2"}},
{Key: "presentation.fontFamily", Name: "App font family", Description: "Choose the bundled font used in Membys typography trial areas.", Type: "enum", Scopes: []string{"global"}, Default: "system", Options: []string{"system", "inter"}},
{Key: "ratings.enabled", Name: "Ratings", Description: "Show ratings throughout the catalogue.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "genres.enabled", Name: "Genres", Description: "Show genre browsing controls.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
@@ -281,6 +282,22 @@ func configurationValue(policy store.FeaturePolicy, definition configurationDefi
return definition.Default, "default"
}
// detailExperienceFor resolves the "detailExperience" configuration value for a session,
// validating the stored value rather than trusting its type: Values/UserValues/DeviceValues
// are opaque JSON, and a value that is not exactly "v1" or "v2" must never reach the client
// as something it has to guess how to handle.
func detailExperienceFor(policy store.FeaturePolicy, sess store.Session) string {
definition, ok := configurationDefinitionFor("detailExperience")
if !ok {
return "v1"
}
value, _ := configurationValue(policy, definition, sess)
if text, ok := value.(string); ok && (text == "v1" || text == "v2") {
return text
}
return "v1"
}
func configurationPayload(policy store.FeaturePolicy, sessions ...store.Session) []evaluatedConfiguration {
result := make([]evaluatedConfiguration, 0, len(configurationCatalogue))
for _, definition := range configurationCatalogue {
+37
View File
@@ -89,6 +89,43 @@ func TestAppFontFamilyIsAValidatedGlobalConfiguration(t *testing.T) {
}
}
func TestDetailExperienceResolvesDeviceThenUserThenGlobalThenDefault(t *testing.T) {
sess := store.Session{EmbyUserID: "user-1", DeviceID: "device-1"}
if got := detailExperienceFor(store.DefaultFeaturePolicy(), sess); got != "v1" {
t.Fatalf("default detail experience = %q, want v1", got)
}
global := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v2"`)}}
if got := detailExperienceFor(global, sess); got != "v2" {
t.Fatalf("global detail experience = %q, want v2", got)
}
perUser := store.FeaturePolicy{
Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v2"`)},
UserValues: map[string]map[string]json.RawMessage{"user-1": {"detailExperience": json.RawMessage(`"v1"`)}},
}
if got := detailExperienceFor(perUser, sess); got != "v1" {
t.Fatalf("per-user detail experience = %q, want v1 to outrank the global v2", got)
}
perDevice := store.FeaturePolicy{
UserValues: map[string]map[string]json.RawMessage{"user-1": {"detailExperience": json.RawMessage(`"v1"`)}},
DeviceValues: map[string]map[string]json.RawMessage{"device-1": {"detailExperience": json.RawMessage(`"v2"`)}},
}
if got := detailExperienceFor(perDevice, sess); got != "v2" {
t.Fatalf("per-device detail experience = %q, want v2 to outrank the per-user v1", got)
}
}
func TestDetailExperienceFallsBackToV1OnAnUnrecognisedStoredValue(t *testing.T) {
sess := store.Session{EmbyUserID: "user-1", DeviceID: "device-1"}
garbage := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`"v3"`)}}
if got := detailExperienceFor(garbage, sess); got != "v1" {
t.Fatalf("garbage detail experience = %q, want v1", got)
}
notAString := store.FeaturePolicy{Values: map[string]json.RawMessage{"detailExperience": json.RawMessage(`42`)}}
if got := detailExperienceFor(notAString, sess); got != "v1" {
t.Fatalf("non-string detail experience = %q, want v1", got)
}
}
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
+6
View File
@@ -195,5 +195,11 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
// else — the handlers refuse regardless, but a menu item that only ever produces a
// refusal is worse than no menu item.
"requests": map[string]any{"allowed": s.requestAllowed(r, sess)},
// The v2 detail-page experiment, as a plain string rather than a boolean feature
// flag: it needs three legal values room to grow into (a later variant), and per-
// user/per-device scope the way the theme does. It rides this poll rather than
// /v1/config because that document is fetched unauthenticated, before sign-in, and
// cannot resolve a session to scope by user or device.
"detailExperience": detailExperienceFor(featurePolicy, sess),
})
}
+11 -1
View File
@@ -119,7 +119,17 @@ func effectiveUpdatePolicy(policy appupdate.Policy) appupdate.Policy {
}
func (s *Server) updateDecision(r *http.Request) appupdate.Decision {
return appupdate.Decide(effectiveUpdatePolicy(s.updatePolicy.get()), clientVersion(r))
policy := effectiveUpdatePolicy(s.updatePolicy.get())
if identity := identityFrom(r.Context()); identity != nil && identity.userID != "" {
if forced, err := s.store.ForcedUpdate(r.Context(), identity.userID); err == nil && forced != "" {
if appupdate.CompareVersions(clientVersion(r), forced) >= 0 {
_ = s.store.ClearForcedUpdate(r.Context(), identity.userID)
} else if policy.Enabled && policy.DownloadURL != "" && (policy.MinimumVersion == "" || appupdate.CompareVersions(policy.MinimumVersion, forced) < 0) {
policy.MinimumVersion = forced
}
}
}
return appupdate.Decide(policy, clientVersion(r))
}
// mustRetireForUpdate is narrower than "mandatory": an operator may temporarily force a
+25
View File
@@ -0,0 +1,25 @@
package store
import (
"context"
"fmt"
)
func (s *Store) UserEnabled(ctx context.Context, userID string) (bool, error) {
var enabled bool
err := s.pool.QueryRow(ctx, `SELECT COALESCE((SELECT enabled FROM user_controls WHERE emby_user_id = $1), true)`, userID).Scan(&enabled)
if err != nil {
return false, fmt.Errorf("store: read user access: %w", err)
}
return enabled, nil
}
func (s *Store) SetUserEnabled(ctx context.Context, userID string, enabled bool) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_controls (emby_user_id, enabled, updated_at) VALUES ($1, $2, now())
ON CONFLICT (emby_user_id) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now()`, userID, enabled)
if err != nil {
return fmt.Errorf("store: set user access: %w", err)
}
return nil
}
+92
View File
@@ -97,6 +97,30 @@ type FeatureStat struct {
LastUsedAt time.Time `json:"lastUsedAt"`
}
// JourneyFeatureStat is the server-derived usage summary for one feature. Users are
// distinct profiles, Uses are recorded feature events, and Journeys are distinct app
// visits containing the feature.
type JourneyFeatureStat struct {
Feature string `json:"feature"`
Users int64 `json:"users"`
Uses int64 `json:"uses"`
Journeys int64 `json:"journeys"`
ActiveUserRate float64 `json:"activeUserRate"`
LastUsedAt time.Time `json:"lastUsedAt"`
}
// JourneyBreakdownStat keeps the three telemetry dimensions separate. SubFeature is
// sourced from the structured entry/source/target context, never parsed from display text.
type JourneyBreakdownStat struct {
Feature string `json:"feature"`
SubFeature string `json:"subFeature"`
Action string `json:"action"`
Users int64 `json:"users"`
Uses int64 `json:"uses"`
Journeys int64 `json:"journeys"`
LastUsedAt time.Time `json:"lastUsedAt"`
}
type PathStat struct {
From string `json:"from"`
To string `json:"to"`
@@ -430,6 +454,74 @@ func (s *Store) UserFeatureStats(ctx context.Context, userID string, since time.
return out, rows.Err()
}
// JourneyFeatureUsage aggregates structured journey telemetry. Lifecycle markers are not
// feature usage: screen views and feature actions are retained so a feature can be viewed
// without being selected, requested or played.
func (s *Store) JourneyFeatureUsage(ctx context.Context, userID string, since time.Time) ([]JourneyFeatureStat, error) {
rows, err := s.pool.Query(ctx, `
WITH active AS (
SELECT count(DISTINCT emby_user_id) AS users
FROM journey_events WHERE occurred_at >= $1
), events AS (
SELECT coalesce(nullif(feature, ''), nullif(category, '')) AS feature,
emby_user_id, journey_id, occurred_at
FROM journey_events
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
AND action NOT IN ('journey_start', 'journey_end')
)
SELECT feature, count(DISTINCT emby_user_id), count(*), count(DISTINCT (emby_user_id, journey_id)),
coalesce(count(DISTINCT emby_user_id)::double precision /
nullif((SELECT users FROM active), 0), 0), max(occurred_at)
FROM events
WHERE feature IS NOT NULL AND feature <> ''
GROUP BY feature ORDER BY count(*) DESC, feature`, since, userID)
if err != nil {
return nil, fmt.Errorf("store: journey feature usage: %w", err)
}
defer rows.Close()
out := []JourneyFeatureStat{}
for rows.Next() {
var value JourneyFeatureStat
if err := rows.Scan(&value.Feature, &value.Users, &value.Uses, &value.Journeys,
&value.ActiveUserRate, &value.LastUsedAt); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
// JourneyFeatureBreakdown is the drill-down behind the feature summary. Source is the
// strongest sub-feature signal (for example text or voice search); target and screen are
// fallbacks for older structured events.
func (s *Store) JourneyFeatureBreakdown(ctx context.Context, userID string, since time.Time) ([]JourneyBreakdownStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT coalesce(nullif(feature, ''), nullif(category, '')) AS feature,
coalesce(nullif(source, ''), nullif(target, ''), nullif(screen, ''), '') AS sub_feature,
action, count(DISTINCT emby_user_id), count(*), count(DISTINCT (emby_user_id, journey_id)), max(occurred_at)
FROM journey_events
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
AND action NOT IN ('journey_start', 'journey_end')
AND coalesce(nullif(feature, ''), nullif(category, '')) IS NOT NULL
GROUP BY coalesce(nullif(feature, ''), nullif(category, '')),
coalesce(nullif(source, ''), nullif(target, ''), nullif(screen, ''), ''), action
ORDER BY count(*) DESC, feature, sub_feature, action`, since, userID)
if err != nil {
return nil, fmt.Errorf("store: journey feature breakdown: %w", err)
}
defer rows.Close()
out := []JourneyBreakdownStat{}
for rows.Next() {
var value JourneyBreakdownStat
if err := rows.Scan(&value.Feature, &value.SubFeature, &value.Action, &value.Users,
&value.Uses, &value.Journeys, &value.LastUsedAt); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) ([]PathStat, error) {
rows, err := s.pool.Query(ctx, `
WITH ordered AS (
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"context"
"fmt"
)
func (s *Store) SetForcedUpdate(ctx context.Context, userID, version string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO forced_updates (emby_user_id, version) VALUES ($1, $2)
ON CONFLICT (emby_user_id) DO UPDATE SET version = EXCLUDED.version, requested_at = now()`, userID, version)
if err != nil {
return fmt.Errorf("store: queue forced update: %w", err)
}
return nil
}
func (s *Store) ForcedUpdate(ctx context.Context, userID string) (string, error) {
var version string
err := s.pool.QueryRow(ctx, `SELECT version FROM forced_updates WHERE emby_user_id = $1`, userID).Scan(&version)
if isNoRows(err) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("store: read forced update: %w", err)
}
return version, nil
}
func (s *Store) ClearForcedUpdate(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM forced_updates WHERE emby_user_id = $1`, userID)
if err != nil {
return fmt.Errorf("store: clear forced update: %w", err)
}
return nil
}
+14
View File
@@ -23,6 +23,20 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAU
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_capabilities TEXT[] NOT NULL DEFAULT '{}';
-- Gateway access is separate from Emby's own account policy. This lets an operator
-- suspend Memby access without changing the upstream account or its other clients.
CREATE TABLE IF NOT EXISTS user_controls (
emby_user_id TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS forced_updates (
emby_user_id TEXT PRIMARY KEY,
version TEXT NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
DELETE FROM sessions older
+25 -2
View File
@@ -50,6 +50,7 @@ type KnownClient struct {
Protocol string `json:"protocol"`
Capabilities []string `json:"capabilities"`
LastSeen time.Time `json:"lastSeen"`
LastIP string `json:"lastIp"`
Versions []DeviceVersion `json:"versions"`
}
@@ -91,6 +92,8 @@ type MembyAccount struct {
LastSeen time.Time `json:"lastSeen"`
Devices []MembyDevice `json:"devices"`
RecommendationPreferences json.RawMessage `json:"-"`
Enabled bool `json:"enabled"`
LastIP string `json:"lastIp"`
}
type MembyDevice struct {
@@ -101,6 +104,7 @@ type MembyDevice struct {
Capabilities []string `json:"capabilities"`
SignedInAt time.Time `json:"signedInAt"`
LastSeen time.Time `json:"lastSeen"`
LastIP string `json:"lastIp"`
Versions []DeviceVersion `json:"versions"`
}
@@ -110,9 +114,21 @@ func (s *Store) MembyAccounts(ctx context.Context) ([]MembyAccount, error) {
rows, err := s.pool.Query(ctx, `
SELECT s.emby_user_id, s.username, s.device_id, s.device_name,
s.client_version, s.client_protocol, s.client_capabilities,
s.created_at, s.last_seen_at, COALESCE(o.preferences, '{}'::jsonb)
s.created_at, s.last_seen_at, COALESCE(o.preferences, '{}'::jsonb),
COALESCE(c.enabled, true), COALESCE(ip.address, ''), COALESCE(dip.address, '')
FROM sessions s
LEFT JOIN recommendation_onboarding o ON o.emby_user_id = s.emby_user_id
LEFT JOIN user_controls c ON c.emby_user_id = s.emby_user_id
LEFT JOIN LATERAL (
SELECT ip_address AS address FROM login_events
WHERE emby_user_id = s.emby_user_id AND success AND ip_address <> ''
ORDER BY occurred_at DESC, id DESC LIMIT 1
) ip ON true
LEFT JOIN LATERAL (
SELECT ip_address AS address FROM login_events
WHERE device_id = s.device_id AND success AND ip_address <> ''
ORDER BY occurred_at DESC, id DESC LIMIT 1
) dip ON true
ORDER BY s.last_seen_at DESC, s.emby_user_id, s.device_name`)
if err != nil {
return nil, fmt.Errorf("store: list Memby accounts: %w", err)
@@ -125,10 +141,12 @@ func (s *Store) MembyAccounts(ctx context.Context) ([]MembyAccount, error) {
var userID, username string
var device MembyDevice
var preferences []byte
var accountsEnabled bool
var lastIP string
if err := rows.Scan(
&userID, &username, &device.ID, &device.Name, &device.Version,
&device.Protocol, &device.Capabilities, &device.SignedInAt,
&device.LastSeen, &preferences,
&device.LastSeen, &preferences, &accountsEnabled, &lastIP, &device.LastIP,
); err != nil {
return nil, fmt.Errorf("store: scan Memby account: %w", err)
}
@@ -140,9 +158,14 @@ func (s *Store) MembyAccounts(ctx context.Context) ([]MembyAccount, error) {
ID: userID, Username: username, CreatedAt: device.SignedInAt,
LastSeen: device.LastSeen, Devices: []MembyDevice{},
RecommendationPreferences: json.RawMessage(preferences),
Enabled: accountsEnabled, LastIP: lastIP,
})
}
account := &accounts[index]
account.Enabled = accountsEnabled
if account.LastIP == "" {
account.LastIP = lastIP
}
if device.SignedInAt.Before(account.CreatedAt) {
account.CreatedAt = device.SignedInAt
}