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.
This commit is contained in:
ScreenTinker 2026-06-09 08:55:15 -05:00
parent 7af9f7a057
commit 8fd971405e
4 changed files with 20 additions and 5 deletions

View file

@ -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',

View file

@ -144,6 +144,14 @@ async function renderEditor(container, layoutId) {
<option value="content">${t('layout.type_content')}</option><option value="widget">${t('layout.type_widget')}</option>
</select>
</div>
<div class="form-group"><label>${t('layout.prop.fit')}</label>
<select id="propFit" class="input" style="background:var(--bg-input)">
<option value="contain">${t('layout.fit_contain')}</option>
<option value="cover">${t('layout.fit_cover')}</option>
<option value="fill">${t('layout.fit_fill')}</option>
</select>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('layout.fit_hint')}</div>
</div>
<button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">${t('layout.delete_zone')}</button>
</div>
</div>
@ -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();

View file

@ -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
);

View file

@ -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);