mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
feat: per-cell content fit (cover|contain|fill), default cover
- 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
This commit is contained in:
parent
9679ae7eb1
commit
1e09582379
9 changed files with 36 additions and 4 deletions
|
|
@ -47,8 +47,12 @@ pub struct BundleCell {
|
||||||
pub web_url: Option<String>,
|
pub web_url: Option<String>,
|
||||||
pub html_content: Option<String>,
|
pub html_content: Option<String>,
|
||||||
pub cooling_timeout_seconds: Option<u32>,
|
pub cooling_timeout_seconds: Option<u32>,
|
||||||
|
#[serde(default = "default_fit")]
|
||||||
|
pub fit: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_fit() -> String { "cover".to_string() }
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct BundleCamera {
|
pub struct BundleCamera {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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) {
|
if let Some((paintable, badge)) = ensure_warm(cam_id, cam, cell.stream_selector.as_deref(), area) {
|
||||||
let picture = Picture::for_paintable(&paintable);
|
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_vexpand(true);
|
||||||
picture.set_hexpand(true);
|
picture.set_hexpand(true);
|
||||||
// Wrap in Overlay so we can stack a stream-role badge on top
|
// Wrap in Overlay so we can stack a stream-role badge on top
|
||||||
|
|
|
||||||
|
|
@ -748,12 +748,16 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null;
|
entityIdRaw && String(entityIdRaw).trim() !== "" ? Number(entityIdRaw) : null;
|
||||||
deps.repo.assignCellEntity(cellId, Number.isFinite(entityId) ? entityId : 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<string, unknown> = {};
|
const dimsPatch: Record<string, unknown> = {};
|
||||||
const streamSelector = body?.["stream_selector"];
|
const streamSelector = body?.["stream_selector"];
|
||||||
if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") {
|
if (streamSelector === "auto" || streamSelector === "main" || streamSelector === "sub") {
|
||||||
dimsPatch["stream_selector"] = streamSelector;
|
dimsPatch["stream_selector"] = streamSelector;
|
||||||
}
|
}
|
||||||
|
const fit = body?.["fit"];
|
||||||
|
if (fit === "cover" || fit === "contain" || fit === "fill") {
|
||||||
|
dimsPatch["fit"] = fit;
|
||||||
|
}
|
||||||
const colSpanRaw = body?.["col_span"];
|
const colSpanRaw = body?.["col_span"];
|
||||||
const rowSpanRaw = body?.["row_span"];
|
const rowSpanRaw = body?.["row_span"];
|
||||||
if (colSpanRaw != null && String(colSpanRaw).trim() !== "") {
|
if (colSpanRaw != null && String(colSpanRaw).trim() !== "") {
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,7 @@ export function rowToLayoutCell(r: Row): LayoutCell {
|
||||||
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
cooling_timeout_seconds: nn(r["cooling_timeout_seconds"]),
|
||||||
options: j<Record<string, unknown>>(r["options"], {}),
|
options: j<Record<string, unknown>>(r["options"], {}),
|
||||||
entity_id: nn(r["entity_id"]),
|
entity_id: nn(r["entity_id"]),
|
||||||
|
fit: (s(r["fit"]) || "cover") as "cover" | "contain" | "fill",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -596,4 +596,9 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER");
|
addColumnIfNotExists(db, "kiosks", "fan_rpm", "INTEGER");
|
||||||
addColumnIfNotExists(db, "kiosks", "fan_pwm", "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'");
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -556,6 +556,7 @@ export class Repository {
|
||||||
cooling_timeout_seconds?: number | null;
|
cooling_timeout_seconds?: number | null;
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
entity_id?: number | null;
|
entity_id?: number | null;
|
||||||
|
fit?: "cover" | "contain" | "fill";
|
||||||
}): LayoutCell {
|
}): LayoutCell {
|
||||||
// Resolve content fields from the entity (if given). The legacy columns
|
// Resolve content fields from the entity (if given). The legacy columns
|
||||||
// remain populated for backward-compatible bundle generation.
|
// remain populated for backward-compatible bundle generation.
|
||||||
|
|
@ -574,8 +575,8 @@ export class Repository {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = this.prep(
|
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)
|
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.layout_id,
|
input.layout_id,
|
||||||
input.row,
|
input.row,
|
||||||
|
|
@ -590,6 +591,7 @@ export class Repository {
|
||||||
input.cooling_timeout_seconds ?? null,
|
input.cooling_timeout_seconds ?? null,
|
||||||
J(input.options ?? {}),
|
J(input.options ?? {}),
|
||||||
input.entity_id ?? null,
|
input.entity_id ?? null,
|
||||||
|
input.fit ?? "cover",
|
||||||
);
|
);
|
||||||
const id = Number(result.lastInsertRowid);
|
const id = Number(result.lastInsertRowid);
|
||||||
void this.notify("layout_cells", "create", id);
|
void this.notify("layout_cells", "create", id);
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export interface BundleCell {
|
||||||
web_url: string | null;
|
web_url: string | null;
|
||||||
html_content: string | null;
|
html_content: string | null;
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
|
fit: "cover" | "contain" | "fill";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BundleLayout {
|
export interface BundleLayout {
|
||||||
|
|
@ -152,6 +153,7 @@ export function generateBundle(
|
||||||
web_url: webUrl,
|
web_url: webUrl,
|
||||||
html_content: htmlContent,
|
html_content: htmlContent,
|
||||||
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
cooling_timeout_seconds: c.cooling_timeout_seconds,
|
||||||
|
fit: c.fit,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ export interface LayoutCell {
|
||||||
cooling_timeout_seconds: number | null;
|
cooling_timeout_seconds: number | null;
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
entity_id: number | null;
|
entity_id: number | null;
|
||||||
|
fit: "cover" | "contain" | "fill";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Kiosk {
|
export interface Kiosk {
|
||||||
|
|
|
||||||
|
|
@ -1610,6 +1610,15 @@ export function renderCell(
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Fit</label>
|
||||||
|
<select name="fit" class="form-input">
|
||||||
|
<option value="cover" selected={c.fit === "cover"}>Cover (fill, crop overflow)</option>
|
||||||
|
<option value="contain" selected={c.fit === "contain"}>Contain (letterbox)</option>
|
||||||
|
<option value="fill" selected={c.fit === "fill"}>Fill (stretch)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group span-grid">
|
<div class="form-group span-grid">
|
||||||
<div>
|
<div>
|
||||||
<label>Width</label>
|
<label>Width</label>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue