This commit is contained in:
ponzischeme89
2026-08-20 15:06:00 +12:00
parent 549f9c5eed
commit f1164db2c5
52 changed files with 5441 additions and 158 deletions
+204 -1
View File
@@ -65,6 +65,24 @@ interface AccountDevice {
versions: DeviceVersion[] | null;
}
/* One person under this account. A viewer is not an account: the credential and the
library permissions stay the Emby user's, and only whose evening it is changes. */
interface Viewer {
id: string;
name: string;
shortName?: string;
colour?: string;
kind: string;
hasPin?: boolean;
createdAt?: string;
}
interface ViewersPayload {
viewers: Viewer[];
enabled: boolean;
maxShadowViewers: number;
}
interface RecommendationState {
prompted?: boolean;
completed?: boolean;
@@ -144,7 +162,8 @@ type Pending =
| { kind: 'reset-recommendations' }
| { kind: 'cancel-prompt' }
| { kind: 'reset-preferences' }
| { kind: 'no-themes' };
| { kind: 'no-themes' }
| { kind: 'remove-viewer'; viewer: Viewer };
export function AccountPage() {
const { userId = '' } = useParams();
@@ -166,8 +185,23 @@ export function AccountPage() {
const [notifications, setNotifications] = useState<NotificationPreferences | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
/* Renaming or adding a viewer. One piece of state for both, because the dialog is the
same question either way and a null id is what says there is nobody to rename yet. */
const [namingViewer, setNamingViewer] = useState<{ id: string | null; name: string } | null>(null);
/* Viewers are their own request rather than a field on the accounts payload: that
response is the whole household and this is a list per person, so folding it in would
make every accounts poll read one table per account for a page showing one of them. */
const viewersQuery = useQuery<ViewersPayload>(`${base}/viewers`);
const account = (data?.accounts ?? []).find((entry) => entry.id === userId);
const shadowViewers = (viewersQuery.data?.viewers ?? []).filter((viewer) => viewer.kind !== 'main');
/* The gateway is what enforces the limit; this only decides whether to offer a button
whose one possible outcome would be a refusal — the rule the television's own picker
follows. */
const canAddViewer =
Boolean(viewersQuery.data?.enabled) &&
shadowViewers.length < (viewersQuery.data?.maxShadowViewers ?? 0);
const catalogue = data?.catalogue ?? [];
const themeCatalogue = data?.themes ?? [];
@@ -319,6 +353,92 @@ export function AccountPage() {
)}
</Card>
{/* The people under this account. It sits beside the devices rather than on a page
of its own because a viewer only exists under an account, and a top-level page
would open by asking which account — the question this page has answered.
The televisions can do all of this themselves now, so this is the operator's
copy: for a household asking for help, and for the case a remote cannot reach,
such as a viewer created on a set that has since been unplugged. */}
<Card
title="Viewers"
intro="Several people under one Emby account, each with their own Continue Watching, watched history and favourites. Only the account itself is synced with Emby; a shadow viewer's watching is kept by Memby and never reported."
icon="people"
tone="note"
actions={
viewersQuery.data && !viewersQuery.data.enabled ? (
<Tag tone="warn">switched off</Tag>
) : (
<Tag tone="ok">{num(shadowViewers.length)} beside the account</Tag>
)
}
footer={
canAddViewer ? (
<Button variant="primary" onClick={() => setNamingViewer({ id: null, name: '' })}>
Add viewer
</Button>
) : (
<Button disabled>
{viewersQuery.data ? `${num(viewersQuery.data.maxShadowViewers)} is the limit` : 'Add viewer'}
</Button>
)
}
>
{/* Stated rather than implied. An operator who has switched viewers off and then
adds one has done something that looks as though it worked and changes nothing
on any television, because the gateway resolves every request to the account. */}
{viewersQuery.data && !viewersQuery.data.enabled ? (
<Banner
message={
'Viewers are switched off for this server, so every television watches as the account. ' +
'Nothing here is deleted — turn the feature on under Features to bring these people back.'
}
/>
) : null}
{viewersQuery.loading ? (
<Loading />
) : (viewersQuery.data?.viewers ?? []).length === 0 ? (
<Empty>Nobody is set up yet, so this account has one viewer: itself.</Empty>
) : (
<div className="list">
{(viewersQuery.data?.viewers ?? []).map((viewer) => (
<div className="list-item" key={viewer.id}>
<div className="list-body">
<b>{viewer.name}</b>
<p>
{viewer.kind === 'main'
? 'The account itself — named by Emby, and the only one whose watching Emby hears about'
: 'Watches privately; nothing reaches Emby'}
{viewer.createdAt && viewer.kind !== 'main' ? ` · added ${when(viewer.createdAt)}` : ''}
</p>
</div>
<div className="list-actions">
{viewer.kind === 'main' ? (
<Tag tone="info">synced with Emby</Tag>
) : (
<>
<Button
size="sm"
onClick={() => setNamingViewer({ id: viewer.id, name: viewer.name })}
>
Rename
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setPending({ kind: 'remove-viewer', viewer })}
>
Remove
</Button>
</>
)}
</div>
</div>
))}
</div>
)}
</Card>
<Card
title="Recommendation setup"
intro="The prompt appears the next time this person opens Memby on any of their televisions."
@@ -687,6 +807,29 @@ export function AccountPage() {
/>
) : null}
{namingViewer ? (
<ViewerNameDialog
initial={namingViewer.name}
renaming={namingViewer.id !== null}
busy={busy === 'viewer-name'}
onCancel={() => setNamingViewer(null)}
onConfirm={(name) =>
void act(
'viewer-name',
() =>
namingViewer.id
? api.put(`${base}/viewers/${encodeURIComponent(namingViewer.id)}`, { name })
: api.post(`${base}/viewers`, { name }),
namingViewer.id ? 'Viewer renamed.' : 'Viewer added.',
() => {
setNamingViewer(null);
void viewersQuery.reload();
},
)
}
/>
) : null}
{pending ? (
<PendingDialog
pending={pending}
@@ -701,6 +844,13 @@ export function AccountPage() {
() => api.del(`${base}/devices/${encodeURIComponent(pending.deviceId)}`),
'Device signed out.',
);
case 'remove-viewer':
return void act(
'remove-viewer',
() => api.del(`${base}/viewers/${encodeURIComponent(pending.viewer.id)}`),
'Viewer removed.',
() => void viewersQuery.reload(),
);
case 'remove-account':
return void act(
'remove-account',
@@ -929,6 +1079,53 @@ function RenameDialog({
);
}
/* Naming a viewer, added or renamed. It is the same question either way, so it is one
dialog with one field rather than two that would drift apart — the shape RenameDialog
above already takes for a device. */
function ViewerNameDialog({
initial,
renaming,
busy,
onConfirm,
onCancel,
}: {
initial: string;
renaming: boolean;
busy: boolean;
onConfirm: (name: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial);
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true">
<h2>{renaming ? 'Rename this viewer' : 'Add a viewer'}</h2>
<p>
The name shown on the television&rsquo;s &ldquo;Who&rsquo;s watching?&rdquo; screen. Everything
they watch is kept by Memby and never reported to Emby.
</p>
<Field label="Name">
<input
type="text"
value={name}
autoFocus
maxLength={40}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" busy={busy} disabled={!name.trim()} onClick={() => onConfirm(name.trim())}>
{renaming ? 'Rename' : 'Add viewer'}
</Button>
</div>
</div>
</div>
);
}
function PendingDialog({
pending,
busy,
@@ -973,6 +1170,12 @@ function PendingDialog({
label: 'Restore defaults',
destructive: true,
},
'remove-viewer': {
title: 'Remove this viewer?',
body: 'What they were part-way through, what they had watched and their favourites are deleted, on every television in the house. The Emby account is untouched.',
label: 'Remove viewer',
destructive: true,
},
'no-themes': {
title: 'Allow this person no colour schemes?',
body: 'They will be left on Midnight with nothing to choose between.',