Preview a rotated display the way people see it, not the way its framebuffer is

#238: the dashboard preview of a 90/270 display was sideways while the panel on the
wall was right — the split that makes a preview useless, because a designer checking
portrait content can no longer tell a real fault from an artefact of the tool.

A portrait panel is a landscape framebuffer that the player rotates content INSIDE
(+90), hung turned the other way (-90); the two cancel and the viewer 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 a box that was pretending to be the finished picture, and nothing anywhere
stood in for the mount. Screenshots had the opposite half missing: they are the raw
framebuffer, shown untouched, so every portrait screen looked wrong on the cards and
in Now Playing too.

So each surface now has a stage (the panel's face) and a frame (its framebuffer),
with the frame turned by the INVERSE of the player's angle. Turning it the same way
is the tempting mistake and the worst kind of wrong: 90+90 lands upside-down, which
reads as nearly-right. The dimension swap is not cosmetic either — composing into the
real framebuffer shape is what makes the player lay content out in the same portrait
box the panel uses; hand it a portrait viewport instead and every zone and object-fit
decision is computed for a canvas no panel has.

The geometry is the players' own rule (server/lib/orientation-style.js), served to the
dashboard rather than re-derived, since a second copy of a rotation rule is exactly how
the two came to disagree. Covers the device preview modal, the playlist preview's
portrait toggle (same fault), Now Playing and the device cards. The Remote canvas stays
raw on purpose: taps are sent as fractions of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-06 09:38:31 -05:00
parent afe3f7f57f
commit 52ab04204a
8 changed files with 366 additions and 10 deletions

View file

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

View file

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

View 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';
}

View file

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

View file

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

View file

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

View file

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

View 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');
});