diff --git a/frontend/css/main.css b/frontend/css/main.css index ddd6c6d..5956950 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -398,6 +398,23 @@ body { transform: translateY(-2px); } +/* #238: a rotated display shown as its viewer sees it. The stage is the panel's face; the frame is + its framebuffer, turned back by the mount (js/lib/device-frame.js sizes and rotates it from the + players' shared rule). The frame is centred on its offset parent, so a stage without + `position: relative` would centre it on the page — hence both halves live here together. */ +.display-stage { + position: relative; + overflow: hidden; +} + +.display-frame { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + .device-card-preview { aspect-ratio: 16/9; background: var(--bg-primary); diff --git a/frontend/index.html b/frontend/index.html index 9b592bf..d8bf22c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,6 +15,11 @@ + + diff --git a/frontend/js/lib/device-frame.js b/frontend/js/lib/device-frame.js new file mode 100644 index 0000000..7cf5275 --- /dev/null +++ b/frontend/js/lib/device-frame.js @@ -0,0 +1,76 @@ +// #238: show a device's output the way a person standing in front of the panel sees it. +// +// Everywhere the dashboard showed a rotated display — the device preview modal, the Now Playing +// screenshot, the device cards — it showed the framebuffer as captured/rendered, i.e. sideways, +// while the panel on the wall was right. Designers use these surfaces to check their work, so a +// sideways preview turned every anomaly on a portrait screen into "is that real?". +// +// The geometry itself is NOT here: it is the same rule the players rotate by +// (server/lib/orientation-style.js, loaded as window.OrientationStyle), because a second copy of a +// rotation rule is precisely how the dashboard and the panel came to disagree in the first place. +// This file only measures the box and applies the answer. + +// stage element -> { inner, orientation }. Weak so a re-rendered dashboard doesn't pin dead nodes. +const framed = new WeakMap(); +let observer = null; + +// Sizes are in px, so they are wrong the moment the stage resizes — and a stage inside an inactive +// tab measures 0x0 until it is shown, which is the common case for Now Playing. One observer for +// every stage: the callback re-measures whatever actually changed, including 0 -> visible. +function ensureObserver() { + if (observer || typeof ResizeObserver === 'undefined') return observer; + observer = new ResizeObserver((entries) => { entries.forEach(e => applyFrame(e.target)); }); + return observer; +} + +function applyFrame(stage) { + const entry = framed.get(stage); + if (!entry) return; + if (!stage.isConnected) { // modal closed / list re-rendered + framed.delete(stage); + if (observer) observer.unobserve(stage); + return; + } + const OS = typeof window !== 'undefined' && window.OrientationStyle; + if (!OS || !OS.previewFrameStyle) return; // shared rule failed to load: leave today's rendering alone + const st = OS.previewFrameStyle(entry.orientation, { width: stage.clientWidth, height: stage.clientHeight }); + const el = entry.inner; + if (!el) return; + el.style.width = st.width; + el.style.height = st.height; + el.style.top = st.top; + el.style.left = st.left; + el.style.transform = st.transform; + el.style.transformOrigin = st.transformOrigin; + // A rotated screenshot has to be letterboxed rather than cropped: the card's `object-fit: cover` + // applied to a frame whose axes are swapped fills the box by discarding most of the picture — + // a "preview" of the middle 30% of the screen. + el.style.objectFit = (st.transform && OS.swapsAxes(entry.orientation)) ? 'contain' : ''; +} + +/** + * Present `inner` (an iframe of the player, or a screenshot img) inside `stage` as the panel's + * face. Safe to call repeatedly — screenshot handlers replace the img element, and re-registering + * is how the new one gets framed. + * + * @param {Element} stage fixed box in the dashboard, sized for the AS-DISPLAYED aspect + * @param {Element} inner the device's output; positioned and rotated inside the stage + * @param {string} orientation the device row's orientation + */ +export function frameDeviceOutput(stage, inner, orientation) { + if (!stage || !inner) return; + // Applied here rather than left to each call site: the frame is absolutely positioned and centred + // on its offset parent, so a stage that forgets `position: relative` centres it on the PAGE. + stage.classList.add('display-stage'); + inner.classList.add('display-frame'); + framed.set(stage, { inner, orientation: orientation || 'landscape' }); + applyFrame(stage); + const ro = ensureObserver(); + if (ro) { ro.unobserve(stage); ro.observe(stage); } +} + +/** Stage aspect for a device, as the viewer sees it ('9 / 16' for a portrait-hung 16:9 panel). */ +export function displayAspectRatio(orientation) { + const OS = typeof window !== 'undefined' && window.OrientationStyle; + return OS && OS.previewAspectRatio ? OS.previewAspectRatio(orientation) : '16 / 9'; +} diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index e79ecfc..e095218 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -5,6 +5,7 @@ import { esc, livenessBadge } from '../utils.js'; import { t, tn } from '../i18n.js'; import * as gettingStarted from '../components/getting-started.js'; import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js'; +import { frameDeviceOutput } from '../lib/device-frame.js'; const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown']; // Command types only — labels resolved through t('dashboard.cmd.') @@ -78,6 +79,22 @@ function renderProgressFor(deviceId) { }); } +// #238: a screenshot is the panel's raw framebuffer, so a device set to 90/270 sends a landscape +// image with the content lying on its side — the wall mount is what turns it upright, and the card +// had no stand-in for the mount. Every portrait screen in the fleet therefore looked wrong at a +// glance on the one screen people scan to check the fleet is fine. +// +// Re-run after any render that replaces card markup; the orientation rides on the card so the +// socket handler can re-frame a single card without re-reading the device list. +function frameCard(stage) { + const img = stage && stage.querySelector('img'); + if (img) frameDeviceOutput(stage, img, stage.dataset.orientation); +} + +function frameCardScreenshots(root) { + (root || document).querySelectorAll('.device-card-preview[data-orientation]').forEach(frameCard); +} + function renderDeviceCard(device) { const token = localStorage.getItem('token'); const screenshotUrl = device.screenshot_path @@ -90,7 +107,7 @@ function renderDeviceCard(device) { -
+
${screenshotUrl ? `Screenshot` : `
@@ -417,6 +434,7 @@ export function render(container) { const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || ''; preview.innerHTML = `Screenshot${statusHtml}`; } + frameCard(preview); // the branch above can swap the img element out from under us }); }; @@ -659,6 +677,7 @@ async function loadDashboard() { } main.innerHTML = html; + frameCardScreenshots(); attachGroupHandlers(groupsWithDevices, dashboardDevices); // Drop any selections for devices that have since been absorbed into a diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 974c787..1fa2a9a 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -4,6 +4,7 @@ import { showToast } from '../components/toast.js'; import { esc, livenessBadge, hydrateAuthImages } from '../utils.js'; import { t, tn } from '../i18n.js'; import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js'; +import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js'; // The player distinguishes three cases for the Wi-Fi name, because "--" was hiding a real // answer: Android 8.1+ refuses to reveal the SSID to an app without location permission, and a @@ -15,6 +16,14 @@ function ssidLabel(ssid) { return esc(ssid); } +// #238: turn the Now Playing screenshot the way the wall mount turns the panel. The placeholder +// ("no screenshot yet") is deliberately left alone — it is dashboard chrome, not device output. +function frameNowPlaying() { + const stage = document.getElementById('screenshotStage'); + const img = document.getElementById('currentScreenshot'); + if (stage && img && img.tagName === 'IMG') frameDeviceOutput(stage, img, currentDevice?.orientation); +} + let currentDevice = null; let statusHandler = null; let screenshotHandler = null; @@ -144,6 +153,10 @@ export function render(container, deviceId) { img.style.cssText = 'width:100%;height:100%;object-fit:contain'; screenshotEl.replaceWith(img); } + // #238: a screenshot is the RAW framebuffer, so a portrait panel's arrives sideways — the + // player rotated the content into it and only the wall mount turns it back. Re-frame on every + // arrival, not just at render: the branch above swaps the element out from under us. + frameNowPlaying(); } // Update remote canvas const canvas = document.getElementById('remoteCanvas'); @@ -260,7 +273,7 @@ async function loadDevice(deviceId, activeTab = null) {
-
+
${device.screenshot ? `Current screen` : `
@@ -626,6 +639,11 @@ async function loadDevice(deviceId, activeTab = null) {
${can('remote.stream') ? `
+
@@ -796,6 +814,7 @@ async function loadDevice(deviceId, activeTab = null) { // offline→online transitions derived from the status log). renderIncidents(device.deviceEvents || [], device.statusLog || []); + frameNowPlaying(); setupTabs(); setupActions(device); setupRemote(device); @@ -905,8 +924,13 @@ function setupTabs() { // same-origin (dashboard CSP frame-src 'self' allows it). Shows the device's CURRENT // playlist in the device's OWN layout/orientation (server payload). wall members // preview full-frame (server forces wall_config:null in v1). +// +// #238: the iframe is the panel's FRAMEBUFFER, not its face. It used to be given the as-displayed +// 9/16 shape directly, so on a portrait device the player rotated content a second time inside a +// box that was already the finished picture and the preview came out sideways — while the panel +// itself was right, which is the worst possible split for someone trying to verify their work. +// The stage is the face; the frame is landscape underneath it and the mount turns it back. function showDevicePreview(device) { - const portrait = (device.orientation || '').includes('portrait'); const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; overlay.innerHTML = ` @@ -916,10 +940,13 @@ function showDevicePreview(device) {
- +
+ +
`; document.body.appendChild(overlay); + frameDeviceOutput(overlay.querySelector('#dpvStage'), overlay.querySelector('#dpvStage iframe'), device.orientation); const close = () => overlay.remove(); overlay.querySelector('#dpvClose').onclick = close; overlay.onclick = (e) => { if (e.target === overlay) close(); }; diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index ec3fc10..3241cd4 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -2,6 +2,7 @@ import { api } from '../api.js'; import { showToast } from '../components/toast.js'; import { esc, hydrateAuthImages } from '../utils.js'; import { t, tn } from '../i18n.js'; +import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.js'; function formatDate(ts) { if (!ts) return '--'; @@ -210,9 +211,13 @@ async function renderDetail(container, playlistId) { // /api/playlists/:id/preview-payload and renders with its unmodified renderer, so the // preview is byte-identical to what a device shows. Orientation toggle just reloads // the iframe with &orientation; the server passes it through. +// #238: Portrait here had the same fault as the device preview — the iframe was given the +// as-displayed 9/16 shape AND the player rotated inside it, so the portrait toggle showed sideways +// content. The stage is the panel's face; the iframe is its landscape framebuffer, turned back by +// the stand-in for the wall mount. function showPlaylistPreview(playlist) { let orientation = 'landscape'; - const aspect = () => (orientation.startsWith('portrait') ? '9 / 16' : '16 / 9'); + const aspect = () => displayAspectRatio(orientation); const frameSrc = () => `/player?preview=1&playlist=${encodeURIComponent(playlist.id)}&orientation=${orientation}&t=${Date.now()}`; const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; @@ -227,16 +232,21 @@ function showPlaylistPreview(playlist) {
- +
+ +
`; document.body.appendChild(overlay); + const stage = overlay.querySelector('#pvpStage'); const frame = overlay.querySelector('#pvpFrame'); + frameDeviceOutput(stage, frame, orientation); const btnL = overlay.querySelector('#pvpLandscape'); const btnP = overlay.querySelector('#pvpPortrait'); const setOrientation = (o) => { orientation = o; - frame.style.aspectRatio = aspect(); + stage.style.aspectRatio = aspect(); + frameDeviceOutput(stage, frame, orientation); frame.src = frameSrc(); btnL.className = 'btn btn-sm ' + (o === 'landscape' ? 'btn-primary' : 'btn-secondary'); btnP.className = 'btn btn-sm ' + (o.startsWith('portrait') ? 'btn-primary' : 'btn-secondary'); diff --git a/server/lib/orientation-style.js b/server/lib/orientation-style.js index 07f0701..64936f4 100644 --- a/server/lib/orientation-style.js +++ b/server/lib/orientation-style.js @@ -1,7 +1,8 @@ 'use strict'; /* - * The CSS needed to rotate a full-screen player container. + * The CSS needed to rotate a full-screen player container, and (below) the CSS needed to show that + * rotated output back to a human in the dashboard. * * This looks trivial and is not, because rotating a box does NOT move it. The web player set * `width:100vh; height:100vw` and `rotate(90deg)` on a container pinned `inset: 0`, which leaves @@ -63,9 +64,72 @@ function orientationStyle(orientation) { }; } +/** Does this orientation put the panel's long edge vertical? 90 and 270 swap the axes; 0/180 don't. */ +function swapsAxes(orientation) { + const deg = ROTATION_DEG[orientation]; + return deg === 90 || deg === 270; +} + +/** + * The CSS needed to show a rotated display's OUTPUT — its framebuffer — inside a fixed dashboard + * box, as a person standing in front of the panel sees it. + * + * #238: the dashboard preview of a 90/270 device was sideways while the panel was right, and the + * reason is that the dashboard only did half the job. A portrait panel is a landscape framebuffer + * that the player rotates content INSIDE (+90), hung on the wall turned the other way (-90); the + * two cancel and the viewer sees upright portrait. The dashboard iframed the player into a box it + * had already made portrait-shaped, so the player rotated content a second time inside a box that + * was pretending to be the finished picture — one rotation applied, the mount's never modelled. + * Designers checking their work on a portrait screen saw sideways content and could not tell a + * real fault from a preview artefact, so every portrait anomaly became a support question. + * + * So the frame stands in for the physical mount and rotates by the INVERSE of the player's angle. + * Rotating it the SAME way instead is the tempting mistake and the worst kind of wrong: 90+90 + * lands upside-down, which reads as "nearly right" and gets shipped. + * + * The dimension swap matters as much as the angle. Composing into a box the shape of the real + * FRAMEBUFFER (stage axes swapped) and turning that is not a no-op round trip — it is what makes + * the player lay the content out in the same portrait box the panel uses. Feed the player a + * portrait-shaped viewport instead and every zone, aspect and object-fit decision is computed for + * the wrong canvas. + * + * @param {string} orientation landscape | portrait | landscape-flipped | portrait-flipped + * @param {{width:number,height:number}} box the on-screen stage, in px, AS THE VIEWER SEES IT + * @returns {{transform:string,width:string,height:string,top:string,left:string,transformOrigin:string}} + * Values to assign directly onto element.style. Empty string means "clear it" — the + * landscape state must clear every property the rotated state sets, or a device switched + * back to landscape keeps a stale swapped size and looks broken until a reload. + */ +function previewFrameStyle(orientation, box) { + const deg = ROTATION_DEG[orientation]; + const w = box && box.width, h = box && box.height; + + // Unknown/landscape, or a stage that has not been laid out yet (a hidden tab measures 0x0): + // clear back to the base CSS rather than pinning a 0px frame nobody can see. + if (!deg || !(w > 0) || !(h > 0)) { + return { transform: '', width: '', height: '', top: '', left: '', transformOrigin: '' }; + } + + const swap = swapsAxes(orientation); + return { + transform: 'translate(-50%, -50%) rotate(' + ((360 - deg) % 360) + 'deg)', + width: (swap ? h : w) + 'px', + height: (swap ? w : h) + 'px', + top: '50%', + left: '50%', + transformOrigin: 'center center', + }; +} + +/** Stage aspect for a device, as the viewer sees it: '9 / 16' for a portrait-hung 16:9 panel. */ +function previewAspectRatio(orientation, panelW, panelH) { + const w = panelW || 16, h = panelH || 9; + return swapsAxes(orientation) ? (h + ' / ' + w) : (w + ' / ' + h); +} + if (typeof module !== 'undefined' && module.exports) { - module.exports = { orientationStyle, ROTATION_DEG }; + module.exports = { orientationStyle, previewFrameStyle, previewAspectRatio, swapsAxes, ROTATION_DEG }; } if (typeof window !== 'undefined') { - window.OrientationStyle = { orientationStyle, ROTATION_DEG }; + window.OrientationStyle = { orientationStyle, previewFrameStyle, previewAspectRatio, swapsAxes, ROTATION_DEG }; } diff --git a/server/test/preview-frame.test.js b/server/test/preview-frame.test.js new file mode 100644 index 0000000..09739a2 --- /dev/null +++ b/server/test/preview-frame.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// #238: the dashboard preview of a 90/270 display was sideways while the panel itself was right. +// +// A portrait panel is a landscape framebuffer that the player rotates content INSIDE (+90), hung on +// the wall turned the other way (-90). The two cancel and a person in front of it sees upright +// portrait. The dashboard modelled only the first half: it iframed the player into a box it had +// already given the finished 9/16 shape, so the player rotated a second time inside it and the +// preview came out at 90 degrees to the panel. Designers verify their work on these surfaces, so +// every anomaly on a portrait screen became "is that the screen or the preview?". +// +// The frame therefore stands in for the mount and turns by the INVERSE angle. The tempting mistake +// is to turn it the SAME way: 90+90 lands upside-down, which reads as nearly-right and ships. +// +// Geometry, not CSS strings: what matters is where the rotated box lands and which way the content +// ends up pointing. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { previewFrameStyle, previewAspectRatio, orientationStyle, ROTATION_DEG } = require('../lib/orientation-style'); + +const px = (v) => Number(String(v).replace('px', '')); +const rotationOf = (style) => { + const m = /rotate\((-?\d+)deg\)/.exec(style.transform || ''); + return m ? ((Number(m[1]) % 360) + 360) % 360 : 0; +}; + +/** Where does the frame land inside the stage, and is it centred on it? */ +function occupies(style, stageW, stageH) { + const bw = style.width ? px(style.width) : stageW; + const bh = style.height ? px(style.height) : stageH; + // top/left 50% put the box ORIGIN at the stage centre; translate(-50%,-50%) pulls it back by half + // its own size, so the box centre lands on the stage centre. + const centreX = style.left === '50%' ? stageW / 2 : bw / 2; + const centreY = style.top === '50%' ? stageH / 2 : bh / 2; + const deg = rotationOf(style); + const swap = deg === 90 || deg === 270; + const spanX = swap ? bh : bw; + const spanY = swap ? bw : bh; + return { + x0: centreX - spanX / 2, x1: centreX + spanX / 2, + y0: centreY - spanY / 2, y1: centreY + spanY / 2, + }; +} + +/** + * Which way the CONTENT points for a viewer of the dashboard: the player's own rotation inside the + * framebuffer, plus whatever the dashboard does to the frame. 0 means it matches the panel. + */ +const contentAngle = (orientation, frameStyle) => + (ROTATION_DEG[orientation] + rotationOf(frameStyle)) % 360; + +// A portrait stage as the viewer sees it: a 16:9 panel hung on its side. +const STAGE = { width: 405, height: 720 }; + +test('THE BUG: portrait preview points the same way as the panel, not 90 degrees off', () => { + const style = previewFrameStyle('portrait', STAGE); + assert.equal(contentAngle('portrait', style), 0); + + // What the dashboard used to do: give the iframe the as-displayed shape and no rotation of its + // own. The player still rotated inside it, so the content sat at 90 degrees to the panel. + assert.equal(contentAngle('portrait', { transform: '' }), 90); +}); + +test('the frame turns the OTHER way from the player — same way lands upside-down', () => { + const inverse = { 'portrait': 270, 'portrait-flipped': 90, 'landscape-flipped': 180 }; + for (const [orientation, deg] of Object.entries(inverse)) { + const style = previewFrameStyle(orientation, STAGE); + assert.equal(rotationOf(style), deg, orientation); + assert.equal(contentAngle(orientation, style), 0, orientation); + } + // The near-miss this exists to catch: repeating the player's angle instead of cancelling it. + assert.equal(contentAngle('portrait', { transform: 'rotate(90deg)' }), 180); +}); + +test('the rotated frame covers the stage exactly — no bleed, no letterbox', () => { + for (const orientation of Object.keys(ROTATION_DEG)) { + const stage = orientation.includes('portrait') ? STAGE : { width: 720, height: 405 }; + const box = occupies(previewFrameStyle(orientation, stage), stage.width, stage.height); + assert.deepEqual(box, { x0: 0, x1: stage.width, y0: 0, y1: stage.height }, orientation); + } +}); + +test('the frame is the FRAMEBUFFER shape, so the player composes into the panel\'s own box', () => { + // Not a cosmetic detail: the player lays zones, aspect and object-fit out against its viewport. + // Handing it the portrait 405x720 stage would compose the content for a canvas no panel has. + const style = previewFrameStyle('portrait', STAGE); + assert.equal(px(style.width), STAGE.height); + assert.equal(px(style.height), STAGE.width); + // ...and inside that landscape frame the player's own rule builds the portrait container back. + const player = orientationStyle('portrait'); + assert.equal(player.width, '100vh'); // = frame height = 405, the stage width + assert.equal(player.height, '100vw'); // = frame width = 720, the stage height +}); + +test('180 does not swap the axes — the frame already fits, it just turns over', () => { + const stage = { width: 720, height: 405 }; + const style = previewFrameStyle('landscape-flipped', stage); + assert.equal(px(style.width), 720); + assert.equal(px(style.height), 405); + assert.equal(rotationOf(style), 180); +}); + +test('the translate comes BEFORE the rotate, or the centring offset is rotated too', () => { + assert.match(previewFrameStyle('portrait', STAGE).transform, /^translate\(-50%, -50%\) rotate\(270deg\)$/); +}); + +test('landscape clears EVERY property the rotated state set', () => { + // A device switched back to landscape must not keep a stale swapped size: a half-reset leaves the + // frame pinned at the old height and the preview looks broken until a reload. + const rotated = previewFrameStyle('portrait', STAGE); + const landscape = previewFrameStyle('landscape', STAGE); + for (const key of Object.keys(rotated)) { + assert.equal(landscape[key], '', key + ' must be cleared'); + } +}); + +test('an unmeasured stage (hidden tab) clears rather than pinning a 0px frame', () => { + // Now Playing lives in an inactive tab and measures 0x0 until it is shown. Sizing to that would + // render an invisible preview that never recovers; the resize observer re-applies when it is. + for (const box of [{ width: 0, height: 0 }, { width: 400, height: 0 }, null]) { + const style = previewFrameStyle('portrait', box); + assert.equal(style.width, ''); + assert.equal(style.transform, ''); + } +}); + +test('an unknown orientation falls back to no rotation, never to a blank frame', () => { + const style = previewFrameStyle('sideways-ish', STAGE); + assert.deepEqual(occupies(style, STAGE.width, STAGE.height), { x0: 0, x1: 405, y0: 0, y1: 720 }); +}); + +test('stage aspect is the panel as the viewer sees it', () => { + assert.equal(previewAspectRatio('landscape'), '16 / 9'); + assert.equal(previewAspectRatio('landscape-flipped'), '16 / 9'); + assert.equal(previewAspectRatio('portrait'), '9 / 16'); + assert.equal(previewAspectRatio('portrait-flipped'), '9 / 16'); +});