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