import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@unom/ui/button"; import { toast } from "@unom/ui/toast"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { type FC, type MouseEvent, type ReactNode, useEffect, useMemo, useRef, useState, } from "react"; import { getGetDisplaySettingsQueryKey, getGetDisplayStateQueryKey, useCreateCustomPreset, useDeleteCustomPreset, useGetDisplaySettings, useGetDisplayState, useReleaseDisplay, useSetDisplayLayout, useSetDisplaySettings, useUpdateCustomPreset, } from "@/api/gen/display/display"; import type { ApiDisplayInfo, CustomPreset, DisplayPolicy, EffectivePolicy, GameSession, Identity, KeepAlive, LayoutMode, ModeConflict, Preset, Topology, } from "@/api/gen/model"; import { QueryState } from "@/components/query-state"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { apiErrorMessage } from "@/lib/errors"; import { cn } from "@/lib/utils"; import { m } from "@/paraglide/messages"; /** * 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 from the server. `seeded` tracks the server value we last seeded // from, so we can adopt a server-side change that happens underneath us — e.g. saving the display // ARRANGEMENT switches `layout` to manual and locks in Custom fields (`PUT /display/layout`) — // WITHOUT clobbering unsaved local edits: re-seed only while the draft still matches the last // seed (no pending edits). Custom edits aren't auto-applied (there's an explicit Save), so a // naive "always re-seed on server change" would eat them. const [draft, setDraft] = useState(null); const seeded = useRef(null); useEffect(() => { if (!q.data) return; const server = q.data.settings; if (draft === null) { setDraft(server); seeded.current = server; } else if ( seeded.current && deepEqual(draft, seeded.current) && !deepEqual(server, seeded.current) ) { setDraft(server); seeded.current = server; } }, [q.data, draft]); // 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: policy }, { onSuccess: (res) => { setDraft(res.settings); seeded.current = res.settings; qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); // The policy auto-saves on every preset pick / field edit — without a signal // users kept looking for a Save button. Errors stay inline (apiErrorMessage). toast.success(m.display_settings_saved()); }, }, ); /** * Save the hand-edited Custom block. * * `capture_monitor` (the streamed-screen pin) belongs to the monitor picker below, not to this * form — but it is a field of the same policy object, so a draft seeded before the operator * changed the streamed screen still carried the OLD value and Save quietly put it back. Defer * that one axis to whatever the server currently reports. */ const saveDraft = () => { if (!draft) return; apply({ ...draft, capture_monitor: q.data?.settings.capture_monitor }); }; /** * Apply ONE orthogonal axis — game-session, DDC, PnP — without dragging unsaved Custom edits * along for the ride. * * These three controls apply immediately by design, but they used to send `{...draft}`: flipping * DDC while the Custom block held unsaved edits committed those edits too, and the shared * `apply` then overwrote the draft with the server's answer, clearing the "unsaved" badge — so * the operator got a policy they never saved with no trace it had happened. Send the axis on top * of the last SAVED policy, and merge only that axis back into the draft. */ const applyAxis = (patch: Partial) => { const base = seeded.current ?? draft; if (!base) return; // Reflect the flip straight away, keeping every other unsaved edit intact. setDraft((d) => (d ? { ...d, ...patch } : d)); save.mutate( { data: { ...base, ...patch } }, { onSuccess: (res) => { seeded.current = res.settings; setDraft((d) => (d ? { ...d, ...patch } : res.settings)); qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); toast.success(m.display_settings_saved()); }, }, ); }; // Pending edits: the Custom fields do NOT auto-apply (unlike a preset click or an experimental // toggle), so the draft can silently diverge from what the host is actually running. Reading the // ref during render is safe here because every write to it is paired with a `setDraft`, so a // changed seed always comes with the re-render that reads it. const dirty = draft !== null && seeded.current !== null && !deepEqual(draft, seeded.current); const revert = () => { if (seeded.current) setDraft(seeded.current); }; // Last line of defence: a reload/close with pending edits loses them silently otherwise. The // browser shows its own generic wording — the text is ignored, only returning a value counts. useEffect(() => { if (!dirty) return; const warn = (e: BeforeUnloadEvent) => e.preventDefault(); window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [dirty]); return (
{m.display_config_title()} {/* Visible without scrolling to the save button — the card is taller than the viewport, which is exactly how the pending edits went unnoticed. */} {dirty && {m.display_unsaved()}}

{m.host_displays_help()}

{q.data && draft && ( )}
{m.display_live()}
); }; /** 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 }[]; customPresets: CustomPreset[]; apply: (p: DisplayPolicy) => void; /** Apply one orthogonal axis on top of the SAVED policy — never the unsaved draft. */ applyAxis: (patch: Partial) => void; /** Commit the Custom block, deferring axes this form does not own to the server's value. */ saveDraft: () => void; busy: boolean; /** The draft differs from what the host has stored — drives the save bar + the discard guard. */ dirty: boolean; /** Throw the draft away and go back to the stored policy. */ revert: () => void; error?: string; }> = ({ draft, setDraft, presets, customPresets, apply, applyAxis, saveDraft, busy, dirty, revert, error, }) => { const qc = useQueryClient(); const createPreset = useCreateCustomPreset(); const updatePreset = useUpdateCustomPreset(); const deletePreset = useDeleteCustomPreset(); const invalidateSettings = () => qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); const presetBusy = createPreset.isPending || updatePreset.isPending || deletePreset.isPending; const presetError = apiErrorMessage( createPreset.error ?? updatePreset.error ?? deletePreset.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) => { // A preset click overwrites the whole policy, so hand-edits that were never saved would // vanish without a word — the same failure as not finding the Save button, one click later. if (dirty && id !== "custom" && !confirm(m.display_discard_confirm())) return; // Already hand-editing: re-seeding would fill in defaults for fields the stored policy // leaves unset and flag "unsaved changes" for a click that changed nothing. if (id === "custom" && isCustom) return; 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, // Game-session + the experimental axes are orthogonal to the preset — carry them // through the Custom switch. game_session: draft.game_session ?? "auto", ddc_power_off: draft.ddc_power_off ?? false, pnp_disable_monitors: draft.pnp_disable_monitors ?? false, // Which screen we stream is not a display-behavior axis at all — swapping the // streamed screen out from under the operator because they changed a preset would be // the worst kind of surprise. capture_monitor: draft.capture_monitor ?? null, }); } else { apply({ ...draft, preset: id as Preset }); } }; // Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the // one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…). const applyCustomPreset = (p: CustomPreset) => { if (dirty && !confirm(m.display_discard_confirm())) return; apply({ version: 1, preset: "custom", ...p.fields, game_session: p.game_session ?? "auto", // The experimental axes aren't part of a preset — keep the current settings. ddc_power_off: draft.ddc_power_off ?? false, pnp_disable_monitors: draft.pnp_disable_monitors ?? false, // Nor is the streamed screen: this builds a FRESH policy object rather than spreading // the draft, so anything not named here is silently dropped — which is exactly how // applying a saved preset used to switch a mirroring host back to a virtual display // (found on-glass, .136). Every orthogonal axis has to be listed. capture_monitor: draft.capture_monitor ?? null, }); }; // A custom card is "current" when the in-force policy is a Custom one whose fields + game-session // value-match this preset (there is no id on DisplayPolicy — match by value). const customSelected = (p: CustomPreset): boolean => isCustom && (draft.game_session ?? "auto") === (p.game_session ?? "auto") && deepEqual(effective, p.fields); const anyCustomSelected = customPresets.some(customSelected); // Save the currently-in-force behavior (built-in OR hand-edited) as a new named preset. const saveAsPreset = () => { const name = prompt(m.display_preset_name())?.trim(); if (!name) return; // cancelled or empty createPreset.mutate( { data: { name, fields: effective, game_session: draft.game_session ?? "auto", }, }, { onSuccess: invalidateSettings }, ); }; const renamePreset = (p: CustomPreset) => { const name = prompt(m.display_preset_name(), p.name)?.trim(); if (!name) return; updatePreset.mutate( { id: p.id, data: { name, fields: p.fields, game_session: p.game_session ?? "auto", }, }, { onSuccess: invalidateSettings }, ); }; const updatePresetToCurrent = (p: CustomPreset) => updatePreset.mutate( { id: p.id, data: { name: p.name, fields: effective, game_session: draft.game_session ?? "auto", }, }, { onSuccess: invalidateSettings }, ); const removePreset = (p: CustomPreset) => { if (!confirm(m.display_preset_delete_confirm())) return; deletePreset.mutate({ id: p.id }, { onSuccess: invalidateSettings }); }; const ka = customFields.keep_alive; // The duration value, remembered across the Off/Keep toggle so switching back restores it. const [keepSecs, setKeepSecs] = useState( ka.mode === "duration" ? ka.seconds : 300, ); return (
{/* One-click presets — a 2-up grid so each has room to breathe */}
{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; // The built-in "Custom" card is the hand-edit mode; when the active Custom policy // value-matches a saved preset, that preset's card owns the "current" ring instead. const selected = preset === id && !(id === "custom" && anyCustomSelected); const soon = DISABLED_PRESETS.has(id); const disabled = busy || soon; const pick = () => { if (!disabled) pickPreset(id); }; return ( { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(); } }} className={cn( "flex h-full flex-col p-4", disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer", selected && "ring-2 ring-primary", )} >
{(PRESET_LABEL[id] ?? (() => id))()} {soon && ( {m.display_preset_soon()} )} {selected && ( {m.display_preset_current()} )}
{summary && (

{summary}

)} {fields && (
{fmtKeepAlive(fields.keep_alive)} {tr(TOPOLOGY_LABEL, fields.topology)} {tr(CONFLICT_LABEL, fields.mode_conflict)} {tr(IDENTITY_LABEL, fields.identity)}
)}
); })}
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but editable/deletable, plus a "Save as preset" that captures the current effective behavior. */}
{customPresets.length > 0 && (
{customPresets.map((p) => ( applyCustomPreset(p)} onRename={() => renamePreset(p)} onUpdate={() => updatePresetToCurrent(p)} onDelete={() => removePreset(p)} /> ))}
)} {presetError && (

{presetError}

)}
{/* Custom: every option by hand. Unlike everything else on this page these fields do NOT auto-apply, so the block is titled, ringed while dirty, and ends in a sticky save bar. */} {isCustom && (

{m.display_custom_title()}

{dirty && {m.display_unsaved()}}
{ka.mode === "duration" && (
{ const n = Math.max(0, Number(e.target.value) || 0); setKeepSecs(n); setDraft({ ...draft, keep_alive: { mode: "duration", seconds: n }, }); }} /> {m.display_keep_alive_seconds()}
)}
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), ), }) } /> {/* Sticky: the Custom block is taller than most viewports, and a save button parked at its bottom edge is invisible until you scroll all the way down — people edited, navigated away, and lost the lot. `bottom-0` pins it to the viewport while any part of the block is on screen, and it settles into place at the end. */}
{dirty ? m.display_unsaved_hint() : m.display_all_saved()} {dirty && ( )}
)} {/* Game-session routing — orthogonal to the preset/lifecycle axes, so it lives outside the Custom block and applies immediately on change (like a preset click). */}
applyAxis({ game_session: v as GameSession })} />
{/* EXPERIMENTAL toggles — orthogonal like game-session (survive preset switches, apply immediately). Windows-only in effect, acted on at the Exclusive isolate. */} applyAxis({ ddc_power_off: on })} /> applyAxis({ pnp_disable_monitors: on })} /> {/* 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}×`} {(draft.game_session ?? "auto") === "dedicated" && ( {m.display_game_session_dedicated()} )} {(draft.ddc_power_off ?? false) && ( {m.display_ddc_badge()} )} {(draft.pnp_disable_monitors ?? false) && ( {m.display_pnp_badge()} )}

{m.display_pending_note()}

{error && (

{error}

)}
); }; /** A labeled config field — label, then the control, then optional help. The single source of the * label→control→help spacing so every field (keep-alive, the button groups, max-displays) lines up. */ const Field: FC<{ label: string; help?: string; children: ReactNode; /** The id of the single control this labels, when there is one — see below. */ htmlFor?: string; /** Set when the field wraps a GROUP of controls rather than one input. */ group?: boolean; }> = ({ label, help, children, htmlFor, group }) => { const helpId = help && htmlFor ? `${htmlFor}-help` : undefined; const helpText = help && (

{help}

); // A set of related buttons IS a fieldset, so say so with the element rather than an ARIA role. // (The single-control case keeps a plain