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 });
+});