mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 06:43:27 -06:00
Portrait on the web player was 420px off-screen — rotating a box does not move it
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
1c7d5f1359
commit
604c390a55
71
server/lib/orientation-style.js
Normal file
71
server/lib/orientation-style.js
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -240,6 +240,7 @@
|
|||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="/player/schedule-eval.js"></script>
|
||||
<script src="/player/media-mute.js"></script>
|
||||
<script src="/player/orientation-style.js"></script>
|
||||
<script src="/player/player-media-health.js"></script>
|
||||
<!-- feat/transition-engine: WebGL transition runtime (renderer + shaders). Optional; if it fails to
|
||||
load the player just hard-cuts. Not deferred so it's ready before the first content swap. -->
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
98
server/test/orientation-style.test.js
Normal file
98
server/test/orientation-style.test.js
Normal file
|
|
@ -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 });
|
||||
});
|
||||
Loading…
Reference in a new issue