From 08b7fa507caf33287eb47e022c4a8987ed2ced67 Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Thu, 6 Aug 2026 10:40:11 +0300 Subject: [PATCH 01/23] fix: keep the picture neutral until the viewer asks A clean profile was showing a film that had already been adjusted. Stored values are now range-checked: anything outside the range the panel can produce, or of the wrong type, falls back to the neutral value rather than tinting a film for reasons the viewer cannot see. --- src/lib/settings.js | 26 ++++++++++++++++++++++++-- test/unit/settings.test.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 test/unit/settings.test.js diff --git a/src/lib/settings.js b/src/lib/settings.js index 66f72b6..985eb6b 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -19,13 +19,35 @@ export const DEFAULTS = Object.freeze({ const KEYS = Object.keys(DEFAULTS); +// The picture must start exactly as the site encoded it, so a stored value that +// is not a number in range is not trusted: it falls back to the neutral one +// rather than tinting somebody's first film for reasons they cannot see. +const RANGES = { + warmth: { min: 0, max: 0.45 }, + brightness: { min: 0.5, max: 1.5 }, + contrast: { min: 0.5, max: 1.5 }, + saturate: { min: 0, max: 2 }, + subtitleScale: { min: 0.5, max: 2 }, + doubleTapSeconds: { min: 5, max: 60 }, + holdSpeed: { min: 1.5, max: 4 }, +}; + +const inRange = (key, value) => { + const range = RANGES[key]; + if (range === undefined) return value; + const isNumber = typeof value === 'number' && Number.isFinite(value); + if (!isNumber) return DEFAULTS[key]; + if (value < range.min || value > range.max) return DEFAULTS[key]; + return value; +}; + // Rebuilt key by key so every settings object shares one hidden class, and so // unknown keys from a future version can never leak into the running state. -const normalize = (stored) => { +export const normalize = (stored) => { const settings = {}; for (const key of KEYS) { const value = stored?.[key]; - settings[key] = value === undefined ? DEFAULTS[key] : value; + settings[key] = value === undefined ? DEFAULTS[key] : inRange(key, value); } settings.schemaVersion = SCHEMA_VERSION; settings.disabledHosts = Array.isArray(settings.disabledHosts) diff --git a/test/unit/settings.test.js b/test/unit/settings.test.js new file mode 100644 index 0000000..5e9e4c4 --- /dev/null +++ b/test/unit/settings.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { DEFAULTS, normalize } from '../../src/lib/settings.js'; + +test('a fresh profile starts with no colour treatment at all', () => { + const settings = normalize(null); + assert.equal(settings.brightness, 1); + assert.equal(settings.contrast, 1); + assert.equal(settings.saturate, 1); + assert.equal(settings.warmth, 0); +}); + +test('a stored value outside its range falls back to neutral', () => { + const settings = normalize({ contrast: 1.3e3, brightness: -4 }); + assert.equal(settings.contrast, DEFAULTS.contrast); + assert.equal(settings.brightness, DEFAULTS.brightness); +}); + +test('a stored value of the wrong type falls back to neutral', () => { + const settings = normalize({ saturate: '1.5', warmth: null }); + assert.equal(settings.saturate, DEFAULTS.saturate); + assert.equal(settings.warmth, DEFAULTS.warmth); +}); + +test('a value the user really chose is kept', () => { + const settings = normalize({ contrast: 1.15, warmth: 0.27 }); + assert.equal(settings.contrast, 1.15); + assert.equal(settings.warmth, 0.27); +}); + +test('unknown keys never reach the running state', () => { + const settings = normalize({ contrast: 1.1, somethingElse: true }); + assert.equal(settings.somethingElse, undefined); + assert.deepEqual(Object.keys(settings).sort(), Object.keys(DEFAULTS).sort()); +}); From 955a63ccafccc216dbdec15042f5bd3508ba521b Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Thu, 6 Aug 2026 10:40:29 +0300 Subject: [PATCH 02/23] fix(controls): move the launcher clear of the site's controls The badge sat in the bottom corner of the video, on top of the play button and the progress line the site draws there, and swallowed taps meant for them. It now sits halfway up the right edge, which is the one part of a video no player puts a control on, and is clamped to stay on screen. --- src/content/controls/badge.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/content/controls/badge.js b/src/content/controls/badge.js index e302991..3b5009b 100644 --- a/src/content/controls/badge.js +++ b/src/content/controls/badge.js @@ -47,6 +47,19 @@ export const createBadge = () => { pinStyle(shell.host, { opacity: '0', 'pointer-events': 'none' }); }; + // Halfway up the right edge, which is the one part of a video that no player + // puts a control on: the bottom corner sat on top of the site's own buttons + // and swallowed taps meant for them. + const placement = (rect) => { + const left = rect.right - SIZE_PX - INSET_PX; + const top = rect.top + rect.height / 2 - SIZE_PX / 2; + const maxTop = window.innerHeight - SIZE_PX - INSET_PX; + return { + left: `${Math.max(INSET_PX, left)}px`, + top: `${Math.min(maxTop, Math.max(INSET_PX, top))}px`, + }; + }; + // Teardown is the watcher's job: when the video goes away it reports a new // primary and calls hide(). Here we only stop drawing. const reposition = () => { @@ -63,8 +76,7 @@ export const createBadge = () => { pinStyle(shell.host, { opacity: '1', 'pointer-events': 'auto', - left: `${rect.right - SIZE_PX - INSET_PX}px`, - top: `${rect.bottom - SIZE_PX - INSET_PX}px`, + ...placement(rect), }); }; From 2e73ed2f5d1adc064fb5294846033eb8385a3973 Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Thu, 6 Aug 2026 10:40:30 +0300 Subject: [PATCH 03/23] fix(gestures): pause on the button only and lift the seek band Two sources of mis-taps, both from a target that was larger than what the viewer could see. The whole picture was a hidden pause button, so a thumb resting on the screen stopped the film. Pausing is now the play button and nothing else; a tap anywhere else brings the controls up or puts them away. The seek band reached the bottom edge, where the Android home swipe starts, so putting the app away threw the film into the middle. The band and the bar it belongs to now sit above that strip, and the bar's centre is measured rather than assumed so the precision curve follows it. The chrome also gets one easing curve throughout, scrims and shadows under the controls so they read over a bright picture, and a reduced-motion rule. --- src/content/controls/seekbar.js | 12 ++- src/content/gestures/recognizer.js | 7 +- src/content/gestures/zones.js | 25 +++-- src/content/player.css | 164 +++++++++++++++++++++++++---- test/unit/zones.test.js | 50 +++++++++ 5 files changed, 219 insertions(+), 39 deletions(-) create mode 100644 test/unit/zones.test.js diff --git a/src/content/controls/seekbar.js b/src/content/controls/seekbar.js index 2a17478..d78cad6 100644 --- a/src/content/controls/seekbar.js +++ b/src/content/controls/seekbar.js @@ -1,7 +1,6 @@ import { formatClock, formatRemaining } from '../../lib/time.js'; import { el } from '../shell.js'; -const BAR_CENTER_FROM_BOTTOM_PX = 34; const PREVIEW_INTERVAL_MS = 120; // Pull the finger away from the bar and the same swipe covers less time, so a @@ -63,12 +62,19 @@ export const createSeekBar = (video) => { root.classList.add('is-scrubbing'); }; + // Measured rather than assumed: the bar sits a fifth of the way up the + // screen, and where exactly that lands depends on the phone. + const barCenter = (fallback) => { + const rect = track.getBoundingClientRect(); + if (rect.height === 0) return fallback; + return rect.top + rect.height / 2; + }; + const move = ({ dx, y, width, height }) => { const total = duration(); if (total === 0) return; - const barCenter = height - BAR_CENTER_FROM_BOTTOM_PX; - const step = precisionFor(Math.abs(y - barCenter)); + const step = precisionFor(Math.abs(y - barCenter(height))); scrubTime += (dx / width) * total * step.factor; scrubTime = Math.min(total, Math.max(0, scrubTime)); paint(scrubTime); diff --git a/src/content/gestures/recognizer.js b/src/content/gestures/recognizer.js index 402d54c..77c71db 100644 --- a/src/content/gestures/recognizer.js +++ b/src/content/gestures/recognizer.js @@ -60,9 +60,10 @@ export const createRecognizer = (surface, handlers) => { }; const registerTap = (zone) => { - // A tap in the pause box acts at once; the side boxes have to wait out the - // window because a second tap there means "seek", not "show the controls". - if (zone === ZONE.PAUSE) { + // A tap on bare picture acts at once — it only brings the controls up or + // puts them away. The side boxes have to wait the window out, because a + // second tap there means "seek", not "show the controls". + if (zone === ZONE.DEAD) { emit('tap', { zone }); return; } diff --git a/src/content/gestures/zones.js b/src/content/gestures/zones.js index 3e5deea..4edc5f6 100644 --- a/src/content/gestures/zones.js +++ b/src/content/gestures/zones.js @@ -1,18 +1,23 @@ export const ZONE = { SEEK: 'seek', - PAUSE: 'pause', HOLD_LEFT: 'holdLeft', HOLD_RIGHT: 'holdRight', DEAD: 'dead', }; -// Bounded targets with dead space between them: a thumb landing off-target -// does nothing rather than triggering the nearest control. -const SEEK_BAND_PX = 72; +// Every gesture target is a bounded box with dead space around it. A thumb that +// lands off-target does nothing at all, which is the only way to stop the +// player from acting on a touch that was never meant for it. +// +// The bottom fifth of the screen belongs to Android: that is where the home +// swipe starts, and a seek band reaching into it turned "put the app away" into +// "jump to the middle of the film". Everything of ours stays above it. +const SEEK_TOP = 0.72; +const SEEK_BOTTOM = 0.88; const BOXES = [ - { zone: ZONE.HOLD_LEFT, center: 0.23, width: 0.18, top: 0.2, bottom: 0.74 }, - { zone: ZONE.HOLD_RIGHT, center: 0.8, width: 0.18, top: 0.2, bottom: 0.74 }, + { zone: ZONE.HOLD_LEFT, center: 0.23, width: 0.18, top: 0.18, bottom: 0.56 }, + { zone: ZONE.HOLD_RIGHT, center: 0.8, width: 0.18, top: 0.18, bottom: 0.56 }, ]; const isInsideBox = (box, x, y, width, height) => { @@ -22,15 +27,15 @@ const isInsideBox = (box, x, y, width, height) => { return y >= box.top * height && y <= box.bottom * height; }; -// Pause is the fallback rather than a box of its own: it is the hidden button -// the whole picture acts as, so it cannot be missed. +// Pausing is not a zone any more. It is the play button and nothing else, so a +// tap on the picture can only ever bring the controls up or put them away. export const hitTest = (x, y, width, height) => { - if (y >= height - SEEK_BAND_PX) return ZONE.SEEK; + if (y >= SEEK_TOP * height && y <= SEEK_BOTTOM * height) return ZONE.SEEK; for (const box of BOXES) { if (isInsideBox(box, x, y, width, height)) return box.zone; } - return ZONE.PAUSE; + return ZONE.DEAD; }; export const isDragZone = (zone) => zone === ZONE.SEEK; diff --git a/src/content/player.css b/src/content/player.css index e3efc6b..c64f6c0 100644 --- a/src/content/player.css +++ b/src/content/player.css @@ -11,6 +11,19 @@ -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; user-select: none; + + /* One curve for everything that moves: slow to leave, quick to arrive, the + way a sheet slides on a phone. */ + --ease: cubic-bezier(0.32, 0.72, 0, 1); + --chrome-fade: 280ms; + --glass: rgba(20, 19, 26, 0.62); + --shadow-soft: 0 10px 34px rgba(0, 0, 0, 0.42); + --shadow-deep: 0 18px 52px rgba(0, 0, 0, 0.55); + --icon-shadow: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.65)); + + /* The seek bar and everything that lines up with it stay clear of the + strip along the bottom where the Android home swipe lives. */ + --seek-lift: 12%; } .layer { @@ -32,9 +45,33 @@ transition: opacity 200ms ease; } +/* Depth under the controls rather than a flat wash: the film keeps playing + underneath, and the buttons still have to be legible over a white scene. */ +.scrim { + background: + linear-gradient( + to bottom, + rgba(0, 0, 0, 0.5) 0%, + rgba(0, 0, 0, 0.16) 16%, + rgba(0, 0, 0, 0) 34% + ), + linear-gradient( + to top, + rgba(0, 0, 0, 0.62) 0%, + rgba(0, 0, 0, 0.22) 22%, + rgba(0, 0, 0, 0) 46% + ), + radial-gradient( + ellipse 70% 55% at 50% 50%, + rgba(0, 0, 0, 0.26) 0%, + rgba(0, 0, 0, 0) 100% + ); + pointer-events: none; +} + .chrome { opacity: 1; - transition: opacity 220ms ease; + transition: opacity var(--chrome-fade) var(--ease); } .chrome[hidden] { @@ -43,6 +80,26 @@ pointer-events: none; } +/* Each band leaves in the direction it came from, so bringing the controls + back reads as one movement instead of a flash. */ +.topbar, +.seekbar, +.centre-row { + transition: transform 340ms var(--ease); +} + +.chrome[hidden] .topbar { + transform: translateY(-12px); +} + +.chrome[hidden] .seekbar { + transform: translateY(16px); +} + +.chrome[hidden] .centre-row { + transform: translate(-50%, -50%) scale(0.94); +} + .topbar { position: absolute; top: 0; @@ -54,7 +111,6 @@ padding: 14px 0; padding-left: max(30px, env(safe-area-inset-left)); padding-right: max(30px, env(safe-area-inset-right)); - background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent); pointer-events: auto; } @@ -73,11 +129,11 @@ background: none; color: #fff; cursor: pointer; - transition: transform 160ms ease; + transition: transform 220ms var(--ease); } .button:active { - transform: scale(0.92); + transform: scale(0.88); } .button[aria-pressed='true'] { @@ -94,6 +150,7 @@ stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; + filter: var(--icon-shadow); } .surface { @@ -107,8 +164,8 @@ position: absolute; left: 0; right: 0; - bottom: 0; - padding: 0 max(30px, env(safe-area-inset-right)) 26px + bottom: var(--seek-lift); + padding: 0 max(30px, env(safe-area-inset-right)) 0 max(30px, env(safe-area-inset-left)); pointer-events: none; } @@ -123,7 +180,7 @@ min-width: 58px; font-size: 13px; font-variant-numeric: tabular-nums; - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85); + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9); } .time:last-child { @@ -135,7 +192,10 @@ flex: 1; height: 14px; border-radius: 999px; - background: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.26); + box-shadow: + 0 2px 10px rgba(0, 0, 0, 0.4), + inset 0 0 0 0.5px rgba(255, 255, 255, 0.18); overflow: hidden; } @@ -151,18 +211,19 @@ position: absolute; top: 50%; left: 50%; - transform: translate(-50%, -50%) scale(0.9); + transform: translate(-50%, -50%) scale(0.92); padding: 10px 18px; - border-radius: 14px; - background: rgba(20, 19, 26, 0.62); + border-radius: 16px; + background: var(--glass); + box-shadow: var(--shadow-soft); backdrop-filter: blur(18px); font-size: 16px; font-variant-numeric: tabular-nums; opacity: 0; pointer-events: none; transition: - opacity 160ms ease, - transform 160ms ease; + opacity 200ms var(--ease), + transform 260ms var(--ease); } .toast.is-visible { @@ -177,17 +238,31 @@ right: max(30px, env(safe-area-inset-right)); width: min(330px, 50vw); padding: 10px 12px; - border-radius: 16px; + border-radius: 18px; background: rgba(20, 19, 26, 0.66); + box-shadow: var(--shadow-deep); backdrop-filter: blur(22px); display: none; flex-direction: column; gap: 6px; pointer-events: auto; + transform-origin: top right; } .panel.is-open { display: flex; + animation: panel-in 260ms var(--ease); +} + +@keyframes panel-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.96); + } + to { + opacity: 1; + transform: none; + } } .step-row { @@ -222,11 +297,15 @@ font-size: 20px; line-height: 1; cursor: pointer; + transition: + background 160ms var(--ease), + transform 200ms var(--ease); } .step-button:active { background: rgba(236, 236, 242, 0.9); color: #16151c; + transform: scale(0.94); } .step-button:disabled { @@ -291,6 +370,13 @@ font: inherit; font-size: 13px; cursor: pointer; + transition: + background 160ms var(--ease), + transform 200ms var(--ease); +} + +.chip:active { + transform: scale(0.94); } .chip[aria-pressed='true'] { @@ -312,7 +398,7 @@ position: absolute; left: 8%; right: 8%; - bottom: 78px; + bottom: calc(var(--seek-lift) + 46px); text-align: center; font-size: calc(20px * var(--cue-scale)); line-height: 1.35; @@ -322,13 +408,16 @@ 0 0 2px rgba(0, 0, 0, 0.9); opacity: 0; pointer-events: none; + transition: opacity 180ms var(--ease); } .cue.is-visible { opacity: 1; } -/* A flex child of the centre row, so it lines up with the skip buttons. */ +/* A flex child of the centre row, so it lines up with the skip buttons. The + pause target is this button and nothing else — tapping the picture only + brings the controls up or puts them away. */ .play-button { width: 114px; height: 114px; @@ -342,7 +431,7 @@ color: #fff; cursor: pointer; pointer-events: auto; - transition: transform 160ms ease; + transition: transform 220ms var(--ease); } .play-button svg { @@ -354,7 +443,7 @@ stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; - stroke-linejoin: round; + filter: var(--icon-shadow); } .centre-row { @@ -378,7 +467,10 @@ pointer-events: none; } -.play-button:active, +.play-button:active { + transform: scale(0.92); +} + .skip-button:active { transform: scale(0.9); } @@ -394,7 +486,7 @@ background: none; color: #fff; cursor: pointer; - transition: transform 160ms ease; + transition: transform 220ms var(--ease); } .seek-area { @@ -411,20 +503,24 @@ position: absolute; bottom: calc(100% + 12px); left: var(--progress); - transform: translateX(-50%); + transform: translateX(-50%) scale(0.94); padding: 4px 10px; - border-radius: 8px; + border-radius: 10px; background: rgba(20, 19, 26, 0.82); + box-shadow: var(--shadow-soft); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; opacity: 0; - transition: opacity 140ms ease; + transition: + opacity 160ms var(--ease), + transform 220ms var(--ease); pointer-events: none; } .seekbar.is-scrubbing .seek-time { opacity: 1; + transform: translateX(-50%) scale(1); } .skip-button svg { @@ -435,8 +531,30 @@ stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; + filter: var(--icon-shadow); } .skip-button text { font-family: inherit; } + +@media (prefers-reduced-motion: reduce) { + .chrome, + .topbar, + .seekbar, + .centre-row, + .toast, + .cue, + .button, + .chip, + .step-button, + .play-button, + .skip-button, + .seek-time { + transition-duration: 1ms; + } + + .panel.is-open { + animation: none; + } +} diff --git a/test/unit/zones.test.js b/test/unit/zones.test.js new file mode 100644 index 0000000..065dbd3 --- /dev/null +++ b/test/unit/zones.test.js @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + hitTest, + isDragZone, + isHoldZone, + ZONE, +} from '../../src/content/gestures/zones.js'; + +const WIDTH = 800; +const HEIGHT = 360; + +const zoneAt = (xRatio, yRatio) => + hitTest(xRatio * WIDTH, yRatio * HEIGHT, WIDTH, HEIGHT); + +test('the bottom of the screen belongs to the system, not the player', () => { + for (const yRatio of [0.89, 0.94, 0.99]) { + assert.equal(zoneAt(0.5, yRatio), ZONE.DEAD); + assert.equal(zoneAt(0.05, yRatio), ZONE.DEAD); + assert.equal(zoneAt(0.95, yRatio), ZONE.DEAD); + } +}); + +test('the seek band sits above the home gesture', () => { + assert.equal(zoneAt(0.5, 0.73), ZONE.SEEK); + assert.equal(zoneAt(0.5, 0.87), ZONE.SEEK); + assert.equal(zoneAt(0.02, 0.8), ZONE.SEEK); +}); + +test('the middle of the picture is not a pause button', () => { + assert.equal(zoneAt(0.5, 0.5), ZONE.DEAD); + assert.equal(zoneAt(0.5, 0.1), ZONE.DEAD); + assert.equal(ZONE.PAUSE, undefined); +}); + +test('the hold boxes stay clear of the seek band', () => { + assert.equal(zoneAt(0.23, 0.4), ZONE.HOLD_LEFT); + assert.equal(zoneAt(0.8, 0.4), ZONE.HOLD_RIGHT); + assert.equal(zoneAt(0.23, 0.8), ZONE.SEEK); + assert.equal(zoneAt(0.8, 0.8), ZONE.SEEK); +}); + +test('only the seek band drags and only the side boxes hold', () => { + assert.ok(isDragZone(ZONE.SEEK)); + assert.ok(!isDragZone(ZONE.DEAD)); + assert.ok(isHoldZone(ZONE.HOLD_LEFT)); + assert.ok(isHoldZone(ZONE.HOLD_RIGHT)); + assert.ok(!isHoldZone(ZONE.DEAD)); +}); From c22b08721d4c1a54ab967b6f02550978c8af7adc Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Thu, 6 Aug 2026 10:40:46 +0300 Subject: [PATCH 04/23] feat(video): quality, site captions and picture-in-picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything a site's own player knows about itself lives in the page's JavaScript. Firefox lets a content script read that through waived Xray wrappers, so the extension asks the player rather than re-implementing it: no code is injected into the page and nothing is evaluated from a string. Quality is an adapter per engine — lists, YouTube, hls.js, dash.js, Shaka — probed again each time the sheet opens, because a stream only learns its ladder after the first segments land. A player that exposes no way in reports the resolution it is playing instead of offering a choice that would do nothing. Captions cover the players that paint their own instead of exposing a text track. YouTube's list comes from its player API and the text it renders is mirrored into our cue layer, which is why subtitles were missing there. The picture used to slide to an edge or stretch across the screen because the site kept rewriting the video's inline style. Position and offsets are now pinned with the size and re-pinned whenever the site writes over them, and the zoom is re-derived from the viewer's intent on every relayout. Losing fullscreen while the app is on its way to the background is what Android's floating-window hand-off looks like, so it is no longer read as the viewer leaving the player. The button uses the standard API where Gecko has one, and a Fullscreen switch in the sheet runs the player as an overlay for anyone who would rather keep the ordinary single swipe home. Pausing no longer fades the picture to grey: people pause to look at it. And the subtitle file button now has the handler it was always missing. --- eslint.config.js | 9 +- src/content/controls/menu.js | 145 ++++++++---- src/content/session.js | 176 +++++++++++++-- src/content/ui.js | 89 +++++--- src/content/video/pageapi.js | 109 +++++++++ src/content/video/quality.js | 75 ++++--- src/content/video/qualityadapters.js | 318 +++++++++++++++++++++++++++ src/content/video/sitecaptions.js | 183 +++++++++++++++ src/content/video/tracks.js | 51 ++++- src/content/video/visuals.js | 111 ++++++---- 10 files changed, 1089 insertions(+), 177 deletions(-) create mode 100644 src/content/video/pageapi.js create mode 100644 src/content/video/qualityadapters.js create mode 100644 src/content/video/sitecaptions.js diff --git a/eslint.config.js b/eslint.config.js index 7d48fb6..6fae826 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,7 +19,14 @@ export default [ { files: ['src/**/*.js'], languageOptions: { - globals: { ...globals.browser, ...globals.webextensions }, + globals: { + ...globals.browser, + ...globals.webextensions, + // Firefox gives content scripts these for talking to the page's own + // compartment; they are not part of the standard browser globals. + cloneInto: 'readonly', + exportFunction: 'readonly', + }, }, }, { diff --git a/src/content/controls/menu.js b/src/content/controls/menu.js index 7aa2037..26495b4 100644 --- a/src/content/controls/menu.js +++ b/src/content/controls/menu.js @@ -4,7 +4,19 @@ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2]; const SUBTITLE_SCALES = [0.8, 1, 1.25, 1.6]; const SYNC_STEP_SECONDS = 0.5; -const buildChips = (label, options, onSelect) => { +// A row of chips whose contents are repainted rather than rebuilt, because a +// streaming site only learns what it can offer after the film has started and +// the sheet has to be able to say so the next time it opens. +const buildChipRow = (label) => { + const chips = el('div', { class: 'chips' }); + const row = el('div', { class: 'menu-row' }, [ + el('span', { class: 'menu-label', text: label }), + chips, + ]); + return { row, chips }; +}; + +const paintChips = (holder, options, onSelect) => { const chips = options.map((option) => el('button', { class: 'chip', @@ -14,15 +26,9 @@ const buildChips = (label, options, onSelect) => { }), ); - const row = el('div', { class: 'menu-row' }, [ - el('span', { class: 'menu-label', text: label }), - el('div', { class: 'chips' }, chips), - ]); - const setActive = (id) => { for (const chip of chips) { - const isActive = chip.dataset.id === String(id); - chip.setAttribute('aria-pressed', String(isActive)); + chip.setAttribute('aria-pressed', String(chip.dataset.id === String(id))); } }; @@ -35,9 +41,12 @@ const buildChips = (label, options, onSelect) => { }); } - return { row, setActive }; + holder.chips.replaceChildren(...chips); + return setActive; }; +const buildNote = (text) => el('span', { class: 'menu-note', text }); + const buildStepper = (label, onStep, initial) => { const value = el('span', { class: 'step-value', text: initial }); const minus = el('button', { @@ -64,29 +73,64 @@ const buildStepper = (label, onStep, initial) => { ]); }; +const buildToggle = (label, isOn, onToggle) => { + const chip = el('button', { class: 'chip', type: 'button', text: label }); + chip.setAttribute('aria-pressed', String(isOn)); + chip.addEventListener('click', () => { + const next = chip.getAttribute('aria-pressed') !== 'true'; + chip.setAttribute('aria-pressed', String(next)); + onToggle(next); + }); + return chip; +}; + export const createMenu = ({ video, tracks, quality, + settings, onStyle, onPickFile, onRate, + onImmersive, }) => { - const speed = buildChips( - 'Speed', + const speedRow = buildChipRow('Speed'); + const setSpeed = paintChips( + speedRow, SPEEDS.map((rate) => ({ id: rate, label: `${rate}x` })), (rate) => { video.playbackRate = rate; onRate(rate); }, ); - speed.setActive(video.playbackRate); + setSpeed(video.playbackRate); - const subtitleOptions = [{ id: -1, label: 'Off' }, ...tracks.list()]; - const subtitles = buildChips('Subtitles', subtitleOptions, (id) => { - tracks.select(id); - }); - subtitles.setActive(tracks.getSelected()); + const subtitleRow = buildChipRow('Subtitles'); + let setSubtitle = () => {}; + + const paintSubtitles = () => { + const options = [{ id: -1, label: 'Off' }, ...tracks.list()]; + setSubtitle = paintChips(subtitleRow, options, (id) => tracks.select(id)); + setSubtitle(tracks.getSelected()); + }; + + const qualityRow = buildChipRow('Quality'); + + // The site keeps the ladder; all we do is ask for a rung. When there is no + // way in, the row says what is playing instead of pretending to offer a + // choice that would do nothing. + const paintQuality = () => { + quality.refresh(); + const options = quality.getOptions(); + if (options.length === 0) { + qualityRow.chips.replaceChildren(buildNote(quality.describe())); + return; + } + const setQuality = paintChips(qualityRow, options, (id) => { + quality.select(id); + }); + setQuality(quality.getCurrent()); + }; let scaleIndex = SUBTITLE_SCALES.indexOf(1); const sizeRow = buildStepper( @@ -119,51 +163,58 @@ export const createMenu = ({ }); loadButton.addEventListener('click', onPickFile); - const nativeToggle = el('button', { - class: 'chip', - type: 'button', - text: 'Native rendering', - }); - nativeToggle.addEventListener('click', () => { - const next = !tracks.isNative(); - tracks.setNative(next); - nativeToggle.setAttribute('aria-pressed', String(next)); + const nativeToggle = buildToggle('Native', tracks.isNative(), () => { + tracks.setNative(!tracks.isNative()); }); - const qualityRow = quality.isSwitchable() - ? buildChips('Quality', quality.options, (id) => quality.select(id)).row - : el('div', { class: 'menu-row' }, [ - el('span', { class: 'menu-label', text: 'Quality' }), - el('span', { class: 'menu-note', text: quality.describe() }), - ]); + const immersiveToggle = buildToggle( + 'Fullscreen', + settings.isFullscreenTakeoverOn, + onImmersive, + ); + + paintSubtitles(); + paintQuality(); const root = el('div', { class: 'panel menu' }, [ - speed.row, - subtitles.row, + speedRow.row, + subtitleRow.row, sizeRow, syncRow, el('div', { class: 'menu-row' }, [ el('span', { class: 'menu-label', text: 'Source' }), el('div', { class: 'chips' }, [loadButton, nativeToggle]), ]), - qualityRow, + qualityRow.row, + el('div', { class: 'menu-row' }, [ + el('span', { class: 'menu-label', text: 'Screen' }), + el('div', { class: 'chips' }, [immersiveToggle]), + ]), ]); + // Both lists can grow while the film plays — a caption track switched on in + // the site's own player, a quality ladder that finished loading — so they are + // read again every time the sheet is opened. + const refresh = () => { + paintSubtitles(); + paintQuality(); + }; + + const open = () => { + refresh(); + root.classList.add('is-open'); + }; + return { root, - toggle: () => root.classList.toggle('is-open'), + refresh, + open, + toggle: () => { + if (root.classList.contains('is-open')) root.classList.remove('is-open'); + else open(); + }, close: () => root.classList.remove('is-open'), isOpen: () => root.classList.contains('is-open'), - setSubtitle: (id) => subtitles.setActive(id), - refreshSubtitles: (id) => { - const options = [{ id: -1, label: 'Off' }, ...tracks.list()]; - const rebuilt = buildChips('Subtitles', options, (next) => - tracks.select(next), - ); - subtitles.row.replaceWith(rebuilt.row); - subtitles.row = rebuilt.row; - subtitles.setActive = rebuilt.setActive; - rebuilt.setActive(id); - }, + setSubtitle: (id) => setSubtitle(id), }; }; diff --git a/src/content/session.js b/src/content/session.js index 1fdd185..1e2efdb 100644 --- a/src/content/session.js +++ b/src/content/session.js @@ -3,6 +3,8 @@ import { createShadowHost, el, pinStyle } from './shell.js'; import playerCss from './player.css'; const STALL_GRACE_MS = 400; +const EMPTIED_GRACE_MS = 600; +const RECOVER_DELAY_MS = 250; const STAGE_STYLE = { position: 'fixed', @@ -20,7 +22,19 @@ const STAGE_STYLE = { 'z-index': '2147483646', }; +// A site's own player keeps rewriting the video's inline style — YouTube sets +// a pixel width, height and offset on every layout pass — which is what used to +// leave the picture stuck against the left edge or stretched across the screen +// after coming back from another app. Position and offsets are pinned here too, +// not only the size, and re-pinned whenever the site writes over them. const VIDEO_STYLE = { + inset: 'auto', + position: 'relative', + left: '0', + top: '0', + right: 'auto', + bottom: 'auto', + float: 'none', width: '100%', height: '100%', 'max-width': 'none', @@ -29,12 +43,16 @@ const VIDEO_STYLE = { 'min-height': '0', margin: '0', padding: '0', + border: '0', display: 'block', 'object-fit': 'contain', + 'object-position': '50% 50%', background: '#000', 'transform-origin': 'center center', }; +const VIDEO_STYLE_KEYS = Object.keys(VIDEO_STYLE); + const captureVideoState = (video) => ({ cssText: video.style.cssText, hasControlsAttribute: video.hasAttribute('controls'), @@ -66,10 +84,22 @@ const auditRestore = (video, state) => { return false; }; +// Written only when it has actually been disturbed, so re-pinning cannot chase +// its own mutation record round in a loop. +const isPinned = (video) => + VIDEO_STYLE_KEYS.every( + (name) => + video.style.getPropertyValue(name) === VIDEO_STYLE[name] && + video.style.getPropertyPriority(name) === 'important', + ); + +// navigationUI is deliberately left at its default. Asking Gecko to hide it put +// Android into sticky immersive mode, where the first swipe up only brings the +// system bars back and a second one is needed to leave the app. const requestFullscreen = async (element) => { if (!document.fullscreenEnabled) return false; try { - await element.requestFullscreen({ navigationUI: 'hide' }); + await element.requestFullscreen(); return true; } catch (error) { console.warn('Nocturne: fullscreen refused', error); @@ -94,6 +124,12 @@ const unlockOrientation = () => { } }; +// Losing fullscreen is how the user leaves the player, and it is also how +// Android announces that it is taking the video into a floating window. The two +// look identical at the moment they happen and only differ a beat later, when +// an app that has gone into the background is no longer the focused one. +const isBackgrounded = () => document.hidden || !document.hasFocus(); + export const createSession = (video, { onExit, settings, onPersist }) => { const state = captureVideoState(video); const anchor = document.createComment('nocturne-player'); @@ -101,11 +137,14 @@ export const createSession = (video, { onExit, settings, onPersist }) => { const ui = createShadowHost(playerCss); const teardown = []; + const timers = new Set(); let isActive = false; let isOrientationLocked = false; - let stallTimer = null; + let isFullscreenWanted = settings.isFullscreenTakeoverOn; + let styleGuard = null; let overlay = null; + let relayoutFrame = 0; const layers = { warm: el('div', { class: 'layer warm' }), @@ -117,13 +156,46 @@ export const createSession = (video, { onExit, settings, onPersist }) => { teardown.push(() => target.removeEventListener(type, handler, options)); }; + const later = (handler, delay) => { + const timer = setTimeout(() => { + timers.delete(timer); + handler(); + }, delay); + timers.add(timer); + return timer; + }; + + const pinVideo = () => { + if (isPinned(video)) return; + pinStyle(video, VIDEO_STYLE); + }; + + const relayout = () => { + relayoutFrame = 0; + pinVideo(); + if (overlay) overlay.relayout(); + }; + + const scheduleRelayout = () => { + if (relayoutFrame !== 0) return; + relayoutFrame = requestAnimationFrame(relayout); + }; + const mount = () => { stage.dataset.nocturnePlayer = ''; pinStyle(stage, STAGE_STYLE); video.replaceWith(anchor); video.removeAttribute('controls'); - pinStyle(video, VIDEO_STYLE); + pinVideo(); + + // The site is free to keep laying its player out; it just does not get to + // move the picture we are showing. + styleGuard = new MutationObserver(pinVideo); + styleGuard.observe(video, { + attributes: true, + attributeFilter: ['style', 'width', 'height'], + }); stage.append(video, ui.host); ui.shadow.append(layers.warm, layers.dim); @@ -131,6 +203,8 @@ export const createSession = (video, { onExit, settings, onPersist }) => { }; const unmount = () => { + if (styleGuard) styleGuard.disconnect(); + styleGuard = null; if (overlay) overlay.destroy(); overlay = null; stage.remove(); @@ -143,8 +217,10 @@ export const createSession = (video, { onExit, settings, onPersist }) => { if (!isActive) return; isActive = false; - if (stallTimer !== null) clearTimeout(stallTimer); - stallTimer = null; + for (const timer of timers) clearTimeout(timer); + timers.clear(); + if (relayoutFrame !== 0) cancelAnimationFrame(relayoutFrame); + relayoutFrame = 0; for (const undo of teardown) undo(); teardown.length = 0; @@ -160,18 +236,71 @@ export const createSession = (video, { onExit, settings, onPersist }) => { onExit(); }; + // Gecko keeps a media element playing across a re-parent, but a site shim + // that reloads it leaves us holding an empty player. A quality switch empties + // the element too, so the verdict waits until the dust settles. + const watchForTeardown = () => { + listen(video, 'emptied', () => { + later(() => { + const hasSource = video.currentSrc !== '' || video.srcObject !== null; + if (hasSource || video.readyState > 0) return; + console.warn('Nocturne: the video was torn down, backing out'); + exit(); + }, EMPTIED_GRACE_MS); + }); + }; + + const applyLandscape = async () => { + if (!settings.isAutoLandscapeOn) return; + const isLocked = await lockLandscape(); + if (isLocked) isOrientationLocked = true; + }; + + const restoreFullscreen = () => { + if (!isFullscreenWanted) return; + if (document.fullscreenElement === stage) return; + // Gecko may refuse this without a fresh gesture. The stage covers the + // viewport on its own, so the player stays usable either way. + requestFullscreen(stage).then((isOn) => { + if (isOn) applyLandscape(); + scheduleRelayout(); + }); + }; + + const watchForReturn = () => { + listen(document, 'fullscreenchange', () => { + if (document.fullscreenElement === stage) return; + later(() => { + if (!isActive) return; + if (isBackgrounded()) return; + exit(); + }, RECOVER_DELAY_MS); + }); + + // Coming back from a floating window or from another app: re-take the + // screen and re-fit the picture to whatever shape it is now. + listen(document, 'visibilitychange', () => { + if (document.hidden) return; + later(() => { + if (!isActive) return; + restoreFullscreen(); + scheduleRelayout(); + }, RECOVER_DELAY_MS); + }); + + listen(window, 'resize', scheduleRelayout); + listen(window, 'orientationchange', scheduleRelayout); + listen(video, 'loadedmetadata', scheduleRelayout); + listen(video, 'resize', scheduleRelayout); + }; + const enter = async () => { if (isActive) return false; isActive = true; - // Gecko keeps a media element playing across a re-parent, but if some site - // shim reloads it we back out rather than leave the page in a broken state. - listen(video, 'emptied', () => { - console.warn('Nocturne: the video reloaded when moved, backing out'); - exit(); - }); - + watchForTeardown(); mount(); + overlay = createOverlay({ video, stage, @@ -180,20 +309,27 @@ export const createSession = (video, { onExit, settings, onPersist }) => { onExit: exit, settings, onPersist, - wasPlaying: state.wasPlaying, + onImmersiveChange: (isOn) => { + isFullscreenWanted = isOn; + if (isOn) { + restoreFullscreen(); + } else if (document.fullscreenElement === stage) { + document.exitFullscreen().catch(() => {}); + } + }, }); - stallTimer = setTimeout(() => { - stallTimer = null; + later(() => { if (state.wasPlaying && video.paused) video.play().catch(() => {}); }, STALL_GRACE_MS); - listen(document, 'fullscreenchange', () => { - if (document.fullscreenElement !== stage) exit(); - }); + watchForReturn(); - await requestFullscreen(stage); - isOrientationLocked = await lockLandscape(); + if (isFullscreenWanted) { + const isOn = await requestFullscreen(stage); + if (isOn) await applyLandscape(); + } + scheduleRelayout(); return true; }; diff --git a/src/content/ui.js b/src/content/ui.js index e537bf5..325f9b3 100644 --- a/src/content/ui.js +++ b/src/content/ui.js @@ -12,6 +12,7 @@ const CHROME_IDLE_MS = 3000; const SCRUB_STEP_SECONDS = 0.2; const SCRUB_TICK_MS = 100; const TOAST_MS = 900; +const HINT_MS = 2200; const SKIP_SECONDS = 10; const FILL_RETRY_MS = 400; @@ -19,7 +20,7 @@ const ICON = { exit: 'M6 6l12 12M18 6L6 18', colour: 'M12 3a9 9 0 1 0 0 18 2.5 2.5 0 0 0 0-5h-1a2 2 0 0 1 0-4h3a5 5 0 0 0 0-9z', - pip: 'M3 5h18v14H3zM12 12h7v5h-7z', + pip: 'M4 5h16v14H4zM12.5 12.5h6v5h-6z', menu: 'M4 7h16M4 12h16M4 17h16', play: 'M8 5 19 12 8 19z', pause: 'M6 5h3.6v14H6zM14.4 5h3.6v14h-3.6z', @@ -72,9 +73,10 @@ export const createOverlay = ({ onExit, settings, onPersist, - wasPlaying, + onImmersiveChange, }) => { const surface = el('div', { class: 'layer surface' }); + const scrim = el('div', { class: 'layer scrim' }); const toast = el('div', { class: 'toast' }); const topbar = el('div', { class: 'topbar' }); const chrome = el('div', { class: 'chrome' }); @@ -94,7 +96,7 @@ export const createOverlay = ({ // were watching, not to the next one. video.playbackRate = 1; - const visuals = createVisuals(video, stage, wasPlaying); + const visuals = createVisuals(video, stage); const quality = createQuality(video); const tracks = createTrackManager( video, @@ -113,14 +115,14 @@ export const createOverlay = ({ let pinchBase = 1; let wasPlayingBeforeScrub = false; - const showToast = (text) => { + const showToast = (text, duration = TOAST_MS) => { toast.textContent = text; toast.classList.add('is-visible'); if (toastTimer !== null) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastTimer = null; toast.classList.remove('is-visible'); - }, TOAST_MS); + }, duration); }; const applyWarmth = (value) => { @@ -137,12 +139,17 @@ export const createOverlay = ({ video, tracks, quality, + settings, onStyle: ({ scale }) => { cueBox.style.setProperty('--cue-scale', String(scale)); onPersist({ subtitleScale: scale }); }, onPickFile: () => filePicker.click(), onRate: () => {}, + onImmersive: (isOn) => { + onImmersiveChange(isOn); + onPersist({ isFullscreenTakeoverOn: isOn }); + }, }); menuRef.setSubtitle = menu.setSubtitle; @@ -165,7 +172,6 @@ export const createOverlay = ({ const closePanels = () => { colorPanel.close(); menu.close(); - visuals.suppressFade(false); buttons.colour?.setAttribute('aria-pressed', 'false'); buttons.menu?.setAttribute('aria-pressed', 'false'); setChromeVisible(true); @@ -190,7 +196,6 @@ export const createOverlay = ({ if (scrubTimer === null) return; clearInterval(scrubTimer); scrubTimer = null; - visuals.suppressFade(false); if (wasPlayingBeforeScrub) video.play().catch(() => {}); }; @@ -201,7 +206,6 @@ export const createOverlay = ({ stopScrub(); wasPlayingBeforeScrub = !video.paused; video.pause(); - visuals.suppressFade(true); scrubTimer = setInterval(() => { const limit = Number.isFinite(video.duration) ? video.duration @@ -214,13 +218,12 @@ export const createOverlay = ({ const dragTargets = { [ZONE.SEEK]: seekBar }; const recognizer = createRecognizer(surface, { - tap: ({ zone }) => { + tap: () => { if (isPanelOpen()) { closePanels(); return; } - if (zone === ZONE.PAUSE) togglePlay(); - else setChromeVisible(chrome.hasAttribute('hidden')); + setChromeVisible(chrome.hasAttribute('hidden')); }, multiTap: ({ zone, count }) => { if (isPanelOpen()) { @@ -283,43 +286,78 @@ export const createOverlay = ({ menu.close(); buttons.menu.setAttribute('aria-pressed', 'false'); colorPanel.toggle(); - const isOpen = colorPanel.isOpen(); - buttons.colour.setAttribute('aria-pressed', String(isOpen)); - visuals.suppressFade(isOpen); + buttons.colour.setAttribute('aria-pressed', String(colorPanel.isOpen())); setChromeVisible(true); }); buttons.menu = buildButton('Settings', ICON.menu, () => { colorPanel.close(); - visuals.suppressFade(false); buttons.colour.setAttribute('aria-pressed', 'false'); menu.toggle(); buttons.menu.setAttribute('aria-pressed', String(menu.isOpen())); setChromeVisible(true); }); + // Where a thumb already is when the phone is held in landscape. Gecko has no + // web API for the floating window on Android — the system offers it when you + // leave the app with a video playing — so the button uses the standard API + // where it exists and otherwise sets the video up and says what to do. + const enterPictureInPicture = () => { + setChromeVisible(true); + if (video.paused) video.play().catch(() => {}); + if (typeof video.requestPictureInPicture !== 'function') { + showToast('Swipe up to keep it floating', HINT_MS); + return; + } + video.requestPictureInPicture().catch(() => { + showToast('Swipe up to keep it floating', HINT_MS); + }); + }; + topbar.append( buildButton('Exit player', ICON.exit, () => onExit()), el('div', { class: 'spacer' }), buttons.colour, buttons.menu, + buildButton('Picture in picture', ICON.pip, enterPictureInPicture), ); - // Firefox for Android has no Picture-in-Picture API yet, so the button - // appears on its own once Gecko ships one. - if (document.pictureInPictureEnabled) { - const pip = buildButton('Picture in picture', ICON.pip, () => { - video.requestPictureInPicture().catch(() => {}); - }); - topbar.insertBefore(pip, buttons.colour); - } + chrome.append( + scrim, + topbar, + centreRow, + colorPanel.root, + menu.root, + seekBar.root, + ); - chrome.append(topbar, centreRow, colorPanel.root, menu.root, seekBar.root); + // Reading a file the user picked themselves, straight into the cue list. + // Nothing leaves the device and nothing is parsed as markup. + const loadSubtitleFile = async () => { + const file = filePicker.files?.[0]; + if (!file) return; + const text = await file.text(); + filePicker.value = ''; + const id = tracks.addCues(file.name.replace(/\.(srt|vtt)$/i, ''), text); + if (id === null) { + showToast('No subtitles in that file', HINT_MS); + return; + } + tracks.select(id); + menu.refresh(); + showToast('Subtitles loaded', HINT_MS); + }; + + filePicker.addEventListener('change', () => { + loadSubtitleFile().catch((error) => { + console.error('Nocturne: could not read the subtitle file', error); + showToast('Could not read that file', HINT_MS); + }); + }); // Only the path data changes, so swapping play for pause cannot make the // button flicker or shift. const handlePlaybackChange = () => { - visuals.setPaused(video.paused); playPath.setAttribute('d', video.paused ? ICON.play : ICON.pause); setChromeVisible(true); }; @@ -351,6 +389,7 @@ export const createOverlay = ({ setChromeVisible(true); return { + relayout: () => visuals.relayout(), destroy: () => { recognizer.destroy(); seekBar.destroy(); diff --git a/src/content/video/pageapi.js b/src/content/video/pageapi.js new file mode 100644 index 0000000..c29ebec --- /dev/null +++ b/src/content/video/pageapi.js @@ -0,0 +1,109 @@ +// Everything a site's own player knows about itself — the quality ladder, the +// caption list — lives in the page's JavaScript, which a content script sees +// only through Gecko's Xray wrappers. Waiving those wrappers is the supported +// way in on Firefox, and it stays a read of objects the page already made: no +// code is injected into the page and nothing is evaluated from a string. +// +// Nothing here can throw at the caller. A site is free to have a getter that +// raises, a method that is missing, or a value from a compartment we cannot +// touch; every one of those means "this player cannot do that", not "the +// player is broken". +const MAX_SCANNED_KEYS = 500; + +export const pageWindow = () => window.wrappedJSObject ?? window; + +// Structured-cloned into the page's compartment so a page function can read it. +// Falls back to the plain value where the helper is absent, which is a context +// that already shares one compartment with the page. +export const toPage = (value) => { + if (typeof cloneInto !== 'function') return value; + try { + return cloneInto(value, pageWindow()); + } catch { + return value; + } +}; + +export const read = (object, key) => { + if (object === null || typeof object !== 'object') return null; + try { + const value = object[key]; + return value === undefined ? null : value; + } catch { + return null; + } +}; + +export const call = (object, name, ...args) => { + if (object === null || typeof object !== 'object') return null; + let method = null; + try { + method = object[name]; + } catch { + return null; + } + if (typeof method !== 'function') return null; + try { + const value = method.apply(object, args); + return value === undefined ? null : value; + } catch (error) { + console.warn(`Nocturne: ${name}() failed on the site's player`, error); + return null; + } +}; + +export const write = (object, key, value) => { + if (object === null || typeof object !== 'object') return false; + try { + object[key] = value; + return true; + } catch { + return false; + } +}; + +// Page arrays come from another compartment, so they are walked by index +// rather than spread: an iterator that throws must not take the player down. +export const toArray = (value) => { + const length = read(value, 'length'); + if (typeof length !== 'number' || !Number.isFinite(length)) return []; + const items = []; + for (let index = 0; index < length; index++) { + items.push(read(value, index)); + } + return items; +}; + +export const isFunction = (object, name) => { + if (object === null || typeof object !== 'object') return false; + try { + return typeof object[name] === 'function'; + } catch { + return false; + } +}; + +// A last resort for players that keep their engine on a global under a name +// only they know. Bounded, and every property read is guarded, because a getter +// on a page global can throw or be expensive. +export const findGlobal = (matches) => { + const scope = pageWindow(); + let keys = []; + try { + keys = Object.keys(scope); + } catch { + return null; + } + + const limit = Math.min(keys.length, MAX_SCANNED_KEYS); + for (let index = 0; index < limit; index++) { + const value = read(scope, keys[index]); + if (value === null || typeof value !== 'object') continue; + try { + if (matches(value)) return value; + } catch { + continue; + } + } + return null; +}; diff --git a/src/content/video/quality.js b/src/content/video/quality.js index 97b8066..72aa908 100644 --- a/src/content/video/quality.js +++ b/src/content/video/quality.js @@ -1,50 +1,55 @@ -const labelFor = (source, index) => { - const explicit = source.dataset.label || source.getAttribute('title'); - if (explicit) return explicit; - const match = /(\d{3,4})[pP]/.exec(source.src); - return match ? `${match[1]}p` : `Source ${index + 1}`; -}; +import { ADAPTERS, AUTO_ID } from './qualityadapters.js'; + +export { AUTO_ID }; -// Only what can honestly be offered: a site streaming HLS or DASH picks the -// quality in its own JavaScript, out of reach from here. +// A streaming player builds its quality ladder after the first segments land, +// so the ladder is looked up again every time the sheet is opened rather than +// once when the session starts. export const createQuality = (video) => { - const sources = Array.from(video.querySelectorAll('source')); + let adapter = null; + let options = []; + + const detect = () => { + for (const create of ADAPTERS) { + try { + const candidate = create(video); + if (candidate !== null) return candidate; + } catch (error) { + console.warn('Nocturne: a quality adapter threw while probing', error); + } + } + return null; + }; - const options = sources.map((source, index) => ({ - id: index, - label: labelFor(source, index), - src: source.src, - })); + const refresh = () => { + adapter = detect(); + if (adapter === null) { + options = []; + return options; + } + const listed = adapter.list(); + const auto = adapter.hasAuto ? [{ id: AUTO_ID, label: 'Auto' }] : []; + options = listed.length > 0 ? auto.concat(listed) : []; + return options; + }; const describe = () => { if (video.videoWidth === 0) return 'Set by the site'; return `${video.videoWidth}×${video.videoHeight} · set by the site`; }; - const select = (id) => { - const option = options[id]; - if (!option || video.currentSrc === option.src) return; - - const resumeAt = video.currentTime; - const wasPlaying = !video.paused; - - const restore = () => { - video.removeEventListener('loadedmetadata', restore); - video.currentTime = resumeAt; - if (wasPlaying) video.play().catch(() => {}); - }; - - video.addEventListener('loadedmetadata', restore); - video.src = option.src; - video.load(); - }; + refresh(); return { - options, + refresh, describe, - select, + getOptions: () => options, + getEngine: () => (adapter === null ? null : adapter.name), isSwitchable: () => options.length > 1, - getCurrent: () => - options.findIndex((option) => option.src === video.currentSrc), + getCurrent: () => (adapter === null ? null : adapter.current()), + select: (id) => { + if (adapter === null) return false; + return adapter.select(String(id)) === true; + }, }; }; diff --git a/src/content/video/qualityadapters.js b/src/content/video/qualityadapters.js new file mode 100644 index 0000000..db523e3 --- /dev/null +++ b/src/content/video/qualityadapters.js @@ -0,0 +1,318 @@ +import { + call, + findGlobal, + isFunction, + read, + toArray, + toPage, + write, +} from './pageapi.js'; + +export const AUTO_ID = 'auto'; + +const YOUTUBE_LABELS = { + tiny: '144p', + small: '240p', + medium: '360p', + large: '480p', + hd720: '720p', + hd1080: '1080p', + hd1440: '1440p', + hd2160: '2160p', + highres: '4320p', +}; + +const YOUTUBE_LOWEST = 'tiny'; +const YOUTUBE_HIGHEST = 'highres'; + +const heightLabel = (height, fallback) => + typeof height === 'number' && height > 0 ? `${height}p` : fallback; + +// Highest first: the reason anyone opens this list is to force a better +// picture, so the answer they want is the first chip. +const byHeightDescending = (first, second) => second.height - first.height; + +// --- Plain children ------------------------------------------------- + +const sourceLabel = (source, index) => { + const explicit = source.dataset.label || source.getAttribute('title'); + if (explicit) return explicit; + const match = /(\d{3,4})[pP]/.exec(source.src); + return match ? `${match[1]}p` : `Source ${index + 1}`; +}; + +const createSourceAdapter = (video) => { + const sources = Array.from(video.querySelectorAll('source')); + if (sources.length < 2) return null; + + const options = sources.map((source, index) => ({ + id: String(index), + label: sourceLabel(source, index), + src: source.src, + })); + + return { + name: 'sources', + hasAuto: false, + list: () => options.map(({ id, label }) => ({ id, label })), + current: () => { + const found = options.find((option) => option.src === video.currentSrc); + return found ? found.id : null; + }, + // The element is reloaded, so the position and the playing state are + // carried across by hand. + select: (id) => { + const option = options.find((entry) => entry.id === id); + if (!option || video.currentSrc === option.src) return false; + + const resumeAt = video.currentTime; + const wasPlaying = !video.paused; + const restore = () => { + video.removeEventListener('loadedmetadata', restore); + video.currentTime = resumeAt; + if (wasPlaying) video.play().catch(() => {}); + }; + + video.addEventListener('loadedmetadata', restore); + video.src = option.src; + video.load(); + return true; + }, + }; +}; + +// --- YouTube ----------------------------------------------------------------- + +const findYouTubePlayer = () => { + const element = document.querySelector('#movie_player, .html5-video-player'); + if (element === null) return null; + const player = element.wrappedJSObject ?? element; + return isFunction(player, 'getAvailableQualityLevels') ? player : null; +}; + +const createYouTubeAdapter = () => { + const player = findYouTubePlayer(); + if (player === null) return null; + + // YouTube reports the quality it is actually playing, which on auto keeps + // moving. The chip should show what the user asked for, so the choice is + // remembered here and the played value is only the opening guess. + let chosen = null; + + const levels = () => + toArray(call(player, 'getAvailableQualityLevels')).filter( + (level) => typeof level === 'string' && level !== AUTO_ID, + ); + + return { + name: 'youtube', + hasAuto: true, + list: () => + levels().map((level) => ({ + id: level, + label: YOUTUBE_LABELS[level] ?? level, + })), + current: () => { + if (chosen !== null) return chosen; + const quality = call(player, 'getPlaybackQuality'); + return typeof quality === 'string' ? quality : null; + }, + // Both calls together: the range is what pins the ladder for the rest of + // the video, while setPlaybackQuality is what older players listen to. + select: (id) => { + chosen = id; + if (id === AUTO_ID) { + call( + player, + 'setPlaybackQualityRange', + YOUTUBE_LOWEST, + YOUTUBE_HIGHEST, + ); + call(player, 'setPlaybackQuality', AUTO_ID); + return true; + } + call(player, 'setPlaybackQualityRange', id, id); + call(player, 'setPlaybackQuality', id); + return true; + }, + }; +}; + +// --- hls.js ------------------------------------------------------------------ + +const isHlsEngine = (value) => + Array.isArray(read(value, 'levels')) && + typeof read(value, 'currentLevel') === 'number'; + +const findHls = (video) => { + const attached = ['hls', '_hls', '__hls', 'hlsPlayer']; + const element = video.wrappedJSObject ?? video; + for (const key of attached) { + const candidate = read(element, key); + if (candidate && isHlsEngine(candidate)) return candidate; + } + return findGlobal(isHlsEngine); +}; + +const createHlsAdapter = (video) => { + const engine = findHls(video); + if (engine === null) return null; + + const levels = () => + toArray(read(engine, 'levels')).map((level, index) => ({ + index, + height: read(level, 'height') ?? 0, + bitrate: read(level, 'bitrate') ?? 0, + })); + + return { + name: 'hls.js', + hasAuto: true, + list: () => + levels() + .sort(byHeightDescending) + .map((level) => ({ + id: String(level.index), + label: heightLabel( + level.height, + `${Math.round(level.bitrate / 1000)}k`, + ), + })), + current: () => { + const level = read(engine, 'currentLevel'); + return typeof level === 'number' && level >= 0 ? String(level) : AUTO_ID; + }, + select: (id) => { + const index = id === AUTO_ID ? -1 : Number(id); + if (Number.isNaN(index)) return false; + write(engine, 'nextLevel', index); + return write(engine, 'currentLevel', index); + }, + }; +}; + +// --- dash.js ----------------------------------------------------------------- + +const isDashPlayer = (value) => + isFunction(value, 'getBitrateInfoListFor') && + isFunction(value, 'setQualityFor'); + +const createDashAdapter = () => { + const player = findGlobal(isDashPlayer); + if (player === null) return null; + + const setAuto = (isOn) => { + call( + player, + 'updateSettings', + toPage({ streaming: { abr: { autoSwitchBitrate: { video: isOn } } } }), + ); + }; + + const levels = () => + toArray(call(player, 'getBitrateInfoListFor', 'video')).map((info) => ({ + index: read(info, 'qualityIndex') ?? 0, + height: read(info, 'height') ?? 0, + bitrate: read(info, 'bitrate') ?? 0, + })); + + return { + name: 'dash.js', + hasAuto: true, + list: () => + levels() + .sort(byHeightDescending) + .map((level) => ({ + id: String(level.index), + label: heightLabel( + level.height, + `${Math.round(level.bitrate / 1000)}k`, + ), + })), + current: () => { + const index = call(player, 'getQualityFor', 'video'); + return typeof index === 'number' ? String(index) : null; + }, + select: (id) => { + if (id === AUTO_ID) { + setAuto(true); + return true; + } + const index = Number(id); + if (Number.isNaN(index)) return false; + setAuto(false); + call(player, 'setQualityFor', 'video', index, true); + return true; + }, + }; +}; + +// --- Shaka Player ------------------------------------------------------------ + +const isShakaPlayer = (value) => + isFunction(value, 'getVariantTracks') && + isFunction(value, 'selectVariantTrack'); + +const createShakaAdapter = () => { + const player = findGlobal(isShakaPlayer); + if (player === null) return null; + + // The originals are handed back to selectVariantTrack untouched: a track is + // a page object, and only the page's own object is accepted there. + let known = new Map(); + + const collect = () => { + known = new Map(); + return toArray(call(player, 'getVariantTracks')).map((track) => { + const id = String(read(track, 'id')); + known.set(id, track); + return { + id, + height: read(track, 'height') ?? 0, + bandwidth: read(track, 'bandwidth') ?? 0, + isActive: read(track, 'active') === true, + }; + }); + }; + + return { + name: 'shaka', + hasAuto: true, + list: () => + collect() + .sort(byHeightDescending) + .map((track) => ({ + id: track.id, + label: heightLabel( + track.height, + `${Math.round(track.bandwidth / 1000)}k`, + ), + })), + current: () => { + const active = collect().find((track) => track.isActive); + return active ? active.id : null; + }, + select: (id) => { + if (id === AUTO_ID) { + call(player, 'configure', toPage({ abr: { enabled: true } })); + return true; + } + if (known.size === 0) collect(); + const track = known.get(id); + if (track === undefined) return false; + call(player, 'configure', toPage({ abr: { enabled: false } })); + call(player, 'selectVariantTrack', track, true); + return true; + }, + }; +}; + +// Cheapest and most certain first: a list is unambiguous, a named +// player is next, and the global scan is the last thing tried. +export const ADAPTERS = [ + createSourceAdapter, + createYouTubeAdapter, + createHlsAdapter, + createDashAdapter, + createShakaAdapter, +]; diff --git a/src/content/video/sitecaptions.js b/src/content/video/sitecaptions.js new file mode 100644 index 0000000..9600d91 --- /dev/null +++ b/src/content/video/sitecaptions.js @@ -0,0 +1,183 @@ +import { call, isFunction, read, toArray, toPage } from './pageapi.js'; + +const PROBE_INTERVAL_MS = 1000; + +// Players that paint their own captions instead of exposing a text track. The +// element the site draws into is still updated while the video sits on our +// stage, so its text is mirrored into the player's own cue layer. +const CONTAINER_SELECTORS = [ + '.ytp-caption-window-container', + '.shaka-text-container', + '.vjs-text-track-display', + '.jw-captions', + '.plyr__captions', + '.pjscaptions', +]; + +// Tried in order; the first selector that matches something decides how the +// lines are split, so a nested wrapper cannot repeat the same words twice. +const LINE_SELECTORS = [ + '.ytp-caption-segment', + '.caption-visual-line', + '.vjs-text-track-cue', + '.jw-text-track-cue', + '.shaka-text-wrapper span', +]; + +const readLines = (container) => { + for (const selector of LINE_SELECTORS) { + const lines = container.querySelectorAll(selector); + if (lines.length === 0) continue; + return Array.from(lines) + .map((line) => line.textContent.trim()) + .filter((line) => line !== '') + .join('\n'); + } + return container.textContent.trim(); +}; + +const findContainer = () => { + for (const selector of CONTAINER_SELECTORS) { + const found = document.querySelector(selector); + if (found !== null) return found; + } + return null; +}; + +const findYouTubePlayer = () => { + const element = document.querySelector('#movie_player, .html5-video-player'); + if (element === null) return null; + const player = element.wrappedJSObject ?? element; + return isFunction(player, 'getOption') ? player : null; +}; + +// YouTube keeps its caption list behind the player API rather than in +// video.textTracks, so the list comes from there and the text comes from the +// mirror above. Other players expose the mirror only, which is still enough to +// show whatever the site has switched on. +const createYouTubeTracks = () => { + const player = findYouTubePlayer(); + if (player === null) return null; + + const tracklist = () => { + const list = toArray(call(player, 'getOption', 'captions', 'tracklist')); + if (list.length > 0) return list; + call(player, 'loadModule', 'captions'); + return toArray(call(player, 'getOption', 'captions', 'tracklist')); + }; + + const describe = (track, index) => { + const name = read(track, 'displayName') ?? read(track, 'languageName'); + if (typeof name === 'string' && name !== '') return name; + const code = read(track, 'languageCode'); + const isNamed = typeof code === 'string' && code !== ''; + return isNamed ? code : `Track ${index + 1}`; + }; + + return { + list: () => tracklist().map((track, index) => describe(track, index)), + isOn: () => { + const track = call(player, 'getOption', 'captions', 'track'); + const code = read(track, 'languageCode'); + return typeof code === 'string' && code !== ''; + }, + activeIndex: () => { + const current = call(player, 'getOption', 'captions', 'track'); + const code = read(current, 'languageCode'); + if (typeof code !== 'string' || code === '') return -1; + return tracklist().findIndex( + (track) => read(track, 'languageCode') === code, + ); + }, + select: (index) => { + const track = tracklist()[index]; + if (track === undefined) return false; + call(player, 'setOption', 'captions', 'track', track); + return true; + }, + disable: () => { + call(player, 'setOption', 'captions', 'track', toPage({})); + }, + }; +}; + +export const createSiteCaptions = (onText) => { + const youtube = createYouTubeTracks(); + + let container = null; + let observer = null; + let probeTimer = null; + let isMirroring = false; + let lastText = ''; + + const emit = (text) => { + if (!isMirroring || text === lastText) return; + lastText = text; + onText(text); + }; + + const readNow = () => { + if (container === null || !container.isConnected) return; + emit(readLines(container)); + }; + + const watch = (found) => { + container = found; + observer = new MutationObserver(readNow); + observer.observe(container, { + childList: true, + subtree: true, + characterData: true, + }); + readNow(); + }; + + const unwatch = () => { + if (observer !== null) observer.disconnect(); + observer = null; + container = null; + }; + + // The container is created the moment captions are first switched on, which + // may be long after the player opened, so it is looked for on a slow tick. + const probe = () => { + probeTimer = null; + if (container !== null && !container.isConnected) unwatch(); + if (container === null) { + const found = findContainer(); + if (found !== null) watch(found); + } else { + readNow(); + } + probeTimer = setTimeout(probe, PROBE_INTERVAL_MS); + }; + + probe(); + + return { + // A site source is worth offering when the site can actually produce one. + list: () => (youtube === null ? [] : youtube.list()), + hasContainer: () => container !== null, + isOn: () => (youtube === null ? container !== null : youtube.isOn()), + activeIndex: () => (youtube === null ? 0 : youtube.activeIndex()), + select: (index) => { + isMirroring = true; + lastText = ''; + if (youtube !== null) youtube.select(index); + readNow(); + }, + // The site's own caption layer sits behind the stage where nobody can see + // it, so turning ours off is a matter of no longer mirroring it. + disable: () => { + isMirroring = false; + lastText = ''; + if (youtube !== null) youtube.disable(); + onText(''); + }, + destroy: () => { + if (probeTimer !== null) clearTimeout(probeTimer); + probeTimer = null; + unwatch(); + }, + }; +}; diff --git a/src/content/video/tracks.js b/src/content/video/tracks.js index 8f99195..346531e 100644 --- a/src/content/video/tracks.js +++ b/src/content/video/tracks.js @@ -1,9 +1,15 @@ +import { createSiteCaptions } from './sitecaptions.js'; import { findCueText, parseSubtitles } from '../../lib/subtitles.js'; const CUE_LOAD_RETRY_MS = 300; const CUE_LOAD_ATTEMPTS = 10; const ADOPT_RETRY_MS = 600; +const FILE_ID_BASE = 1000; +const SITE_ID_BASE = 2000; + +const isSiteId = (id) => id >= SITE_ID_BASE; + const describe = (track, index) => track.label || track.language || `Track ${index + 1}`; @@ -53,11 +59,23 @@ export const createTrackManager = (video, onCue, onSelection) => { onCue(text); }; + // Only the site source may paint while it is the selected one; a stale + // mutation arriving after a switch must not put its line back on screen. + const site = createSiteCaptions((text) => { + if (isSiteId(selected)) emit(text); + }); + const update = () => { - if (selected === -1 || isNative) return; + if (selected === -1 || isNative || isSiteId(selected)) return; emit(findCueText(cues, video.currentTime + offset)); }; + const siteOptions = () => + site.list().map((label, index) => ({ + id: SITE_ID_BASE + index, + label, + })); + const list = () => { const items = nativeTracks().map((track, index) => ({ id: index, @@ -66,15 +84,31 @@ export const createTrackManager = (video, onCue, onSelection) => { for (const entry of loaded) { items.push({ id: entry.id, label: entry.label }); } + const fromSite = siteOptions(); + if (fromSite.length > 0) return items.concat(fromSite); + // A player that paints captions without naming them still deserves a way + // to turn them on. + if (site.hasContainer()) { + items.push({ id: SITE_ID_BASE, label: 'Site captions' }); + } return items; }; const select = (id) => { + const wasSite = isSiteId(selected); selected = id; cues = []; emit(''); onSelection(id); + if (isSiteId(id)) { + silenceAll(); + site.select(id - SITE_ID_BASE); + return; + } + + if (wasSite) site.disable(); + if (id === -1) { silenceAll(); return; @@ -105,13 +139,14 @@ export const createTrackManager = (video, onCue, onSelection) => { const addCues = (label, text) => { const parsed = parseSubtitles(text); if (parsed.length === 0) return null; - const id = 1000 + loaded.length; + const id = FILE_ID_BASE + loaded.length; loaded.push({ id, label, cues: parsed }); return id; }; - // A site that ships already has Gecko painting cues. Adopt - // that track so it renders through our own layer instead of underneath the + // A site that ships already has Gecko painting cues, and a + // site with its own caption layer already has them on screen. Either way the + // track is adopted so it renders through our layer instead of underneath the // controls, and so the menu tells the truth about what is on. const adoptShowingTrack = () => { if (selected !== -1) return true; @@ -121,6 +156,10 @@ export const createTrackManager = (video, onCue, onSelection) => { select(index); return true; } + if (site.isOn()) { + select(SITE_ID_BASE + Math.max(0, site.activeIndex())); + return true; + } return false; }; @@ -145,9 +184,13 @@ export const createTrackManager = (video, onCue, onSelection) => { if (selected !== -1) select(selected); }, isNative: () => isNative, + // The site paints its own captions, so ours are the only ones with a + // timing offset or a size to speak of. + isSiteSelected: () => isSiteId(selected), destroy: () => { video.removeEventListener('timeupdate', update); video.removeEventListener('seeked', update); + site.destroy(); silenceAll(); }, }; diff --git a/src/content/video/visuals.js b/src/content/video/visuals.js index 88c1b82..37164f4 100644 --- a/src/content/video/visuals.js +++ b/src/content/video/visuals.js @@ -1,44 +1,37 @@ import { clampPan, clampScale, computeCoverScale, snapScale } from './zoom.js'; -const PAUSE_GRAYSCALE = 0.85; -const PAUSE_BRIGHTNESS = 0.7; -const FILTER_TRANSITION = 'filter 350ms ease'; -const TRANSFORM_TRANSITION = 'transform 220ms ease'; +const TRANSFORM_TRANSITION = 'transform 260ms cubic-bezier(0.32, 0.72, 0, 1)'; +const FILTER_TRANSITION = 'filter 320ms ease'; // Every filter function is always present and always in the same order, so the -// browser can interpolate between two states and the pause fade stays smooth. -const buildFilter = ({ grayscale, saturate, contrast, brightness }) => { +// browser can interpolate between two states smoothly. +const buildFilter = ({ saturate, contrast, brightness }) => { // Untouched settings mean no filter at all, so the film is passed through // exactly as the site encoded it. - const isNeutral = - grayscale === 0 && saturate === 1 && contrast === 1 && brightness === 1; + const isNeutral = saturate === 1 && contrast === 1 && brightness === 1; if (isNeutral) return 'none'; - return ( - `grayscale(${grayscale}) saturate(${saturate}) ` + - `contrast(${contrast}) brightness(${brightness})` - ); + const tone = `contrast(${contrast}) brightness(${brightness})`; + return `saturate(${saturate}) ${tone}`; }; -export const createVisuals = (video, stage, wasPlaying) => { +// What the zoom means, rather than the number it currently works out to. The +// stage changes size whenever the phone rotates, the system takes the video +// into a floating window, or the app comes back from the background — and the +// same intent has to survive all three. +const FIT = 'fit'; +const FILL = 'fill'; +const FREE = 'free'; + +export const createVisuals = (video, stage) => { const colour = { saturate: 1, contrast: 1, brightness: 1 }; const view = { scale: 1, x: 0, y: 0 }; - // Gecko reports the element as paused for a moment while it is re-parented, - // which would otherwise flash the pause fade over a film that is playing. - let isPaused = wasPlaying ? false : video.paused; + let intent = FIT; + let freeFactor = 1; let isPinching = false; - let isFadeSuppressed = false; const apply = () => { - // While the colour panel is open the fade is held off, otherwise the user - // would be tuning colours against a grey picture. - const isFaded = isPaused && !isFadeSuppressed; - const filter = buildFilter({ - grayscale: isFaded ? PAUSE_GRAYSCALE : 0, - saturate: colour.saturate, - contrast: colour.contrast, - brightness: colour.brightness * (isFaded ? PAUSE_BRIGHTNESS : 1), - }); + const filter = buildFilter(colour); const isUnzoomed = view.scale === 1 && view.x === 0 && view.y === 0; const offset = `translate(${view.x}px, ${view.y}px)`; const transform = isUnzoomed ? 'none' : `${offset} scale(${view.scale})`; @@ -56,7 +49,17 @@ export const createVisuals = (video, stage, wasPlaying) => { return { width: rect.width, height: rect.height }; }; - const setScale = (scale) => { + const coverScale = () => { + const size = stageSize(); + return computeCoverScale( + video.videoWidth, + video.videoHeight, + size.width, + size.height, + ); + }; + + const place = (scale) => { const size = stageSize(); const cover = computeCoverScale( video.videoWidth, @@ -72,31 +75,49 @@ export const createVisuals = (video, stage, wasPlaying) => { return view.scale; }; - apply(); + const remember = (scale) => { + const cover = coverScale(); + if (scale === 1) { + intent = FIT; + } else if (scale === cover) { + intent = FILL; + } else { + intent = FREE; + freeFactor = cover > 0 ? scale / cover : 1; + } + }; - const coverScale = () => { - const size = stageSize(); - return computeCoverScale( - video.videoWidth, - video.videoHeight, - size.width, - size.height, - ); + const setScale = (scale) => { + const applied = place(scale); + remember(applied); + return applied; }; + // Re-derives the scale the intent asks for at the current stage size. This is + // what stops the picture from being left at yesterday's crop — blown up and + // stretched — after the window has changed shape underneath it. + const relayout = () => { + if (video.videoWidth === 0) return false; + const cover = coverScale(); + if (intent === FIT) { + place(1); + } else if (intent === FILL) { + place(cover); + } else { + place(freeFactor * cover); + } + return true; + }; + + apply(); + return { - setPaused: (value) => { - isPaused = value; - apply(); - }, - suppressFade: (value) => { - isFadeSuppressed = value; - apply(); - }, + relayout, // Crops the letterbox away, which is what most people want on a phone. fillScreen: () => { if (video.videoWidth === 0) return false; - setScale(coverScale()); + intent = FILL; + place(coverScale()); return true; }, setColour: (patch) => { From a877f2670a0148808dc9064e6b0a4c7465a5d636 Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Thu, 6 Aug 2026 10:40:55 +0300 Subject: [PATCH 05/23] docs: record what changed in 0.3.0 Also corrects the parts of the README that no longer described the extension: YouTube is in scope now, the pause fade is gone, and the volume strip and gesture lock were never built. --- CHANGELOG.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++- README.md | 63 +++++++++++++++++++++++++++++++--------------------- 2 files changed, 98 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78329ef..0058257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased][unreleased] +## [0.3.0][] - 2026-08-06 + +Everything in this release comes from watching real films on a real phone with +0.2.0 installed. + +### Added + +- **Quality selection.** The site keeps its ladder; the sheet asks for a rung. + Adapters for `` lists, YouTube, hls.js, dash.js and Shaka, read + through the page's own objects — no code is injected into the page and nothing + is evaluated from a string. Where a player exposes no way in, the row reports + the resolution being played instead of offering a choice that does nothing. +- **Captions from players that paint their own.** YouTube's caption list is read + through its player API and the text it renders is mirrored into the player's + cue layer, which is why subtitles were missing there before. The same mirror + covers Shaka, video.js, JW Player, Plyr and Playerjs. +- **Picture-in-picture button**, at the top right. Gecko ships no Web API for it + on Android, so it uses the standard call where that exists and otherwise + prepares the video and says what to do. +- **Fullscreen switch** in the settings sheet. Off runs the player as an overlay + instead of taking the screen, which leaves Android's ordinary single swipe + home working. +- Loading an `.srt` / `.vtt` file now actually loads it: the file input had no + handler, so the button did nothing. + +### Changed + +- **Pausing is the play button and nothing else.** The invisible pause zone that + covered the whole picture is gone; a tap anywhere else brings the controls up + or puts them away. +- **The seek band sits above the bottom of the screen**, clear of the Android + home swipe, which used to be read as a scrub and threw the film into the + middle. +- **The badge sits halfway up the right edge** of an inline video rather than in + the bottom corner, where it covered the site's own controls. +- Motion follows one easing curve throughout, with scrims and shadows under the + controls so they stay legible over a bright picture. +- The launcher no longer asks Gecko to hide the navigation UI, which is what put + Android into sticky immersive mode. + +### Fixed + +- **The picture stays centred and keeps its shape.** A site that rewrites the + video's inline style — YouTube does it on every layout pass — could push the + picture against an edge or stretch it across the screen after a return from + another app. Position and offsets are pinned along with the size and re-pinned + whenever the site writes over them, and the zoom is re-derived from what the + viewer asked for whenever the stage changes shape. +- **Colour starts neutral.** Values stored out of range, or of the wrong type, + now fall back to the neutral one instead of tinting a film for reasons the + viewer cannot see. +- **No more fade to grey while paused.** People pause to look at the picture. +- Leaving the app no longer tears the session down. Losing fullscreen while the + app is on its way to the background is what Android's floating-window hand-off + looks like, and it is now told apart from the viewer leaving the player. +- A quality switch that empties the media element no longer backs the player + out; the check waits to see whether the element really was torn down. + ## [0.2.0][] - 2026-08-02 First release submitted to addons.mozilla.org. @@ -51,5 +109,6 @@ First release submitted to addons.mozilla.org. There is no sound while scrubbing. - Volume is left to the phone's own buttons. -[unreleased]: https://github.com/kvachikk/nocturne-player/compare/v0.2.0...HEAD +[unreleased]: https://github.com/kvachikk/nocturne-player/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/kvachikk/nocturne-player/releases/tag/v0.3.0 [0.2.0]: https://github.com/kvachikk/nocturne-player/releases/tag/v0.2.0 diff --git a/README.md b/README.md index 19c1488..dd8af96 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,11 @@ adds them. Built for **ordinary sites that play video through a plain `