mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 22:33:12 -06:00
Merge #238: preview a rotated display the way the wall shows it
This commit is contained in:
commit
97f53a5b72
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@
|
|||
<link rel="stylesheet" href="/css/reset.css">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<!-- #238: the players' own rotation rule (server/lib/orientation-style.js), served under /player
|
||||
because it IS the player's — the dashboard previews of rotated screens went sideways for as
|
||||
long as this side derived its own geometry. Classic script: it publishes window.OrientationStyle
|
||||
for both the player and the ES-module dashboard. -->
|
||||
<script src="/player/orientation-style.js"></script>
|
||||
<!-- OAuth providers loaded on-demand by login.js when needed -->
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
76
frontend/js/lib/device-frame.js
Normal file
76
frontend/js/lib/device-frame.js
Normal file
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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.<type>')
|
||||
|
|
@ -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) {
|
|||
<label class="device-card-select" title="${t('dashboard.select_for_wall')}" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
|
||||
</label>
|
||||
<div class="device-card-preview" id="preview-${device.id}">
|
||||
<div class="device-card-preview" id="preview-${device.id}" data-orientation="${esc(device.orientation || 'landscape')}">
|
||||
${screenshotUrl
|
||||
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
|
||||
: `<div class="no-preview">
|
||||
|
|
@ -417,6 +434,7 @@ export function render(container) {
|
|||
const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || '';
|
||||
preview.innerHTML = `<img src="${imgSrc}" alt="Screenshot" loading="lazy">${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
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
|
||||
<!-- Now Playing Tab -->
|
||||
<div class="tab-content active" id="tab-nowplaying">
|
||||
<div class="screenshot-container">
|
||||
<div class="screenshot-container" id="screenshotStage">
|
||||
${device.screenshot
|
||||
? `<img id="currentScreenshot" src="/api/devices/${device.id}/screenshot?t=${Date.now()}&token=${localStorage.getItem('token')}" alt="Current screen">`
|
||||
: `<div class="no-screenshot" id="currentScreenshot">
|
||||
|
|
@ -626,6 +639,11 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
<div class="remote-container">
|
||||
${can('remote.stream') ? `
|
||||
<div class="remote-screen" id="remoteScreen">
|
||||
<!-- Deliberately NOT rotated with the rest of the previews (#238). This is a control
|
||||
surface: taps and swipes are sent as fractions of THIS canvas, which is the raw
|
||||
framebuffer the device replays them into, and it also shows the Android system UI —
|
||||
which really is landscape on a portrait-hung panel. Turning the picture without
|
||||
inverting the touch mapping would send every tap to the wrong place. -->
|
||||
<canvas id="remoteCanvas" width="960" height="540" style="background:#000;width:100%"></canvas>
|
||||
<div class="no-screenshot" id="remoteOverlay" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
|
||||
<div style="text-align:center">
|
||||
|
|
@ -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) {
|
|||
<button class="btn btn-secondary btn-sm" id="dpvClose">${t('widget.close')}</button>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
|
||||
<iframe style="height:78vh;max-width:92vw;aspect-ratio:${portrait ? '9 / 16' : '16 / 9'};border:0;background:#000" src="/player?preview=1&device=${encodeURIComponent(device.id)}&t=${Date.now()}"></iframe>
|
||||
<div id="dpvStage" style="height:78vh;max-width:92vw;aspect-ratio:${displayAspectRatio(device.orientation)};background:#000">
|
||||
<iframe style="border:0;background:#000" src="/player?preview=1&device=${encodeURIComponent(device.id)}&t=${Date.now()}"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
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(); };
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
|
||||
<iframe id="pvpFrame" style="height:78vh;max-width:92vw;aspect-ratio:${aspect()};border:0;background:#000" src="${frameSrc()}"></iframe>
|
||||
<div id="pvpStage" style="height:78vh;max-width:92vw;aspect-ratio:${aspect()};background:#000">
|
||||
<iframe id="pvpFrame" style="border:0;background:#000" src="${frameSrc()}"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
138
server/test/preview-frame.test.js
Normal file
138
server/test/preview-frame.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in a new issue