Skip to content

0.3.0: quality, chapters, captions and a player that stays standing - #6

Merged
kvachikk merged 23 commits into
mainfrom
feat/v0.3.0-player-fixes
Aug 7, 2026
Merged

kvachikk merged 23 commits into
mainfrom
feat/v0.3.0-player-fixes

Conversation

@kvachikk

@kvachikk kvachikk commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Everything here comes from watching real films on a real phone with 0.2.0
installed, and from driving the same build on an Android emulator.

The one that mattered

Every call into a site's player was failing with Permission denied to access property "length". 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. Calls
go through Reflect.apply now. That single bug is why quality, captions and
chapters were all missing on YouTube; all three work.

Also fixed

  • A drag along the seek bar threw the viewer back to the page: YouTube removes
    its <video> element when told a seek is final, and a drag said so every
    120ms. Seeks go through the site's own seekTo, and only the finger coming
    off is final.
  • The picture stopped filling the screen after a quality switch — the crop
    lives in transform, which the style guard did not know about.
  • The fullscreen button obeyed a stored switch a stray tap could turn off for
    good. It takes the screen now; the switch in the sheet is per session.
  • The style guard answered its own writes and rewrote twenty properties per
    mutation, which is what made the player crawl.
  • Colour starts neutral (settings schema 2 drops stale colour values once).

Added

Quality selection, chapter markers with section titles, captions from players
that paint their own, a fullscreen button on the inline video that fades when
untouched, PiP as an Android home-screen hand-off, and a settings sheet that
leads with quality and no longer overlaps itself.

Checks

eslint + prettier + web-ext lint (0 errors, 0 warnings) + the privacy
check (28 files, permissions exactly storage and activeTab) + 35 unit
tests, all green. Verified on an Android 16 emulator with Firefox 153: quality
switching on YouTube, chapters, captions, scrubbing, fullscreen and colour.

Docs: CHANGELOG.md, REVIEWERS.md (build instructions for AMO) and
AMO-LISTING.md (store copy).

kvachikk added 23 commits August 6, 2026 10:40
A clean profile was showing a film that had already been adjusted. Stored
values are now range-checked: anything outside the range the panel can
produce, or of the wrong type, falls back to the neutral value rather than
tinting a film for reasons the viewer cannot see.
The badge sat in the bottom corner of the video, on top of the play button
and the progress line the site draws there, and swallowed taps meant for
them. It now sits halfway up the right edge, which is the one part of a
video no player puts a control on, and is clamped to stay on screen.
Two sources of mis-taps, both from a target that was larger than what the
viewer could see.

The whole picture was a hidden pause button, so a thumb resting on the
screen stopped the film. Pausing is now the play button and nothing else;
a tap anywhere else brings the controls up or puts them away.

The seek band reached the bottom edge, where the Android home swipe starts,
so putting the app away threw the film into the middle. The band and the
bar it belongs to now sit above that strip, and the bar's centre is
measured rather than assumed so the precision curve follows it.

The chrome also gets one easing curve throughout, scrims and shadows under
the controls so they read over a bright picture, and a reduced-motion rule.
Everything a site's own player knows about itself lives in the page's
JavaScript. Firefox lets a content script read that through waived Xray
wrappers, so the extension asks the player rather than re-implementing it:
no code is injected into the page and nothing is evaluated from a string.

Quality is an adapter per engine — <source> lists, YouTube, hls.js,
dash.js, Shaka — probed again each time the sheet opens, because a stream
only learns its ladder after the first segments land. A player that exposes
no way in reports the resolution it is playing instead of offering a choice
that would do nothing.

Captions cover the players that paint their own instead of exposing a text
track. YouTube's list comes from its player API and the text it renders is
mirrored into our cue layer, which is why subtitles were missing there.

The picture used to slide to an edge or stretch across the screen because
the site kept rewriting the video's inline style. Position and offsets are
now pinned with the size and re-pinned whenever the site writes over them,
and the zoom is re-derived from the viewer's intent on every relayout.

Losing fullscreen while the app is on its way to the background is what
Android's floating-window hand-off looks like, so it is no longer read as
the viewer leaving the player. The button uses the standard API where Gecko
has one, and a Fullscreen switch in the sheet runs the player as an overlay
for anyone who would rather keep the ordinary single swipe home.

Pausing no longer fades the picture to grey: people pause to look at it.
And the subtitle file button now has the handler it was always missing.
Also corrects the parts of the README that no longer described the
extension: YouTube is in scope now, the pause fade is gone, and the volume
strip and gesture lock were never built.
The guard compared the video's inline style against the values it had been
asked to write, but the browser serialises them differently — `left: 0`
reads back as `0px` — so it never once found the style already pinned. Every
mutation record, including the ones its own writes produced, triggered
twenty more !important writes: an unbounded loop of style recalculation that
made the player crawl, the picture stutter and fullscreen arrive late.

It now compares against what the browser serialised, which converges after a
single write, and gives up after a bounded number of re-pins so a site that
insists on !important of its own cannot trade writes with us all film long.
Range-checking was not enough: a saturation of zero is a value the panel can
produce, so a profile that had been left there kept playing every film in
black and white, and a contrast of 1.3 kept every film tinted. Neither is
something a viewer would connect to a setting they touched once. Schema 2
drops the four colour values once on upgrade; after that they are theirs
again.
Opening the player walked the page's globals once per streaming engine,
through cross-compartment wrappers, before fullscreen was even requested.
The three sweeps are now one, and the ladder is only looked up when the
sheet is opened rather than while the viewer waits for the picture.

Also: the caption mirror no longer reads the site's caption layer while
nothing is being mirrored, the fill retry gives up instead of ticking for
the length of the film on a stream that never reports its dimensions, and
the vignette is lighter — it was washing the picture out.
Two changes to the thing that sits on an inline video.

There is now a second button under the launcher: plain fullscreen on the
site's own player, for a film you are happy to watch with the site's own
controls and only want bigger.

And both of them fade out when nothing has happened for a few seconds. Two
buttons parked on somebody's video are two buttons in the way; a touch
anywhere on the page brings them back.
YouTube ships its chapter list inside the data it renders the page from.
The shape of that blob differs between the phone and desktop sites and
changes without notice, so the renderer is looked for by name rather than
followed down one path, with a node budget so a page that turns out to be a
maze cannot cost more than a moment.

The bar gets a divider at each chapter and the name of the section being
played sits above it.
Quality could not be changed anywhere, and this is the likeliest reason: the
adapters looked for the site's player object by searching the document, but
by then the video had already been lifted out of it and onto our stage.

The player API is now read from the video's own ancestors at the moment the
session starts, before anything is moved, and handed to the quality adapters
and the caption reader. The document search stays as the fallback.

The sheet also says which of the two things happened — no player found, or a
player that is offering nothing — rather than the same flat note for both.
Android floats a video when the app it is playing in goes to the background,
and Gecko gives a page no way to ask for that directly. It does leave one
way to go to the background: an intent URL for the launcher, loaded in a
throwaway frame so a browser that does not handle the scheme fails quietly
instead of navigating the film away.

Also in this change: the button order at the top is picture-in-picture,
colour, settings; the scrim behind the controls is heavier, because the film
keeps playing underneath and the buttons have to stay legible; choosing a
quality now says so, since the change itself takes a second to show; and the
chapter list is read once the picture is up rather than in the way of it.
The phone's YouTube player answers getAvailableQualityLevels() with an empty
list, which is why the sheet kept saying it had found the player but had
nothing to offer. The ladder is now worked out from the formats the video
actually has — each one carries the height it was encoded at, which is the
same thing the quality names stand for — and the note says which of the two
ways in produced something.

Chapters are read from the player's own response first, then from the two
page blobs, in both the shapes YouTube writes them in, and the read is tried
again twice: a page navigated to from the feed fills its data in late.

The badge is one button again, wearing the fullscreen arrows: what it does
is make this video fill the screen, and that is what a viewer looks for.
The changelog stopped at the middle of the release: the home-screen
hand-off, the chapters, the button on the site's own player and the
ladder read off the formats all landed after it was written.

Reviewers get build instructions, because a bundled package without
them is a package that waits in the queue.
Every call into a site's player failed with "Permission denied to access
property length", which is why the quality ladder, the caption list and
the chapter list all came back empty on YouTube: 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 our
compartment.

Reflect.apply is ours. The arguments are unpacked on this side and the
page only ever sees the values.

Found in the phone's log, after the sheet had spent three versions
politely reporting that YouTube offers no quality levels at all.
Three things took the player down or left it looking broken, all found
by driving the emulator rather than by reading the code:

A drag along the seek bar threw the viewer back to the page. YouTube
removes its <video> element outright when it is told a seek is final,
and a drag was telling it that every 120ms. Seeks now go through the
site's own seekTo where it has one, and a preview says so: isFinal is
true only when the finger comes off. The teardown watch is slower and
harder to convince as well — any sign of life cancels it, and only
NETWORK_EMPTY counts as empty.

The picture stopped filling the screen after a quality switch. The crop
lives in `transform`, which the style guard knew nothing about, so the
first time the site rewrote the video's inline style the picture fell
back into its letterbox and stayed there.

The fullscreen button did not go fullscreen. It obeyed a stored switch
that a stray tap could turn off for good. A button drawn as a fullscreen
icon takes the screen; the switch in the sheet still drops back to the
overlay, for this session only, the way playback speed does.

Also gone: the toast on every quality tap and the stale swipe-up hint.
The chip lighting up already says the choice was taken.
Quality leads the sheet now. It is the row people open it for, it is the
longest, and on a phone held sideways the sheet scrolls — so anything
below the first two rows was a row somebody had to go looking for.

The rows themselves were climbing over each other: a fixed row height
with a nine-rung quality ladder that wraps meant the second line of
chips lay across the row beneath. Rows are as tall as their chips, a
hairline separates them, and the sheet is bounded above the seek bar
rather than running off the bottom of the screen. It also opens at the
top, instead of wherever a chip left the focus last time.
The release notes described three features that did not work on a phone
and a fullscreen switch that outlived its session. They now describe
what the build does.
40% to 200% in steps of ten, rather than 50% to 150% in steps of five.
Measured on the emulator, the old control did move the picture — a full
sweep took the frame from 20 to 85 mean luminance — but it took ten
presses to do anything a viewer would call brightening, and a control
that needs ten presses reads as a control that does not work.

A film mastered dark for a cinema can now be lifted far enough to watch
on a phone.
The store page has to answer one question — what does this do that the
browser does not — and the old summary did not. Every claim is
checkable against the source, the limits are stated up front rather than
discovered, and the privacy section says what is enforced by the build
instead of what is promised.
Playerjs, which many film sites embed, publishes the hls.js constructor
and a player object with one opaque method. The instance holding the
ladder never leaves the closure, so there is no way in from an
extension. Better said in the notes than discovered in the sheet.
@kvachikk
kvachikk merged commit 3fa2e33 into main Aug 7, 2026
2 checks passed
@kvachikk
kvachikk deleted the feat/v0.3.0-player-fixes branch August 7, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant