feat(web): full virtual-display config surface — one-click presets + every axis editable
The Virtual displays card previously only exposed keep_alive/topology/max_displays as editable custom fields; conflict/identity/layout (enforced since Stages 3-5) had no controls, and the presets weren't surfaced as one-click options. Rework the card so the whole policy is configurable WITHOUT any client connected: - Presets front-and-center: each of the five (default/shared-desktop/hotdesk/workstation/ gaming-rig) is a one-click row showing its story AND what it sets (keep-alive · topology · conflict · identity badges), highlighting the active one. A click applies it immediately. gaming-rig stays disabled + "coming soon" (keep_alive: forever isn't cross-platform yet). - Custom mode reveals EVERY axis editably — keep-alive, topology, conflict, identity, layout, max-displays — seeded from the current effective behavior, with a Save button. A reusable `Choice` button-group + a tolerant `tr()` label lookup keep it tidy. - The live-display list + multi-monitor arrangement table stay below (they need a live session); the settings above work standalone. - en+de i18n for the new controls; refreshed the effective-preview row to show all axes. web tsc + vite build + biome-lint green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,26 @@
|
|||||||
"display_arrange": "Anzeigen anordnen",
|
"display_arrange": "Anzeigen anordnen",
|
||||||
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
|
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
|
||||||
"display_arrange_save": "Anordnung speichern",
|
"display_arrange_save": "Anordnung speichern",
|
||||||
|
"display_custom_desc": "Jede Option selbst festlegen.",
|
||||||
|
"display_preset_current": "Aktiv",
|
||||||
|
"display_preset_soon": "in Kürze",
|
||||||
|
"display_keep_alive_help": "Wie lange eine Anzeige (und bei gamescope ihr Spiel) nach dem Trennen bestehen bleibt. 0 = sofort abbauen.",
|
||||||
|
"display_topology_help": "Was mit den physischen Monitoren des Hosts während des Streamings geschieht.",
|
||||||
|
"display_conflict": "Wenn ein weiterer Client verbindet",
|
||||||
|
"display_conflict_help": "Was passiert, wenn ein zweiter Client verbindet, während bereits gestreamt wird, und eine andere Auflösung anfragt.",
|
||||||
|
"display_conflict_separate": "Eigene Anzeige",
|
||||||
|
"display_conflict_steal": "Übernehmen",
|
||||||
|
"display_conflict_join": "Ansicht teilen",
|
||||||
|
"display_conflict_reject": "Besetzt — ablehnen",
|
||||||
|
"display_identity": "Client-Identität",
|
||||||
|
"display_identity_help": "Jedem Client eine stabile Anzeige geben, damit der Desktop seine Monitor-Einstellungen merkt (z. B. Skalierung).",
|
||||||
|
"display_identity_shared": "Geteilt",
|
||||||
|
"display_identity_per_client": "Pro Client",
|
||||||
|
"display_identity_per_client_mode": "Pro Client + Auflösung",
|
||||||
|
"display_layout_mode": "Multi-Monitor-Anordnung",
|
||||||
|
"display_layout_help": "Wie mehrere Anzeigen auf dem Desktop angeordnet werden. Manuell nutzt die Anordnungstabelle unten (ab 2 Anzeigen).",
|
||||||
|
"display_layout_auto_row": "Automatisch (nebeneinander)",
|
||||||
|
"display_layout_manual": "Manuell",
|
||||||
"clients_title": "Gekoppelte Geräte",
|
"clients_title": "Gekoppelte Geräte",
|
||||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -80,6 +80,26 @@
|
|||||||
"display_arrange": "Arrange displays",
|
"display_arrange": "Arrange displays",
|
||||||
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
|
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
|
||||||
"display_arrange_save": "Save arrangement",
|
"display_arrange_save": "Save arrangement",
|
||||||
|
"display_custom_desc": "Set every option yourself.",
|
||||||
|
"display_preset_current": "Active",
|
||||||
|
"display_preset_soon": "coming soon",
|
||||||
|
"display_keep_alive_help": "How long a display (and, on gamescope, its game) survives after the client disconnects. 0 = tear down immediately.",
|
||||||
|
"display_topology_help": "What happens to the host's physical monitors while streaming.",
|
||||||
|
"display_conflict": "When another client connects",
|
||||||
|
"display_conflict_help": "What happens if a second client connects while one is already streaming and asks for a different resolution.",
|
||||||
|
"display_conflict_separate": "Own display",
|
||||||
|
"display_conflict_steal": "Take over",
|
||||||
|
"display_conflict_join": "Share view",
|
||||||
|
"display_conflict_reject": "Busy — reject",
|
||||||
|
"display_identity": "Per-client identity",
|
||||||
|
"display_identity_help": "Give each client a stable display so the desktop remembers its per-monitor settings (e.g. scaling).",
|
||||||
|
"display_identity_shared": "Shared",
|
||||||
|
"display_identity_per_client": "Per client",
|
||||||
|
"display_identity_per_client_mode": "Per client + resolution",
|
||||||
|
"display_layout_mode": "Multi-monitor layout",
|
||||||
|
"display_layout_help": "How several displays are arranged on the desktop. Manual uses the arrangement table below (with 2+ displays).",
|
||||||
|
"display_layout_auto_row": "Auto (side by side)",
|
||||||
|
"display_layout_manual": "Manual",
|
||||||
"clients_title": "Paired clients",
|
"clients_title": "Paired clients",
|
||||||
"clients_empty": "No paired clients yet.",
|
"clients_empty": "No paired clients yet.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ import {
|
|||||||
useSetDisplayLayout,
|
useSetDisplayLayout,
|
||||||
useSetDisplaySettings,
|
useSetDisplaySettings,
|
||||||
} from "@/api/gen/display/display";
|
} from "@/api/gen/display/display";
|
||||||
import type { ApiDisplayInfo } from "@/api/gen/model";
|
|
||||||
import { ApiError } from "@/api/fetcher";
|
|
||||||
import type {
|
import type {
|
||||||
|
ApiDisplayInfo,
|
||||||
DisplayPolicy,
|
DisplayPolicy,
|
||||||
EffectivePolicy,
|
EffectivePolicy,
|
||||||
|
Identity,
|
||||||
KeepAlive,
|
KeepAlive,
|
||||||
|
LayoutMode,
|
||||||
|
ModeConflict,
|
||||||
Preset,
|
Preset,
|
||||||
Topology,
|
Topology,
|
||||||
} from "@/api/gen/model";
|
} from "@/api/gen/model";
|
||||||
|
import { ApiError } from "@/api/fetcher";
|
||||||
import { QueryState } from "@/components/query-state";
|
import { QueryState } from "@/components/query-state";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -27,26 +30,27 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { m } from "@/paraglide/messages";
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Container: the host's virtual-display management policy (design/display-management.md). Reads the
|
* Container: the host's virtual-display management policy (design/display-management.md). Lets the
|
||||||
* stored policy + preset expansions, lets the operator pick a preset or set Custom fields, and PUTs
|
* operator pick a one-click preset OR set every option by hand — all WITHOUT any client connected
|
||||||
* the result — a change applies to the next session. Stage 0 enforces keep-alive + topology; the
|
* (this is the host's *next-connect* behavior). The live-display list + multi-monitor arrangement
|
||||||
* other stored options are shown but marked not-yet-enforced.
|
* table below act on whatever is currently streaming.
|
||||||
*/
|
*/
|
||||||
export const DisplaySection: FC = () => {
|
export const DisplaySection: FC = () => {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useGetDisplaySettings();
|
const q = useGetDisplaySettings();
|
||||||
const save = useSetDisplaySettings();
|
const save = useSetDisplaySettings();
|
||||||
|
|
||||||
// Local edit buffer, seeded once from the server and re-seeded after a successful save.
|
// Local edit buffer, seeded once from the server and re-seeded after every successful apply.
|
||||||
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
|
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (q.data && draft === null) setDraft(q.data.settings);
|
if (q.data && draft === null) setDraft(q.data.settings);
|
||||||
}, [q.data, draft]);
|
}, [q.data, draft]);
|
||||||
|
|
||||||
const onSave = () => {
|
// Apply a policy (a one-click preset, or the hand-edited Custom draft). A change takes effect on
|
||||||
if (!draft) return;
|
// the next connect; a live session keeps the display it opened on.
|
||||||
|
const apply = (policy: DisplayPolicy) =>
|
||||||
save.mutate(
|
save.mutate(
|
||||||
{ data: draft },
|
{ data: policy },
|
||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setDraft(res.settings);
|
setDraft(res.settings);
|
||||||
@@ -54,7 +58,6 @@ export const DisplaySection: FC = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -69,7 +72,7 @@ export const DisplaySection: FC = () => {
|
|||||||
draft={draft}
|
draft={draft}
|
||||||
setDraft={setDraft}
|
setDraft={setDraft}
|
||||||
presets={q.data.presets}
|
presets={q.data.presets}
|
||||||
onSave={onSave}
|
apply={apply}
|
||||||
busy={save.isPending}
|
busy={save.isPending}
|
||||||
error={apiErrorMessage(save.error)}
|
error={apiErrorMessage(save.error)}
|
||||||
/>
|
/>
|
||||||
@@ -81,6 +84,265 @@ export const DisplaySection: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Preset display order — Default first (the safe baseline), the situational ones, then Custom. */
|
||||||
|
const PRESET_ORDER = [
|
||||||
|
"default",
|
||||||
|
"shared-desktop",
|
||||||
|
"hotdesk",
|
||||||
|
"workstation",
|
||||||
|
"gaming-rig",
|
||||||
|
"custom",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DisplayForm: FC<{
|
||||||
|
draft: DisplayPolicy;
|
||||||
|
setDraft: (p: DisplayPolicy) => void;
|
||||||
|
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
||||||
|
apply: (p: DisplayPolicy) => void;
|
||||||
|
busy: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> = ({ draft, setDraft, presets, apply, busy, error }) => {
|
||||||
|
const preset: Preset = draft.preset ?? "custom";
|
||||||
|
const isCustom = preset === "custom";
|
||||||
|
|
||||||
|
// The Custom fields (defaults filled): the edit buffer when preset === "custom", and what a
|
||||||
|
// preset→Custom switch is seeded from, so you customize starting from the current behavior.
|
||||||
|
const customFields: EffectivePolicy = {
|
||||||
|
keep_alive: draft.keep_alive ?? { mode: "duration", seconds: 10 },
|
||||||
|
topology: draft.topology ?? "auto",
|
||||||
|
mode_conflict: draft.mode_conflict ?? "separate",
|
||||||
|
identity: draft.identity ?? "per-client",
|
||||||
|
layout: draft.layout ?? { mode: "auto-row", positions: {} },
|
||||||
|
max_displays: draft.max_displays ?? 4,
|
||||||
|
};
|
||||||
|
const effective: EffectivePolicy =
|
||||||
|
(isCustom ? undefined : presets.find((p) => p.id === preset)?.fields) ?? customFields;
|
||||||
|
|
||||||
|
// The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current
|
||||||
|
// effective behavior (nothing changes until you Save).
|
||||||
|
const pickPreset = (id: string) => {
|
||||||
|
if (id === "custom") {
|
||||||
|
setDraft({
|
||||||
|
version: 1,
|
||||||
|
preset: "custom",
|
||||||
|
keep_alive: effective.keep_alive,
|
||||||
|
topology: effective.topology,
|
||||||
|
mode_conflict: effective.mode_conflict,
|
||||||
|
identity: effective.identity,
|
||||||
|
layout: effective.layout,
|
||||||
|
max_displays: effective.max_displays,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
apply({ ...draft, preset: id as Preset });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ka = customFields.keep_alive;
|
||||||
|
const secondsValue = ka.mode === "duration" ? ka.seconds : 300;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* One-click presets */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{m.display_preset()}</Label>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{PRESET_ORDER.map((id) => {
|
||||||
|
const p = presets.find((x) => x.id === id);
|
||||||
|
const fields = id === "custom" ? undefined : p?.fields;
|
||||||
|
const summary = id === "custom" ? m.display_custom_desc() : p?.summary;
|
||||||
|
const selected = preset === id;
|
||||||
|
const soon = DISABLED_PRESETS.has(id);
|
||||||
|
const cls = [
|
||||||
|
"w-full rounded-md border p-3 text-left transition-colors",
|
||||||
|
selected ? "border-primary ring-1 ring-primary" : "hover:bg-muted/50",
|
||||||
|
soon ? "opacity-60" : "",
|
||||||
|
].join(" ");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
disabled={busy || soon}
|
||||||
|
onClick={() => pickPreset(id)}
|
||||||
|
className={cls}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{(PRESET_LABEL[id] ?? (() => id))()}
|
||||||
|
{soon && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||||
|
{m.display_preset_soon()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{selected && (
|
||||||
|
<Badge variant="success">{m.display_preset_current()}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{summary && <p className="mt-0.5 text-xs text-muted-foreground">{summary}</p>}
|
||||||
|
{fields && (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
|
||||||
|
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
|
||||||
|
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge>
|
||||||
|
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom: every option by hand */}
|
||||||
|
{isCustom && (
|
||||||
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{m.display_keep_alive()}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={ka.mode === "off" ? "default" : "outline"}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
|
||||||
|
>
|
||||||
|
{m.display_keep_alive_off()}
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="w-24"
|
||||||
|
value={secondsValue}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
keep_alive: {
|
||||||
|
mode: "duration",
|
||||||
|
seconds: Math.max(0, Number(e.target.value) || 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{m.display_keep_alive_seconds()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{m.display_keep_alive_help()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Choice
|
||||||
|
label={m.display_topology()}
|
||||||
|
help={m.display_topology_help()}
|
||||||
|
value={customFields.topology}
|
||||||
|
options={["auto", "extend", "primary", "exclusive"]}
|
||||||
|
labels={TOPOLOGY_LABEL}
|
||||||
|
disabled={busy}
|
||||||
|
onPick={(v) => setDraft({ ...draft, topology: v as Topology })}
|
||||||
|
/>
|
||||||
|
<Choice
|
||||||
|
label={m.display_conflict()}
|
||||||
|
help={m.display_conflict_help()}
|
||||||
|
value={customFields.mode_conflict}
|
||||||
|
options={["separate", "steal", "join", "reject"]}
|
||||||
|
labels={CONFLICT_LABEL}
|
||||||
|
disabled={busy}
|
||||||
|
onPick={(v) => setDraft({ ...draft, mode_conflict: v as ModeConflict })}
|
||||||
|
/>
|
||||||
|
<Choice
|
||||||
|
label={m.display_identity()}
|
||||||
|
help={m.display_identity_help()}
|
||||||
|
value={customFields.identity}
|
||||||
|
options={["shared", "per-client", "per-client-mode"]}
|
||||||
|
labels={IDENTITY_LABEL}
|
||||||
|
disabled={busy}
|
||||||
|
onPick={(v) => setDraft({ ...draft, identity: v as Identity })}
|
||||||
|
/>
|
||||||
|
<Choice
|
||||||
|
label={m.display_layout_mode()}
|
||||||
|
help={m.display_layout_help()}
|
||||||
|
value={customFields.layout.mode ?? "auto-row"}
|
||||||
|
options={["auto-row", "manual"]}
|
||||||
|
labels={LAYOUT_LABEL}
|
||||||
|
disabled={busy}
|
||||||
|
onPick={(v) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="disp-max">{m.display_max()}</Label>
|
||||||
|
<Input
|
||||||
|
id="disp-max"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={16}
|
||||||
|
className="w-24"
|
||||||
|
value={draft.max_displays ?? 4}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => apply(draft)} disabled={busy}>
|
||||||
|
{m.display_save()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* What's in force right now */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
|
||||||
|
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
|
||||||
|
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, effective.topology)}</Badge>
|
||||||
|
<Badge variant="outline">{tr(CONFLICT_LABEL, effective.mode_conflict)}</Badge>
|
||||||
|
<Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge>
|
||||||
|
<Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge>
|
||||||
|
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||||||
|
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A labeled row of mutually-exclusive option buttons (topology / conflict / identity / layout). */
|
||||||
|
const Choice: FC<{
|
||||||
|
label: string;
|
||||||
|
help?: string;
|
||||||
|
value: string;
|
||||||
|
options: readonly string[];
|
||||||
|
labels: Record<string, () => string>;
|
||||||
|
disabled: boolean;
|
||||||
|
onPick: (v: string) => void;
|
||||||
|
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((o) => (
|
||||||
|
<Button
|
||||||
|
key={o}
|
||||||
|
size="sm"
|
||||||
|
variant={value === o ? "default" : "outline"}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onPick(o)}
|
||||||
|
>
|
||||||
|
{(labels[o] ?? (() => o))()}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
|
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
|
||||||
* for lingering/pinned ones (active displays can't be released — that's session control).
|
* for lingering/pinned ones (active displays can't be released — that's session control).
|
||||||
@@ -179,8 +441,7 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
|
|||||||
return (
|
return (
|
||||||
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm">
|
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="w-44 truncate">
|
<span className="w-44 truncate">
|
||||||
{d.mode}{" "}
|
{d.mode} <code className="text-xs text-muted-foreground">#{slot}</code>
|
||||||
<code className="text-xs text-muted-foreground">#{slot}</code>
|
|
||||||
</span>
|
</span>
|
||||||
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
|
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
|
||||||
X
|
X
|
||||||
@@ -267,8 +528,8 @@ const apiErrorMessage = (err: unknown): string | undefined => {
|
|||||||
return err ? String(err) : undefined;
|
return err ? String(err) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The `gaming-rig` preset expands to `keep_alive: forever`, which the host rejects until the
|
/** `gaming-rig` expands to `keep_alive: forever`, which the host still rejects (Windows has no
|
||||||
* display-lifecycle stage — disable it rather than let the Save 400. */
|
* Pinned state yet) — surface it, but disabled, rather than let the one-click apply 400. */
|
||||||
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
|
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
|
||||||
|
|
||||||
const PRESET_LABEL: Record<string, () => string> = {
|
const PRESET_LABEL: Record<string, () => string> = {
|
||||||
@@ -280,13 +541,37 @@ const PRESET_LABEL: Record<string, () => string> = {
|
|||||||
workstation: m.display_preset_workstation,
|
workstation: m.display_preset_workstation,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOPOLOGY_LABEL: Record<Topology, () => string> = {
|
const TOPOLOGY_LABEL: Record<string, () => string> = {
|
||||||
auto: m.display_topology_auto,
|
auto: m.display_topology_auto,
|
||||||
extend: m.display_topology_extend,
|
extend: m.display_topology_extend,
|
||||||
primary: m.display_topology_primary,
|
primary: m.display_topology_primary,
|
||||||
exclusive: m.display_topology_exclusive,
|
exclusive: m.display_topology_exclusive,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONFLICT_LABEL: Record<string, () => string> = {
|
||||||
|
separate: m.display_conflict_separate,
|
||||||
|
steal: m.display_conflict_steal,
|
||||||
|
join: m.display_conflict_join,
|
||||||
|
reject: m.display_conflict_reject,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IDENTITY_LABEL: Record<string, () => string> = {
|
||||||
|
shared: m.display_identity_shared,
|
||||||
|
"per-client": m.display_identity_per_client,
|
||||||
|
"per-client-mode": m.display_identity_per_client_mode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAYOUT_LABEL: Record<string, () => string> = {
|
||||||
|
"auto-row": m.display_layout_auto_row,
|
||||||
|
manual: m.display_layout_manual,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
|
||||||
|
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
|
||||||
|
const fn = key == null ? undefined : map[key];
|
||||||
|
return fn ? fn() : String(key ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
const fmtKeepAlive = (k: KeepAlive): string => {
|
const fmtKeepAlive = (k: KeepAlive): string => {
|
||||||
switch (k.mode) {
|
switch (k.mode) {
|
||||||
case "off":
|
case "off":
|
||||||
@@ -297,156 +582,3 @@ const fmtKeepAlive = (k: KeepAlive): string => {
|
|||||||
return "∞";
|
return "∞";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const DisplayForm: FC<{
|
|
||||||
draft: DisplayPolicy;
|
|
||||||
setDraft: (p: DisplayPolicy) => void;
|
|
||||||
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
|
||||||
onSave: () => void;
|
|
||||||
busy: boolean;
|
|
||||||
error?: string;
|
|
||||||
}> = ({ draft, setDraft, presets, onSave, busy, error }) => {
|
|
||||||
const preset: Preset = draft.preset ?? "custom";
|
|
||||||
const isCustom = preset === "custom";
|
|
||||||
const keepAlive: KeepAlive = draft.keep_alive ?? { mode: "duration", seconds: 10 };
|
|
||||||
const topology: Topology = draft.topology ?? "auto";
|
|
||||||
|
|
||||||
// Preview the effective fields: from the selected preset's expansion, or the Custom fields.
|
|
||||||
const effective: EffectivePolicy | undefined = isCustom
|
|
||||||
? {
|
|
||||||
keep_alive: keepAlive,
|
|
||||||
topology,
|
|
||||||
mode_conflict: draft.mode_conflict ?? "separate",
|
|
||||||
identity: draft.identity ?? "per-client",
|
|
||||||
layout: draft.layout ?? { mode: "auto-row", positions: {} },
|
|
||||||
max_displays: draft.max_displays ?? 4,
|
|
||||||
}
|
|
||||||
: presets.find((p) => p.id === preset)?.fields;
|
|
||||||
|
|
||||||
const presetSummary = presets.find((p) => p.id === preset)?.summary;
|
|
||||||
|
|
||||||
const secondsValue = keepAlive.mode === "duration" ? keepAlive.seconds : 300;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Preset picker */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{m.display_preset()}</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(["custom", "default", "gaming-rig", "shared-desktop", "hotdesk", "workstation"] as const).map(
|
|
||||||
(id) => (
|
|
||||||
<Button
|
|
||||||
key={id}
|
|
||||||
size="sm"
|
|
||||||
variant={preset === id ? "default" : "outline"}
|
|
||||||
disabled={busy || DISABLED_PRESETS.has(id)}
|
|
||||||
onClick={() => setDraft({ ...draft, preset: id as Preset })}
|
|
||||||
>
|
|
||||||
{(PRESET_LABEL[id] ?? (() => id))()}
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{presetSummary && !isCustom && (
|
|
||||||
<p className="text-xs text-muted-foreground">{presetSummary}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom fields: keep-alive + topology + max displays */}
|
|
||||||
{isCustom && (
|
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{m.display_keep_alive()}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={keepAlive.mode === "off" ? "default" : "outline"}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
|
|
||||||
>
|
|
||||||
{m.display_keep_alive_off()}
|
|
||||||
</Button>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="w-24"
|
|
||||||
value={secondsValue}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({
|
|
||||||
...draft,
|
|
||||||
keep_alive: {
|
|
||||||
mode: "duration",
|
|
||||||
seconds: Math.max(0, Number(e.target.value) || 0),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{m.display_keep_alive_seconds()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{m.display_topology()}</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(["auto", "extend", "primary", "exclusive"] as const).map((t) => (
|
|
||||||
<Button
|
|
||||||
key={t}
|
|
||||||
size="sm"
|
|
||||||
variant={topology === t ? "default" : "outline"}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setDraft({ ...draft, topology: t })}
|
|
||||||
>
|
|
||||||
{TOPOLOGY_LABEL[t]()}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="disp-max">{m.display_max()}</Label>
|
|
||||||
<Input
|
|
||||||
id="disp-max"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={16}
|
|
||||||
className="w-24"
|
|
||||||
value={draft.max_displays ?? 4}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft({
|
|
||||||
...draft,
|
|
||||||
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Effective preview */}
|
|
||||||
{effective && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
|
|
||||||
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
|
|
||||||
<Badge variant="secondary">{TOPOLOGY_LABEL[effective.topology]()}</Badge>
|
|
||||||
<Badge variant="outline">{effective.mode_conflict}</Badge>
|
|
||||||
<Badge variant="outline">{effective.identity}</Badge>
|
|
||||||
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={onSave} disabled={busy}>
|
|
||||||
{m.display_save()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user