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
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<source>` 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
Expand Down Expand Up @@ -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
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.4.0",
"version": "0.5.0",
"private": true,
"description": "A touch-first video player for Firefox on Android",
"license": "MPL-2.0",
Expand Down
27 changes: 27 additions & 0 deletions src/content/controls/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const createMenu = ({
video,
tracks,
quality,
audio,
onStyle,
onPickFile,
onRate,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -200,6 +226,7 @@ export const createMenu = ({
const refresh = () => {
paintSubtitles();
paintQuality();
audioRow.paint();
};

const open = () => {
Expand Down
86 changes: 86 additions & 0 deletions src/content/player.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
81 changes: 80 additions & 1 deletion src/content/ui.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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];
Expand All @@ -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',
Expand Down Expand Up @@ -104,13 +108,20 @@ 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.
video.playbackRate = 1;

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) => {
Expand Down Expand Up @@ -154,6 +165,7 @@ export const createOverlay = ({
video,
tracks,
quality,
audio,
onStyle: ({ scale }) => {
cueBox.style.setProperty('--cue-scale', String(scale));
onPersist({ subtitleScale: scale });
Expand All @@ -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;
Expand All @@ -185,6 +201,7 @@ export const createOverlay = ({
};

const closePanels = () => {
playlistRef.close();
colorPanel.close();
menu.close();
buttons.colour?.setAttribute('aria-pressed', 'false');
Expand Down Expand Up @@ -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,
Expand Down
Loading