Free software, MPL-2.0. No accounts, no telemetry, no network access at all.
Firefox for Android's built-in video controls are a thin progress line and a play button. There is no volume gesture, no zoom to crop black bars, no colour control, no comfortable way to scrub through a two-hour film. This extension 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.
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.
Seek bar — thick, raised off the bottom edge, spanning the full width, with elapsed time on the left and remaining time on the right. You can grab it anywhere, not just at the current position, and it moves relative to 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.
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 the right zone to jump 10 seconds forward, the left one to step 5 seconds back.
Pinch to zoom — the picture fills the screen from the start, with no black bars; pinch to go back to the full frame and it snaps cleanly between the two.
Night Light — warms the picture to a comfortable amber for watching in the dark, with adjustable intensity.
Colour — brightness, contrast and saturation. Three sliders, nothing more.
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, 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.
Chapters — where a site publishes sections, the seek bar is cut at each one and names the section your finger is over.
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.
Saving a frame. A screenshot button — press it, the controls step aside,
the frame you are watching is saved to the phone — cannot be built on Firefox
for Android today. The browser will not give the decoded frame to any script:
drawing the video into a 2D canvas leaves it empty, uploading it to a WebGL
texture returns one flat placeholder colour, and tabs.captureVisibleTab
renders the page with the video area black. This holds for the page's own
scripts as much as for an add-on, so there is nothing an add-on can do about
it. The phone's own screenshot still works, controls and all.
This is the whole story, and you can verify every line of it:
- The extension requests two permissions:
storage, to remember your settings on your device, andactiveTab, so the popup can tell you which site you are on.activeTabis granted only for the moment you tap the extension button, and Firefox shows no permission warning for it. - It has no network access. Not "we promise not to use it" — there is no
fetch, noXMLHttpRequest, no beacon, no remote URL anywhere in the source. A CI check (npm run lint:privacy) fails the build if any of those appear. - It never uses
storage.sync, which would tie your settings to an account. - Nothing is collected, logged, or sent anywhere. There is nowhere for it to go.
- It works fully offline.
- The published build is not minified, so what you read here is what runs.
The content script matches <all_urls> because it has to notice a <video>
element on whatever site you happen to open — there is no way to detect videos
on a site without being able to run on that site. It reads nothing else from the
page and sends nothing anywhere. You can disable it per site from the extension
popup.
See PRIVACY.md.
These are platform limits, not oversights:
- 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. Covered:
<source>lists, YouTube, hls.js, dash.js, Shaka, and Playerjs — the one most film sites embed. Playerjs keeps its engine inside a closure, so there is no ladder object to find; what it does is answer about itself, and the row is built fromapi('qualities')andapi('quality', label), the same call its own menu makes. A player that exposes neither an engine nor an answer is still beyond reach, and there the row reports the resolution being played instead of offering a choice that would do nothing. - 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".
playbackRatecannot 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.
From addons.mozilla.org once published.
Requires Firefox for Android 142 or newer (Firefox 140 on desktop). That
floor comes from data_collection_permissions, the manifest key that lets the
add-on declare "collects no data" in a form Firefox itself can check.
npm ci
npm run lint # eslint + prettier + web-ext lint + privacy check
npm test # unit tests for the pure helpers
npm run build # unminified bundle into dist/Run it on desktop Firefox for fast iteration (use Responsive Design Mode with
touch simulation, and the page in test/fixtures/local.html):
npm startRun it on a connected Android device. First enable Remote debugging via USB in Firefox (Settings → About Firefox → tap the logo five times → Settings → Developer tools), then:
npm run fixture # generate the test clip and subtitles
npm run serve & # serve them on :8422
adb reverse tcp:8422 tcp:8422 # let the phone reach that port
npm run start:androidThen open http://localhost:8422/ on the phone.
Two things worth knowing:
web-extdefaults to an activity name current Firefox builds no longer use. Thestart:androidscript passes--firefox-apk-component .AppSolidDark; checkadb shell cmd package resolve-activity --brief -c \ android.intent.category.LAUNCHER org.mozilla.firefoxif that ever changes.npm run buildoverwritesdist/in place rather than recreating it. Do not runnpm run build:cleanwhileweb-ext runis active: deleting the directory detaches its watcher, and web-ext then stops Firefox on the device and exits. Usebuild:cleanfor CI and release builds only.- Re-opening the same URL on the phone only refocuses the tab, leaving the old
content script in place. Append a changing query (
?r=2) to force a reload after a rebuild.
Debug with about:debugging on the desktop, connected to the device over USB.
Build a signable package:
npm run package # artifacts/nocturne_player-<version>.zipnpm audit reports advisories in adm-zip and shell-quote, reached through
Mozilla's own web-ext tool. These are development-only dependencies; they
are not part of the extension and nothing from node_modules is shipped. The
published add-on contains only the files in dist/.