Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
08b7fa5
fix: keep the picture neutral until the viewer asks
kvachikk Aug 6, 2026
955a63c
fix(controls): move the launcher clear of the site's controls
kvachikk Aug 6, 2026
2e73ed2
fix(gestures): pause on the button only and lift the seek band
kvachikk Aug 6, 2026
c22b087
feat(video): quality, site captions and picture-in-picture
kvachikk Aug 6, 2026
a877f26
docs: record what changed in 0.3.0
kvachikk Aug 6, 2026
b8a7104
chore: release 0.3.0
kvachikk Aug 6, 2026
b4d3441
fix(session): stop the style guard chasing its own writes
kvachikk Aug 6, 2026
dca9900
fix: drop colour left over from schema 1 on upgrade
kvachikk Aug 6, 2026
6c22119
perf(video): keep the work out of the moment the player opens
kvachikk Aug 6, 2026
71fae25
docs: note the colour reset in the 0.3.0 changes
kvachikk Aug 6, 2026
c7b0301
feat(controls): a fullscreen button and a badge that hides
kvachikk Aug 6, 2026
f206ff5
feat(video): chapter markers on the seek bar
kvachikk Aug 6, 2026
d996194
fix(video): find the site's player before the video moves
kvachikk Aug 6, 2026
c66baba
feat(controls): picture-in-picture leaves for the home screen
kvachikk Aug 6, 2026
9fd758e
fix(video): read the quality ladder off the video's own formats
kvachikk Aug 6, 2026
6c493e0
docs: notes for reviewers and the rest of the 0.3.0 story
kvachikk Aug 7, 2026
c83ad63
fix(video): the site's player was refusing every call
kvachikk Aug 7, 2026
9b2d3ce
fix(session): keep the player standing through a scrub
kvachikk Aug 7, 2026
0920264
feat(controls): quality first, and a sheet that fits the screen
kvachikk Aug 7, 2026
0017f29
docs: what the emulator turned up
kvachikk Aug 7, 2026
4ff3a8e
feat(controls): brightness worth pressing
kvachikk Aug 7, 2026
f8aed2a
docs: listing copy for the add-on store
kvachikk Aug 7, 2026
3eb26d1
docs: say why quality stops at some sites
kvachikk Aug 7, 2026
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
99 changes: 99 additions & 0 deletions AMO-LISTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Listing copy for addons.mozilla.org

Ready to paste into the AMO submission form. Everything here is checkable
against the source; nothing claims a capability the build does not have.

## Summary (250 characters)

Touch-first video controls for Firefox on Android: seek from anywhere with a
thumb, pick the quality the site hid, read subtitles the site paints itself,
and warm the picture down for a dark room. No network access at all.

## What it does that the built-in controls do not

These are the reasons to install it. Each one is a thing Firefox for Android,
or the site's own mobile player, does not give you.

- **Seek from anywhere on the screen, at the precision you choose.** Put a
thumb anywhere in the lower band and drag: the film moves with it. Pull the
thumb away from the bar and the same swipe covers less time — 1x on the bar,
down to a tenth of that at the top of the band — so a two-hour film can be
landed on the exact scene without pinching or a stylus. The left label holds
the moment the drag began, so you can always find your way back.
- **The quality the mobile site will not offer you.** YouTube's mobile web
player publishes no quality menu at all; the sheet reads the ladder out of
the player and lets you pin a rung. The same row covers `<source>` lists,
hls.js, dash.js and Shaka, so it is not a YouTube trick.
- **Chapters on the seek bar, on a site that does not draw them there.** Where
a video publishes sections, the bar is cut at each one and the section under
the finger is named above it. YouTube's mobile site does not show them.
- **Subtitles from players that paint their own.** A player like YouTube's
draws captions itself, outside the browser's text-track machinery, so
nothing can restyle or shift them. They are mirrored into the player's own
cue layer, where they get a size control and a **sync offset** — the fix for
subtitles that run half a second ahead, which the web platform gives you no
way to correct.
- **Load an `.srt` or `.vtt` from the phone** onto a video that has no
subtitles of its own.
- **Night light, brightness, contrast and saturation, live on the film.**
A warm layer for watching in the dark, and a picture that can be lifted when
a film is mastered too dark for a phone screen. With every value left alone,
no filter is applied at all — the film is passed through exactly as encoded.
- **Fill the screen, without stretching.** The letterbox is cropped away by
default; a pinch moves between the whole frame and the cropped one and goes
no further, and a pan can never expose an edge. The crop is re-derived
whenever the screen changes shape, so it survives a rotation, a return from
another app, and a quality switch.
- **Hold either side to scrub at 2x**, double-tap to skip ten seconds, and a
play button that is the only place a tap pauses — so no misfires while
reaching across the picture.
- **It leaves the Android home swipe alone.** The player does not put the
system into sticky immersive mode, so one swipe still leaves the app, and the
seek band sits clear of the gesture strip at the bottom of the screen.
- **It works on any site with a `<video>`**, not a list of supported ones.

## Privacy, stated plainly

- **No network access whatsoever.** There is no `fetch`, no `XMLHttpRequest`,
no beacon, no WebSocket and no remote URL anywhere in the source. A check in
the build (`npm run lint:privacy`) fails on any of them, so this is enforced
rather than promised.
- **Two permissions**: `storage`, to keep your settings on the device, and
`activeTab`, so the popup can name the site you are on. No host permissions.
- **Nothing is collected, logged or transmitted.** `data_collection_permissions`
is declared as `{ required: ["none"] }`, which makes the absence
machine-checkable rather than a claim in a paragraph.
- Settings never use `storage.sync`, so they are not tied to an account.
- **The published build is not minified.** What you read in the source is what
runs on the phone.
- It works fully offline.

## Why it needs to run on all sites

The content script matches `<all_urls>` because it has to notice a `<video>`
element on whatever site you open — there is no way to detect a video on a site
without being allowed to run there. It reads nothing else from the page and
sends nothing anywhere, and it can be switched off per site from the popup.

## Known limits, said up front

- There is no Web API for picture-in-picture on Firefox for Android. The button
asks Android for the home screen and leaves the film playing behind it, which
is the hand-off the system already knows how to float — whether it floats is
Android's decision, not the extension's.
- Quality depends on what the site's player exposes. A player that keeps its
engine inside a closure cannot be reached from an extension at all — Playerjs,
which many film sites embed, hands out nothing but an opaque method — and the
sheet then reports the resolution being played rather than offering a choice
that would do nothing. YouTube, `<source>` lists, hls.js, dash.js and Shaka
are covered.
- Volume is left to the phone's own buttons: the web platform has no access to
the system volume.
- Hold-to-scrub steps the clock rather than raising `playbackRate`, which
Firefox for Android accepts but does not appear to honour. There is no sound
while scrubbing.

## Notes for reviewers

See [REVIEWERS.md](REVIEWERS.md) — build environment, the exact commands that
reproduce the submitted package, and what the privacy check enforces.
117 changes: 116 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased][unreleased]

## [0.3.0][] - 2026-08-07

Everything in this release comes from watching real films on a real phone with
0.2.0 installed.

### Added

- **Quality selection.** The site keeps its ladder; the sheet asks for a rung.
Adapters for `<source>` lists, YouTube, hls.js, dash.js and Shaka, read
through the page's own objects — no code is injected into the page and nothing
is evaluated from a string. Where a player exposes no way in, the row reports
the resolution being played instead of offering a choice that does nothing.
- **Captions from players that paint their own.** YouTube's caption list is read
through its player API and the text it renders is mirrored into the player's
cue layer, which is why subtitles were missing there before. The same mirror
covers Shaka, video.js, JW Player, Plyr and Playerjs.
- **Picture-in-picture button**, last in the top row after colour and settings,
where a thumb reaches it. Gecko ships no Web API for it on Android, so the
button does what a viewer does by hand: it asks Android for the home screen
and leaves Firefox playing behind it, which is the hand-off Android already
knows how to float.
- **Chapters on the seek bar.** Where a site publishes sections — YouTube does —
the bar is cut at each one and the title of the section under the finger is
named above it.
- **A fullscreen button on the inline video.** One button, drawn as the
fullscreen arrows, sitting on the site's own small player. It fades out after
a few seconds untouched and comes back on any tap, so it is there when it is
wanted and gone when it is not.
- **Quality leads the settings sheet**, above speed and subtitles. The rows are
as tall as their chips with a hairline between them, and the sheet ends above
the seek bar — a nine-rung quality ladder used to wrap across the row beneath
it and run off the bottom of the screen.
- **Fullscreen switch** in the settings sheet. Off runs the player as an overlay
instead of taking the screen, which leaves Android's ordinary single swipe
home working. It applies to the session it is used in and is deliberately not
remembered, the way playback speed is not.
- Loading an `.srt` / `.vtt` file now actually loads it: the file input had no
handler, so the button did nothing.

### Changed

- **Pausing is the play button and nothing else.** The invisible pause zone that
covered the whole picture is gone; a tap anywhere else brings the controls up
or puts them away.
- **The seek band sits above the bottom of the screen**, clear of the Android
home swipe, which used to be read as a scrub and threw the film into the
middle.
- **The badge sits halfway up the right edge** of an inline video rather than in
the bottom corner, where it covered the site's own controls.
- **The scrim is heavier** where the controls sit, so they stay readable when
they come up over a bright, playing picture rather than over a paused one.
- Motion follows one easing curve throughout, with scrims and shadows under the
controls so they stay legible over a bright picture.
- The launcher no longer asks Gecko to hide the navigation UI, which is what put
Android into sticky immersive mode.

### Fixed

- **The picture stays centred and keeps its shape.** A site that rewrites the
video's inline style — YouTube does it on every layout pass — could push the
picture against an edge or stretch it across the screen after a return from
another app. Position and offsets are pinned along with the size and re-pinned
whenever the site writes over them, and the zoom is re-derived from what the
viewer asked for whenever the stage changes shape.
- **Colour starts neutral.** Settings move to schema 2, which drops the four
colour values once on upgrade: a profile left on zero saturation was playing
every film in black and white, and one left on 1.3 contrast kept every film
tinted, neither of them anything a viewer would connect to a setting they
touched once. Values stored out of range or of the wrong type now fall back
to the neutral one as well.
- **No more fade to grey while paused.** People pause to look at the picture.
- **Quieter.** Choosing a quality no longer prints a message over the film; the
chip lighting up says it. Only a refusal is worth a word.
- **Brightness reaches further and moves faster**: 40%–200% in steps of ten,
rather than 50%–150% in steps of five. A film mastered dark can now actually
be lifted, and one press does something you can see — at five percent a press
the control read as one that did nothing.
- Leaving the app no longer tears the session down. Losing fullscreen while the
app is on its way to the background is what Android's floating-window hand-off
looks like, and it is now told apart from the viewer leaving the player.
- A quality switch that empties the media element no longer backs the player
out; the check waits to see whether the element really was torn down.
- **Every call into a site's player was being refused.** `method.apply()`
reaches the page's own `Function.prototype.apply`, and the page — the less
privileged side — cannot read the `length` of an array built in the content
script's compartment, so each call died with "Permission denied to access
property length" before it arrived. Calls go through `Reflect.apply` now.
This one bug is why quality, captions and chapters were all missing on
YouTube; all three work.
- **A drag along the seek bar no longer throws the viewer back to the page.**
YouTube removes its `<video>` element when it is told a seek is final, and a
drag was telling it so every 120ms. Seeks go through the site's own `seekTo`
where there is one, and only the finger coming off counts as final. The watch
that decides a video has been torn down is slower and harder to convince too.
- **The fullscreen button takes the screen.** It used to obey a stored switch
that a stray tap could turn off for good, after which the button looked
broken. The switch in the sheet still drops back to the overlay, for the
session it is used in.
- The site's player object is captured before the video is moved, so the
quality, caption and chapter readers still have something to ask once the
element no longer sits inside it.
- The style guard no longer answers its own writes. It compared what it had
asked for against what the browser had stored, never matched, and rewrote
twenty properties on every mutation it caused — which is what made the player
crawl and fullscreen take seconds to arrive.

### Notes

- Quality on a site that embeds Playerjs — common on film sites — is still the
site's to choose. The page publishes the hls.js constructor and a player
object with one opaque method; the instance that holds the ladder never
leaves the closure. Driving the player's own menu is the way in, and it is
left for a later version.

## [0.2.0][] - 2026-08-02

First release submitted to addons.mozilla.org.
Expand Down Expand Up @@ -51,5 +165,6 @@ 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.2.0...HEAD
[unreleased]: https://github.com/kvachikk/nocturne-player/compare/v0.3.0...HEAD
[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
75 changes: 50 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ adds them.
Built for **ordinary sites that play video through a plain `<video>` element** —
the kind of site you use to watch a film or a series.

**Not** for YouTube, Vimeo or TikTok. They ship their own player layers, and
overriding those is a fundamentally different (and much larger) problem. This
extension deliberately does not try.
It also works on **YouTube**, which was not the original plan: the site's own
player keeps running underneath, and the extension drives it through the API the
page already exposes — the quality ladder, the caption track — rather than
re-implementing it. Sites that hide their player behind a proprietary layer with
no such API still fall back to the plain controls.

## Features

Expand All @@ -35,11 +37,10 @@ your finger, like the scrubber in Safari on iOS. Moving your finger away from
the bar vertically reduces the scrubbing speed, so you can land on an exact
moment instead of overshooting.

**Volume** — a vertical strip on the right edge, same relative-drag feel.

**Tap to pause** — a large invisible zone in the middle of the screen, plus a
visible button when the controls are showing. When paused, the picture fades to
grey, like Netflix.
**Tap to pause** — the play button and nothing else. Tapping anywhere else on
the picture brings the controls up or puts them away, so a thumb landing on the
screen can never stop the film by accident. Pausing leaves the colour alone, for
when you paused to look at something.

**Hold for 2x** — hold the right zone to play at double speed, hold the left
zone to rewind. Double-tap either zone for ±10 seconds.
Expand All @@ -52,12 +53,25 @@ dark, with adjustable intensity.

**Colour** — brightness, contrast and saturation. Three sliders, nothing more.

**Subtitles** — use the site's own tracks or load an `.srt` / `.vtt` file from
your phone. Adjustable size, background, position, and a sync offset for when
the subtitles drift.
**Subtitles** — the site's own text tracks, the captions a player like
YouTube's paints for itself, or an `.srt` / `.vtt` file from your phone.
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.

**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
system already knows how to float. The player survives being backgrounded, so
coming back finds it where you left it.

**Chapters** — where a site publishes sections, the seek bar is cut at each one
and names the section your finger is over.

**Lock** — locks all gestures so your palm cannot pause the film. Unlocking
takes two deliberate gestures.
**On the site's own player** — a single fullscreen button sits on an inline
video. It fades out when it is left alone and comes back on any tap.

## Privacy

Expand Down Expand Up @@ -89,22 +103,33 @@ See [PRIVACY.md](PRIVACY.md).

These are platform limits, not oversights:

- **Picture-in-Picture does not work on Android.** Firefox for Android has no
PiP Web API — `requestPictureInPicture()` rejects with `NotSupportedError`
because the underlying GeckoView support does not exist yet. The button is
written and feature-detected, so it stays hidden on Android and will appear on
its own when Gecko ships support.
- **Video quality usually cannot be changed.** Sites that stream over HLS or
DASH pick the quality in their own JavaScript, and no extension can reach into
that generically. Where a site offers several `<source>` elements, the quality
menu switches between them; otherwise it shows the current resolution as
read-only information.
- **Volume is the video's volume, not the system volume.** The web platform has
no access to the device volume.
- **There is no Web API for Picture-in-Picture on Android.** Gecko does not
ship `requestPictureInPicture()` there. What Android does offer is its own
hand-off: leave the app while a video is playing and the system floats it. So
the button does exactly what a viewer would do by hand — it asks Android for
the home screen — and the session survives being backgrounded so the hand-off
is not torn down halfway through. Whether the window actually floats is the
system's decision, not the extension's.
- **Quality depends on what the site's player exposes.** The common ones are
covered — `<source>` lists, YouTube, hls.js, dash.js, Shaka — but a player
that keeps its engine inside a closure cannot be reached from an extension at
all. Playerjs, which many film sites embed, is the case in point: the page
publishes the hls.js _constructor_ and a player object with one opaque
method, and the instance holding the quality ladder is never handed out.
There the row reports the resolution being played instead of offering a
choice that would do nothing. Reaching those players by driving their own
menus is being looked at for a later version.
- **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
holding the left zone seeks backwards continuously at roughly 2x instead.
- **Landscape lock is best-effort.** Gecko may refuse the request; the player
carries on without it.
- **Android asks for a second swipe to leave a fullscreen app.** That is the
system's sticky-immersive behaviour, not something a page can turn off. The
player no longer asks Gecko to hide the navigation UI, and the settings sheet
has a **Fullscreen** switch: turn it off and the player runs as an overlay,
which leaves the ordinary single swipe home.

## Install

Expand Down
Loading