0.3.21
This commit is contained in:
@@ -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,
|
||||
|
||||
-1
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<script type="module" crossorigin src="/admin/assets/index-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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 viewer’s 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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 30–40dp. */
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 Memby’s 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 {
|
||||
|
||||
@@ -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,"+
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user