From 8fd971405ee82ce517fa37a1a3cf2d9705c04b77 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 9 Jun 2026 08:55:15 -0500 Subject: [PATCH] feat(layouts): per-zone fit mode + default to 'contain' Multi-zone videos/images were cropped: every template zone inherited fit_mode 'cover' (fill+crop) and the layout editor had no control to change it, so a landscape video in a tall split zone showed only a center strip. The player already honors fit_mode (web object-fit, Android scaleType) - the gap was the UI and the default. Add a per-zone Fit selector (Contain/Cover/Stretch) to the layout editor, and make 'contain' (show the whole frame) the default for new zones, the schema column, and the save fallbacks. Existing built-in templates are migrated separately. --- frontend/js/i18n/en.js | 5 +++++ frontend/js/views/layout-editor.js | 14 ++++++++++++-- server/db/schema.sql | 2 +- server/routes/layouts.js | 4 ++-- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 9e665b2..a643255 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1013,6 +1013,11 @@ export default { 'layout.prop.type': 'Type', 'layout.type_content': 'Content', 'layout.type_widget': 'Widget', + 'layout.prop.fit': 'Fit', + 'layout.fit_contain': 'Contain (whole image, may letterbox)', + 'layout.fit_cover': 'Cover (fill zone, may crop)', + 'layout.fit_fill': 'Stretch (fill zone, may distort)', + 'layout.fit_hint': 'How video/images scale to the zone. Contain shows the whole frame without cropping.', // Video walls 'wall.title': 'Video Walls', diff --git a/frontend/js/views/layout-editor.js b/frontend/js/views/layout-editor.js index 01d0de0..33bfe5c 100644 --- a/frontend/js/views/layout-editor.js +++ b/frontend/js/views/layout-editor.js @@ -144,6 +144,14 @@ async function renderEditor(container, layoutId) { +
+ +
${t('layout.fit_hint')}
+
@@ -250,9 +258,10 @@ async function renderEditor(container, layoutId) { document.getElementById('propW').value = z.width_percent; document.getElementById('propH').value = z.height_percent; document.getElementById('propType').value = z.zone_type; + document.getElementById('propFit').value = z.fit_mode || 'cover'; } - ['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].forEach(id => { + ['propName', 'propX', 'propY', 'propW', 'propH', 'propType', 'propFit'].forEach(id => { document.getElementById(id).oninput = () => { if (selectedZone === null) return; const z = zones[selectedZone]; @@ -262,12 +271,13 @@ async function renderEditor(container, layoutId) { z.width_percent = parseFloat(document.getElementById('propW').value) || 10; z.height_percent = parseFloat(document.getElementById('propH').value) || 10; z.zone_type = document.getElementById('propType').value; + z.fit_mode = document.getElementById('propFit').value; renderZones(); }; }); document.getElementById('addZoneBtn').onclick = () => { - zones.push({ id: null, name: t('layout.zone_n', { n: zones.length + 1 }), x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'cover', background_color: '#000000', sort_order: zones.length }); + zones.push({ id: null, name: t('layout.zone_n', { n: zones.length + 1 }), x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'contain', background_color: '#000000', sort_order: zones.length }); selectedZone = zones.length - 1; renderZones(); updateProperties(); diff --git a/server/db/schema.sql b/server/db/schema.sql index 4575128..8395854 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -141,7 +141,7 @@ CREATE TABLE IF NOT EXISTS layout_zones ( height_percent REAL NOT NULL DEFAULT 100, z_index INTEGER NOT NULL DEFAULT 0, zone_type TEXT NOT NULL DEFAULT 'content', - fit_mode TEXT NOT NULL DEFAULT 'cover', + fit_mode TEXT NOT NULL DEFAULT 'contain', background_color TEXT DEFAULT '#000000', sort_order INTEGER NOT NULL DEFAULT 0 ); diff --git a/server/routes/layouts.js b/server/routes/layouts.js index 837f945..7653aa6 100644 --- a/server/routes/layouts.js +++ b/server/routes/layouts.js @@ -101,7 +101,7 @@ router.post('/', (req, res) => { zones.forEach((z, i) => { stmt.run(uuidv4(), id, z.name || `Zone ${i + 1}`, z.x_percent || 0, z.y_percent || 0, z.width_percent || 100, z.height_percent || 100, z.z_index || 0, - z.zone_type || 'content', z.fit_mode || 'cover', z.background_color || '#000000', i); + z.zone_type || 'content', z.fit_mode || 'contain', z.background_color || '#000000', i); }); } @@ -151,7 +151,7 @@ router.post('/:id/zones', (req, res) => { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, req.params.id, name || 'New Zone', x_percent || 0, y_percent || 0, width_percent || 50, height_percent || 50, z_index || 0, - zone_type || 'content', fit_mode || 'cover', background_color || '#000000', maxOrder + 1); + zone_type || 'content', fit_mode || 'contain', background_color || '#000000', maxOrder + 1); db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);