diff --git a/api/openapi.json b/api/openapi.json index ea96b07..b2e2ac9 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -190,6 +190,237 @@ } } }, + "/api/v1/display/presets": { + "get": { + "tags": [ + "display" + ], + "summary": "List the saved custom presets", + "description": "The operator's named field-bundles (`display-presets.json`). These also ride the\n`GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.", + "operationId": "listCustomPresets", + "responses": { + "200": { + "description": "The saved custom presets", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomPreset" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "post": { + "tags": [ + "display" + ], + "summary": "Save a custom preset", + "description": "Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can\napply later. The host assigns a stable id, returned in the body. Applying a preset is a\n`PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.", + "operationId": "createCustomPreset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomPresetInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Preset created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomPreset" + } + } + } + }, + "400": { + "description": "Empty name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/display/presets/{id}": { + "put": { + "tags": [ + "display" + ], + "summary": "Update a custom preset", + "operationId": "updateCustomPreset", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The custom preset id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomPresetInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Preset updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomPreset" + } + } + } + }, + "400": { + "description": "Empty name", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No custom preset with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "display" + ], + "summary": "Delete a custom preset", + "description": "Removes it from the catalog. The active policy is untouched — if this preset was the one applied,\nthe running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).", + "operationId": "deleteCustomPreset", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The custom preset id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Preset deleted" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No custom preset with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/display/release": { "post": { "tags": [ @@ -2220,6 +2451,52 @@ } } }, + "CustomPreset": { + "type": "object", + "description": "A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a\nbuilt-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names\nand applies from the console.\n\nUnlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in\n`/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these\nfields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and\nthe built-in set is never touched. The catalog is decoupled from the active `display-settings.json`:\nediting or deleting a preset never mutates the running policy (re-apply to adopt a change).", + "required": [ + "id", + "name", + "fields" + ], + "properties": { + "fields": { + "$ref": "#/components/schemas/EffectivePolicy", + "description": "The six display-behavior axes this preset applies (the same shape a built-in preset expands to)." + }, + "game_session": { + "$ref": "#/components/schemas/GameSession", + "description": "The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]).\nA custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying\none does set this axis." + }, + "id": { + "type": "string", + "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." + }, + "name": { + "type": "string", + "description": "User-facing name shown on the preset card; editable." + } + } + }, + "CustomPresetInput": { + "type": "object", + "description": "Request body to create or replace a custom preset (no `id` — the host owns it).", + "required": [ + "name", + "fields" + ], + "properties": { + "fields": { + "$ref": "#/components/schemas/EffectivePolicy" + }, + "game_session": { + "$ref": "#/components/schemas/GameSession" + }, + "name": { + "type": "string" + } + } + }, "DisplayLayoutRequest": { "type": "object", "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).", @@ -2284,6 +2561,7 @@ "configured", "effective", "presets", + "custom_presets", "enforced" ], "properties": { @@ -2291,6 +2569,13 @@ "type": "boolean", "description": "True once a `display-settings.json` exists (the console has configured this host)." }, + "custom_presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomPreset" + }, + "description": "The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered\nalongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a\n`Custom` policy carrying the preset's fields." + }, "effective": { "$ref": "#/components/schemas/EffectivePolicy", "description": "The effective (preset-expanded) policy currently in force." diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 81bc239..5ca5f61 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -161,6 +161,8 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(get_display_state)) .routes(routes!(release_display)) .routes(routes!(set_display_layout)) + .routes(routes!(list_custom_presets, create_custom_preset)) + .routes(routes!(update_custom_preset, delete_custom_preset)) .routes(routes!(get_status)) .routes(routes!(get_local_summary)) .routes(routes!(list_paired_clients)) @@ -993,6 +995,10 @@ struct DisplaySettingsState { effective: crate::vdisplay::policy::EffectivePolicy, /// Every named preset and what it expands to (for the picker's preview). presets: Vec, + /// The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered + /// alongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a + /// `Custom` policy carrying the preset's fields. + custom_presets: Vec, /// Option names this build enforces right now. All five axes are now acted on (keep_alive + /// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console /// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout @@ -1037,6 +1043,7 @@ fn display_settings_state() -> DisplaySettingsState { settings, configured, presets, + custom_presets: policy::load_custom_presets(), enforced: vec![ "keep_alive".into(), "topology".into(), @@ -1266,6 +1273,109 @@ async fn set_display_layout(ApiJson(req): ApiJson) -> Resp Json(display_settings_state()).into_response() } +/// List the saved custom presets +/// +/// The operator's named field-bundles (`display-presets.json`). These also ride the +/// `GET /display/settings` response (`custom_presets`), so the console rarely needs this directly. +#[utoipa::path( + get, + path = "/display/presets", + tag = "display", + operation_id = "listCustomPresets", + responses( + (status = OK, description = "The saved custom presets", body = Vec), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn list_custom_presets() -> Json> { + Json(crate::vdisplay::policy::load_custom_presets()) +} + +/// Save a custom preset +/// +/// Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can +/// apply later. The host assigns a stable id, returned in the body. Applying a preset is a +/// `PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route. +#[utoipa::path( + post, + path = "/display/presets", + tag = "display", + operation_id = "createCustomPreset", + request_body = crate::vdisplay::policy::CustomPresetInput, + responses( + (status = CREATED, description = "Preset created", body = crate::vdisplay::policy::CustomPreset), + (status = BAD_REQUEST, description = "Empty name", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), + ) +)] +async fn create_custom_preset( + ApiJson(input): ApiJson, +) -> Response { + if input.name.trim().is_empty() { + return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty"); + } + match crate::vdisplay::policy::add_custom_preset(input) { + Ok(preset) => (StatusCode::CREATED, Json(preset)).into_response(), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +/// Update a custom preset +#[utoipa::path( + put, + path = "/display/presets/{id}", + tag = "display", + operation_id = "updateCustomPreset", + params(("id" = String, Path, description = "The custom preset id")), + request_body = crate::vdisplay::policy::CustomPresetInput, + responses( + (status = OK, description = "Preset updated", body = crate::vdisplay::policy::CustomPreset), + (status = BAD_REQUEST, description = "Empty name", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = NOT_FOUND, description = "No custom preset with that id", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), + ) +)] +async fn update_custom_preset( + Path(id): Path, + ApiJson(input): ApiJson, +) -> Response { + if input.name.trim().is_empty() { + return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty"); + } + match crate::vdisplay::policy::update_custom_preset(&id, input) { + Ok(Some(preset)) => Json(preset).into_response(), + Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +/// Delete a custom preset +/// +/// Removes it from the catalog. The active policy is untouched — if this preset was the one applied, +/// the running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled). +#[utoipa::path( + delete, + path = "/display/presets/{id}", + tag = "display", + operation_id = "deleteCustomPreset", + params(("id" = String, Path, description = "The custom preset id")), + responses( + (status = NO_CONTENT, description = "Preset deleted"), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = NOT_FOUND, description = "No custom preset with that id", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), + ) +)] +async fn delete_custom_preset(Path(id): Path) -> Response { + match crate::vdisplay::policy::delete_custom_preset(&id) { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + /// Live host status #[utoipa::path( get, diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/punktfunk-host/src/vdisplay/policy.rs index f120871..fc0791c 100644 --- a/crates/punktfunk-host/src/vdisplay/policy.rs +++ b/crates/punktfunk-host/src/vdisplay/policy.rs @@ -23,10 +23,11 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::Result; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use utoipa::ToSchema; /// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game) @@ -458,10 +459,163 @@ pub fn prefs() -> &'static DisplayPolicyStore { }) } +// --------------------------------------------------------------------------------------- +// User-defined custom presets (`/display-presets.json`) +// --------------------------------------------------------------------------------------- + +/// A user-defined named preset: a saved bundle of the six display-behavior axes (exactly what a +/// built-in [`Preset`] expands to) plus the orthogonal game-session axis, that the operator names +/// and applies from the console. +/// +/// Unlike the built-in [`Preset`]s (a closed enum), custom presets are **data** — a catalog stored in +/// `/display-presets.json`. Applying one writes a `Custom` [`DisplayPolicy`] carrying these +/// fields (the console reuses `PUT /display/settings`), so [`DisplayPolicy::effective`] stays pure and +/// the built-in set is never touched. The catalog is decoupled from the active `display-settings.json`: +/// editing or deleting a preset never mutates the running policy (re-apply to adopt a change). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +pub struct CustomPreset { + /// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path). + pub id: String, + /// User-facing name shown on the preset card; editable. + pub name: String, + /// The six display-behavior axes this preset applies (the same shape a built-in preset expands to). + pub fields: EffectivePolicy, + /// The game-session routing this preset applies (orthogonal to the six axes; see [`GameSession`]). + /// A custom preset captures the operator's *full* setup, so — unlike a built-in preset — applying + /// one does set this axis. + #[serde(default)] + pub game_session: GameSession, +} + +/// Request body to create or replace a custom preset (no `id` — the host owns it). +#[derive(Clone, Debug, Deserialize, ToSchema)] +pub struct CustomPresetInput { + pub name: String, + pub fields: EffectivePolicy, + #[serde(default)] + pub game_session: GameSession, +} + +fn custom_presets_path() -> PathBuf { + crate::gamestream::config_dir().join("display-presets.json") +} + +/// Clamp a saved preset's fields to their valid ranges — the same bounds [`DisplayPolicy::sanitized`] +/// enforces, so a preset can never carry an out-of-range `max_displays` that a later apply would reject. +fn sanitize_preset_fields(mut fields: EffectivePolicy) -> EffectivePolicy { + fields.max_displays = fields.max_displays.clamp(1, 16); + fields +} + +/// Load the saved custom presets (empty + non-fatal if the file is absent or malformed — a bad +/// catalog never breaks the console's settings GET). +pub fn load_custom_presets() -> Vec { + match std::fs::read(custom_presets_path()) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { + tracing::warn!(error = %e, "display-presets.json malformed — ignoring custom presets"); + Vec::new() + }), + Err(_) => Vec::new(), + } +} + +/// Persist the catalog (private dir, temp-write + atomic rename — the [`DisplayPolicyStore::set`] +/// discipline, so a crash mid-write never truncates it). +fn save_custom_presets(presets: &[CustomPreset]) -> Result<()> { + let path = custom_presets_path(); + if let Some(dir) = path.parent() { + crate::gamestream::create_private_dir(dir)?; + } + let tmp = path.with_extension("json.tmp"); + crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(presets)?)?; + std::fs::rename(&tmp, &path)?; + Ok(()) +} + +/// 12 hex chars from the name + wall-clock nanos — collision-free in practice, no uuid dep (the +/// [`crate::library`] custom-entry id scheme). +fn new_preset_id(name: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + hex::encode(&Sha256::digest(format!("{name}:{nanos}").as_bytes())[..6]) +} + +/// Create a custom preset, returning it with its assigned id. +pub fn add_custom_preset(input: CustomPresetInput) -> Result { + let mut presets = load_custom_presets(); + let preset = CustomPreset { + id: new_preset_id(&input.name), + name: input.name, + fields: sanitize_preset_fields(input.fields), + game_session: input.game_session, + }; + presets.push(preset.clone()); + save_custom_presets(&presets)?; + Ok(preset) +} + +/// Replace a custom preset's fields (id preserved). `None` ⇒ no preset with that id. +pub fn update_custom_preset(id: &str, input: CustomPresetInput) -> Result> { + let mut presets = load_custom_presets(); + let Some(slot) = presets.iter_mut().find(|p| p.id == id) else { + return Ok(None); + }; + slot.name = input.name; + slot.fields = sanitize_preset_fields(input.fields); + slot.game_session = input.game_session; + let updated = slot.clone(); + save_custom_presets(&presets)?; + Ok(Some(updated)) +} + +/// Delete a custom preset. `false` ⇒ no preset with that id. +pub fn delete_custom_preset(id: &str) -> Result { + let mut presets = load_custom_presets(); + let before = presets.len(); + presets.retain(|p| p.id != id); + if presets.len() == before { + return Ok(false); + } + save_custom_presets(&presets)?; + Ok(true) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn custom_preset_serde_roundtrips_and_defaults_game_session() { + let preset = CustomPreset { + id: "abc123".into(), + name: "My Rig".into(), + fields: preset_fields(Preset::GamingRig).unwrap(), + game_session: GameSession::Dedicated, + }; + let json = serde_json::to_string(&preset).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), preset); + + // A catalog written before `game_session` existed still loads (defaults to `Auto`). + let legacy: CustomPreset = serde_json::from_value(serde_json::json!({ + "id": "x", + "name": "Legacy", + "fields": serde_json::to_value(preset_fields(Preset::Default).unwrap()).unwrap(), + })) + .unwrap(); + assert_eq!(legacy.game_session, GameSession::Auto); + } + + #[test] + fn sanitize_preset_fields_clamps_max_displays() { + let mut f = preset_fields(Preset::Default).unwrap(); + f.max_displays = 999; + assert_eq!(sanitize_preset_fields(f.clone()).max_displays, 16); + f.max_displays = 0; + assert_eq!(sanitize_preset_fields(f).max_displays, 1); + } + #[test] fn keep_alive_serializes_tagged_on_mode() { assert_eq!( diff --git a/docs-site/content/docs/virtual-displays.md b/docs-site/content/docs/virtual-displays.md index c33d56c..b6fefc2 100644 --- a/docs-site/content/docs/virtual-displays.md +++ b/docs-site/content/docs/virtual-displays.md @@ -38,6 +38,24 @@ the individual options documented further down. | **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. | | **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. | +## Save your own preset + +The five above are curated starting points. When you've dialed in a setup you like — whether by +picking a preset and tweaking it or by setting every option under **Custom** — you can **save it as +your own named preset** and switch back to it in one click later. + +- **Save as preset** — names the settings currently in force (all of the options below **plus** + *Dedicated game sessions*) and adds it to the picker alongside the built-ins. +- **Apply** — selecting a saved preset writes exactly those settings, the same as picking a built-in. +- **Edit / delete** — rename a saved preset, update it to your current settings, or remove it. Deleting + a preset never changes what's running — it only takes the card out of the picker. + +Unlike the built-in presets (which deliberately leave *Dedicated game sessions* alone so switching +presets never changes your game-launch routing), a **custom preset captures your full setup**, +including that axis — because it's *your* saved configuration, not a curated behavior bundle. Custom +presets live on the host in `display-presets.json` (next to `display-settings.json`); the catalog and +the active policy are independent, so editing a preset never disturbs a running session. + ## Options reference Choose **Custom** in the console to set these directly. diff --git a/web/messages/de.json b/web/messages/de.json index 52dbc80..8fdeb4d 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -108,6 +108,13 @@ "display_layout_help": "Automatisch ordnet die Anzeigen nebeneinander an (links nach rechts). Manuell: Du platzierst jede selbst — ein X/Y-Editor pro Anzeige erscheint im Abschnitt „Aktive Displays“ unten, sobald zwei oder mehr streamen.", "display_layout_auto_row": "Automatisch (nebeneinander)", "display_layout_manual": "Manuell", + "display_preset_custom_label": "Eigene Voreinstellungen", + "display_preset_save_as": "Als Voreinstellung speichern…", + "display_preset_name": "Name der Voreinstellung", + "display_preset_edit": "Umbenennen", + "display_preset_update": "Auf aktuelle Einstellungen aktualisieren", + "display_preset_delete": "Löschen", + "display_preset_delete_confirm": "Diese eigene Voreinstellung löschen?", "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 1dd88ea..a9ef0b6 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -108,6 +108,13 @@ "display_layout_help": "Auto lays displays out side by side, left to right. Manual: you position each one yourself — a per-display X/Y editor appears in the Live displays section below once two or more are streaming.", "display_layout_auto_row": "Auto (side by side)", "display_layout_manual": "Manual", + "display_preset_custom_label": "Custom presets", + "display_preset_save_as": "Save as preset…", + "display_preset_name": "Preset name", + "display_preset_edit": "Rename", + "display_preset_update": "Update to current settings", + "display_preset_delete": "Delete", + "display_preset_delete_confirm": "Delete this custom preset?", "clients_title": "Paired clients", "clients_empty": "No paired clients yet.", "clients_name": "Name", diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 2c7a413..70ee540 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -1,17 +1,22 @@ import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@unom/ui/button"; -import { type FC, type ReactNode, useEffect, useState } from "react"; +import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { type FC, type MouseEvent, type ReactNode, useEffect, useState } from "react"; import { getGetDisplayStateQueryKey, getGetDisplaySettingsQueryKey, + useCreateCustomPreset, + useDeleteCustomPreset, useGetDisplaySettings, useGetDisplayState, useReleaseDisplay, useSetDisplayLayout, useSetDisplaySettings, + useUpdateCustomPreset, } from "@/api/gen/display/display"; import type { ApiDisplayInfo, + CustomPreset, DisplayPolicy, EffectivePolicy, GameSession, @@ -75,6 +80,7 @@ export const DisplaySection: FC = () => { draft={draft} setDraft={setDraft} presets={q.data.presets} + customPresets={q.data.custom_presets} apply={apply} busy={save.isPending} error={apiErrorMessage(save.error)} @@ -109,10 +115,23 @@ const DisplayForm: FC<{ draft: DisplayPolicy; setDraft: (p: DisplayPolicy) => void; presets: { id: string; summary: string; fields: EffectivePolicy }[]; + customPresets: CustomPreset[]; apply: (p: DisplayPolicy) => void; busy: boolean; error?: string; -}> = ({ draft, setDraft, presets, apply, busy, error }) => { +}> = ({ draft, setDraft, presets, customPresets, apply, busy, 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"; @@ -150,6 +169,56 @@ const DisplayForm: FC<{ } }; + // 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) => + apply({ + version: 1, + preset: "custom", + ...p.fields, + game_session: p.game_session ?? "auto", + }); + + // 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); @@ -164,7 +233,9 @@ const DisplayForm: FC<{ 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; + // 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 = () => { @@ -221,6 +292,44 @@ const DisplayForm: FC<{ + {/* 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 */} {isCustom && (
@@ -412,6 +521,95 @@ const Choice: FC<{ ); +/** + * One saved custom preset — the same interactive card as the built-ins (click to apply → writes a + * `Custom` policy carrying `preset.fields`), plus rename / update-to-current / delete affordances + * (each stops propagation so it doesn't also fire the card's apply). Field badges mirror the + * built-ins; the game-session badge shows only when it isn't the default `auto`. + */ +const CustomPresetCard: FC<{ + preset: CustomPreset; + selected: boolean; + busy: boolean; + onApply: () => void; + onRename: () => void; + onUpdate: () => void; + onDelete: () => void; +}> = ({ preset, selected, busy, onApply, onRename, onUpdate, onDelete }) => { + const fields = preset.fields; + const stop = (fn: () => void) => (e: MouseEvent) => { + e.stopPropagation(); + if (!busy) fn(); + }; + return ( + !busy && onApply()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (!busy) onApply(); + } + }} + className={cn( + "flex h-full flex-col p-4", + busy ? "cursor-not-allowed opacity-60" : "cursor-pointer", + selected && "ring-2 ring-primary", + )} + > +
+ {preset.name} +
+ {selected && {m.display_preset_current()}} + + + +
+
+
+ {fmtKeepAlive(fields.keep_alive)} + {tr(TOPOLOGY_LABEL, fields.topology)} + {tr(CONFLICT_LABEL, fields.mode_conflict)} + {tr(IDENTITY_LABEL, fields.identity)} + {(preset.game_session ?? "auto") !== "auto" && ( + {tr(GAME_SESSION_LABEL, preset.game_session)} + )} +
+
+ ); +}; + /** * 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). @@ -640,6 +838,19 @@ const GAME_SESSION_LABEL: Record string> = { dedicated: m.display_game_session_dedicated, }; +/** Structural equality for the value-match of a custom preset's fields against the effective policy + * (handles the nested `keep_alive` variants + `layout.positions` map; key order doesn't matter). */ +const deepEqual = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + const ak = Object.keys(a as object); + const bk = Object.keys(b as object); + if (ak.length !== bk.length) return false; + return ak.every((k) => + deepEqual((a as Record)[k], (b as Record)[k]), + ); +}; + /** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */ const tr = (map: Record string>, key: string | null | undefined): string => { const fn = key == null ? undefined : map[key];