From 60bea0f85fffcff317ececc43cb5386210c6027f Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Sun, 9 Aug 2026 21:52:08 +0300 Subject: [PATCH 1/4] refactor(video): find the page's player in one place Quality hunted for hls.js, dash.js, Shaka and Playerjs on its own, and audio tracks were about to hunt for the same four. The search moves into engines.js so both ask once, and findGlobalMatch becomes findGlobalMatches: one pass over the page's globals answers for every engine rather than one pass each. --- src/content/video/engines.js | 74 +++++++++++++++++++++ src/content/video/pageapi.js | 33 +++++++--- src/content/video/qualityadapters.js | 96 +++++++--------------------- 3 files changed, 119 insertions(+), 84 deletions(-) create mode 100644 src/content/video/engines.js diff --git a/src/content/video/engines.js b/src/content/video/engines.js new file mode 100644 index 0000000..489447a --- /dev/null +++ b/src/content/video/engines.js @@ -0,0 +1,74 @@ +import { + call, + isFunction, + pageWindow, + read, + findGlobalMatches, +} from './pageapi.js'; + +// The quality ladder and the audio tracks are asked of the same object, so the +// hunt for that object lives here rather than twice over. Nothing in this file +// changes anything on the page: it is all identification. + +export const isHlsEngine = (value) => + Array.isArray(read(value, 'levels')) && + typeof read(value, 'currentLevel') === 'number'; + +export const isDashPlayer = (value) => + isFunction(value, 'getBitrateInfoListFor') && + isFunction(value, 'setQualityFor'); + +export const isShakaPlayer = (value) => + isFunction(value, 'getVariantTracks') && + isFunction(value, 'selectVariantTrack'); + +// Playerjs hands out one method and keeps everything else in a closure. What +// identifies it is that api('id') answers with the id of the element it was +// built on — cheap, and true of a player that has no ladder to offer. +export const isPlayerjsInstance = (value) => + isFunction(value, 'api') && typeof call(value, 'api', 'id') === 'string'; + +const ATTACHED_KEYS = ['hls', '_hls', '__hls', 'hlsPlayer']; + +// Cheapest first: some pages hang the engine straight off the media element. +const findAttachedHls = (video) => { + const element = video.wrappedJSObject ?? video; + for (const key of ATTACHED_KEYS) { + const candidate = read(element, key); + if (candidate && isHlsEngine(candidate)) return candidate; + } + return null; +}; + +const MATCHERS = [ + { name: 'hls', matches: isHlsEngine }, + { name: 'dash', matches: isDashPlayer }, + { name: 'shaka', matches: isShakaPlayer }, + { name: 'playerjs', matches: isPlayerjsInstance }, +]; + +// One sweep of the page's globals for all of them. Sweeping once per engine +// meant walking the page's globals four times over, through cross-compartment +// wrappers, in the moment the viewer was waiting for the sheet to open. +export const findEngines = (video, host = null) => { + const found = findGlobalMatches(MATCHERS); + if (host !== null) { + for (const matcher of MATCHERS) { + try { + if (matcher.matches(host)) found[matcher.name] = host; + } catch { + continue; + } + } + } + if (found.hls === undefined) { + const attached = findAttachedHls(video); + if (attached !== null) found.hls = attached; + } + return found; +}; + +// Playerjs is only worth looking for once the page has said it has one: the +// check is a call into an api() that belongs to somebody, and asking that of +// every global with a method by that name is not a question worth asking. +export const hasPlayerjs = () => isFunction(pageWindow(), 'Playerjs'); diff --git a/src/content/video/pageapi.js b/src/content/video/pageapi.js index 2f0f936..1d70a70 100644 --- a/src/content/video/pageapi.js +++ b/src/content/video/pageapi.js @@ -10,7 +10,13 @@ // player is broken". const MAX_SCANNED_KEYS = 500; -export const pageWindow = () => window.wrappedJSObject ?? window; +// Null rather than a throw where there is no window at all, so that a sweep of +// the page's globals is simply an empty answer instead of an error every caller +// would have to catch. +export const pageWindow = () => { + if (typeof window === 'undefined') return null; + return 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 @@ -109,30 +115,37 @@ export const findApiAncestor = (element, names) => { // only they know. Bounded, and every property read is guarded, because a getter // on a page global can throw or be expensive. // -// Every matcher is tried against each global in one sweep. Sweeping once per -// engine instead meant walking the page's globals three times over, through -// cross-compartment wrappers, in the moment the viewer was waiting for the -// player to open. -export const findGlobalMatch = (matchers) => { +// Every matcher is tried against each global in one sweep, and the first object +// that answers to a matcher is kept for it. Sweeping once per engine instead +// meant walking the page's globals several times over, through +// cross-compartment wrappers, in the moment the viewer was waiting. +export const findGlobalMatches = (matchers) => { const scope = pageWindow(); + const found = {}; + if (scope === null) return found; let keys = []; try { keys = Object.keys(scope); } catch { - return null; + return found; } + let missing = matchers.length; const limit = Math.min(keys.length, MAX_SCANNED_KEYS); - for (let index = 0; index < limit; index++) { + for (let index = 0; index < limit && missing > 0; index++) { const value = read(scope, keys[index]); if (value === null || typeof value !== 'object') continue; for (const matcher of matchers) { + if (found[matcher.name] !== undefined) continue; try { - if (matcher.matches(value)) return { name: matcher.name, value }; + if (matcher.matches(value)) { + found[matcher.name] = value; + missing--; + } } catch { continue; } } } - return null; + return found; }; diff --git a/src/content/video/qualityadapters.js b/src/content/video/qualityadapters.js index 16d5dcd..4938a07 100644 --- a/src/content/video/qualityadapters.js +++ b/src/content/video/qualityadapters.js @@ -1,13 +1,5 @@ -import { - call, - findGlobalMatch, - isFunction, - pageWindow, - read, - toArray, - toPage, - write, -} from './pageapi.js'; +import { call, isFunction, read, toArray, toPage, write } from './pageapi.js'; +import { findEngines, hasPlayerjs } from './engines.js'; export const AUTO_ID = 'auto'; @@ -191,20 +183,6 @@ const createYouTubeAdapter = (video, host) => { // --- hls.js ------------------------------------------------------------------ -const isHlsEngine = (value) => - Array.isArray(read(value, 'levels')) && - typeof read(value, 'currentLevel') === 'number'; - -const findHlsOnElement = (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 null; -}; - const buildHlsAdapter = (engine) => { const levels = () => toArray(read(engine, 'levels')).map((level, index) => ({ @@ -241,10 +219,6 @@ const buildHlsAdapter = (engine) => { // --- dash.js ----------------------------------------------------------------- -const isDashPlayer = (value) => - isFunction(value, 'getBitrateInfoListFor') && - isFunction(value, 'setQualityFor'); - const buildDashAdapter = (player) => { const setAuto = (isOn) => { call( @@ -294,10 +268,6 @@ const buildDashAdapter = (player) => { // --- Shaka Player ------------------------------------------------------------ -const isShakaPlayer = (value) => - isFunction(value, 'getVariantTracks') && - isFunction(value, 'selectVariantTrack'); - const buildShakaAdapter = (player) => { // The originals are handed back to selectVariantTrack untouched: a track is // a page object, and only the page's own object is accepted there. @@ -418,54 +388,32 @@ export const buildPlayerjsAdapter = (instance) => ({ }, }); -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, - shaka: buildShakaAdapter, -}; - -const MATCHERS = [ - { name: 'hls', matches: isHlsEngine }, - { name: 'dash', matches: isDashPlayer }, - { name: 'shaka', matches: isShakaPlayer }, +// An engine that hands over its ladder can say more about it than a player that +// only answers in labels, so Playerjs is the last one asked. +const BUILDERS = [ + { kind: 'hls', build: buildHlsAdapter }, + { kind: 'dash', build: buildDashAdapter }, + { kind: 'shaka', build: buildShakaAdapter }, + { kind: 'playerjs', build: buildPlayerjsAdapter }, ]; -// One sweep of the page's globals for all three streaming engines, after the -// cheap check of whether the engine is hanging off the element itself. -const createStreamAdapter = (video, host) => { - if (host !== null && isHlsEngine(host)) return buildHlsAdapter(host); - - const attached = findHlsOnElement(video); - if (attached !== null) return buildHlsAdapter(attached); - - const found = findGlobalMatch(MATCHERS); - if (found === null) return null; - return BUILDERS[found.name](found.value); +const createEngineAdapter = (video, host) => { + const engines = findEngines(video, host); + for (const { kind, build } of BUILDERS) { + const engine = engines[kind]; + if (engine === undefined) continue; + if (kind === 'playerjs' && !hasPlayerjs()) continue; + const adapter = build(engine); + // A player with nothing to offer is not an answer; the next one might be. + if (adapter.list().length > 0) return adapter; + } + return null; }; // Cheapest and most certain first: a list is unambiguous, a named -// 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. +// player is next, and the sweep of the page's globals is the last thing tried. export const ADAPTERS = [ createSourceAdapter, createYouTubeAdapter, - createStreamAdapter, - createPlayerjsAdapter, + createEngineAdapter, ]; From 17a9984a3f2df59d0ca440d886b7f84614921ede Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Sun, 9 Aug 2026 21:52:20 +0300 Subject: [PATCH 2/4] feat(video): switch the audio track from the sheet Film sites often carry two or three dubs, and switching them from a phone was close to impossible. The row reads hls.js audioTracks, dash.js getTracksFor, Shaka's audio languages, Playerjs's own list and the audioTracks a browser exposes on the video element itself. The sheet grows a way to hide a row that has nothing to offer, so quality and audio both stay out of the way on a page with one of each. --- src/content/controls/menu.js | 27 ++++ src/content/video/audiotracks.js | 214 +++++++++++++++++++++++++++++++ test/unit/audiotracks.test.js | 79 ++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 src/content/video/audiotracks.js create mode 100644 test/unit/audiotracks.test.js diff --git a/src/content/controls/menu.js b/src/content/controls/menu.js index de4a078..4fd9977 100644 --- a/src/content/controls/menu.js +++ b/src/content/controls/menu.js @@ -88,6 +88,7 @@ export const createMenu = ({ video, tracks, quality, + audio, onStyle, onPickFile, onRate, @@ -135,6 +136,29 @@ export const createMenu = ({ setQuality(quality.getCurrent()); }; + // A row that offers one answer is a row in the way, so both of these are + // hidden until the site turns out to have something to choose between. + const buildOptionalRow = (label, source, onFail) => { + const holder = buildChipRow(label); + const paint = () => { + source.refresh(); + const options = source.getOptions(); + holder.row.classList.toggle('is-empty', !source.isSwitchable()); + if (!source.isSwitchable()) return; + const setActive = paintChips(holder, options, (id) => { + if (!source.select(id)) onNotice(onFail); + }); + setActive(source.getCurrent()); + }; + return { row: holder.row, paint }; + }; + + const audioRow = buildOptionalRow( + 'Audio', + audio, + 'The site would not change the track', + ); + let scaleIndex = SUBTITLE_SCALES.indexOf(1); const sizeRow = buildStepper( 'Size', @@ -174,12 +198,14 @@ export const createMenu = ({ paintSubtitles(); qualityRow.chips.replaceChildren(buildNote('Reading the site…')); + audioRow.row.classList.add('is-empty'); // Quality leads. It is the row people open this sheet for, it is the longest, // and on a phone held sideways the sheet scrolls — so anything below the // first two rows is a row somebody has to go looking for. const root = el('div', { class: 'panel menu' }, [ qualityRow.row, + audioRow.row, speedRow.row, subtitleRow.row, sizeRow, @@ -200,6 +226,7 @@ export const createMenu = ({ const refresh = () => { paintSubtitles(); paintQuality(); + audioRow.paint(); }; const open = () => { diff --git a/src/content/video/audiotracks.js b/src/content/video/audiotracks.js new file mode 100644 index 0000000..99d5d8b --- /dev/null +++ b/src/content/video/audiotracks.js @@ -0,0 +1,214 @@ +import { call, read, toArray, write } from './pageapi.js'; +import { findEngines } from './engines.js'; + +// A film with two dubs carries them as two audio renditions of the same stream, +// and every engine names them differently. What they agree on is that there is +// a list, one of them is playing, and one of them can be asked for — which is +// all a row of chips needs. + +const trackLabel = (name, language, index) => { + if (typeof name === 'string' && name !== '') return name; + if (typeof language === 'string' && language !== '') return language; + return `Track ${index + 1}`; +}; + +// --- hls.js ------------------------------------------------------------------ + +const buildHlsTracks = (engine) => ({ + name: 'hls.js', + list: () => + toArray(read(engine, 'audioTracks')).map((track, index) => ({ + id: String(index), + label: trackLabel(read(track, 'name'), read(track, 'lang'), index), + })), + current: () => { + const index = read(engine, 'audioTrack'); + return typeof index === 'number' && index >= 0 ? String(index) : null; + }, + select: (id) => write(engine, 'audioTrack', Number(id)), +}); + +// --- dash.js ----------------------------------------------------------------- + +const buildDashTracks = (player) => { + // The originals go back to setCurrentTrack 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, 'getTracksFor', 'audio')).map( + (track, index) => { + const id = String(index); + known.set(id, track); + const labels = toArray(read(track, 'labels')); + return { + id, + label: trackLabel( + read(labels[0] ?? null, 'text'), + read(track, 'lang'), + index, + ), + }; + }, + ); + }; + + return { + name: 'dash.js', + list: collect, + current: () => { + const track = call(player, 'getCurrentTrackFor', 'audio'); + const language = read(track, 'lang'); + const found = collect().find( + (entry) => read(known.get(entry.id), 'lang') === language, + ); + return found === undefined ? null : found.id; + }, + select: (id) => { + if (known.size === 0) collect(); + const track = known.get(id); + if (track === undefined) return false; + call(player, 'setCurrentTrack', track); + return true; + }, + }; +}; + +// --- Shaka Player ------------------------------------------------------------ + +const buildShakaTracks = (player) => ({ + name: 'shaka', + list: () => + toArray(call(player, 'getAudioLanguages')) + .filter((language) => typeof language === 'string' && language !== '') + .map((language) => ({ id: language, label: language })), + current: () => { + const active = toArray(call(player, 'getVariantTracks')).find( + (track) => read(track, 'active') === true, + ); + const language = read(active ?? null, 'language'); + return typeof language === 'string' && language !== '' ? language : null; + }, + select: (id) => { + call(player, 'selectAudioLanguage', id); + return true; + }, +}); + +// --- Playerjs ---------------------------------------------------------------- + +const buildPlayerjsTracks = (instance) => ({ + name: 'playerjs', + list: () => + toArray(call(instance, 'api', 'audiotracks')) + .filter((label) => typeof label === 'string' && label !== '') + .map((label) => ({ id: label, label })), + current: () => { + const shown = call(instance, 'api', 'audiotrack'); + return typeof shown === 'string' && shown !== '' ? shown : null; + }, + select: (id) => { + call(instance, 'api', 'audiotrack', id); + return true; + }, +}); + +// --- The element's own list -------------------------------------------------- + +// Where the browser exposes it, this is the plainest answer of all. Gecko does +// not ship audioTracks on Android today, so it is the fallback rather than the +// first thing asked. +const buildNativeTracks = (video) => { + const element = video.wrappedJSObject ?? video; + const list = read(element, 'audioTracks'); + if (typeof read(list, 'length') !== 'number') return null; + + return { + name: 'element', + list: () => + toArray(list).map((track, index) => ({ + id: String(index), + label: trackLabel(read(track, 'label'), read(track, 'language'), index), + })), + current: () => { + const tracks = toArray(list); + const index = tracks.findIndex( + (track) => read(track, 'enabled') === true, + ); + return index === -1 ? null : String(index); + }, + // Exactly one enabled at a time: switching is turning the others off. + select: (id) => { + const wanted = Number(id); + const tracks = toArray(list); + if (Number.isNaN(wanted) || tracks[wanted] === undefined) return false; + tracks.forEach((track, index) => + write(track, 'enabled', index === wanted), + ); + return true; + }, + }; +}; + +const BUILDERS = [ + { kind: 'hls', build: buildHlsTracks }, + { kind: 'dash', build: buildDashTracks }, + { kind: 'shaka', build: buildShakaTracks }, + { kind: 'playerjs', build: buildPlayerjsTracks }, +]; + +export const createAudioTracks = (video, host = null) => { + let adapter = null; + let options = []; + // The same bargain the quality row strikes: what the viewer asked for + // outlives the adapter, which is rebuilt every time the sheet opens. + let chosen = null; + + const detect = () => { + const engines = findEngines(video, host); + for (const { kind, build } of BUILDERS) { + const engine = engines[kind]; + if (engine === undefined) continue; + try { + const candidate = build(engine); + if (candidate.list().length > 0) return candidate; + } catch (error) { + console.warn('Nocturne: an audio adapter threw while probing', error); + } + } + try { + const native = buildNativeTracks(video); + if (native !== null && native.list().length > 0) return native; + } catch (error) { + console.warn('Nocturne: the element refused its audio list', error); + } + return null; + }; + + const refresh = () => { + adapter = detect(); + options = adapter === null ? [] : adapter.list(); + if (!options.some((option) => option.id === chosen)) chosen = null; + return options; + }; + + return { + refresh, + getOptions: () => options, + // One track is not a choice, it is a fact, and a row offering it is a row + // in the way. + isSwitchable: () => options.length > 1, + getCurrent: () => { + if (chosen !== null) return chosen; + return adapter === null ? null : adapter.current(); + }, + select: (id) => { + if (adapter === null) return false; + const wanted = String(id); + if (adapter.select(wanted) !== true) return false; + chosen = wanted; + return true; + }, + }; +}; diff --git a/test/unit/audiotracks.test.js b/test/unit/audiotracks.test.js new file mode 100644 index 0000000..a48f728 --- /dev/null +++ b/test/unit/audiotracks.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createAudioTracks } from '../../src/content/video/audiotracks.js'; + +// createAudioTracks finds its engine among the page's globals, which a test has +// none of; the engine is handed in as the player host instead, which is the +// same door a site that names its player takes. +const VIDEO = { wrappedJSObject: {} }; + +const hlsEngine = (tracks, current) => ({ + levels: [], + currentLevel: 0, + audioTracks: tracks, + audioTrack: current, +}); + +test('hls tracks are listed by name and switched by index', () => { + const engine = hlsEngine( + [ + { name: 'Українська', lang: 'uk' }, + { name: 'English', lang: 'en' }, + ], + 0, + ); + const audio = createAudioTracks(VIDEO, engine); + assert.deepEqual( + audio.refresh().map((option) => option.label), + ['Українська', 'English'], + ); + assert.equal(audio.getCurrent(), '0'); + assert.equal(audio.select('1'), true); + assert.equal(engine.audioTrack, 1); +}); + +test('a nameless track falls back to its language, then its place', () => { + const engine = hlsEngine([{ lang: 'uk' }, {}], 0); + const audio = createAudioTracks(VIDEO, engine); + assert.deepEqual( + audio.refresh().map((option) => option.label), + ['uk', 'Track 2'], + ); +}); + +test('a single track is not offered as a choice', () => { + const audio = createAudioTracks(VIDEO, hlsEngine([{ name: 'only' }], 0)); + audio.refresh(); + assert.equal(audio.isSwitchable(), false); +}); + +test('a player with no tracks at all offers nothing', () => { + const audio = createAudioTracks(VIDEO, hlsEngine([], -1)); + assert.deepEqual(audio.refresh(), []); + assert.equal(audio.getCurrent(), null); + assert.equal(audio.select('0'), false); +}); + +test('the chip holds the choice the engine has not caught up with', () => { + const engine = hlsEngine([{ name: 'a' }, { name: 'b' }], 0); + const audio = createAudioTracks(VIDEO, engine); + audio.refresh(); + audio.select('1'); + engine.audioTrack = 0; + assert.equal(audio.getCurrent(), '1'); +}); + +test('a choice the engine stops offering is forgotten', () => { + const engine = hlsEngine([{ name: 'a' }, { name: 'b' }], 0); + const audio = createAudioTracks(VIDEO, engine); + audio.refresh(); + audio.select('1'); + // The stream was rebuilt with one track and the engine is playing it. With + // the choice forgotten, the row follows the engine again rather than pointing + // at a track that is no longer there. + engine.audioTracks = [{ name: 'a' }]; + engine.audioTrack = 0; + audio.refresh(); + assert.equal(audio.getCurrent(), '0'); +}); From a9b1a3f4edb01fb68324997e621fc46d00dd43ef Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Sun, 9 Aug 2026 21:52:37 +0300 Subject: [PATCH 3/4] feat(video): pick the season and the episode from the player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A series on a Playerjs site keeps a path — the season, the dub, the episode — and the player draws one control per step of it. Those are read and put in the top-left corner of the player, laid out across, where a thumb reaches with the phone held sideways: the season, then the episode, each opening a list with the one being watched marked. Playerjs answers next and prev but refuses an entry by index or by name: api('playlist') is undefined, api('play', n) throws and api('find') returns false. So a choice is made the way its own menu makes it — by pressing the row the player drew. The rows are read again at the moment of the press, because choosing a season has the player rebuild every step under it and rows held from before that are detached. A step with one entry in it is not a choice and is not drawn; a page that is a film has no bar at all. --- src/content/player.css | 86 +++++++++++++++++++++ src/content/ui.js | 81 +++++++++++++++++++- src/content/video/playlist.js | 140 ++++++++++++++++++++++++++++++++++ test/unit/playlist.test.js | 60 +++++++++++++++ 4 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/content/video/playlist.js create mode 100644 test/unit/playlist.test.js diff --git a/src/content/player.css b/src/content/player.css index 69832b3..d73a501 100644 --- a/src/content/player.css +++ b/src/content/player.css @@ -110,6 +110,88 @@ pointer-events: auto; } +/* The playlist sits next to the way out, where a thumb reaches it with the + phone held sideways: one dropdown per step of the path the site keeps, laid + out the way the site itself reads them — the season, then the episode. */ +.playlist-bar { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.playlist-bar.is-empty { + display: none; +} + +.playlist-pick { + position: relative; +} + +.playlist-value { + display: flex; + align-items: center; + gap: 2px; + max-width: 34vw; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: rgb(255 255 255 / 12%); + color: inherit; + font: inherit; + font-size: 13px; +} + +.playlist-value svg { + width: 16px; + height: 16px; + flex: none; +} + +.playlist-current { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* The list hangs under the value it belongs to, and is allowed to scroll: + a season can run to twenty-odd episodes and the screen is short. */ +.playlist-list { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 2; + display: flex; + overflow-y: auto; + flex-direction: column; + min-width: 100%; + max-height: 52vh; + padding: 4px; + border-radius: 10px; + background: rgb(18 18 20 / 96%); + gap: 2px; +} + +.playlist-list[hidden] { + display: none; +} + +.playlist-option { + padding: 9px 14px; + border: 0; + border-radius: 7px; + background: none; + color: inherit; + font: inherit; + font-size: 14px; + text-align: left; + white-space: nowrap; +} + +.playlist-option.is-current { + background: rgb(255 255 255 / 16%); +} + .spacer { flex: 1; } @@ -381,6 +463,10 @@ /* A hairline instead of a gap: with rows of different heights, a line is what * makes it plain which chips belong to which label. */ +.menu-row.is-empty { + display: none; +} + .menu-row + .menu-row { border-top: 1px solid rgba(255, 255, 255, 0.08); } diff --git a/src/content/ui.js b/src/content/ui.js index 5b7aba1..0a16133 100644 --- a/src/content/ui.js +++ b/src/content/ui.js @@ -1,6 +1,8 @@ import { readSiteChapters } from './video/chapters.js'; import { createColorPanel } from './controls/colorpanel.js'; import { createMenu } from './controls/menu.js'; +import { createAudioTracks } from './video/audiotracks.js'; +import { createPlaylist } from './video/playlist.js'; import { createQuality } from './video/quality.js'; import { createRecognizer } from './gestures/recognizer.js'; import { createSeekBar } from './controls/seekbar.js'; @@ -16,6 +18,7 @@ const SCRUB_TICK_MS = 100; const TOAST_MS = 900; const HINT_MS = 2200; const SKIP_SECONDS = 10; +const PLAYLIST_SETTLE_MS = 600; const FILL_RETRY_MS = 400; const FRAME_LIFE_MS = 2000; const CHAPTER_TRIES_MS = [1200, 4000, 10000]; @@ -30,6 +33,7 @@ const FILL_ATTEMPTS = 25; const ICON = { exit: 'M6 6l12 12M18 6L6 18', + chevron: 'M7 10l5 5 5-5', 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: 'M4 5h16v14H4zM12.5 12.5h6v5h-6z', @@ -104,6 +108,11 @@ export const createOverlay = ({ // to things created after them. const buttons = { colour: null, menu: null, play: null }; const menuRef = { setSubtitle: () => {} }; + const playlistRef = { + refresh: () => {}, + isOpen: () => false, + close: () => {}, + }; // Playback speed is deliberately not restored: it belongs to the film you // were watching, not to the next one. @@ -111,6 +120,8 @@ export const createOverlay = ({ const visuals = createVisuals(video, stage); const quality = createQuality(video, playerHost); + const audio = createAudioTracks(video, playerHost); + const playlist = createPlaylist(video, playerHost); const tracks = createTrackManager( video, (text) => { @@ -154,6 +165,7 @@ export const createOverlay = ({ video, tracks, quality, + audio, onStyle: ({ scale }) => { cueBox.style.setProperty('--cue-scale', String(scale)); onPersist({ subtitleScale: scale }); @@ -169,11 +181,15 @@ export const createOverlay = ({ menuRef.setSubtitle = menu.setSubtitle; - const isPanelOpen = () => colorPanel.isOpen() || menu.isOpen(); + const isPanelOpen = () => + colorPanel.isOpen() || menu.isOpen() || playlistRef.isOpen(); // The chrome must never fade out from under an open panel: the panel lives // inside it, and hiding it mid-adjustment looked like the player had frozen. const setChromeVisible = (isVisible) => { + // Read again on the way in: an episode that finished and rolled on to the + // next one has left the bar naming the one before it. + if (isVisible) playlistRef.refresh(); chrome.toggleAttribute('hidden', !isVisible); if (chromeTimer !== null) clearTimeout(chromeTimer); chromeTimer = null; @@ -185,6 +201,7 @@ export const createOverlay = ({ }; const closePanels = () => { + playlistRef.close(); colorPanel.close(); menu.close(); buttons.colour?.setAttribute('aria-pressed', 'false'); @@ -337,8 +354,70 @@ export const createOverlay = ({ leaveForHomeScreen(); }; + // The playlist sits where a thumb reaches it while the phone is held + // sideways — beside the way out, not buried in the settings sheet, which is + // no place to be changing episode from. One dropdown per step of the path + // the player itself keeps: the season, then the episode. + const playlistBar = el('div', { class: 'playlist-bar' }); + + const closePlaylistLists = () => { + for (const list of playlistBar.querySelectorAll('.playlist-list')) { + list.toggleAttribute('hidden', true); + } + }; + + const buildPlaylistPick = (level, index) => { + const list = el('div', { class: 'playlist-list', hidden: '' }); + const value = el('button', { class: 'playlist-value', type: 'button' }, [ + el('span', { class: 'playlist-current' }, [level.current]), + buildIcon(ICON.chevron), + ]); + + value.addEventListener('click', () => { + const wasOpen = !list.hasAttribute('hidden'); + closePlaylistLists(); + list.toggleAttribute('hidden', wasOpen); + }); + + for (const [option, label] of level.labels.entries()) { + const isCurrent = label === level.current; + const attributes = { + class: isCurrent ? 'playlist-option is-current' : 'playlist-option', + type: 'button', + }; + const entry = el('button', attributes, [label]); + entry.addEventListener('click', () => { + closePlaylistLists(); + playlist.select(index, option); + // The player rewrites its own path as it switches, so the bar is read + // again rather than guessing what the press will have done — once now, + // and once more after the step it had to fetch has landed. + playlistRef.refresh(); + setTimeout(() => playlistRef.refresh(), PLAYLIST_SETTLE_MS); + }); + list.append(entry); + } + + return el('div', { class: 'playlist-pick' }, [value, list]); + }; + + // A series only admits to having a playlist once its player has drawn one, so + // the bar appears when there is something to choose from and stays away when + // the page is a film. + playlistRef.refresh = () => { + playlist.refresh(); + const levels = playlist.getLevels(); + playlistBar.replaceChildren(...levels.map(buildPlaylistPick)); + playlistBar.classList.toggle('is-empty', levels.length === 0); + }; + playlistRef.isOpen = () => + playlistBar.querySelector('.playlist-list:not([hidden])') !== null; + playlistRef.close = closePlaylistLists; + playlistRef.refresh(); + topbar.append( buildButton('Exit player', ICON.exit, () => onExit()), + playlistBar, el('div', { class: 'spacer' }), buildButton('Picture in picture', ICON.pip, enterPictureInPicture), buttons.colour, diff --git a/src/content/video/playlist.js b/src/content/video/playlist.js new file mode 100644 index 0000000..d28120a --- /dev/null +++ b/src/content/video/playlist.js @@ -0,0 +1,140 @@ +import { call } from './pageapi.js'; +import { findEngines } from './engines.js'; + +// A series carries its playlist as a path — a season, a dub, an episode — and +// Playerjs draws one element per step of that path, each holding the value it +// is showing and the list it would open. Reading those is how the steps are +// known, and pressing a row in one of the lists is how a step is changed: +// api('playlist'), api('play') and api('find') all refuse an entry by index or +// by name, while a press on the row the player drew answers. +// +// Nothing here is a selector borrowed from a stylesheet. The levels are found +// through the id the player itself reports, so a themed or renamed skin still +// reads. + +const MAX_LEVELS = 8; +const MIN_OPTIONS = 2; + +const textOf = (element) => (element.textContent || '').trim(); + +// Every level is a value and the list behind it, in that order. A level that +// is drawn but never filled — the player keeps a few spare — has neither. +const readLevel = (element) => { + const [chip, list] = element.children; + if (chip === undefined || list === undefined) return null; + + const current = textOf(chip); + if (current === '') return null; + + const options = []; + for (const row of list.children) { + const label = textOf(row); + if (label !== '') options.push({ label, row }); + } + // One option is not a choice, and a lone dub named above every episode is + // noise rather than a control. + if (options.length < MIN_OPTIONS) return null; + + return { chip, current, options }; +}; + +// The rows answer a press, not a call: this is the sequence a finger makes, +// sent to the row the player drew. +const press = (target) => { + const box = target.getBoundingClientRect(); + const clientX = box.left + box.width / 2; + const clientY = box.top + box.height / 2; + const shared = { + bubbles: true, + cancelable: true, + composed: true, + clientX, + clientY, + view: window, + }; + const touch = new Touch({ identifier: 1, target, clientX, clientY }); + const touching = { + bubbles: true, + cancelable: true, + composed: true, + touches: [touch], + targetTouches: [touch], + changedTouches: [touch], + }; + const lifted = { ...touching, touches: [], targetTouches: [] }; + + target.dispatchEvent(new PointerEvent('pointerdown', shared)); + target.dispatchEvent(new TouchEvent('touchstart', touching)); + target.dispatchEvent(new MouseEvent('mousedown', shared)); + target.dispatchEvent(new TouchEvent('touchend', lifted)); + target.dispatchEvent(new PointerEvent('pointerup', shared)); + target.dispatchEvent(new MouseEvent('mouseup', shared)); + target.dispatchEvent(new MouseEvent('click', shared)); +}; + +export const createPlaylist = (video, host = null) => { + let player = null; + let playerId = ''; + let levels = []; + + const levelAt = (index) => + document.getElementById(`${playerId}_playlist${index}`); + + const refresh = () => { + levels = []; + playerId = ''; + player = findEngines(video, host).playerjs ?? null; + if (player === null) return false; + + const id = call(player, 'api', 'id'); + if (typeof id !== 'string' || id === '') return false; + playerId = id; + + const found = []; + for (let index = 1; index <= MAX_LEVELS; index++) { + const element = levelAt(index); + if (element === null) continue; + const level = readLevel(element); + if (level !== null) found.push({ index, ...level }); + } + // Playerjs numbers its levels from the deepest — the episode is first and + // the season is last — while a reader goes the other way. + levels = found.reverse(); + return levels.length > 0; + }; + + return { + refresh, + // A film has no path to walk; a series has one. + has: () => levels.length > 0, + // What the bar draws: a value and the choices behind it, one per step. + getLevels: () => + levels.map(({ current, options }) => ({ + current, + labels: options.map((option) => option.label), + })), + select: (level, option) => { + const step = levels[level]; + if (step === undefined) return false; + + // The rows are read again rather than kept: choosing a season has the + // player rebuild every step under it, and the rows held from before that + // are detached elements no press can reach. + const opened = readLevel(levelAt(step.index)); + if (opened === null) return false; + // Only the step the player is currently showing has live rows, so the + // step is opened first — the same press its own breadcrumb takes — and + // the choice made in the list that opening puts on screen. + press(opened.chip); + + const shown = readLevel(levelAt(step.index)) ?? opened; + const chosen = shown.options[option]; + if (chosen === undefined) return false; + press(chosen.row); + return true; + }, + }; +}; + +// Exported for the tests, which build a level rather than a whole player. +export const readPlaylistLevel = readLevel; diff --git a/test/unit/playlist.test.js b/test/unit/playlist.test.js new file mode 100644 index 0000000..495f561 --- /dev/null +++ b/test/unit/playlist.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { readPlaylistLevel } from '../../src/content/video/playlist.js'; + +// The shape Playerjs builds for each step of its path: an element holding the +// value it is showing and, beside it, the list it would open. +const fakeElement = (text, children = []) => ({ textContent: text, children }); + +const fakeLevel = (current, labels) => + fakeElement('', [ + fakeElement(current), + fakeElement( + '', + labels.map((label) => fakeElement(label)), + ), + ]); + +test('a level reads as its value and the choices behind it', () => { + const level = readPlaylistLevel(fakeLevel('2 серія', ['1 серія', '2 серія'])); + + assert.equal(level.current, '2 серія'); + assert.deepEqual( + level.options.map((option) => option.label), + ['1 серія', '2 серія'], + ); +}); + +test('the row itself is kept: a press is what the player answers', () => { + const element = fakeLevel('1 сезон', ['1 сезон', '2 сезон']); + const level = readPlaylistLevel(element); + + assert.equal(level.options[1].row, element.children[1].children[1]); +}); + +test('a level the player drew but never filled is not a level', () => { + assert.equal(readPlaylistLevel(fakeElement('', [])), null); + assert.equal(readPlaylistLevel(fakeLevel('', ['1 серія', '2 серія'])), null); +}); + +test('a lone dub named above every episode is not a choice', () => { + assert.equal(readPlaylistLevel(fakeLevel('HDrezka', ['HDrezka'])), null); +}); + +test('an entry the site left unnamed is passed over', () => { + const element = fakeLevel('2 серія', ['1 серія', '', '2 серія']); + const level = readPlaylistLevel(element); + + assert.deepEqual( + level.options.map((option) => option.label), + ['1 серія', '2 серія'], + ); +}); + +test('whitespace the site laid out around a name is not part of it', () => { + const level = readPlaylistLevel(fakeLevel(' 2 серія\n', ['a', ' b '])); + + assert.equal(level.current, '2 серія'); + assert.equal(level.options[1].label, 'b'); +}); From 87b910b2442ee0eb93bcd4eea3ee2dd6f53d79f5 Mon Sep 17 00:00:00 2001 From: Vlad Pohorilets Date: Sun, 9 Aug 2026 21:52:47 +0300 Subject: [PATCH 4/4] docs: 0.5.0, seasons, episodes and audio tracks --- CHANGELOG.md | 35 ++++++++++++++++++++++++++++++++++- README.md | 16 ++++++++++++++-- package.json | 2 +- src/manifest.json | 2 +- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3202340..9ae3ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased][unreleased] +## [0.5.0][] - 2026-08-09 + +### Added + +- **Seasons and episodes, as two dropdowns in the corner of the screen.** A + series on a Playerjs site keeps its path — the season, the dub, the episode — + and the player draws one control per step of it. Nocturne now reads those and + puts them where a thumb reaches with the phone held sideways, beside the way + out: the season on the left, the episode next to it, each opening a list with + the one you are on marked. Picking an episode plays it without leaving the + player. A step with only one thing in it — a film with one dub — is not drawn, + and a page that is a film has no bar at all. +- **Audio tracks.** Film sites often carry two or three dubs and switching them + from a phone was close to impossible. The sheet now has a row for it, reading + hls.js `audioTracks`, dash.js `getTracksFor('audio')`, Shaka's audio + languages, Playerjs's own list and the `audioTracks` a browser exposes on the + video element. Like the quality row, it stays hidden when there is nothing to + choose between. + +### Changed + +- Quality and audio now find the page's player through one shared piece of + detection rather than each hunting for it separately. + +### Notes + +- Playerjs answers `api('next')` and `api('prev')`, but refuses an entry by + index or by name: `api('playlist')` is undefined, `api('play', n)` throws and + `api('find')` returns false. The dropdowns therefore press the player's own + rows, which is what its own menu does — reads and presses on elements the page + already drew, no code injected and nothing evaluated from a string. + ## [0.4.0][] - 2026-08-08 ### Added @@ -196,7 +228,8 @@ 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.4.0...HEAD +[unreleased]: https://github.com/kvachikk/nocturne-player/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/kvachikk/nocturne-player/releases/tag/v0.5.0 [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 f608c91..d7acb05 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,15 @@ Adjustable size and a sync offset for when the subtitles drift. **Quality** — the first row of the sheet: pick the rung of the ladder rather than letting the site choose for you. Works with `` lists, YouTube, -hls.js, dash.js and Shaka; where a player exposes nothing, the sheet says what -is playing instead of offering a choice that would do nothing. +hls.js, dash.js, Shaka and Playerjs; where a player exposes nothing, the sheet +says what is playing instead of offering a choice that would do nothing. + +**Audio tracks** — film sites often carry two or three dubs. The row below +quality switches between them, and stays out of the way when there is only one. + +**Seasons and episodes** — a series shows two dropdowns in the top-left corner +of the player: the season, and the episode beside it. Picking one plays it +without leaving the player. A film has no bar at all. **Picture in picture** — the last button in the top row asks Android for the home screen and leaves the film playing behind it, which is the hand-off the @@ -118,6 +125,11 @@ These are platform limits, not oversights: 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. +- **Episode selection follows the site's own player.** The dropdowns are read + from the controls Playerjs draws for its own path and are changed by pressing + those controls, because Playerjs refuses an entry asked for by index or by + name. A site that lists its episodes as ordinary page links, outside the + player, is not covered. - **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 c4712c6..11baeaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nocturne-player", - "version": "0.4.0", + "version": "0.5.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 365f960..5e24fa2 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Nocturne Player", - "version": "0.4.0", + "version": "0.5.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",