0.2.82
This commit is contained in:
@@ -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’s “Who’s watching?” 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.',
|
||||
|
||||
Reference in New Issue
Block a user