From 1e095823795d2d4dfc24c2ff00e2c4db355c3b58 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Mon, 11 May 2026 13:52:22 +0200 Subject: [PATCH] feat: per-cell content fit (cover|contain|fill), default cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration adds layout_cells.fit column (default 'cover') - LayoutCell type + mapper + repo accept/persist fit - Bundle ships fit per cell - Admin cell edit form: Fit dropdown with industry-default Cover - Rust kiosk applies ContentFit::Cover|Contain|Fill per cell.fit Cover = fill cell, crop overflow (industry default — Nx Witness etc) Contain = letterbox, no crop Fill = stretch, distort --- kiosk/src/bundle.rs | 4 ++++ kiosk/src/ui.rs | 6 +++++- server/src/plugins/service-admin-http/routes-admin.ts | 6 +++++- server/src/plugins/service-store/mappers.ts | 1 + server/src/plugins/service-store/migrations.ts | 5 +++++ server/src/plugins/service-store/repository.ts | 6 ++++-- server/src/shared/bundle.ts | 2 ++ server/src/shared/types.ts | 1 + server/src/web-templates/admin-pages.tsx | 9 +++++++++ 9 files changed, 36 insertions(+), 4 deletions(-) diff --git a/kiosk/src/bundle.rs b/kiosk/src/bundle.rs index 7b9a62e..89a0d1a 100644 --- a/kiosk/src/bundle.rs +++ b/kiosk/src/bundle.rs @@ -47,8 +47,12 @@ pub struct BundleCell { pub web_url: Option, pub html_content: Option, pub cooling_timeout_seconds: Option, + #[serde(default = "default_fit")] + pub fit: String, } +fn default_fit() -> String { "cover".to_string() } + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BundleCamera { pub id: u32, diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 1540c02..ee43985 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -256,7 +256,11 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s let area = (cell.col_span * cell.row_span) as f32 / total_area; if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) { let picture = Picture::for_paintable(&paintable); - picture.set_content_fit(gtk::ContentFit::Contain); + picture.set_content_fit(match cell.fit.as_str() { + "contain" => gtk::ContentFit::Contain, + "fill" => gtk::ContentFit::Fill, + _ => gtk::ContentFit::Cover, + }); picture.set_vexpand(true); picture.set_hexpand(true); // Wrap in Overlay so we can stack a stream-role badge on top diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 23d4217..1ea6143 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -748,12 +748,16 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null; deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : null); - // stream_selector + spans are still settable on the cell. + // stream_selector + spans + fit are still settable on the cell. const dimsPatch: Record = {}; const streamSelector = body?.["stream_selector"]; if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") { dimsPatch["stream_selector"] = streamSelector; } + const fit = body?.["fit"]; + if (fit === "cover" || fit === "contain" || fit === "fill") { + dimsPatch["fit"] = fit; + } const colSpanRaw = body?.["col_span"]; const rowSpanRaw = body?.["row_span"]; if (colSpanRaw != null && String(colSpanRaw).trim() !== "") { diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index cd83db4..a3a5962 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -208,6 +208,7 @@ export function rowToLayoutCell(r: Row): LayoutCell { cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]), options: j>(r["options"], {}), entity_id: nn(r["entity_id"]), + fit: (s(r["fit"]) || "cover") as "cover" | "contain" | "fill", }; } diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index c767fe7..313e6e6 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -596,4 +596,9 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER"); addColumnIfNotExists(db, "kiosks", "fan_pwm", "INTEGER"); }, + + // ---- per-cell content fit (cover|contain|fill) ---- + (db: DatabaseSync) => { + addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'"); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 57886c8..320b1bd 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -556,6 +556,7 @@ export class Repository { cooling_timeout_seconds?: number | null; options?: Record; entity_id?: number | null; + fit?: "cover" | "contain" | "fill"; }): LayoutCell { // Resolve content fields from the entity (if given). The legacy columns // remain populated for backward-compatible bundle generation. @@ -574,8 +575,8 @@ export class Repository { } const result = this.prep( - `INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO layout_cells (layout_id, "row", col, row_span, col_span, content_type, camera_id, stream_selector, web_url, html_content, cooling_timeout_seconds, options, entity_id, fit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( input.layout_id, input.row, @@ -590,6 +591,7 @@ export class Repository { input.cooling_timeout_seconds ?? null, J(input.options ?? {}), input.entity_id ?? null, + input.fit ?? "cover", ); const id = Number(result.lastInsertRowid); void this.notify("layout_cells", "create", id); diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index 1ba6a0a..a70d71f 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -41,6 +41,7 @@ export interface BundleCell { web_url: string | null; html_content: string | null; cooling_timeout_seconds: number | null; + fit: "cover" | "contain" | "fill"; } export interface BundleLayout { @@ -152,6 +153,7 @@ export function generateBundle( web_url: webUrl, html_content: htmlContent, cooling_timeout_seconds: c.cooling_timeout_seconds, + fit: c.fit, }; }), }; diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index cd1edde..e8b961f 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -187,6 +187,7 @@ export interface LayoutCell { cooling_timeout_seconds: number | null; options: Record; entity_id: number | null; + fit: "cover" | "contain" | "fill"; } export interface Kiosk { diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 2b28e7e..7ebb731 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1610,6 +1610,15 @@ export function renderCell( +
+ + +
+