From 604c390a553f7f88131e1f2390ae4056405d354a Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 5 Aug 2026 12:58:50 -0500 Subject: [PATCH] =?UTF-8?q?Portrait=20on=20the=20web=20player=20was=20420p?= =?UTF-8?q?x=20off-screen=20=E2=80=94=20rotating=20a=20box=20does=20not=20?= =?UTF-8?q?move=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "rotation doesn't work correctly". It is a geometry bug, not a rendering one, which is why it reads as mysterious. #playerContainer is pinned `inset: 0`. Rotation set width:100vh, height:100vw and rotate(90deg) — leaving the box in the TOP-LEFT corner and spinning it about its own centre rather than the viewport's. On a 1920x1080 panel the content landed at x -420..1500, y 420..1500 against a viewport of 0..1920, 0..1080: correctly rotated, wrongly placed, cropped on two edges. Tizen already did this correctly — top/left 50% plus translate(-50%,-50%) — and Android does the equivalent with translationX/Y of (w-h)/2. The web player was the odd one out, and BrightSign inherited it on top of its own hardware-plane problem. The rule now lives in server/lib/orientation-style.js, served to the player from its single source, with the arithmetic pinned by tests that compute where the rotated box actually lands on 16:9 and 5:4 panels. Three things those tests hold that are easy to get wrong: the translate must come BEFORE the rotate (transforms apply right-to-left, so reversing them rotates the correction too), 180 must NOT swap dimensions (the box already fits; swapping letterboxes it), and landscape must clear EVERY property the rotated state set (a half-reset leaves the container stuck at 100vh wide, so rotation appears to persist after switching back). 1074 pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- server/lib/orientation-style.js | 71 +++++++++++++++++++ server/player/index.html | 34 ++++++---- server/server.js | 8 +++ server/test/orientation-style.test.js | 98 +++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 server/lib/orientation-style.js create mode 100644 server/test/orientation-style.test.js diff --git a/server/lib/orientation-style.js b/server/lib/orientation-style.js new file mode 100644 index 0000000..07f0701 --- /dev/null +++ b/server/lib/orientation-style.js @@ -0,0 +1,71 @@ +'use strict'; + +/* + * The CSS needed to rotate a full-screen player container. + * + * 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 + * the box in the top-left corner and spins it about ITS OWN centre — not the viewport's. On a + * 1920x1080 panel that put the content 420px off-screen to the left and 420px off the bottom: + * portrait content, correctly rotated, in the wrong place and cropped on two edges. + * + * The box has to be centred on the viewport BEFORE it is turned. Tizen already does this + * (`top:50%; left:50%; translate(-50%,-50%) rotate()`), and Android does the equivalent with + * translationX/Y offsets of (w-h)/2 — so the web player was the odd one out. + * + * Returned as plain style values so the same rule can be asserted in a test without a browser, + * and shared rather than re-derived per player. Every property the rotated state sets is also + * cleared by the landscape state: a half-reset leaves a container stuck at 100vh wide. + */ + +const ROTATION_DEG = { + 'landscape': 0, + 'portrait': 90, + 'landscape-flipped': 180, + 'portrait-flipped': 270, +}; + +/** + * @param {string} orientation landscape | portrait | landscape-flipped | portrait-flipped + * @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". + */ +function orientationStyle(orientation) { + const deg = ROTATION_DEG[orientation]; + + // Unknown orientation falls back to landscape rather than throwing: a bad value from the server + // should leave a readable screen, not a blank or sideways one. + if (!deg) { + return { transform: '', width: '', height: '', top: '', left: '', transformOrigin: '' }; + } + + // 180 needs no dimension swap — the box already matches the viewport, it just turns over. Giving + // it the portrait treatment would swap width and height for no reason and letterbox it. + const swap = deg === 90 || deg === 270; + if (!swap) { + return { + transform: 'rotate(180deg)', + width: '', height: '', top: '', left: '', + transformOrigin: 'center center', + }; + } + + return { + // translate BEFORE rotate: transforms apply right-to-left, so the box is turned about its own + // centre and then that centre is moved onto the viewport's. Reversing them rotates the offset + // as well and puts the content back off-screen. + transform: 'translate(-50%, -50%) rotate(' + deg + 'deg)', + width: '100vh', + height: '100vw', + top: '50%', + left: '50%', + transformOrigin: 'center center', + }; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { orientationStyle, ROTATION_DEG }; +} +if (typeof window !== 'undefined') { + window.OrientationStyle = { orientationStyle, ROTATION_DEG }; +} diff --git a/server/player/index.html b/server/player/index.html index 2a4f5ff..9bcd9e5 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -240,6 +240,7 @@ + @@ -2154,20 +2155,25 @@ console.log('[bs] output rotated by host; CSS transform cleared'); }); } - const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' }; - const portrait = data.orientation.includes('portrait'); - [document.getElementById('playerContainer'), document.getElementById('pipContainer')].forEach((el) => { - if (!el) return; - el.style.transform = `rotate(${rotations[data.orientation] || '0deg'})`; - if (portrait) { - el.style.transformOrigin = 'center center'; - el.style.width = '100vh'; - el.style.height = '100vw'; - } else { - el.style.width = ''; - el.style.height = ''; - } - }); + // Geometry from the shared rule (server/lib/orientation-style.js). The previous inline + // version rotated the container about its own centre while it stayed pinned top-left, so + // portrait content landed 420px off-screen on a 1920x1080 panel — rotated correctly and + // placed wrongly. Tizen and Android both centre the box first; the web player did not. + const st = window.OrientationStyle + ? window.OrientationStyle.orientationStyle(data.orientation) + : null; + if (st) { + [document.getElementById('playerContainer'), document.getElementById('pipContainer')] + .forEach((el) => { + if (!el) return; + el.style.transform = st.transform; + el.style.transformOrigin = st.transformOrigin; + el.style.width = st.width; + el.style.height = st.height; + el.style.top = st.top; + el.style.left = st.left; + }); + } } // Apply (or clear) wall mode. Force re-render when wall config changes diff --git a/server/server.js b/server/server.js index a08b182..40f4b26 100644 --- a/server/server.js +++ b/server/server.js @@ -380,6 +380,14 @@ app.get('/player/st-sync.js', (req, res) => { // duplicated because this rule disagreed with itself across players for exactly as long as it was // written three times: a YouTube embed ignored the per-item mute on the web player and could never // unmute at all on Tizen. +// Orientation geometry, from its single source. Rotating a box does not move it: the previous +// inline rule spun the container about its own top-left-pinned centre and put portrait content +// 420px off-screen on a 1920x1080 panel. +app.get('/player/orientation-style.js', (req, res) => { + res.type('application/javascript').setHeader('Cache-Control', 'no-cache'); + res.sendFile(path.join(__dirname, 'lib', 'orientation-style.js')); +}); + app.get('/player/media-mute.js', (req, res) => { res.type('application/javascript').setHeader('Cache-Control', 'no-cache'); res.sendFile(path.join(__dirname, 'lib', 'media-mute.js')); diff --git a/server/test/orientation-style.test.js b/server/test/orientation-style.test.js new file mode 100644 index 0000000..c5c3bb1 --- /dev/null +++ b/server/test/orientation-style.test.js @@ -0,0 +1,98 @@ +'use strict'; + +// Rotating a box does not move it, and that is the whole bug. +// +// The web player set `width:100vh; height:100vw` and `rotate(90deg)` on a container pinned +// `inset: 0`. The box stayed in the top-left corner and spun about ITS OWN centre rather than the +// viewport's, so on a 1920x1080 panel the content landed 420px off-screen left and 420px off the +// bottom — correctly rotated, wrongly placed, cropped on two edges. Reported as "rotation doesn't +// work"; it looked like a rendering bug rather than a geometry one. +// +// Tizen already did this correctly (top/left 50% + translate(-50%,-50%)) and Android does the +// equivalent with translationX/Y of (w-h)/2. The web player was the odd one out, which is why the +// rule now lives in one place with the arithmetic pinned below. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { orientationStyle } = require('../lib/orientation-style'); + +/** Where does the rotated box actually land, given these styles on a viewport? */ +function occupies(style, vw, vh) { + const px = (v) => { + if (v === '100vh') return vh; + if (v === '100vw') return vw; + return null; + }; + const bw = px(style.width) ?? vw; + const bh = px(style.height) ?? vh; + // top/left 50% place the box's ORIGIN at the viewport centre; translate(-50%,-50%) then pulls it + // back by half its own size, so the box centre coincides with the viewport centre. + const centreX = style.left === '50%' ? vw / 2 : bw / 2; + const centreY = style.top === '50%' ? vh / 2 : bh / 2; + const rot = /rotate\((\d+)deg\)/.exec(style.transform || ''); + const deg = rot ? Number(rot[1]) : 0; + 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, + }; +} + +test('THE BUG: portrait covers the viewport exactly, instead of hanging off two edges', () => { + const box = occupies(orientationStyle('portrait'), 1920, 1080); + assert.equal(box.x0, 0, 'left edge — was -420 before the fix'); + assert.equal(box.x1, 1920); + assert.equal(box.y0, 0, 'top edge — was 420 before the fix'); + assert.equal(box.y1, 1080); +}); + +test('portrait-flipped lands identically — only the content direction differs', () => { + const box = occupies(orientationStyle('portrait-flipped'), 1920, 1080); + assert.deepEqual(box, { x0: 0, x1: 1920, y0: 0, y1: 1080 }); +}); + +test('the translate comes BEFORE the rotate, or the offset is rotated too', () => { + // transform functions apply right-to-left: rotate about the box centre, THEN move that centre. + // Swapping them turns the -50%,-50% correction by 90 degrees and puts the content back off-screen. + const t = orientationStyle('portrait').transform; + assert.match(t, /^translate\(-50%, -50%\) rotate\(90deg\)$/); +}); + +test('portrait swaps the box dimensions', () => { + const s = orientationStyle('portrait'); + assert.equal(s.width, '100vh'); + assert.equal(s.height, '100vw'); +}); + +test('180 does NOT swap dimensions — the box already fits, it just turns over', () => { + // Giving it the portrait treatment would swap width and height for no reason and letterbox it. + const s = orientationStyle('landscape-flipped'); + assert.equal(s.transform, 'rotate(180deg)'); + assert.equal(s.width, ''); + assert.equal(s.height, ''); + const box = occupies(s, 1920, 1080); + assert.deepEqual(box, { x0: 0, x1: 1920, y0: 0, y1: 1080 }); +}); + +test('landscape clears EVERY property the rotated state set', () => { + // A half-reset leaves the container stuck at 100vh wide — rotation appears to "stick" after + // switching back, which is its own bug report. + const rotated = orientationStyle('portrait'); + const reset = orientationStyle('landscape'); + for (const k of Object.keys(rotated)) { + assert.equal(reset[k], '', `${k} must be cleared, or it survives the switch back to landscape`); + } +}); + +test('an unknown orientation falls back to landscape rather than throwing', () => { + // A bad value from the server should leave a readable screen, not a blank or sideways one. + assert.deepEqual(orientationStyle('sideways-ish'), orientationStyle('landscape')); + assert.deepEqual(orientationStyle(undefined), orientationStyle('landscape')); +}); + +test('the geometry holds on a non-16:9 panel too', () => { + const box = occupies(orientationStyle('portrait'), 1280, 1024); + assert.deepEqual(box, { x0: 0, x1: 1280, y0: 0, y1: 1024 }); +});