Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
17 changes: 8 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<source>` 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:
`<source>` 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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 18 additions & 4 deletions src/content/video/quality.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
};

Expand All @@ -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;
},
};
};
98 changes: 90 additions & 8 deletions src/content/video/qualityadapters.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
call,
findGlobalMatch,
isFunction,
pageWindow,
read,
toArray,
toPage,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -381,9 +460,12 @@ const createStreamAdapter = (video, host) => {
};

// Cheapest and most certain first: a <source> 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,
];
2 changes: 1 addition & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
78 changes: 78 additions & 0 deletions test/unit/playerjs.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading