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