From 959e53cd2784c6c7f2ea29edc5ba639378fa7705 Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Sat, 8 Aug 2026 17:21:35 +0300 Subject: [PATCH 1/2] feat(video): ask playerjs for its own quality ladder Playerjs keeps the streaming engine it drives inside a closure, so there is no hls.js instance to find the way there is elsewhere. What it does answer is its own question: api('qualities') lists the rungs the site built and api('quality', label) is the call its own menu makes. That is the whole adapter, and it is what makes the row work on the run of film sites that embed this player. The rung the viewer picked is now remembered in one place, above the adapters, because the ladder is looked up again every time the sheet opens and a player that has been given a rung goes back to reporting whatever its own auto has drifted to. The YouTube adapter kept its own copy of that for the same reason; it no longer needs one. --- src/content/video/quality.js | 22 +++++-- src/content/video/qualityadapters.js | 98 +++++++++++++++++++++++++--- test/unit/playerjs.test.js | 78 ++++++++++++++++++++++ test/unit/quality.test.js | 96 +++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 12 deletions(-) create mode 100644 test/unit/playerjs.test.js create mode 100644 test/unit/quality.test.js diff --git a/src/content/video/quality.js b/src/content/video/quality.js index 09d39b1..28aeb08 100644 --- a/src/content/video/quality.js +++ b/src/content/video/quality.js @@ -5,12 +5,17 @@ export { AUTO_ID }; // 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, host = null) => { +export const createQuality = (video, host = null, adapters = ADAPTERS) => { let adapter = null; let options = []; + // What the viewer asked for has to outlive the adapter. The ladder is looked + // up again every time the sheet opens, which builds a new adapter each time, + // and a player that has been given a rung goes back to reporting whatever its + // own auto has drifted to. The chip should say what was asked for. + let chosen = null; const detect = () => { - for (const create of ADAPTERS) { + for (const create of adapters) { try { const candidate = create(video, host); if (candidate !== null) return candidate; @@ -25,11 +30,14 @@ export const createQuality = (video, host = null) => { adapter = detect(); if (adapter === null) { options = []; + chosen = null; return options; } const listed = adapter.list(); const auto = adapter.hasAuto ? [{ id: AUTO_ID, label: 'Auto' }] : []; options = listed.length > 0 ? auto.concat(listed) : []; + // A choice lives only as long as the rung it names is still on offer. + if (!options.some((option) => option.id === chosen)) chosen = null; return options; }; @@ -54,10 +62,16 @@ export const createQuality = (video, host = null) => { getOptions: () => options, getEngine: () => (adapter === null ? null : adapter.name), isSwitchable: () => options.length > 1, - getCurrent: () => (adapter === null ? null : adapter.current()), + getCurrent: () => { + if (chosen !== null) return chosen; + return adapter === null ? null : adapter.current(); + }, select: (id) => { if (adapter === null) return false; - return adapter.select(String(id)) === true; + const wanted = String(id); + if (adapter.select(wanted) !== true) return false; + chosen = wanted; + return true; }, }; }; diff --git a/src/content/video/qualityadapters.js b/src/content/video/qualityadapters.js index a3e4755..16d5dcd 100644 --- a/src/content/video/qualityadapters.js +++ b/src/content/video/qualityadapters.js @@ -2,6 +2,7 @@ import { call, findGlobalMatch, isFunction, + pageWindow, read, toArray, toPage, @@ -121,11 +122,6 @@ const createYouTubeAdapter = (video, host) => { const player = findYouTubePlayer(host); 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 advertised = () => toArray(call(player, 'getAvailableQualityLevels')).filter( (level) => typeof level === 'string' && level !== AUTO_ID, @@ -170,14 +166,12 @@ const createYouTubeAdapter = (video, host) => { 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, @@ -355,6 +349,91 @@ const buildShakaAdapter = (player) => { }; }; +// --- Playerjs ---------------------------------------------------------------- + +// Playerjs hands the page a single method — api() — and keeps the streaming +// engine it drives inside a closure, so there is no ladder object to find the +// way there is with hls.js. What it will answer is its own question: +// api('qualities') lists the rungs the site built, in the site's own words, +// and api('quality', label) is the same call its own menu makes. Those two are +// the whole adapter, and they are what makes quality work on the run of film +// sites that ship this player. +const AUTO_WORDS = /^(auto|авто|авто\u0301|autom)/i; + +const heightOf = (label) => { + const match = /(\d{3,4})/.exec(label); + return match === null ? 0 : Number(match[1]); +}; + +// Auto first, then the heights from best to worst — the order the chips want, +// whatever order the site happened to list them in. +const orderLabels = (labels) => { + const auto = labels.filter((label) => AUTO_WORDS.test(label)); + const rest = labels + .filter((label) => !AUTO_WORDS.test(label)) + .sort((first, second) => heightOf(second) - heightOf(first)); + return auto.concat(rest); +}; + +// On auto the player answers with both words — "Авто 720p" — because it is +// naming the rung it picked as well as saying who picked it. The chip that +// should light up is the one the viewer chose, so the longest label the answer +// starts with wins: "Авто" over "720p", but a pinned "1080p" over nothing. +export const matchLabel = (labels, shown) => { + if (typeof shown !== 'string' || shown === '') return null; + const found = labels + .filter((label) => shown.startsWith(label)) + .sort((first, second) => second.length - first.length); + return found.length > 0 ? found[0] : null; +}; + +const listQualities = (instance) => + toArray(call(instance, 'api', 'qualities')).filter( + (label) => typeof label === 'string' && label !== '', + ); + +export const buildPlayerjsAdapter = (instance) => ({ + name: 'playerjs', + // The site's own list already carries its own word for auto, and it is the + // only one this player answers to. + hasAuto: false, + diagnose: () => `${listQualities(instance).length} in the site's list`, + list: () => + orderLabels(listQualities(instance)).map((label) => ({ + id: label, + label, + })), + current: () => + matchLabel(listQualities(instance), call(instance, 'api', 'quality')), + select: (id) => { + const labels = listQualities(instance); + if (!labels.includes(id)) return false; + // Asking for the rung that is already playing would have the site tear + // the stream down and build it again for no change at all. + if (matchLabel(labels, call(instance, 'api', 'quality')) === id) { + return true; + } + call(instance, 'api', 'quality', id); + return true; + }, +}); + +const isPlayerjsInstance = (value) => { + if (!isFunction(value, 'api')) return false; + return listQualities(value).length > 0; +}; + +// Only looked for once the page has said it has this player, because the check +// itself is a call into an api() that belongs to somebody, and a sweep of every +// global object with a method by that name is not a question worth asking. +const createPlayerjsAdapter = () => { + if (!isFunction(pageWindow(), 'Playerjs')) return null; + const found = findGlobalMatch([ + { name: 'playerjs', matches: isPlayerjsInstance }, + ]); + return found === null ? null : buildPlayerjsAdapter(found.value); +}; + const BUILDERS = { hls: buildHlsAdapter, dash: buildDashAdapter, @@ -381,9 +460,12 @@ const createStreamAdapter = (video, host) => { }; // Cheapest and most certain first: a list is unambiguous, a named -// player is next, and the sweep of page globals is the last thing tried. +// player is next, and the sweeps of page globals come last — the streaming +// engines before Playerjs, because an engine that hands over its ladder can say +// more about it than a player that only answers in labels. export const ADAPTERS = [ createSourceAdapter, createYouTubeAdapter, createStreamAdapter, + createPlayerjsAdapter, ]; diff --git a/test/unit/playerjs.test.js b/test/unit/playerjs.test.js new file mode 100644 index 0000000..4cc8da2 --- /dev/null +++ b/test/unit/playerjs.test.js @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + buildPlayerjsAdapter, + matchLabel, +} from '../../src/content/video/qualityadapters.js'; + +// Stands in for the one method Playerjs exposes: api(name) reads, and +// api(name, value) writes. +const fakePlayer = (qualities, quality) => { + const state = { quality, asked: [] }; + return { + state, + api: (name, value) => { + if (name === 'qualities') return qualities; + if (name !== 'quality') return null; + if (value === undefined) return state.quality; + state.asked.push(value); + state.quality = value; + return null; + }, + }; +}; + +const LADDER = ['480p', '720p', '1080p', 'Авто']; + +test('the site list is offered auto first, then best to worst', () => { + const adapter = buildPlayerjsAdapter(fakePlayer(LADDER, 'Авто 720p')); + assert.deepEqual( + adapter.list().map((option) => option.label), + ['Авто', '1080p', '720p', '480p'], + ); +}); + +test('on auto the auto chip is current, not the rung auto picked', () => { + const adapter = buildPlayerjsAdapter(fakePlayer(LADDER, 'Авто 720p')); + assert.equal(adapter.current(), 'Авто'); +}); + +test('a pinned rung is current', () => { + const adapter = buildPlayerjsAdapter(fakePlayer(LADDER, '1080p')); + assert.equal(adapter.current(), '1080p'); +}); + +test('choosing a rung asks the site for it', () => { + const player = fakePlayer(LADDER, '480p'); + const adapter = buildPlayerjsAdapter(player); + assert.equal(adapter.select('1080p'), true); + assert.deepEqual(player.state.asked, ['1080p']); +}); + +test('choosing the rung already playing asks for nothing', () => { + const player = fakePlayer(LADDER, '1080p'); + const adapter = buildPlayerjsAdapter(player); + assert.equal(adapter.select('1080p'), true); + assert.deepEqual(player.state.asked, []); +}); + +test('a rung the site does not list is refused', () => { + const player = fakePlayer(LADDER, '480p'); + const adapter = buildPlayerjsAdapter(player); + assert.equal(adapter.select('2160p'), false); + assert.deepEqual(player.state.asked, []); +}); + +test('a player with no list of its own has no current rung', () => { + const adapter = buildPlayerjsAdapter(fakePlayer([], '')); + assert.deepEqual(adapter.list(), []); + assert.equal(adapter.current(), null); +}); + +test('the longest label the answer starts with wins', () => { + assert.equal(matchLabel(LADDER, 'Авто 1080p'), 'Авто'); + assert.equal(matchLabel(LADDER, '720p'), '720p'); + assert.equal(matchLabel(LADDER, ''), null); + assert.equal(matchLabel(LADDER, null), null); +}); diff --git a/test/unit/quality.test.js b/test/unit/quality.test.js new file mode 100644 index 0000000..5e6944b --- /dev/null +++ b/test/unit/quality.test.js @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createQuality } from '../../src/content/video/quality.js'; + +// A player that is asked for a rung, takes it, and then goes back to reporting +// whatever its own auto has drifted to — the shape of the problem the sheet has +// to hide from the viewer. +const driftingPlayer = () => { + const state = { asked: [] }; + const adapter = { + name: 'test', + hasAuto: false, + list: () => [ + { id: '1080p', label: '1080p' }, + { id: '480p', label: '480p' }, + ], + current: () => 'Auto', + select: (id) => { + state.asked.push(id); + return true; + }, + }; + return { state, create: () => adapter }; +}; + +const VIDEO = { videoWidth: 0, videoHeight: 0 }; + +test('the chip shows the choice, not what the player drifted to', () => { + const player = driftingPlayer(); + const quality = createQuality(VIDEO, null, [player.create]); + quality.refresh(); + assert.equal(quality.getCurrent(), 'Auto'); + assert.equal(quality.select('1080p'), true); + assert.equal(quality.getCurrent(), '1080p'); +}); + +test('the choice survives the ladder being looked up again', () => { + const player = driftingPlayer(); + const quality = createQuality(VIDEO, null, [player.create]); + quality.refresh(); + quality.select('480p'); + quality.refresh(); + assert.equal(quality.getCurrent(), '480p'); +}); + +test('a choice the player no longer offers is forgotten', () => { + let listed = [ + { id: '1080p', label: '1080p' }, + { id: '480p', label: '480p' }, + ]; + const adapter = { + name: 'test', + hasAuto: false, + list: () => listed, + current: () => 'Auto', + select: () => true, + }; + const quality = createQuality(VIDEO, null, [() => adapter]); + quality.refresh(); + quality.select('1080p'); + listed = [ + { id: '720p', label: '720p' }, + { id: '480p', label: '480p' }, + ]; + quality.refresh(); + assert.equal(quality.getCurrent(), 'Auto'); +}); + +test('a refused choice is not remembered', () => { + const adapter = { + name: 'test', + hasAuto: false, + list: () => [ + { id: '1080p', label: '1080p' }, + { id: '480p', label: '480p' }, + ], + current: () => 'Auto', + select: () => false, + }; + const quality = createQuality(VIDEO, null, [() => adapter]); + quality.refresh(); + assert.equal(quality.select('1080p'), false); + assert.equal(quality.getCurrent(), 'Auto'); +}); + +test('an adapter that throws leaves the sheet with nothing to offer', () => { + const quality = createQuality(VIDEO, null, [ + () => { + throw new Error('the page said no'); + }, + ]); + assert.deepEqual(quality.refresh(), []); + assert.equal(quality.getCurrent(), null); + assert.equal(quality.isSwitchable(), false); +}); From 64fe2671a3830d9b69df5452b3937c4cfce22336 Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Sat, 8 Aug 2026 17:22:37 +0300 Subject: [PATCH 2/2] docs: 0.4.0, and what playerjs can actually be asked 0.3.0 said a player keeping its engine in a closure was out of reach. It is out of reach through its engine; it is not out of reach through its own answers, which is how the quality row now works there. --- CHANGELOG.md | 34 +++++++++++++++++++++++++++++++++- README.md | 17 ++++++++--------- package.json | 2 +- src/manifest.json | 2 +- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8dee3..3202340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased][unreleased] +## [0.4.0][] - 2026-08-08 + +### Added + +- **Quality on ordinary film sites.** Sites that embed Playerjs — a large part + of what people actually watch on a phone — now offer their ladder in the + sheet like any other player. Playerjs keeps its streaming engine inside a + closure, but it answers about itself: `api('qualities')` lists the rungs the + site built, in the site's own words, and `api('quality', label)` is the call + its own menu makes. Both are reads and calls on an object the page already + published; no code is injected and nothing is evaluated from a string. The + chips come out auto first, then best to worst, whatever order the site listed + them in. + +### Changed + +- The rung the viewer picked is remembered above the adapters rather than + inside them. The ladder is looked up again every time the sheet opens, which + builds a fresh adapter, and a player that has been given a rung goes back to + reporting whatever its own auto has drifted to — so the chip used to fall + back to Auto a few seconds after a choice. It now stays on the choice until + the rung stops being offered. + +### Notes + +- 0.3.0 said a player that keeps its engine in a closure could not be reached + from an extension at all. That was too strong: Playerjs cannot be reached + *through its engine*, but it answers questions about itself, and that is + enough. Players that expose neither still report the resolution being played + rather than offering a choice that would do nothing. + ## [0.3.0][] - 2026-08-07 Everything in this release comes from watching real films on a real phone with @@ -165,6 +196,7 @@ 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.3.0...HEAD +[unreleased]: https://github.com/kvachikk/nocturne-player/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/kvachikk/nocturne-player/releases/tag/v0.4.0 [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 5658658..f608c91 100644 --- a/README.md +++ b/README.md @@ -110,15 +110,14 @@ These are platform limits, not oversights: the home screen — and the session survives being backgrounded so the hand-off is not torn down halfway through. Whether the window actually floats is the system's decision, not the extension's. -- **Quality depends on what the site's player exposes.** The common ones are - covered — `` lists, YouTube, hls.js, dash.js, Shaka — but a player - that keeps its engine inside a closure cannot be reached from an extension at - all. Playerjs, which many film sites embed, is the case in point: the page - publishes the hls.js _constructor_ and a player object with one opaque - method, and the instance holding the quality ladder is never handed out. - There the row reports the resolution being played instead of offering a - choice that would do nothing. Reaching those players by driving their own - menus is being looked at for a later version. +- **Quality depends on what the site's player exposes.** Covered: + `` lists, YouTube, hls.js, dash.js, Shaka, and Playerjs — the one + most film sites embed. Playerjs keeps its engine inside a closure, so there + is no ladder object to find; what it does is answer about itself, and the row + is built from `api('qualities')` and `api('quality', label)`, the same call + its own menu makes. A player that exposes neither an engine nor an answer is + still beyond reach, and there the row reports the resolution being played + instead of offering a choice that would do nothing. - **Volume is left to the phone's own buttons.** The web platform has no access to the device volume. - **Rewind is not "negative 2x".** `playbackRate` cannot go below zero, so diff --git a/package.json b/package.json index 7989df9..c4712c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nocturne-player", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "A touch-first video player for Firefox on Android", "license": "MPL-2.0", diff --git a/src/manifest.json b/src/manifest.json index f603539..365f960 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Nocturne Player", - "version": "0.3.0", + "version": "0.4.0", "description": "A touch-first video player for streaming sites and YouTube: thick seek bar, gesture controls, quality and subtitle picker, night light and colour tuning.", "author": "Vlad Pohorilets", "homepage_url": "https://github.com/kvachikk/nocturne-player",