diff --git a/web/messages/de.json b/web/messages/de.json index 19fb54b..353ecfe 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -80,6 +80,26 @@ "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_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_empty": "Noch keine gekoppelten Geräte.", "clients_name": "Name", diff --git a/web/messages/en.json b/web/messages/en.json index e0e50e0..f7b1442 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -80,6 +80,26 @@ "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_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_empty": "No paired clients yet.", "clients_name": "Name", diff --git a/web/src/sections/Host/DisplayCard.tsx b/web/src/sections/Host/DisplayCard.tsx index 13e50d6..35e034a 100644 --- a/web/src/sections/Host/DisplayCard.tsx +++ b/web/src/sections/Host/DisplayCard.tsx @@ -10,15 +10,18 @@ import { useSetDisplayLayout, useSetDisplaySettings, } from "@/api/gen/display/display"; -import type { ApiDisplayInfo } from "@/api/gen/model"; -import { ApiError } from "@/api/fetcher"; import type { + ApiDisplayInfo, DisplayPolicy, EffectivePolicy, + Identity, KeepAlive, + LayoutMode, + ModeConflict, Preset, Topology, } from "@/api/gen/model"; +import { ApiError } from "@/api/fetcher"; import { QueryState } from "@/components/query-state"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -27,26 +30,27 @@ import { Label } from "@/components/ui/label"; import { m } from "@/paraglide/messages"; /** - * Container: the host's virtual-display management policy (design/display-management.md). Reads the - * stored policy + preset expansions, lets the operator pick a preset or set Custom fields, and PUTs - * the result — a change applies to the next session. Stage 0 enforces keep-alive + topology; the - * other stored options are shown but marked not-yet-enforced. + * Container: the host's virtual-display management policy (design/display-management.md). Lets the + * operator pick a one-click preset OR set every option by hand — all WITHOUT any client connected + * (this is the host's *next-connect* behavior). The live-display list + multi-monitor arrangement + * table below act on whatever is currently streaming. */ export const DisplaySection: FC = () => { const qc = useQueryClient(); const q = useGetDisplaySettings(); 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(null); useEffect(() => { if (q.data && draft === null) setDraft(q.data.settings); }, [q.data, draft]); - const onSave = () => { - if (!draft) return; + // Apply a policy (a one-click preset, or the hand-edited Custom draft). A change takes effect on + // the next connect; a live session keeps the display it opened on. + const apply = (policy: DisplayPolicy) => save.mutate( - { data: draft }, + { data: policy }, { onSuccess: (res) => { setDraft(res.settings); @@ -54,7 +58,6 @@ export const DisplaySection: FC = () => { }, }, ); - }; return ( @@ -69,7 +72,7 @@ export const DisplaySection: FC = () => { draft={draft} setDraft={setDraft} presets={q.data.presets} - onSave={onSave} + apply={apply} busy={save.isPending} 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 ( +
+ {/* One-click presets */} +
+ +
+ {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 ( + + ); + })} +
+
+ + {/* Custom: every option by hand */} + {isCustom && ( +
+
+ +
+ + + setDraft({ + ...draft, + keep_alive: { + mode: "duration", + seconds: Math.max(0, Number(e.target.value) || 0), + }, + }) + } + /> + + {m.display_keep_alive_seconds()} + +
+

{m.display_keep_alive_help()}

+
+ + setDraft({ ...draft, topology: v as Topology })} + /> + setDraft({ ...draft, mode_conflict: v as ModeConflict })} + /> + setDraft({ ...draft, identity: v as Identity })} + /> + + setDraft({ + ...draft, + layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} }, + }) + } + /> + +
+ + + setDraft({ + ...draft, + max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> +
+ + +
+ )} + + {/* What's in force right now */} +
+ {m.display_effective()}: + {fmtKeepAlive(effective.keep_alive)} + {tr(TOPOLOGY_LABEL, effective.topology)} + {tr(CONFLICT_LABEL, effective.mode_conflict)} + {tr(IDENTITY_LABEL, effective.identity)} + {tr(LAYOUT_LABEL, effective.layout.mode)} + {`${effective.max_displays}×`} +
+ +

{m.display_pending_note()}

+ {error &&

{error}

} +
+ ); +}; + +/** 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>; + disabled: boolean; + onPick: (v: string) => void; +}> = ({ label, help, value, options, labels, disabled, onPick }) => ( +
+ +
+ {options.map((o) => ( + + ))} +
+ {help &&

{help}

} +
+); + /** * 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). @@ -179,8 +441,7 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => return (
- {d.mode}{" "} - #{slot} + {d.mode} #{slot}