/**
* Custom HTML element for Movi Player
* Usage:
*
* Note: Custom element names must contain a hyphen per HTML spec.
*
* Supports native video element properties:
* - src, autoplay, controls, loop, muted, playsinline, preload, poster
* - width, height, crossorigin
* - volume, playbackRate, currentTime, duration, paused, ended
*/
import { MoviPlayer } from "../core/MoviPlayer";
import type { RendererType, SubtitleRenderer, PlaybackAssessment, PlaybackAssessmentOptions, PlaybackQuery, SubtitleCue, DecodeAudioOptions, DecodedAudioChunk } from "../types";
import type { StoryboardSpec } from "../utils/Storyboard";
import type { SourceAdapter } from "../source/SourceAdapter";
import { type QoESink, type QoESession } from "../utils/QoE";
/**
* A control the HOST adds — to the bottom bar, to the context menu, or both.
*
* Described rather than constructed: the player builds the button and the menu
* row from this, keeps them in step, and removes them together. See
* MoviElement.addControl.
*
* ```ts
* player.addControl({
* id: "autoplay",
* label: "Autoplay",
* icon: autoplaySvg, // markup or a node; omit for a plain word
* side: "right",
* before: "cc", // sits just left of the subtitles button
* toggle: true,
* active: true,
* placement: "both", // bar AND context menu, one shared state
* onSelect: (on) => setAutoplay(on),
* });
* ```
*/
/**
* A panel the HOST puts over the picture — see MoviElement.showOverlay.
*
* `content` is trusted exactly as far as addControl's `icon` is: it comes from
* the page embedding this player, not from the media or the network, and it is
* inserted as markup. A host passing anything it did not write should sanitise
* it first, or hand over an Element it built itself.
*/
export interface MoviOverlaySpec {
/** Unique, and the handle for updateOverlay / hideOverlay. */
id: string;
/** Markup, or an element to mount. Keep the element if you mean to mutate it. */
content: string | Element;
/** "fill" covers the picture (an end screen), "center" floats in the middle,
* "bottom-end" tucks above the controls on the right (an up-next card).
* Default "fill". */
placement?: "fill" | "center" | "bottom-end";
/** False lets clicks through to the video. Default true — an overlay with
* links in it needs them. */
interactive?: boolean;
/** Take it away by itself when playback resumes, the viewer seeks, or Escape
* is pressed. Nothing by default: an end screen should sit there. */
dismissOn?: Array<"play" | "seek" | "escape">;
/** Called whenever it goes, with what took it: one of the dismissOn reasons,
* or "host" for hideOverlay. */
onDismiss?: (reason: string, player: MoviElement) => void;
}
/**
* One entry in a queue of things to play.
*
* `src` is the only part that must be there. The rest is what the PLAYER would
* otherwise have to guess between items — the heading over the picture, the
* still shown before the first frame, where in the video to come in — and each
* is owned by the item rather than the element, so it goes away again when the
* queue moves on to something that doesn't carry one.
*
* A bare string is accepted anywhere an item is, and means `{ src }`.
*/
export interface MoviPlaylistItem {
/**
* What to play. Optional, because a page whose sources are `` /
* `` children — a quality ladder, per-language audio, subtitle tracks
* — cannot say any of that in one URL, and neither can a page where each
* item is its own route. Those queues leave `src` off, cancel `itemchange`,
* and load the item themselves; see MoviElement.playlist.
*/
src?: string | File;
/** The host's own name for this item, handed back on `itemchange`. The
* element never reads it — it is there so a queue of src-less items has
* something to be identified BY. */
id?: string;
/** Heading over the picture, and the name the OS media session announces. */
title?: string;
/** Still shown before this item's first frame. */
poster?: string;
/** Where to come in, in seconds. */
startAt?: number;
/** Anything else the host put on the item is carried through untouched. */
[key: string]: unknown;
}
/** One row of a custom control's submenu. */
export interface MoviControlItem {
/** Handed back to onPick. */
id: string;
/** The row's text. */
label: string;
/** Right-hand text — a value, a hint, a duration. */
hint?: string;
/** Children, for a nested list. A row with children OPENS rather than picks,
* so it never reaches onPick; only leaves do. Any depth. */
items?: MoviControlItem[];
}
export interface MoviControlSpec {
/** Unique, and the handle for updateControl / removeControl. */
id: string;
/** Accessible name, tooltip, and the text shown in the context menu. */
label: string;
/** Inline SVG markup or an element to clone. Without one the label is drawn
* as text, which is a perfectly good control. */
icon?: string | Element;
/** Tooltip override. Pass null for no tooltip at all. */
title?: string | null;
/** Which end of the bar. Default "right". */
side?: "left" | "right";
/** Position against a built-in: "play", "back10", "forward10", "volume",
* "time", "audio", "cc", "quality", "speed", "stableaudio", "hdr", "loop",
* "settings", "aspect", "pip", "fullscreen", "more". In the context menu,
* the built-in's action name instead. Unknown or absent → the end.
*
* A LIST is tried in order, first match wins. That is not a nicety: half
* the bar is conditional — the audio-track button exists only for a video
* with dubs, HDR only for an HDR source, captions only when there are any —
* so "the leftmost control in the right-hand capsule" cannot be named by a
* single anchor. ["audio", "hdr", "cc"] says it once and keeps saying it
* whichever of them the video turns out to have. */
before?: string | string[];
after?: string | string[];
/** Per-SURFACE placement, for when one pair cannot serve both.
*
* The bar and the context menu are different lists with different
* neighbours: a control can belong at the left edge of the bar's settings
* capsule and directly under Loop in the menu, and before/after above can
* only describe one of those. Anything given here wins for that surface;
* anything left out falls back to before/after, so a host that only cares
* about one surface never has to mention the other.
*
* (The gear panel is not a surface a host control can be placed in — it
* renders the player's own settings only. `placement` is bar, menu, or
* both, and this mirrors it.) */
anchors?: {
bar?: {
before?: string | string[];
after?: string | string[];
};
menu?: {
before?: string | string[];
after?: string | string[];
};
top?: {
before?: string | string[];
after?: string | string[];
};
center?: {
before?: string | string[];
after?: string | string[];
};
};
/** WHICH capsule it sits in — the bar draws one behind each group of
* controls, and this says which group this one belongs to.
*
* A built-in group's name joins that capsule: "play", "seek", "volume",
* "time", "settings". Any other string MAKES a capsule of that name, so two
* host controls naming the same string share one. "none" means what it says
* — no capsule, and none of the neighbours' either: the control stands on
* the bar on its own, which is also how it gets OUT of the right-hand
* capsule that every control on that side otherwise shares.
*
* Left off, a control on the left gets its own capsule and one on the right
* joins the settings capsule it is already inside — the arrangement each
* side already had.
*
* Separate from before/after on purpose: those say where it goes, this says
* what it belongs WITH. A control can sit beside the subtitles button and
* still be in nobody's capsule. */
group?: "play" | "seek" | "volume" | "time" | "settings" | "none" | (string & {});
/** Which surface (or surfaces) it appears on. Default "bar".
*
* "bar" the control bar along the bottom
* "center" the MIDDLE of that bar — the free width between the left run
* and the settings run, which the player itself never puts
* anything in. Its own surface and not a side of "bar", because
* it is not one: `side`, `group` and every built-in anchor
* describe the two runs, and the middle is neither of them. A
* control here is ordered only against the host's OWN other
* centre controls, by id.
*
* The middle is also the first thing to go when the bar runs out
* of room — the clusters grow towards each other and a control
* drawn across the clock is worse than one that stepped aside —
* so it suits a control that is an offer rather than a duty.
* Paired with `screen: "fullscreen"` it is the "Suggestions" /
* "Up next" affordance a full-screen player has the width for
* and a 320px embed does not.
* "top" the top-right corner, beside the three-dots button that opens
* the context menu on touch — the corner a player puts the things
* that are ABOUT the session rather than about playback: cast,
* share, close. Shown on every device, not only touch: the
* three-dots is a touch affordance (a mouse has right-click), the
* corner is not.
* "menu" a row in the context menu
* "both" bar and menu, which is what it has always meant
*
* A LIST puts one control on several surfaces — ["top", "menu"] is the
* arrangement a cast button wants, reachable in the corner while watching
* and still named in the long list. "both" stays exactly what it was, so
* nothing written against the old three values changes meaning.
*
* The corner is a row like the bar is, and it is ordered the same way: with
* `before`/`after`, against a built-in, against another of the host's
* controls by id, or against `"dots"` — the three-dots itself, which is one
* member of that row and not a fixture at its end. A control that names no
* anchor goes in front of the three-dots, so the corner still ENDS with it
* unless someone asks for otherwise; `after: "dots"` is that asking. The
* order holds on touch and on desktop alike — the three-dots is hidden where
* there is a right-click to open the menu with, and the row simply closes up
* around it.
*
* `group` and `side` describe the bar and are ignored there. Its tooltip is
* the browser's, like the three-dots' own — the player's tooltip belongs to
* the bar and points at it. */
placement?: "bar" | "center" | "menu" | "top" | "both" | Array<"bar" | "center" | "menu" | "top">;
/** WHICH kind of media it belongs to. Default "both".
*
* The player collapses to an audio presentation when the media has no
* picture — cover art in place of the canvas, or the compact strip — and the
* built-ins that mean nothing there (captions, quality, aspect, PiP,
* fullscreen) take themselves out of the bar and the menu. A host control is
* no different: "Cast to TV" has no business on a podcast, and "Sleep timer"
* is the one control an album view wants and a film does not.
*
* "video" only while a picture is playing
* "audio" only in the audio presentation
* "both" always (the default)
*
* Enforced in CSS against the host's audio class, so it follows a source
* swap from video to audio with no work from the host. */
media?: "video" | "audio" | "both";
/** WHERE the player has to be for this control to exist. Default "both".
*
* "fullscreen" only while the player is fullscreen
* "windowed" only while it is not
* "both" always (the default)
*
* The same words `titlemode` uses, and for the same reason: fullscreen is a
* different amount of room and a different amount of attention. A control
* that would crowd a 360px embed has a whole screen to sit in once the
* viewer has committed to watching, and a control that only makes sense
* beside the page it came from should not follow them out of it.
*
* Every fullscreen route counts — native, a host-driven one (see
* `setHostFullscreen`) and the iOS pseudo-fullscreen fallback — so the
* control appears and disappears on its own with no work from the host.
* Like `media`, a scoped-out control leaves the bar, the corner, the
* context menu AND the shortcuts panel, and its hotkey stops firing: an
* invisible control with a live key is worse than no control. */
screen?: "fullscreen" | "windowed" | "both";
/** A toggle carries state: pressed styling, On/Off in the menu, and the
* boolean handed to onSelect. Without it, a plain button. */
toggle?: boolean;
/** Starting state for a toggle. */
active?: boolean;
/** A key that uses this control, e.g. "a", "shift+a", "ctrl+alt+p". Matched
* case-insensitively against the physical key plus its modifiers. Checked
* AFTER the player's own shortcuts, so a host cannot accidentally take over
* space or the arrows; a collision is logged once at registration. Appears
* on the control's menu row automatically. */
hotkey?: string;
/** Right-hand text on the menu row for a non-toggle. Defaults to the hotkey
* when one is set, which is what the built-in rows show. */
shortcutHint?: string;
/** Turn the control's context-menu row into a submenu of choices — the shape
* Speed and Aspect Ratio already use. The row opens the list instead of
* doing anything itself, so `toggle` is ignored and onSelect never fires;
* the pick arrives on `onPick` with the chosen item's id. Menu only: a bar
* button has nowhere to put a list. */
items?: MoviControlItem[];
/** Called with the id of the submenu item the viewer chose. */
onPick?: (itemId: string, player: MoviElement) => void;
/** Which submenu item is currently the chosen one, by id. Update it with
* updateControl(id, { value }) when the host's own state moves. */
value?: string;
/** Remember this toggle's state across loads and sessions, under the
* element's `persistkey` namespace. The stored value wins over `active` at
* registration — read it back with isControlActive(id) if the host keeps its
* own copy. Ignored without `toggle`. */
persist?: boolean;
/** Set false to stay silent when the control is used by its HOTKEY. On by
* default: a key press has no other feedback, which is why every built-in
* shortcut flashes the OSD. A click never flashes — the button itself is
* the feedback. */
osd?: boolean;
/** Called on every use, with the state AFTER the toggle flipped. Also
* emitted as a "movi-control" event for hosts that prefer listeners. */
onSelect?: (active: boolean, player: MoviElement) => void;
}
/**
* A divider the HOST adds — the same quiet line the player draws between its
* track controls and its viewing controls, put wherever the host's own
* controls want one. Passed to addControl like any control:
*
* ```ts
* player.addControl({ id: "sep", divider: true, after: "cc" });
* player.addControl({ id: "sep2", divider: true, group: "extras" });
* ```
*
* Placed, grouped, scoped and removed exactly as a control is — same anchors,
* same `group` (so it can sit INSIDE a capsule), same `controlslist` token by
* its id. On the bar, the centre or the corner it is a vertical line; in the
* context menu, the menu's own horizontal rule. A divider left with nothing
* visible on one side of it inside its row or capsule hides itself, so a
* control that is unavailable for this source doesn't strand a line at the
* end of a pill. Styled by --movi-divider-color / -width / -height / -gap and
* ::part(controls-divider), which reach the built-in one too.
*/
export interface MoviDividerSpec {
/** Unique; the handle for removeControl and the controlslist token. */
id: string;
divider: true;
side?: MoviControlSpec["side"];
before?: MoviControlSpec["before"];
after?: MoviControlSpec["after"];
anchors?: MoviControlSpec["anchors"];
group?: MoviControlSpec["group"];
placement?: MoviControlSpec["placement"];
media?: MoviControlSpec["media"];
screen?: MoviControlSpec["screen"];
}
export declare class MoviElement extends HTMLElement {
private canvas;
private video;
private subtitleOverlay;
private _captionLive;
private _captionObserver;
private _sourceObserver;
private _sourceSignature;
private _sourceSwapQueued;
private _lastCaptionText;
private _qoe;
private _qoeHeartbeat;
/**
* The engine behind the element: the `MoviPlayer` that loads, decodes and
* plays the current source. For what the element does not forward —
* `getPreviewFrame()`, `getMediaInfo()`, `getCacheStats()` and the rest of
* the core API.
*
* Read it, don't assign it, and don't keep it: the element builds a new one
* for every source (and on some recoveries) and destroys the old one, so a
* reference held across a `loadstart` points at a player that is gone. Take
* it after `loadeddata`. `null` before the first load and after `src` is
* cleared.
*/
player: MoviPlayer | null;
private isLoading;
private _isUnsupported;
private eventHandlers;
private controlsContainer;
private unmuteOverlay;
private _userHasUnmuted;
/** The viewer muted this deliberately — through the setter, which is where
* the button, the M key and a slider dragged to zero all arrive. The
* autoplay fallback mutes by writing `_muted` directly, so it never trips
* this and its pill still appears. */
private _userChoseMute;
private brokenIndicator;
private _errorTitle;
private _errorMessage;
private emptyStateIndicator;
/** Shown in the page while the canvas is living in a PiP window. */
private pipPlaceholder;
private coverArtOverlay;
private coverArtCanvas;
private coverArtBitmap;
/** The dotted level meter drawn along the scrubber in cover-art mode. */
private _eqCanvas;
private _eqRaf;
private _eqSmoothed;
/** The loudest band seen lately — the meter is drawn relative to it. */
private _eqPeak;
private _posterCoverBitmap;
private _posterCoverUrl;
private _posterCoverLoading;
private _coverArtBgEl;
private _coverPaletteBitmap;
private _coverPalette;
private _coverArtBgUrl;
private _coverArtResolved;
private _lastStripDispatched;
private _lastAudioModeSeen;
private controlsTimeout;
private isOverControls;
/** Document-level "you tapped somewhere else" — see where it is set up. */
private _outsideTapHandler;
private _settingsOutsideHandler;
/** The bar's post-touch re-show, pending — cancellable by a tap elsewhere. */
private _barTouchReshowTimer;
private isSeeking;
private pendingSeekTarget;
private _pendingSeek;
private isDragging;
/** The chapter section currently raised under the pointer, so the playback
* tick can keep its paint current without re-scanning every segment. */
private _hoveredChapter;
/** Media time the pointer is over on the seek bar, or -1 when it is off it.
* Kept so the tick can repaint the hover range against a moving playhead —
* see paintSeekRangeHover. */
private _hoverBarTime;
private isTouchDragging;
private touchStartX;
private touchStartY;
private touchStartTime;
private gesturePerformed;
private clickTimer;
private lastSeekTime;
private lastSeekSide;
private cumulativeSeekAmount;
private _seekChainTarget;
private _contextMenuVisible;
private _contextMenuJustClosed;
private _menuPortalHost;
private _menuPortalRoot;
private _menuHome;
private lastTouchTime;
private _holdSpeedTimer;
private _holdSpeedActive;
private _rateBeforeHold;
private _openMenuViaGear;
private _mediaSessionReady;
private _mediaSessionLastPos;
private _mediaSessionArtworkUrl;
private _nerdStatsVisible;
private _currentManualRotation;
private _timelineGenerating;
private _timelineCancelled;
private _timelineComplete;
private _timelineNextIndex;
private nerdStatsInterval;
private networkSpeedHistory;
private _stutterInterval;
private _stutterLastPresented;
private _stutterHistory;
private _stutterCooldown;
private _stutterCooldownTimer;
private _stutterGraceUntil;
private static readonly STUTTER_GRACE_MS;
private static readonly GRAPH_MAX_SAMPLES;
private _src;
private _sourceAdapter;
private _headers;
private _attrLoadScheduled;
private _hasConnected;
private _audioOnly;
private _audioSrc;
private _videoQualities;
private _lastNotifiedQualityHeight;
private _qualityHighWater;
private _audioTracks;
private _subtitleTracks;
private _autoplay;
private _autoplayStarting;
/** Pending "did startup really stop?" check — see the paused branch in the
* state handler. */
private _autoplayPausedTimer;
/** Set when pause() lands before playback has started, so the pending
* autoplay/queued play doesn't fire once loading finishes. */
private _startCancelled;
/** "The viewer asked for this pause." Set by pause(), cleared by play() and
* by dispose(), which every source change runs through — so it never
* carries a decision about one video over to the next. The unmute pill
* reads it: unmuting must not restart a video someone stopped on purpose.
* Separate from _startCancelled, which the autoplay path owns. */
private _pauseWasAskedFor;
/**
* "A seek is under way and playback is meant to continue through it."
*
* The play/pause glyph reads intent, not raw state, so a stall cannot bounce
* it — but intent is only reported for `playing`, `buffering` and `seeking`,
* and a seek does not always land back on one of those. seekFromEvent has
* known this all along: it checks whether the player came out of a seek NOT
* playing and puts it back 100ms later. For those 100ms, plus however long
* the resume itself takes, the state is `paused` or `ready` and every icon
* flipped to "play" and back — a blink after every scrub, on the bar and on
* the centre button together.
*
* Held from the seek that starts it until playback genuinely resumes, the
* viewer asks for a pause, or the source ends. Backstopped by a timer, so a
* resume that never comes leaves an honest "play" behind rather than a glyph
* that lies for the rest of the session.
*/
private _iconHeldForSeek;
private _iconHoldTimer;
/**
* Bumped by every initializePlayer(), and only there — a load() that bumps
* on its own can invalidate a live init without starting one to replace it.
* An init awaits twice — the
* pre-play probe and player.load() — and load() clears `isLoading` on its
* way through, so a source change landing inside either window starts a
* SECOND init while the first is still running. Both then own a player and
* a live source: the superseded one kept streaming its rendition in the
* background, and when its demux failed it painted "Can't Play This"
* over the load that was about to play fine. Each init captures the
* generation it started with and drops itself the moment it no longer
* matches — silently, since whoever superseded it owns the screen now.
*/
private _loadGeneration;
/** The pre-play probe currently running, so a concurrent init awaits its
* result instead of opening the seed rung. Null when none is in flight. */
private _startProbeInFlight;
private _autoplayPendingVisible;
private _autoMutedForAutoplay;
private _hasEverPlayed;
/**
* Whether `play` has been announced and not yet answered by `pause` — the
* element's own idea of "currently playing", which keeps the pair ordered
* and stops a startup state change from reporting a pause nobody asked for.
*/
private _playFired;
/**
* The paused flag, in the sense a media element means it: not "is a frame
* being painted right now" but "has playback been asked for and not
* stopped". It flips on play() and pause(), and at the end of the media —
* which is why buffering, seeking and a slow start all still read as
* playing, exactly as they do on a .
*/
private _playIntent;
/** A pause waiting to see whether it was really a seek — see the state handler. */
private _pauseAnnounceTimer;
/** The seek target already announced, so the same one is not announced twice. */
private _seekAnnouncedFor;
private _pendingPlay;
/**
* The last play() came from a script with no gesture behind it — a page
* autoplaying by calling play() rather than by declaring `autoplay`. See
* play().
*/
private _playUnprompted;
private _preloadGateActive;
private _resumeDialogPending;
private _posterTime;
private _generatedPosterUrl;
private _posterGenId;
private _controls;
/** What `loop` was asked for. `_loop` is the ITEM half of it, kept as its
* own boolean because everything that guards on "this never ends" — the
* suppressed `ended`, the suppressed pause, the gapless turn — means the
* item and not the queue. */
private _loopMode;
private _loop;
private _muted;
private _playsinline;
private _preload;
private _poster;
/** Which of the two sizes this element wrote itself (see applySizeAttributes). */
private _sizedByAttr;
private _volume;
private _playbackRate;
private _subtitleDelay;
/**
* The wait a page gets without asking for one.
*
* A ring that comes and goes before it can be read is not information — it
* is a player reporting a fault over playback that is about to be fine, and
* opening a local file is the case where that happens every single time:
* the file is there, the open takes a few hundred milliseconds, and the
* viewer is shown a "something is wrong" for all of them. So the honest
* default is a wait, not an instant ring: anything the player sorts out
* inside it, the viewer never hears about.
*
* The opening gets longer than a mid-play stall, for the reason the two are
* separate at all — a video that has not started has its poster up and looks
* like it is starting, while one that stops mid-picture has frozen and wants
* reporting sooner.
*
* `spinnerdelay` overrides both, and `spinnerdelay="0"` is how a page asks
* for the ring the instant anything loads, which is what this did before.
*/
private static readonly DEFAULT_SPINNER_DELAY_S;
private static readonly DEFAULT_SPINNER_DELAY_OPENING_S;
private _spinnerDelay;
private _spinnerDelayOpening;
/**
* How long to wait before putting the OPENING poster up, in milliseconds.
*
* Zero — show it at once — is the default, because that is what every host
* using this today already gets and a poster is the honest thing to show
* while a video loads.
*
* It stops being honest when the video does not need loading. A host that
* prefetches (movi-tube warms the next video's first bytes, so the picture
* lands in ~300ms) shows a thumbnail that is gone again before it has been
* read: the viewer sees a flash where nothing was wrong. `posterdelay="600"`
* says "only cover this if it is actually going to take a moment" — under
* that, the poster never appears at all and the picture is simply there.
*
* The same shape as spinnerdelay above, and for the same reason: a wait that
* resolves inside the delay is a wait the viewer never needs to hear about.
*/
private _posterDelayMs;
private _posterDelayTimer;
private _subtitleRenderer;
private _subtitleSettings;
private _ambientMode;
private _renderer;
/**
* How the POSTER is fitted, when it should not be fitted the way the video
* is. Empty means "follow the video", which is what it did before this
* existed and is still the default.
*
* The two are genuinely different pictures. A vertical page can want its
* video letterboxed at its true shape and its cover image filling the box
* behind it — which is what YouTube's Shorts does — and deriving one fit
* from the other made that impossible to ask for.
*/
private _posterFit;
private _objectFit;
private _rotate;
private _currentFit;
private _thumb;
/**
* thumb="precise": show the frame the pointer is ON rather than the keyframe
* before it. Opt-in, because it decodes the run of frames in between — see
* MoviPlayer.setPrecisePreviews.
*/
private _thumbPrecise;
/**
* video.js's `data-setup`: the options a page would otherwise pass to a
* constructor, written on the element as JSON.
*
* Markup written for that player should work here with the tag name changed
* and nothing else, and `data-setup` is the one piece of it that is not an
* attribute this element already answers to. So the keys are turned into
* those attributes — and an attribute already written wins, since it is the
* more specific statement of the two.
*/
private applyDataSetup;
/**
* A thumbnail track declared the way other players declare it.
*
* video.js carries scrub previews on ``, because `kind` is a fixed HTML enum with no value for
* this and `metadata` is the one it does have. That is the spelling read
* here, and the only way to DECLARE a board: there is no attribute of our
* own for it, because a thumbnail track is a track and the players that have
* them already write one. The `storyboard` PROPERTY takes a spec object, for
* a board worked out at runtime rather than written in the markup.
*/
private static thumbnailTrackSrc;
/**
* The RemotePlayback object, when there is something that has one.
*
* Casting an AirPlay/Remote Playback target needs a real media element, and
* on the WASM path the picture is a canvas — there is nothing to hand over.
* In native fallback the `` underneath is the real thing, so code
* written against `video.remote` reaches it. Null otherwise, which is what a
* caller has to handle anyway on a browser without the API.
*/
get remote(): unknown;
/** thumb="precise" — anything else (including a bare `thumb`) is keyframes. */
private static wantsPrecisePreviews;
/**
* Scrub previews the source already has: a WebVTT thumbnail track (the
* attribute), or a tile spec (the property, which is the shape YouTube
* publishes). Either way the preview becomes a crop out of a mosaic instead
* of a seek and a decode — see MoviPlayer.setStoryboard.
*/
private _storyboard;
/** The task in which the board was set, against the counter below. */
private _storyboardTask;
/** …and when, for the swaps that reach the load a microtask later. */
private _storyboardSetAt;
/** How close to a source change a board still counts as having come with it. */
private static readonly STORYBOARD_TOGETHER_MS;
/** The task the host last wrote a source in, and whether a load owes it an answer. */
private _srcAssignedTask;
private _srcAssignedPending;
private _taskId;
private _taskOpen;
/**
* An id for the run of code we are in — the same for everything up to the
* next microtask checkpoint, different after it. Enough to tell "these two
* lines were written together" from "this happened later", which is the only
* question asked of it.
*/
private currentTaskId;
/**
* A new source arrives with no thumbnails of its own until it is given some.
*
* A board set through the PROPERTY is an object describing one video: the
* frames in it are that video's frames, so carrying it into the next source
* means hovering the new video and being shown the old one — which is what a
* page swapping `src` and then fetching the new board (a route change, then
* an API call) saw on every first hover. Unless the two were written
* together, in which case the board came WITH the source and stays.
*
* A board declared in the MARKUP is a different statement: the attribute (or
* a ) is still there, still says what it says, and is re-read here
* rather than thrown away.
*/
/**
* The host has written a new source. Remembered rather than acted on: the
* load it starts runs a task later, and the question this answers is only
* legible now —
*
* el.storyboard = board; el.src = url; ← written together
* el.src = url; el.storyboard = board; ← also together
* el.src = url; … an API call later … ← the board is the old
* video's
*
* — which is a comparison of WHEN, not of order.
*/
private noteSourceAssigned;
private dropStaleStoryboard;
/**
* Wipe what is painted on the preview card.
*
* The tile layer keeps the mosaic it last painted as a background, and the
* frame beside it keeps its object URL, so both survive a source change and
* the first hover on the new video answered with the old one's picture. The
* card is only ever repainted by a hover, so it has to be emptied here.
*/
private clearPreviewSurfaces;
get storyboard(): string | StoryboardSpec | null;
set storyboard(value: string | StoryboardSpec | null);
private _linearMode;
private _hdr;
private _theme;
private _sw;
private _nativeFallbackActive;
private _nativeFallbackAttempted;
private _streamDemuxTried;
private _streamDemuxNext;
private _streamEngineTried;
private _lastOsdVolumeKey;
private _lastOsdRate;
private _engineTried;
private _streamEngineNext;
private _forcedStreamTried;
private _forcedStreamNext;
private _forcedDashRendition;
private _uiUpdatesRunning;
private _swForcedForCurrentSource;
private _userAcceptedSoftwareFallback;
private _suppressSwReload;
private _fps;
private _gesturefs;
private _noHotkeys;
private _startAt;
private _fastSeek;
/** WHICH skip affordances are on. The attribute began as a plain boolean and
* still reads that way (bare `fastseek` = all three), but the three are not
* one feature: a touch build wants the double-tap without two more buttons
* crowding a phone-width bar, a kiosk wants the buttons and nothing a
* keyboard can reach, and a page with its own ⏪/⏩ wants only the keys. */
private _fastSeekModes;
private _doubleTap;
private _themeColor;
private _bufferSize;
private _title;
private _showTitle;
/** `subtitlepicker` — offer "Add subtitle…" in the subtitle menu. */
private _subtitlePicker;
/** `smoothwarning` — say so when this source is not expected to play
* smoothly here. See checkSmoothPlayback. */
private _smoothWarning;
/** `nolinkwarning` — keep smoothwarning on but never raise the link-budget
* notice ("this media needs about N Mbps"). For hosts that serve local
* files over HTTP — the desktop shell streams the user's own disk from
* 127.0.0.1, so the player sees a sized HTTP source, probes loopback, and
* blames a connection that doesn't exist. The decode notices stay. */
private _noLinkWarning;
/** The source and speed last judged, so the same question is not asked —
* or answered with a popup — twice. */
private _smoothWarnKey;
/** Whether the MEASURED notice has been shown for the source now loaded — see
* the decode-bound branch in sampleStutter. Cleared with the source. */
private _smoothWarnedByMeasurement;
private _warnedLinkBudget;
private _linkProbeAbort;
/** Buffering episodes since this source started — see sampleLinkBudget. */
private _linkStalls;
/** Of those, the stalls with a read parked on the network — evidence about
* the delivery that holds even when the device reads slow too. */
private _deliveryStalls;
/** How many stalls before a low reading is allowed to become a verdict. One
* is a hiccup; a file the link cannot carry does it again and again. */
private static readonly LINK_BUDGET_STALLS;
/** How many probes to take the best of — see bestLinkBps. */
private static readonly LINK_PROBE_TRIES;
/** Spacing between them, long enough that two do not measure the same dip. */
private static readonly LINK_PROBE_GAP_MS;
/** The rounding step at a given speed, so the figure lands where connection
* speeds actually land: singles below 10, fives to 50, tens above. */
private static mbpsStep;
/** Bits per second as a round number of Mbps. Never zero — a link too slow to
* round to one is still "about 1", and "about 0 Mbps" is not a sentence. */
private static niceMbps;
private _smoothWarnTimer;
/** Bumped per check: a slower answer for a source or speed that has since
* changed must not put up a popup about the old one. */
private _smoothCheckSeq;
/** Object URLs minted for picked subtitle files, revoked on teardown. */
private _pickedSubtitleUrls;
/** Where the title bar is allowed to appear — see `applyTitleMode`. */
private _titleMode;
/** Whether the title bar carries a back arrow (`titlemode` "back" token). */
private _titleBack;
/** Back arrow restricted to phones (`titlemode` "back-mobile" token). */
private _titleBackMobileOnly;
/** Back arrow restricted to fullscreen (`titlemode` "back-fullscreen"). */
private _titleBackFullscreenOnly;
/** Back arrow dropped in fullscreen (`titlemode` "back-windowed"). */
private _titleBackWindowedOnly;
private _resume;
private _resumeKey;
/**
* Where the outgoing media was when the host swapped the `` children,
* filed under its `resumekey`. Taken by the next load if the key still
* matches — see _reloadFromSourceChildren.
*/
private _refreshCarry;
/** Crop the black bars that are baked into the picture — see `cropbars`. */
private _cropBars;
/** Let autoplay start while the tab is hidden — see `backgroundplay`. */
private _backgroundPlay;
/** Sound and picture stall together — see MoviPlayer's _bindAV. Read from
* the attribute, pushed to the core on load. On unless turned off. */
private _bindAV;
/**
* Read `bindav`, which is an opt-OUT.
*
* A bare boolean attribute cannot express this: absent has to mean ON, so
* "off" needs a value to carry it. `bindav="false"` (or off/0/no) unbinds;
* anything else, including the attribute being absent or empty, binds.
*/
private static readBindAV;
private _stableVolume;
private _audioOutputDeviceId;
private _audioOutputs;
private _audioOutputsBound;
private _vr360;
private _vrPointerDown;
private _vrMoved;
private _vrLastX;
private _vrLastY;
private _vrPinchDist;
private _aspectPinchDist;
private _vrSuppressClick;
private _vrPadDragging;
private _encrypted;
private _tokenUrl;
private _videoUrl;
private _videoId;
private _resumeSaveInterval;
private _posterSeekActive;
private _titleAutoLoaded;
private _resumeCheckedWithTitle;
private _stripTitleAttr;
private _lastDuration;
private posterElement;
/** Where the ambient wash currently sits, and how fast it is drifting there.
* It wanders rather than sweeping: a fixed round trip is a metronome, and a
* metronome is something the eye learns and then starts watching for. The
* velocity takes a small random nudge on every sample and is capped, so the
* path never repeats but also never moves fast enough to be caught at it. */
private _ambientWashPhase;
private _ambientWashVelocity;
private _ambientWrapper;
private ambientWrapperElement;
private _ambientRafId;
private _lastAmbientSampleTime;
private _lastRateChangeTime;
private static readonly AMBIENT_RATE_CHANGE_COOLDOWN_MS;
/** The most of the main thread ambient may take: one part in AMBIENT_DUTY.
* 100 is 1% — enough that the glow keeps up on anything, little enough that
* it cannot be heard in audio scheduled from the same thread. */
private static readonly AMBIENT_DUTY;
private static readonly AMBIENT_MIN_INTERVAL_MS;
/** Smoothed cost of one sample on THIS device. */
private _ambientCostMs;
private _ambientSampleInterval;
private currentAmbientColors;
private _contextLostTime;
private _contextLostPlaying;
private _lastFrameSnapshot;
private _snapshotPosterActive;
private _snapshotPosterPrev;
private _showSnapshotPoster;
private _hideSnapshotPoster;
private _onVisibilityChange;
/**
* The player version, baked in at build time — jQuery-style discoverability
* (`$.fn.jquery`). Read it off the class (`MoviElement.version`), off any
* instance (`document.querySelector("movi-player").version`), or import the
* `VERSION` export from `movi-player/element`.
*/
static readonly version: string;
/**
* Will this play smoothly on this device, in this browser, at this speed —
* asked before committing to playing it.
*
* const a = await MoviElement.canPlaySmoothly("https://cdn.example/film.mkv");
* if (!a.smooth) showLowerQualityOption(a.reasons);
*
* await MoviElement.canPlaySmoothly(file, { rate: 2 }); // a File, at 2x
* await MoviElement.canPlaySmoothly({ codec: "av1", width: 3840, height: 2160, fps: 60 });
*
* A URL or File is opened just long enough to read its tracks, on its own
* WASM instance, so a player already playing is not disturbed. A
* PlaybackQuery needs no file at all. Resolves — never rejects — with
* `playable`, `smooth`, `powerEfficient`, which decoder would carry the
* video, and plain-language `reasons` for anything short of smooth.
*
* This is the same judgement the player acts on when it picks a rendition,
* so the answer and the behaviour cannot disagree. See
* MoviPlayer.assessPlayback for how it is reached.
*/
static canPlaySmoothly(input: string | File | Blob | PlaybackQuery, options?: PlaybackAssessmentOptions): Promise;
/**
* The same question about what this element is playing — or about to play.
*
* Uses the tracks already open when there are any, and opens `src` when there
* are not. Judged at the element's own current speed unless `rate` says
* otherwise, since that is the speed the viewer will actually get.
*/
/**
* Decode the audio of a URL or File to mono PCM chunks (16 kHz by default),
* without playing it. See MoviPlayer.decodeAudio.
*/
static decodeAudio(input: string | File | Blob, options?: DecodeAudioOptions): AsyncGenerator;
/** The same, for what this element has loaded. Empty if nothing is. */
decodeAudio(options?: DecodeAudioOptions): AsyncGenerator;
/**
* Add cues to a generated subtitle track — "English (auto)" written by speech
* recognition as it goes. The first call for a `lang` puts the track in the
* subtitle menu; later calls extend it, in any order. `select: true` switches
* it on, without recording it as the viewer's own subtitle choice.
* `pending: true` lists the track straight away with a working indicator in
* the menus, before there is a cue to show; the first cue clears it, as does
* `pending: false` when none are coming.
* Returns false when nothing is loaded to attach it to.
*/
appendSubtitleCues(cues: SubtitleCue[], options: {
lang: string;
label: string;
select?: boolean;
pending?: boolean;
}): boolean;
/**
* Take a generated subtitle track out of the menus, cues and all — the
* captions were switched off before any were made, say. Subtitles go off if
* it was showing. False if there is no such track.
*/
removeSubtitleCues(lang: string): boolean;
/**
* `smoothwarning`: show a notice when what is loaded is not expected to play
* smoothly on this device at the current speed — "This video may not play
* smoothly", with the reason. Off by default. Mirrors the attribute.
*/
get smoothWarning(): boolean;
set smoothWarning(value: boolean);
/** Present, and not a framework writing it off as "false". */
private isSmoothWarningOn;
/**
* `nolinkwarning`: suppress only the link-budget notice while smoothwarning
* is on. See _noLinkWarning for who needs this. Mirrors the attribute.
*/
get noLinkWarning(): boolean;
set noLinkWarning(value: boolean);
/**
* Ask canPlaySmoothly about what is loaded, and say so if the answer is no.
*
* Asked when a source finishes loading and again whenever the speed changes,
* because speed is half the question: a 4K file can be fine at 1x and not at
* 2x. This is the PREDICTION; the stutter hint ("Play at 1x for smoother
* playback") is the MEASUREMENT, raised only after eight bad seconds. They
* answer different moments and both stay.
*
* Only for sources this player demuxes itself. An adaptive stream or the
* native fallback has no tracks here to judge, and asking about its URL
* instead would open a manifest as if it were a file and report it
* unplayable while it plays perfectly well.
*
* Fires a cancelable `smoothwarning` event first, carrying the assessment
* and `media: "video" | "audio"`; preventDefault() keeps the built-in notice
* down for a host that shows its own.
*/
private checkSmoothPlayback;
/**
* What the notice says, in words for the person watching.
*
* `reasons` are for developers — codec strings, pixel counts, budgets — and
* put in front of a viewer they read as an error log. The viewer needs two
* things: will this be a problem, and is there anything they can do. So the
* technical reasons stay in the event and the API, and this is written from
* the verdict instead. The one piece of advice given is the one that is
* always true when it is given: if the file is fine at normal speed, say
* so. "Try a lower quality" is not offered — a local file has none.
*/
private static plainSmoothMessage;
private showSmoothWarning;
private hideSmoothWarning;
canPlaySmoothly(options?: PlaybackAssessmentOptions): Promise;
/** Instance mirror of {@link MoviElement.version}. */
get version(): string;
/**
* Which bundle is running — `"slim"` or `"full"`. Same discoverability as
* {@link MoviElement.version} (class, instance, or the `BUILD` export), and
* deliberately separate from it: the version says WHAT shipped, this says
* HOW the engine is packaged, which is what decides whether a `movi.wasm`
* has to be reachable and whether an unplayable source degrades to native
* `` on its own.
*/
static readonly build: "slim" | "full";
/** Instance mirror of {@link MoviElement.build}. */
get build(): "slim" | "full";
/**
* The effective fallback mode. Honours an explicit `fallback` attribute
* exactly as before.
*
* When none is set, the slim build defaults to `"native"` — but ONLY when the
* consumer hasn't told us where the WASM lives. The native-first default
* exists for the consumer who never hosts the separate `movi.wasm`; once they
* point `wasmurl` at it they've committed to the WASM engine, so it should be
* authoritative (behave like the embedded build) and a source it can't open
* should surface an error rather than silently degrading to ``. They
* can still opt back in with an explicit `fallback="native"`.
*
* The default (embedded) build returns the attribute value unchanged.
*/
private _fallbackMode;
/**
* `engine` — which playback engine leads, and what follows it.
*
* Movi has four ways to play something and, by default, a fixed order:
* its own WASM demuxer + WebCodecs pipeline first; Shaka (then dash.js /
* hls.js) for adaptive manifests; the WASM demuxer again as the manifest
* fallback; the browser's `` last. `engine` re-orders that — the first
* name listed is attempted first, and any others define what's tried when it
* fails, replacing the built-in escalation.
*
* engine="native" → native first, nothing after it
* engine="native wasm" → native first, Movi's pipeline if it fails
* engine="dashjs shaka" → dash.js first for manifests, Shaka after
* engine="wasm" → force Movi's own demuxer, even for manifests
*
* Unset (the default) keeps the built-in order untouched.
*/
private _enginePriority;
static get observedAttributes(): string[];
constructor();
private createContextMenu;
private createControls;
/** The single tooltip element, and the button it is currently describing. */
private controlTipEl;
private controlTipFor;
/**
* Name every button on hover. An icon row is only legible to someone who
* already knows the icons, and the native title attribute is not the answer:
* it waits about a second, paints in the OS style rather than the player's,
* and cannot show the key that does the same thing.
*
* One listener on the row rather than three per button — the row is rebuilt
* whenever a host adds or removes a control, and delegated handlers survive
* that on their own.
*/
private setupControlTooltips;
/** How long a finger has to stay on a control before it is asking what it
* is, rather than pressing it. */
private static readonly TIP_HOLD_MS;
private showControlTip;
/**
* Put the tooltip over its button. Separate from showing it because the row
* moves under a still cursor: Picture-in-Picture appears once support is
* confirmed, the clock widens crossing ten minutes, quality arrives with the
* ladder. Each shifts every button beside it, and a tooltip placed once ends
* up naming its neighbour.
*/
private positionControlTip;
private hideControlTip;
/**
* What a button is called right now, and the key that does the same thing.
*
* The labels are not the aria-labels: those name the CONTROL and stay put
* ("Play/Pause", "Mute/Unmute"), which is right for a screen reader reading a
* button and wrong for a tooltip, which is read as what the click is about to
* do. The keys are the ones the shortcuts sheet lists — one set of answers.
*/
private controlTipInfo;
private setupControlHandlers;
private seekFromEvent;
private seekFromTouchEvent;
/** Begin press-and-hold fast playback (2x). No-op unless actively playing, so
* a long-press while paused doesn't silently change the saved rate. */
private startHoldSpeed;
/** End press-and-hold fast playback, restoring the pre-hold rate. */
private stopHoldSpeed;
private setupGestures;
/**
* 360° look-around for mouse + trackpad. Touch is handled inside
* setupGestures (it already owns touchstart/move). These listeners no-op
* unless 360° mode is active, and a drag past a few pixels suppresses the
* trailing click so dragging never toggles play/pause.
*/
private setupVRControls;
/**
* Enable/disable VR rendering and pick the format:
* - half: VR180 (front hemisphere) vs full 360°.
* - fisheye: equidistant fisheye vs equirectangular.
* - sbs: side-by-side stereo (render the left eye) vs mono.
* Forwards to the player's renderer and flips the host class (for cursor +
* touch-action CSS). There's no toggle button: VR turns on automatically for
* sources we know are 360/180 (spherical metadata or the `vr` attribute), the
* same way YouTube treats them.
*/
setVR360(enabled: boolean, half?: boolean, fisheye?: boolean, sbs?: boolean, stereographic?: boolean): void;
/**
* When a custom/generated poster image is showing and 360° is on, draw the
* poster onto the canvas through the equirectangular projection (so it looks
* right, not flat-distorted) and hide the flat overlay. No-op for the
* decoded-frame poster path (seek/postertime) — that already paints in 360.
*/
private renderVRPosterIfNeeded;
/**
* Resolve the VR format for the CURRENT source from spherical metadata
* (track.projection) + the live `vr` attribute. Never carries the previous
* clip's state over — switching to a normal clip drops back to flat unless
* `vr` is still set.
*
* `vr` attribute tokens (space/comma separated), e.g. `vr="fisheye sbs"`:
* (bare) / 360 → full 360°; 180 → VR180; sbs / 3d → side-by-side stereo
* (left eye); fisheye → equidistant fisheye (implies 180).
*
* projection metadata: 0/undefined = none; 1 = equirectangular;
* 3 = equirectangular-tile; 4 = half-equirectangular (VR180). Stereo/fisheye
* aren't auto-surfaced, so those need the attribute.
*/
private resolveVRMode;
/** Apply the resolved VR format to the renderer (enable OR disable). */
private detectAndApplyVR360;
private setupKeyboardShortcuts;
private setupContextMenu;
private updateContextMenuContent;
/**
* Highlight the current fit as the active row in the aspect-ratio submenu, so
* it reflects the live fit however it was last changed — context menu, the
* bottom-controls aspect button, or the keyboard shortcut. Called each time
* the fit submenu is shown: on hover (desktop, via showSubmenu) and on tap
* (touch, via the action==="fit" branch). Kept OUT of updateContextMenuContent
* (which runs on the main menu's open) to avoid touch-open side effects.
*/
/**
* Mark the current rate in the speed submenu. Its active row was only ever
* set by clicking a row there, so a rate changed any other way — the keyboard,
* the settings panel, a host call — left the old row ticked. Same shape as
* the fit sync below, and for the same reason: the submenu is a moved-out
* sibling of the menu, so nothing that sweeps the menu reaches it.
*/
private syncSpeedSubmenuActive;
private syncFitSubmenuActive;
/**
* Place a submenu panel beside its row.
*
* Lived inside the hover closure, which is why a submenu opened by CLICK
* came up with no coordinates at all: portaled, an unpositioned panel
* resolves to its containing block's origin, so it appeared pinned to the
* top-left — and moved again on close, when the panel went home and the same
* empty coords started resolving against the player. Both paths now measure
* through here, so a click opens the panel exactly where a hover does.
*/
private positionSubmenu;
private setupSubmenuHover;
private toggleFullscreen;
private _pipWindow;
/** The page's stand-in for a picture that is currently elsewhere. */
private setPipPlaceholderVisible;
private restorePiPCanvas;
/** Caption styling injected into the Document-PiP window (the shadow-root CSS
* doesn't reach it). Mirrors the `.movi-subtitle-*` rules; keep in sync. The
* var() fallbacks let it render even before the --movi-sub-* props are set. */
private static readonly PIP_SUBTITLE_CSS;
private togglePiP;
/** Tracks host-driven fullscreen so toggleFullscreen() and the
* movi-fullscreen-request event can reflect it correctly even when
* document.fullscreenElement is null. */
private _hostFullscreen;
/** CSS "fill the viewport" fallback used where the element Fullscreen API
* isn't available (iOS Safari only exposes it for , and we render to
* a canvas). Not real fullscreen — no chrome hiding — but the player takes
* over the visible viewport. */
private _pseudoFullscreen;
private _prevBodyOverflow;
/** Touch-first device — no hover to fall back on, so affordances that a
* mouse can do without (the centre play button while the chrome is up) have
* to be on screen. Read live: a 2-in-1 can switch input mid-session. */
private _touchLikeMql;
private isTouchLike;
/** Whether the control bar is currently up. */
private areControlsVisible;
/** True when the player is fullscreen by any route: native element FS, a
* host-driven FS, or the iOS pseudo-fullscreen fallback. */
private isFullscreenActive;
/** Whether the browser exposes the element Fullscreen API for this element
* (false on iOS Safari, which only fullscreens ). */
private nativeFullscreenSupported;
/** Re-evaluate on device rotation / viewport change while pseudo-fullscreen. */
private _onPseudoFsResize;
/**
* Forced-landscape for the pseudo-fullscreen fallback: iOS won't rotate the
* screen for us, so a landscape video on a portrait screen is turned 90° via
* CSS (the .movi-pseudo-fs-rotate class) to fill it — matching what Chrome /
* Android do with an orientation lock. Once the device itself is landscape
* (window wider than tall) the rotation is dropped.
*/
private updatePseudoFsRotation;
/** Enter/leave the CSS viewport-fill fallback. */
private setPseudoFullscreen;
private updateFullscreenIcon;
private updateFullscreenContextMenu;
/**
* On phones/tablets, rotate to match the video when entering fullscreen and
* release the lock on exit. Wide clips lock landscape, tall clips portrait;
* defaults to landscape (the common case) if dimensions aren't known. No-op on
* desktop, or where the Screen Orientation lock API is missing (e.g. iOS
* Safari, which rejects programmatic lock — caught and ignored).
* Audio never locks: a sleeve has no orientation of its own, so the phone is
* left free to be held however the viewer holds it — and entering with
* audio RELEASES a lock, which is what updateCoverArtOverlay relies on when
* a film turns into sound while fullscreen.
*/
private applyFullscreenOrientation;
private applyFullscreenUiState;
/**
* Public hook for hosts (VS Code extension, embedded apps) that take over
* fullscreen via the cancelable `movi-fullscreen-request` event. Call this
* whenever the host enters/exits its custom fullscreen so the player's
* toolbar icon and right-click context menu reflect the correct state.
*/
setHostFullscreen(active: boolean): void;
/**
* Leave fullscreen by whichever route the player entered it — native,
* host-driven or the iOS pseudo fallback. `document.exitFullscreen()` only
* covers the first of those, so a host with its own back/close affordance
* needs this. No-op when the player isn't fullscreen.
*/
exitFullscreen(): void;
/**
* @internal For upgrade.ts. A page asking its old to go fullscreen —
* requestFullscreen, or iOS's webkitEnterFullscreen — gets the route the
* player's own button takes, not the bare element API, which iOS does not
* have and which skips the host and pseudo fallbacks.
*/
_enterFullscreenForPage(): Promise;
/** @internal For upgrade.ts — see _enterFullscreenForPage. */
_isFullscreenForPage(): boolean;
/**
* Which symbol each crop is drawn with.
*
* The name, not the body. Pouring one symbol's paths into another's
* shell was how this worked, and it left the shell saying data-icon="aspect"
* over a Zoom drawing — so setIcon("zoom", …) changed nothing on the bar and
* setIcon("aspect", …) was undone the next time the crop changed.
*/
private static readonly ASPECT_SYMBOLS;
private static aspectSymbol;
private updateAspectRatioIcon;
private static readonly TRACK_ICON_AUDIO;
private static readonly TRACK_ICON_SUBTITLE;
/** Stands in for a generated subtitle track's language badge until its
* first cue arrives: a small turning ring, in the bar's list and the
* context menu alike. */
private static readonly SUBTITLE_PENDING;
/** An open context menu is built when it opens; a generated subtitle track
* that changes under it (listed, or its first cue landed) redraws it. */
private refreshOpenContextMenu;
/** Below this height the settings panel packs its rows closer — see
* publishPlayerWidth. */
private static readonly SHORT_PLAYER_PX;
private static readonly TRACK_ICON_OFF;
private static readonly TRACK_ICON_CHECK;
private formatAudioBadge;
private formatSubtitleBadge;
private updateAudioTrackMenu;
/** Move the tick in the audio menu to the entry just chosen, ahead of the
* player confirming it. Purely visual and always overwritten by the next
* real redraw. */
private markAudioItemChosen;
private updateQualityMenu;
/**
* Map a video height to its YouTube-style quality badge (HD/4K/8K) or
* empty string when the resolution doesn't qualify.
*/
/**
* Tell the host about a rung the viewer picked by hand, when nothing else
* will.
*
* A pick is normally reported when the switch it starts lands — the rung
* changes, tracksChange sees a new height, `qualitychange` goes out with
* `auto: false`. Two picks never got that far: the rung Auto was already
* sitting on (nothing to switch to), and a rung of the height last reported
* (the switch lands on a height that is "not news"). Both are the viewer
* saying "this one, always" — and a host that remembers the rung (movi-tube
* keeps the height and marks it as the next video's default source) kept the
* one before, while the player's own Auto preference had already been turned
* off. The next video then opened on the old, fixed rung.
*
* Also ends any rescue window: a pick made inside the 30s after a rescue is
* still the viewer's, and was being reported as `auto: true`.
*/
private announceQualityPick;
/** Persisted "Auto quality" preference. Survives movi-tube's per-video element
* rebuild (localStorage), so once the user picks Auto it stays Auto on the
* next video instead of falling back to a fixed height. */
private _readQualityAutoPref;
private _writeQualityAutoPref;
private _qualityRecoveryAttempts;
private static readonly MAX_QUALITY_RECOVERIES;
private _fullRecreates;
private static readonly MAX_FULL_RECREATES;
private _decodeDownshifts;
private static readonly MAX_DECODE_DOWNSHIFTS;
private _decodeMaxHeight;
private _connectionRetries;
/**
* How many times THIS source has been recovered from, in total.
*
* The per-attempt budgets are refilled once playback gets 20 seconds past a
* recovery, on the reasoning that a recovery which fails never progresses. It
* does here: a source that starts refusing mid-file still has a window of
* bytes in hand, so the recreated player resumes, plays out of that window
* for half a minute, refills every budget on the way, and hits the same wall
* — for as long as anyone is watching. What the viewer sees is a spinner that
* keeps coming back and a clock that keeps climbing, and never a word about
* what is wrong.
*
* This one is not refilled by progress. Playing for a while between failures
* is what the failure LOOKS like; it is not evidence that it is over.
*/
private _sourceRecoveriesThisSource;
private static readonly MAX_SOURCE_RECOVERIES;
private static readonly MAX_CONNECTION_RETRIES;
private _connectionRetryTimer;
private _frozenLastTime;
private _frozenLastFrames;
private _frozenSince;
private _frozenRecoveries;
private static readonly MAX_FROZEN_RECOVERIES;
private _frozenRecreateTried;
private _starvedSince;
private _starvedLastTime;
private _lastRungRescueAt;
private _involuntaryQualityUntil;
private _restoreRungSrc;
private _restoreHealthySince;
private _renditionSwitching;
private _becameVisibleAt;
private _roundingTimers;
private static readonly RESTORE_MIN_BUFFER_S;
private static readonly RESTORE_STEADY_MS;
private static readonly STARVED_RESCUE_MS;
private static readonly VISIBILITY_SETTLE_MS;
/** How long the picture may spend catching up to the sound before the wait
* earns a spinner. See videoCatchUpElapsedMs. */
private static readonly CATCHUP_SPINNER_GRACE_MS;
/** How long a seek is given to land before it earns a spinner. Most land in
* well under this, and a loader that appears and vanishes inside a quarter
* of a second reads as a fault rather than as progress. */
private static readonly SEEK_SPINNER_GRACE_MS;
/** When the current seek-driven interruption began — see the grace above.
* 0 whenever playback is not in one. */
private _seekRunSince;
/** How long a freshly landed rendition is left alone by the frozen-video
* watchdog while its queue refills. Longer than the visibility settle: this
* one starts from an empty buffer AND an empty frame queue, and it is the
* window a slow link needs to prove the rung is actually unaffordable rather
* than merely new. */
private static readonly SWITCH_SETTLE_MS;
private static readonly RUNG_RESCUE_COOLDOWN_MS;
private _stuckRecoverySince;
private _stuckRecoveryLastTime;
private _stuckLastBuffered;
private _stuckLastBytes;
private _stuckRecoveries;
private static readonly MAX_STUCK_RECOVERIES;
private _stuckGaveUp;
private _stuckRungRescues;
private static readonly MAX_STUCK_RUNG_RESCUES;
private _preserveDecodeCapOnce;
private _recoveryResumeTime;
/**
* What `_startAt` was before a recovery rebuild borrowed it.
*
* The rebuild resumes by writing the position into `_startAt`, the value
* every load seeks to — and nothing ever put it back. It outlived the
* rebuild and the video: a watch page whose split-audio link expired
* rebuilt at 27.6s, and every video played after that, in the same element,
* opened at 27.6s. A quality switch has always saved and restored it
* (_startAtBeforeSwitch); the rebuild now does the same.
*/
private _startAtBeforeRecovery;
/** The rebuild's resume has landed: `_startAt` is the host's again. */
private endRecoveryResume;
/**
* A new source: whatever a rebuild of the last one meant to resume at
* belongs to that one — its `_startAt`, and a held seek it had not placed
* yet.
*/
private forgetRecoveryResume;
private _recoveryDedup;
/**
* Parse the declarative children (Video.js-style) into the quality
* ladder (`_videoQualities`), the initial `_src`, and any split / multi-
* language audio (`_audioSrc` / `_audioTracks`). Only runs when `_src` is
* unset (a bare `src` attribute wins and hides the children). Idempotent —
* every field is a fresh assignment — so it's safe to re-run on a fresh
* recreate after clearing `_src`.
*/
/** The ladder as declared right now, for spotting a real change. */
private _sourceChildrenSignature;
/**
* Reload when the host swaps the `` children for a different video.
*
* Without this the only way to change the ladder is to replace the whole
* element — which is what a framework does when it keys the player by video
* id. That works, but it takes fullscreen down with it: fullscreen belongs to
* the element, and the moment it leaves the DOM the browser exits. Playing
* the next video then drops the viewer back to the page, and re-entering
* needs a fresh gesture the auto-advance doesn't have. Reacting to the
* children instead lets the same element carry on — fullscreen, the unlocked
* AudioContext and the volume all survive the change.
*
* A batch of adds and removes is one change: React replaces children one node
* at a time, so this settles on a microtask and compares the resulting list
* against the last one. Identical children (a re-render of the same video)
* do nothing.
*/
private _watchSourceChildren;
private _reloadFromSourceChildren;
/**
* Parse the declarative children into the external subtitle list.
*
* `replace` is what a source swap needs. The connect-time call deliberately
* yields to a list already set through the `source` property (a host that
* passed subtitles in JS must not have them clobbered by children), but on a
* new video the children ARE the new truth — without replacing, the previous
* video's subtitle list and its URLs stayed in the menu.
*/
private _parseChildSubtitleTracks;
/**
* Which of several same-language audio streams to open.
*
* Audio has no ABR: the stream is chosen once and kept, because switching it
* mid-playback would mean re-opening the decoder and re-anchoring the clock
* for a change nobody asked to hear. So the pick has to be right first time,
* and it is made the same way the video ladder's is — from what the link has
* actually been measured to carry, not from what the page marked default
* (a consumer marks the HIGHEST default, which is right for a manual pick
* and wrong for an automatic one).
*
* The budget is a share of the link rather than the whole of it: the video
* rung is chosen from the same pipe and is an order of magnitude larger, so
* audio taking its fill would leave the picture paying for it. A floor keeps
* the smallest rung always reachable — silence is not an improvement on a
* slow link.
*
* With nothing measured yet the highest is kept, which is what this did
* before any of this existed. An unmeasured link is not evidence of a slow
* one, and audio is small enough that guessing high is the cheaper mistake.
*/
private pickAudioRendition;
/** The share of a measured link audio may spend. The picture is chosen from
* the same pipe and costs an order of magnitude more. */
private static readonly AUDIO_LINK_SHARE;
private _parseChildSources;
/**
* Recover from a fatal source error on a premuxed multi-source setup (e.g.
* movi-tube) by re-initialising the WHOLE player from the children,
* preserving the playback position. Unlike a single-src quality reload — which
* sets `src`, so the children (every other rendition AND the separate audio
* track) are never parsed, pinning playback to one possibly-corrupt URL with no
* audio — this rebuilds the full rendition list + split audio on a fresh WASM
* instance, which recovers where the reload can't. Bounded; returns false once
* exhausted so the caller falls through to the overlay.
*/
/**
* Recovery ran out of in-place recreates. On a flaky/slow link the failures (a
* dropped fetch, a demux hiccup on a slow read) are usually transient, so rather
* than dead-end at "Connection Problem" on every slow patch, show a
* "Reconnecting…" and auto-retry with backoff — a fresh recreate once the link
* has had a moment to steady. Bounded: returns false once the retries are spent,
* so the caller surfaces the permanent error.
*/
private _scheduleConnectionRetry;
private _recreatePlayerFresh;
/**
* Tear the current player down and re-initialise from the children at
* `targetSrc` (or the lowest rung when null), preserving the playback position.
* Shared by the source-error recreate (lowest rung) and the decode-error
* downshift (one rung lower). Clears any stuck error/unsupported state — load()
* re-inits on a fresh WASM instance.
*/
private _recreateAt;
/**
* Decode-error recovery for a multi-quality Auto source: the hardware decoder
* can't handle the CURRENT rung (e.g. an 8K AV1 that exceeds the GPU), so drop
* to the next rung down — the GPU can almost always decode a lower resolution —
* on a fresh player, preserving position. Caps the ABR ladder at the new rung
* so Auto can't climb straight back into the undecodable one and re-crash (a
* loop). Returns false once at the lowest rung or after MAX_DECODE_DOWNSHIFTS,
* so the caller falls through to the software fallback.
*/
private _fullRecreateInFlight;
private _carrySubtitleLang;
private _carrySubtitleTrackId;
private _carryAudioLang;
/** Snapshot the active subtitle/audio choices before a player rebuild. */
private _captureTrackSelection;
/**
* Re-apply the picks captured before the rebuild. Called once the replacement
* reaches a playable state — its track lists only exist after it has opened
* the source, so applying earlier would silently find nothing to select.
*/
private _restoreTrackSelection;
/**
* The next `` along, when the current one wouldn't open.
*
* This is what the list is for. A page that writes
*
*
*
*
* is naming alternatives, and an element that fails on the first one is
* expected to try the second — the error belongs to the LIST, and is only
* reported once every candidate is spent. We picked by type up front (see
* pickSource) but a type hint says nothing about whether the file is
* actually there: a moved or mistyped first URL used to end the load with
* "Not Found" while a perfectly good second source sat unread underneath it.
*
* Only during resource selection: once media has loaded, the list has done
* its job and a later failure is this source's failure, not a reason to
* start another one. Returns true when the next candidate is loading, and
* the caller then leaves the overlay alone.
*/
private _trySiblingSource;
private _recreateAtLowerQuality;
/**
* On a network/source error, switch to a different (lower) rendition instead
* of surfacing a fatal overlay — a failure often hits just one variant (an
* expired/hiccupped URL), and another quality frequently still loads. Returns
* true when a recovery switch was started (caller then skips the overlay).
*/
private _tryQualityRecovery;
/** Rough H.264 bitrate (bps) for a resolution height — used to size premuxed
* renditions for the ABR when a source declares no explicit bitrate. */
private _measuredStartBps;
private _startProbeDone;
private _startPickConfirmed;
/**
* Pre-play speed test: measure the link, then set the opening rung from it.
*
* Runs before the player is built (initializePlayer awaits it), so Auto opens
* DIRECTLY on the quality the link can carry instead of starting on the
* smallest rung and visibly ramping up — the "good internet but started ugly-
* low" complaint. The probe measures the SUSTAINED rate past the proxy burst
* (see probeLinkBandwidth), bounded so it never delays startup by more than a
* few seconds; a link too slow to measure in that window is genuinely slow, so
* the smallest rung `_parseChildSources` already chose stands.
*/
private _pickStartRungByProbe;
/**
* Put an earlier init's decision back into `_src` — and finish the job if
* that init never got to.
*
* Re-applying alone is what the two latch branches used to do, and it is
* only half of the pass: the opening pick is a SEED until the confirm pass
* has read the rung's own stream and re-picked from that. An init that is
* superseded mid-confirm sets the latch, leaves the seed pick standing, and
* the init that replaces it then re-applied the seed and opened on it — the
* confirmation silently skipped because a probe had technically "run".
*/
private _reapplyStartPick;
/**
* The measurement itself. Split out so concurrent inits can await the one
* that is already running instead of starting their own — see
* `_startProbeInFlight`.
*/
private _runStartProbe;
/**
* Re-measure on the rung we actually chose, and step down while it doesn't
* fit.
*
* The first probe reads ONE mid rung and that number sizes the whole ladder —
* but googlevideo paces each stream to its own bitrate, so a mid rung's rate
* is not what the 8K rung will deliver. Getting it wrong doesn't cost the
* second this spends: it costs a stall, an in-place rendition switch, and the
* viewer watching the quality fall in front of them. One more probe is the
* cheaper side of that trade.
*
* Bounded to two extra probes, and only ever steps DOWN — a confirmation
* can't talk the opening pick up.
*/
private _confirmStartPick;
/**
* Choose the opening rung from a measured link rate and put it in `_src`.
*
* Split out from the probe so a later init can re-apply the same decision
* without re-measuring — see the `_startProbeDone` branch above.
* `reapplied` only changes the log wording.
*/
private _applyProbePick;
/**
* Read the head of a rung, keep the bytes, and time the part of the download
* that means something.
*
* The old probe skipped 2MB to get past the CDN's opening burst and then
* timed 900KB — ~3MB downloaded and every byte discarded, after which the
* source fetched the same opening bytes over again. This keeps both halves:
* everything read is handed to HttpSource as the opening buffer, and only
* what arrives AFTER the burst window is timed, so the number is still a
* measurement of the link rather than of the cache in front of it.
*/
private _probeHeadAndWarm;
/**
* The next rung DOWN from `src` in the ladder, or null when there isn't one.
*
* Ordered by height rather than bitrate here: this is answering "what else
* could this browser decode", and on a mixed ladder the codec changes with
* resolution — dropping a step in height is what crosses from AV1 back to
* H.264, which is the whole point.
*/
private _lowerRungThan;
private _estimateBitrate;
/**
* A ladder rung's badge, tiered on the frame it will actually paint.
*
* A rung carries a height and nothing else, and height alone is not a
* resolution: a 1.9:1 master's 1920-wide rung is 1012 tall, and its
* 3840-wide rung is 2026. Tiered on those numbers the menu called Full HD
* nothing at all and 4K "HD" — while the gear, which reads the PLAYING
* frame and so knows both dimensions, correctly said HD. Same ladder, two
* answers, and the one the viewer notices is the disagreement.
*
* Every rung of a ladder is the same picture at a different size, so the
* frame that is playing gives all of them their aspect. Nothing is playing
* yet (or it is square-ish) → fall back to the height alone, which is right
* for the 16:9 ladders that are most of them.
*/
private _rungBadge;
private _heightBadge;
/**
* Paint or hide the small badge pill on the gear button itself so the
* user can see the active quality tier at a glance — same convention
* as YouTube's player.
*/
/** Last quality badge ("HD", "4K", ...) so the gear can be re-rendered when
* HDR flips without waiting for the next quality change. */
private _qualityBadge;
private _qualityBadgeReported;
/**
* The gear's badge, composed.
*
* HDR is the headline: on an HDR source it REPLACES "HD" (the resolution is
* implied - nothing below HD ships HDR) and rides ALONGSIDE 4K/8K, where the
* resolution is the bigger claim. That is the shape people already read on
* other players, so it needs no explaining.
*/
private _renderGearBadge;
private _updateQualityBtnBadge;
/**
* Render a quality menu for pre-muxed multi-source MP4s (no HLS manifest).
* Driven by the cached `_videoQualities` list; switching just swaps the
* active URL and lets the existing src-change pipeline reload
* the player while preserving currentTime / paused state.
*/
private renderPremuxedQualityMenu;
/**
* Quality menu for DASH-fallback (demuxer) mode: lists the manifest's video
* Representations. Built via DOM nodes (not innerHTML). Picking one re-loads
* the same .mpd forcing that rendition through the demuxer, preserving the
* separate audio + subtitle tracks (which a bare-file swap would drop).
*/
private renderDashQualityMenu;
/**
* Switch DASH quality in demuxer mode. Reuses the resume + frozen-frame
* machinery of switchPremuxedQuality, but instead of swapping `src` to a bare
* video file (which loses the split audio + subtitles) it keeps the .mpd and
* re-loads with config.forceStreamDemux + forceVideoRendition, so
* analyzeDashFallback re-attaches the audio/subtitle and only the video
* Representation changes.
*/
/**
* The rung the viewer just chose, shown as chosen before it arrives.
*
* A pick used to leave the menu and the gear reading the OLD quality until
* the switch landed — and the switch got slower on purpose when it stopped
* dropping frames: the new rendition is opened, seeked and decoded PAST the
* playhead before anything swaps, and only then does the active rendition
* change and the UI catch up. On a slow link that is seconds of a menu
* insisting you are still on 480p after you asked for 1080p.
*
* So the pick shows immediately and the switch confirms it. Cleared when the
* real rendition catches up (the renderers below check), and on a switch that
* bails — a rung that could not be prepared must not stay ticked.
*/
private _pendingQualityKey;
/** Read by every quality renderer instead of the player's active rendition,
* so the chosen rung is the one shown from the moment it is chosen. */
private effectiveQualityKey;
/** Show a pick as taken, at once, and repaint the places that show it. */
private markQualityPending;
/** The switch is over — by arriving, or by failing. Either way the menu goes
* back to telling the truth. */
private clearQualityPending;
private switchDashRendition;
/**
* Full teardown + reload at the chosen rendition. The fallback path for the
* in-place swap: freezes the current frame as a poster and restores the
* playhead after the reload.
*/
private _reloadQualitySwitch;
private _carryAudioEl;
private _qualitySwitchInProgress;
private _switchResumeTime;
private _startAtBeforeSwitch;
private _switchResumeDuration;
/** Hold the current duration across a player rebuild. Only ever raises a real
* number — a crashed player that throws leaves the last good value in place,
* which is exactly what the UI should keep showing. */
private _stashDurationForRebuild;
private _switchPosterTimeout;
/**
* Swap the active video source and resume playback at the same position.
* The src setter pipeline tears down the player; we re-seek once metadata
* is available on the new instance.
*/
private switchPremuxedQuality;
private _reloadPremuxedQuality;
private getMaxAllowedRate;
/** Volume ceiling: 200% normally (boost via AudioContext gain), but 100% when
* audio plays through a native element (adaptive stream / native split-source)
* which can't boost — so the UI doesn't promise a boost that won't happen. */
private getMaxVolume;
/** Re-apply the volume ceiling to the slider + current volume when the audio
* path changes (source load, audio-track switch between muxed and native). */
private updateVolumeCap;
/**
* Start sampling render health once per second to detect sustained stutter
* (decoder can't keep up above 1x on a heavy source → dropped video frames,
* smooth audio). Runs only during active playback; cheap (reads a couple of
* numbers). Idempotent.
*/
private startStutterMonitor;
private stopStutterMonitor;
/**
* Clear the stutter-hint cooldown so the "play at 1x" warning can fire again.
* Called on every playback-rate change — each new speed the user picks gets a
* fresh chance to warn if it stutters (instead of showing only once per
* source). The 3-second sustained requirement still prevents instant spam.
*
* Also opens a short warm-up grace window: right after a speed change the
* decoder is still ramping its frame queue to the new rate, so those first
* seconds legitimately present few frames. Sampling ignores them until the
* grace passes, giving the pipeline headroom to settle before we judge it.
*/
private static readonly JUDDER_RATIO;
private static readonly JUDDER_SECONDS;
/**
* The "Play at 1x for smoother playback" hint is a claim about the DEVICE —
* that it cannot hold this speed — so it needs more than a passing dip.
*
* It used to fire after three bad seconds, and a speed change on its own
* costs about that much: the corrective seek re-primes the pipeline, the
* read-ahead unwinds, and the picture is thin for a second or two before it
* settles. So the hint was going up over a disturbance the player had just
* caused itself and then recovered from, telling the viewer their machine was
* at fault. Sustained incapacity outlasts that easily; a self-inflicted hitch
* does not.
*/
private static readonly STUTTER_RATIO;
private static readonly STUTTER_SECONDS;
private static readonly STUTTER_WINDOW;
private _judderSeconds;
private _juddering;
private resetStutterHint;
/** One stutter sample: compare presented FPS to the smooth-playback baseline. */
/**
* Say so when the connection cannot carry this media at all.
*
* Separate from canPlaySmoothly, which asks whether the DEVICE can decode it.
* A 40GB remux can be perfectly decodable and still unwatchable because the
* bytes cannot arrive in time, and nothing here said that.
*
* The link rate comes from a PROBE, and nothing else will do. Two earlier
* versions of this read the ABR's throughput instead and both cried wolf:
* once the buffer is ahead the source parks at the prefetch gate, so the
* window sees a stream being paced rather than a link being slow. Measured on
* a healthy link, 0.41 Mbps against a 2.84 Mbps file; reported from the wild,
* "2 Mbps" on a 40 Mbps line. That number is a FLOOR, not a capacity, and no
* amount of corroborating evidence turns it into one — the stalls it was
* paired with can come from decode, from a seek, from startup.
*
* So the stalls only decide WHEN to ask, and probeLinkBandwidth answers: it
* skips the proxy burst and times the sustained tail, a few MB, bounded, and
* only ever once per source. No answer, or an answer that clears the bar, and
* nothing is said.
*/
private sampleLinkBudget;
/**
* The best reading a few probes can find, which is the only one worth having.
*
* A single probe is not a measurement here. It runs WHILE the player is
* streaming the same media from the same origin, so the two share the link
* and the probe gets whatever is left at that moment — reported from the
* wild, the same media reading 10, then 40, then 50 Mbps on one connection.
* Contention, a busy edge and a momentary dip all push a reading DOWN and
* none of them push it up, so the largest of several is the closest thing to
* what the path can actually do, and the average would just be a record of
* how busy the player was while asking.
*
* It stops the moment a reading clears the requirement, which is the whole
* point: the case this gets wrong is the false alarm, and that one now costs
* a single probe. Only a path that keeps falling short pays for the rest —
* and there the bytes are already not arriving in time anyway.
*/
private bestLinkBps;
/** The notice itself, once the probes have confirmed the shortfall. */
private announceLinkShortfall;
private sampleStutter;
/**
* "May not play smoothly", from what playback actually did — once per source,
* through the same cancelable event as the load-time notice.
*/
private warnSmoothFromMeasurement;
/**
* Show On-Screen Display (OSD) notification
*/
private osdTimeout;
/** Once per source: the native element is decoding a picture and no sound. */
private _nativeAudioSilentReported;
/** True while the volume control is switched off for want of any audio. */
private _noAudibleAudio;
private _nativeAudioCheckTimer;
/**
* Say so when the browser is playing the video and dropping the audio.
*
* The native handoff plays a file the WASM path could not read, and the
* browser decodes only what it has decoders for. A Hindi WEB-DL is typically
* H.264 with E-AC-3 (DDP5.1): Chrome plays the picture out of the MKV and has
* no E-AC-3 decoder at all, so the sound is dropped without a word. From the
* viewer's side that is a silent video and a player that looks broken.
*
* Chromium counts what it decoded, and the two counters answer it exactly —
* video bytes climbing while audio bytes sit at zero is the browser saying it
* never decoded a sample. There is nothing to do about it here (the fixes are
* CORS on the host, so the WASM path can decode it, or a file the browser
* knows) but saying it is better than silence.
*
* Chromium-only, because the counters are: elsewhere we don't claim.
*/
private watchNativeAudio;
/**
* Switch the volume control off (or back on): the button, the slider and the
* keys that drive them. See the CSS note on .movi-no-audible-audio.
*/
private setNoAudibleAudio;
private showOSD;
private static readonly SUBTITLE_SETTINGS_STORAGE_KEY;
/**
* Returns the kind of subtitle currently active so the customize
* panel can show only the options that apply.
*
* - "vtt" → WebVTT (karaoke-paced from YouTube proxy etc): all
* text styling options apply, INCLUDING the backdrop.
* - "text" → SRT/ASS/SSA/TTML or muxed text subs: size/color/edge
* and shift apply; backdrop doesn't (it's gated to VTT
* in CSS).
* - "image" → PGS/DVD/DVB or other muxed image subs: only the
* subtitle-shift control applies; styling is baked
* into the bitmap.
* - null → no subtitle selected → no customize gear shown.
*/
private getActiveSubtitleKind;
private static readonly SUBTITLE_EDGE_STYLES;
private static readonly SUBTITLE_COLOR_PALETTE;
private loadSubtitleSettings;
private saveSubtitleSettings;
private applySubtitleSettings;
/**
* Let the viewer put the caption where it suits them, and leave it there.
*
* On the caption itself, which is the only part of the overlay that takes a
* pointer at all (the rest stays transparent so a click lands on the picture
* behind it). The move is tracked on the window rather than on the caption:
* the overlay's contents are rewritten on every karaoke tick, so the element
* the drag started on is routinely gone before the drag ends.
*
* A double click puts it back. Hosts that don't want any of this write
* `controlslist="nosubtitledrag"`.
*/
private wireSubtitleDrag;
/**
* Pull a caption that has left the picture back to its edge.
*
* Measured after the move rather than bounded before it, because the ground
* shifts under a drag: the bar hides and the caption's home moves the height
* of the bar, the cue changes and the block changes width. Bounds worked out
* when the caption was picked up are wrong by then, and the caption stopped
* short of the top of the picture by exactly the height of the bar that
* hid while it was being carried there.
*/
private keepCaptionInPicture;
private _captionHint;
private _captionHintTimer;
private static readonly CAPTION_HINT_STORAGE_KEY;
/**
* Has the viewer ever put a caption back?
*
* The one thing that proves the hint has been read. Counting drags would
* only prove the hint has been SHOWN, which is not the same and stops
* telling people who have not looked at it yet.
*/
private captionHintLearned;
private markCaptionHintLearned;
/**
* Tell the viewer, while they are dragging, how to put the caption back.
*
* A drag has no undo the eye can find: the caption is somewhere new and
* nothing on screen says the way back. So it is said at the one moment it
* is wanted, in the smallest thing that can carry a sentence — on every
* drag, until the viewer puts a caption back for the first time. That, and
* not a number of showings, is what says the sentence has been read; after
* it, the hint is a label on a door they know how to open.
*/
private showCaptionHint;
/** Let it go, a beat after the caption is let go of. */
private dismissCaptionHint;
/**
* Put the hint at the end of the picture the caption is not at — and then
* leave it there for the whole drag.
*
* It used to be worked out on every movement, so carrying a caption past
* the middle of the frame sent the hint sailing the other way across it,
* animated, in the middle of the gesture it was there to explain. Where it
* goes is decided once, when it is raised.
*/
private placeCaptionHint;
/**
* The caption has been carried to where the hint is standing.
*
* The one case a fixed note cannot answer by staying put. It does not dodge
* — a note that jumps out of the way is the thing that distracts — it steps
* out while the caption is on top of it and comes back the moment the
* caption moves off, so carrying one to the top of the picture does not
* cost the sentence for the rest of the drag.
*/
private syncCaptionHintClearance;
/** Standing aside for a caption that is on top of it, as opposed to done. */
private _captionHintCrowdedOut;
private _subtitleDrag;
/** A drag ends in a click event the transcript must not answer. */
private _subtitleDragSuppressClick;
private _captionClickTimer;
/**
* A sanity bound on a STORED position, not a leash on the drag.
*
* How far the caption may actually travel is decided by the picture: the
* drag is clamped to the player's own box, so the caption stops at the edge
* it reached and nowhere sooner. This only catches a number that came back
* from localStorage as something no drag could have produced, which would
* park the caption off-screen where it cannot be dragged back.
*/
private static readonly SUBTITLE_POS_LIMIT;
private static clampSubtitlePos;
/**
* Put the caption where it was dragged to.
*
* The independent `translate` property, not `transform`: the renderer owns
* `transform` on this overlay (it carries the video's rotation, rewritten on
* every cue), and the two compose without either having to know about the
* other. `translate` also applies OUTSIDE the rotation, so a caption on a
* rotated video still follows the pointer rather than the picture's idea of
* which way is right.
*
* Written on the overlay rather than the host so it travels with the element
* into the document-PiP window, where the host's own custom properties do
* not reach.
*/
private applySubtitlePosition;
/**
* Apply one of the public subtitle-customize attributes onto
* _subtitleSettings. Returns true if the attribute name matched
* (caller can decide whether to call applySubtitleSettings()).
*
* subtitlesize="150" (50–200, treated as %; also accepts 1.5)
* subtitlecolor="#FFEB3B" (any CSS color hex, 3 or 6 digits)
* subtitlebg="50" (0–100, treated as %; also accepts 0.5)
* subtitleedge="outline" (none | shadow | outline | raised)
*/
private applySubtitleAttribute;
/**
* Pick a backdrop RGB that contrasts with the text color so the cue
* stays readable regardless of the user's color choice. Dark text on
* dark backdrop (or vice versa) would render the subtitle invisible.
*/
private static contrastBackdropRgb;
private renderSubtitleCustomizePanel;
private wireSubtitleCustomizePanel;
private _showingSubtitleCustomize;
private _cuesPanelCues;
private _cuesPanelFiltered;
private _cuesPanelQuery;
private _cuesPanelActiveIdx;
private _cuesPanelTimeUpdateUnsub;
private _cuesPanelEscHandler;
private openCuesPanel;
private closeCuesPanel;
private renderCuesPanel;
private updateCuesPanelActive;
private updateSubtitleTrackMenu;
/**
* True when an actual media source is loaded (plain src, a caller-supplied
* SourceAdapter, or the encrypted video URL). Used to gate the controls
* auto-hide — in the empty "No Video" state we keep the bar pinned.
*/
/**
* Whether a play/pause press should PAUSE right now.
*
* Not just "is it playing": while a source is still loading, autoplay (or a
* queued play()) has already been asked for, the bar is showing the pause
* glyph, and the viewer pressing it means "don't start". Reading only the raw
* state there turned that press into another PLAY — the press was swallowed
* and the video rolled the moment data arrived. Intent is what the icon
* shows, so intent is what the press has to act on.
*/
/** When the current press on the player began, and whether playback was
* paused then. Read by toggledThisGesture and _revertsThisGesture. */
private _gestureAt;
private _gesturePausedAtStart;
private _gestureWatch;
private static readonly GESTURE_WINDOW_MS;
/** Has playback already flipped since this press on the player began? */
private toggledThisGesture;
/**
* Would going to `wantPaused` undo a toggle already made for the press in
* progress? For the upgrade proxy (see forwardTo in upgrade.ts), whose
* page may answer the same click the player already answered.
* @internal
*/
_revertsThisGesture(wantPaused: boolean): boolean;
private shouldPauseOnToggle;
/**
* A start is queued but hasn't happened yet: autoplay is armed (or a play()
* arrived) while the source is still loading. The player has no intent to
* report yet — it hasn't been told to play, that happens once loading
* settles — but from the viewer's side the video IS about to start, so the
* bar shows the pause glyph and a press cancels the start.
*/
private isStartPending;
private hasMediaSource;
/**
* Take the play/pause glyph out of the seek's hands — see _iconHeldForSeek.
*
* Armed only from a seek the viewer is meant to come out of still playing,
* so a seek made while paused leaves the glyph alone and it keeps saying
* "play". The backstop is long enough to cover a scrub that has to fetch
* across the network and short enough that a resume which never arrives is
* admitted rather than hidden.
*/
private holdIconThroughSeek;
/**
* Is the centre button standing in for the ring right now?
*
* On touch, with the chrome up, that button IS the play control — and during
* a seek it is showing the pause glyph, held there by _iconHeldForSeek. The
* ring wants the same spot, and the stylesheet's "never the button and the
* spinner together" rule resolves that by taking the button away: measured on
* a phone scrub, is-spinner-pending came on at 583ms and faded the button out
* over ~190ms, the ring took over at 1.6s, and the button faded back in at
* 1.8s. A live control blinking out and back for a seek the viewer asked for.
*
* Here the rule is resolved the other way. The button is the one that stays:
* it is a control the viewer may want to press, the seek already has feedback
* of its own in the scrubber and the clock, and the glyph it shows is a pause
* — not the play triangle inside a loading ring that the rule was written
* against. Bounded by the hold's own backstop, so a seek that really is
* taking a long time still gets a ring once the hold expires.
*
* Only where all of that holds: a seek in flight, touch, and the chrome up.
* With the chrome down there is no button to protect, on a mouse it is not a
* control, and a seek made while paused never arms the hold — which is the
* case the original rule is still exactly right about.
*/
private centrePinnedForSeek;
/** The seek is over, one way or another. Safe to call when nothing is held. */
private releaseIconHold;
/**
* Put the chrome back the way a freshly-created, source-less player looks.
*
* Clearing the source ran dispose(), and dispose() does refresh the controls
* — but it runs BEFORE `_src` is nulled, so hasMediaSource() was still true
* at that moment and the two exemptions that key off it (play/pause and
* fullscreen, both deliberately live while a source loads) stayed lit. The
* clear-path then only swapped in the empty-state art and never asked the
* controls again, so an emptied player sat there with a working play button
* and a fullscreen button over nothing at all.
*
* Called from every route that lands on "no source": the property setter's
* two null branches and the attribute callback's removal.
*/
/**
* Everything a fresh load has to forget, whichever route it arrived by.
*
* Any src (re)set — even to the same URL — is a fresh load, so the
* DASH-fallback guards clear: the fallback must be able to run again, and a
* stale forced-quality pick must not carry over. Same for the `engine`
* priority walk (the new source starts at the top of the author's list, not
* wherever the last one gave up) and the pre-play speed test.
*
* `_hasEverPlayed` goes with them, EXCEPT during a rebuild. A quality switch
* on a premuxed ladder comes through the attribute too — each rung is its own
* URL — but the CONTENT is the one already playing, and clearing the flag
* there makes the poster eligible again, so the switch paints a static
* thumbnail over a video that never stopped. That is what the last-frame
* snapshot is meant to cover, and the snapshot is best-effort, which is why
* the cover appeared only sometimes.
*
* Both callers matter. The attribute callback returns early when the value
* has not changed — a deliberate guard against hosts that re-write props on
* every render — so a page re-sending the URL it is already on reaches this
* only through the setter's same-value branch. Until that branch called this,
* a re-set reloaded the media, landed it paused at zero, and left the element
* believing the source had already played: no centre play button, which is
* only offered before the first play, at the end, or on touch. A paused video
* with nothing to press, from pressing the web app's URL bar twice.
*/
private resetForFreshSource;
private resetToEmptyState;
/**
* Switch the UI into linear (forward-only) playback: the source has no Range
* support and is too big to cache, so seeking is impossible. Hide the
* timeline and disable seek + thumbnail previews via the `movi-linear` host
* class. Idempotent — safe to call from both the event and the loadEnd
* backstop.
*/
/**
* In linear (non-seekable-source) playback only the bytes currently in the
* RAM window are reachable, so clamp any seek target to the buffered time
* range [bufferStart, bufferEnd]. A small margin keeps the (approximate,
* linear byte→time) clamp safely inside the window so the keyframe for the
* target is actually present. Returns the time unchanged when not linear.
*/
private clampToBufferedWindow;
private enterLinearMode;
/**
* Mirror "is the controls bar currently taking up bottom space?" onto a
* plain host class so the empty-state placeholder can re-center reliably.
* The old approach keyed off `:host:has(.movi-controls-hidden)` /
* `:host(:not([controls]))`, but `:has()` is flaky in Safari/Firefox (and
* an unsupported selector in a comma list drops the WHOLE rule), which left
* the "No Video" text stuck off-center there. `:host(.movi-bar-collapsed)`
* is universally supported, so the centering works in every browser.
*/
private syncBarCollapsedClass;
/** Last state announced as `controlschange`, so the event fires on a change
* and not on every mousemove. */
private _controlsAnnounced;
/** Say so when the bar comes or goes. A host that draws its own chrome over
* the player has no other way to know — the bar hides itself on a timer, and
* everything overlaid on it was left sitting over nothing. */
private announceControls;
private showControls;
/**
* Close every bottom-controls dropdown (speed, audio, subtitle,
* quality) plus the context menu. Used to enforce one-menu-at-a-time
* and to swallow a player-area click when a menu is open.
* Pass a `keep` selector to skip closing the menu currently being
* opened, otherwise everything goes away.
*/
private closeAllBottomMenus;
/**
* Toggle a bottom-bar dropdown with a pop-in / pop-out animation.
* Inline display:none stays the truly-hidden terminal state so the
* existing dom-presence checks elsewhere keep working — we just clear
* it on open, run the CSS transition, and restore it once the exit
* transition finishes.
*/
private setBottomMenuOpen;
/**
* Strip-mode menu positioning: place the menu against the anchor button
* using viewport-relative position:fixed. The strip CSS drops the host's
* `contain: paint` + `container-type` so fixed coords reach the viewport
* again and paint extends outside the 56px strip box.
*
* Two things the first version of this got wrong, both of which left the
* subtitle menu looking like it had not opened at all:
*
* - Its height. The stylesheet caps these menus against the PLAYER's own
* height, which in strip mode IS the bar: min(56px - 100px, 460px)
* floors at zero, so the menu opened 2px tall. A strip's room is the
* viewport's, so measure that instead.
* - Its position. `fixed` is viewport-relative only while nothing up the
* tree carries a transform, and the controls' right-hand cluster
* animates with one — which makes IT the containing block. The menu
* landed a cluster's width and a bar's height away from where it was
* put (measured: asked for 771,476 and arrived at 1772,918, off-screen).
* Rather than hunt for the transformed ancestor, put the menu down, see
* where it actually went, and take the difference out.
*/
private applyStripFixedMenuPosition;
private isBottomMenuOpen;
/**
* Restart the subtle fade-in on the subtitle dropdown's content area so
* toggling between the track list and the customize panel feels like a
* crossfade rather than a snap. Removing + re-adding the class with a
* forced reflow restarts the CSS animation; without the reflow the
* browser deduplicates the class change and the animation never replays.
*/
private flashSubtitleListFade;
/**
* Hide the seek OSD pill immediately and reset the chain state.
* Used when a relative-seek press lands on the boundary (delta 0)
* so the previous chain's "- 25s" cue doesn't keep reading like the
* playhead has gone past zero — the cue had served its purpose
* before the boundary press, but holding it on screen with no
* forward movement is what the user sees as "minus".
*/
private dismissSeekOSD;
/**
* Run a relative seek (button / key / double-tap) and surface it
* through the OSD. The OSD label tracks the *actual* delta between
* the pre-seek time and the clamped target — so pressing left at 5s
* with a 10s step shows "- 5s", not "- 10s", and a follow-up press
* at 0s suppresses the OSD entirely instead of accumulating phantom
* seconds the playhead never travelled. Same on the duration end.
*
* For rapid chained presses we anchor "before" on the previous
* target rather than the (still-stale) playback time, so the cue
* tracks where the playhead is *headed* even mid-seek.
*/
private performRelativeSeek;
/**
* The Timeline panel (opened with "T") sits just above the controls bar but
* is a shadow-root sibling of the controls container, so hovering it never
* sets isOverControls — without this the 3s inactivity auto-hide fires while
* the user scrubs the storyboard. Gates ONLY the inactivity timer (in
* showControls) so the bar stays put while the timeline is open; the
* cursor-left-the-controls / cursor-left-the-player hides keep their normal
* behavior. Kept out of isAnyMenuOpen(), which also drives click-to-close.
*/
private isTimelineOpen;
/** True when the engine can route output to a chosen device. */
private static audioSinkSupported;
/** Running inside a (possibly cross-origin) iframe. */
private isEmbeddedIframe;
/**
* Whether a Permissions-Policy feature (e.g. "fullscreen",
* "picture-in-picture", "speaker-selection") is allowed in this document.
* At the top level features are allowed by default; inside an iframe they
* must be delegated via the `allow` attribute. Uses the (Chromium) feature
* policy API when present and defaults to allowed when it isn't — the
* features we gate this way are all Chromium-only, so a missing API means a
* browser that wouldn't expose the capability regardless.
*/
private featureAllowed;
/**
* List the available audio output devices. Labels may be empty until the
* page has been granted audio-device access (the desktop app has it; a bare
* web embed may show blank labels until a getUserMedia prompt is accepted).
*/
getAudioOutputs(): Promise>;
/** Current audio output device id ("" = system default). */
getAudioOutput(): string;
/**
* Route audio to a device. Accepts a concrete deviceId, or — for
* convenience — a label substring (case-insensitive); "" / "default" →
* the system default. Returns false when unsupported or the device is gone.
*/
setAudioOutput(deviceId: string): Promise;
/** Resolve a deviceId or a label substring to a concrete deviceId. */
private resolveAudioOutputId;
/** Apply the `audiooutput` attribute once the player exists. */
private applyAudioOutput;
/**
* Cache the device list, re-applying any pending attribute selection and
* re-rendering the menu. Bound once to `devicechange` so hot-plugging a
* headset updates the list live.
*/
private setupAudioOutputs;
/** Refresh the cached device list and re-render the menu if it's open. */
private refreshAudioOutputs;
/**
* Browsers only expose output device ids/labels after the page holds audio
* permission. When the user clicks "Show output devices…" in the (otherwise
* locked) submenu, request mic permission — the universal unlock, same as
* Google Meet — then re-render with the now-visible devices and re-open the
* submenu. Triggered from an explicit click so the prompt has a user
* gesture. No-op in the desktop app (devices already visible).
*/
private unlockAudioOutputs;
/** (Re)build the Audio Output submenu from the cached device list. */
private updateAudioOutputMenu;
/**
* The settings panel behind the gear.
*
* Every setting used to have its own button on the bar, which meant the row
* grew with each feature and a viewer had to know which icon hid what. One
* gear holding a list is what almost every player does, so it is what people
* reach for.
*
* The per-setting menus are NOT rebuilt here. Their markup, their population
* and their click handling all still live where they were; opening a page
* BORROWS the list node into the panel and closing puts it back. So the
* quality list the panel shows is the same element quality logic has always
* written to — nothing had to be duplicated or kept in sync.
*/
private static readonly SETTINGS_PAGES;
/** Row icons, drawn to match the context menu's (16px, 1.8-2 stroke). A list
* of bare words is slower to scan than a list with marks against it — and
* these are the same marks the context menu already trained people on. */
private static readonly SETTINGS_ICONS;
private static readonly ASPECT_CHOICES;
private _settingsPage;
/**
* Whether a row has anything to offer. Two independent signals, because
* either one alone lies: the owning container hides itself when a setting
* doesn't apply (no adaptive ladder), but a container that was never
* evaluated is simply visible-by-default — and a menu can be "visible" while
* holding nothing but its own "Off" entry. A row that opens onto one choice,
* or none, is worse than no row: it reads as a broken feature.
*/
private settingsRowAvailable;
/**
* Prefix a track label with its language code, unless the label already says
* which language it is.
*
* The "already says it" test goes through Intl: a substring check reads "HI"
* inside "Hindi (auto)" and suppresses a code that the label never actually
* carried — while an ISO code and its English name ("hin" vs "Hindi") share
* no useful prefix at all. Intl knows the mapping; when it can't resolve one,
* a word-boundary match on the raw code is the honest fallback.
*/
private withLangCode;
/** HTML-escape a value before it goes into a row template. Row values come
* from the media and from host markup — a track label or a label —
* which on an app like movi-tube is whatever an upstream API returned. */
private static escapeSettingsText;
/** Current value shown on the right of a root row. */
private settingsRowValue;
/** Resolution of the only rung there is, e.g. "1080p" - shown as a dead row
* when there is nothing to choose between. */
private singleQualityLabel;
/** The aspect row's icon is the CURRENT crop, not a generic frame — the value
* text says "Fill" and the mark shows what that looks like, so the row
* answers "what is it set to" without opening anything. */
private aspectRowIcon;
/**
* Decide whether the mobile "more" tray is worth having.
*
* It folds the audio-track / HDR / captions controls away on narrow players,
* but what it holds depends entirely on the source: an MP4 with one audio
* track and sidecar captions leaves exactly one button in there, and then the
* tray costs a tap and hides the control instead of organising anything.
*/
private syncMobileExtras;
private buildSettingsRoot;
private openSettingsPage;
private animateSettingsView;
/** Put a borrowed list back where its own menu expects it. */
/** Shut the gear panel, wherever the reason came from. */
private closeSettingsMenu;
private closeSettingsPage;
/** Rows for a page that has no borrowable list of its own. */
private renderSettingsChoices;
private static readonly ASPECT_OSD_LABELS;
/** The toast a fit change puts up — same one the old aspect button showed. */
private showAspectOsd;
/**
* Announce a viewer-changeable setting so a host can remember it.
*
* The gear panel and the context menu let the viewer change loop, stable
* volume, HDR, ambient, rotation and audio-only, and none of those reached
* the page: the value went into a private field (and, for some, the player's
* own SettingsStorage) and stopped there. A host that wants a preference to
* outlive the element — the way movi-tube keeps quality and aspect — had
* nothing to listen to. Fired on real changes only, so a host echoing the
* value back cannot feed itself.
*/
private emitSettingChange;
/**
* Tell the host the viewer picked a fit. The gear panel and the context
* menu did; the bar's aspect button, the A key and the pinch only saved it
* in the player's own store — so a host that remembers the fit itself (and
* hands it back as `objectfit` on the next page) kept the old value, and the
* two stores drifted: a change made from the bar or the key did not stick.
*/
private announceAspect;
/**
* Where a chosen fit lands, and the fact that it is remembered.
*
* Four things change the fit — the settings page, the bar's aspect button,
* the A key and the pinch gesture — and only the first went through
* applyAspectChoice. The other three wrote the field inline, so three of the
* four ways a viewer can change the fit were never saved, which is what
* "aspect doesn't persist" actually was. They all come here now.
*/
private setFit;
private applyAspectChoice;
/**
* Keep the panel inside the player.
*
* It hangs off the gear, and the gear is not at the right edge (PiP and
* fullscreen sit beyond it), so on a phone the panel's own width ran past the
* left edge of the frame and got clipped. CSS can't fix that: the offset
* needed depends on where the gear happens to sit in the row. So measure once
* on open and nudge it back, capping the width to the player first.
*/
private clampSettingsPanel;
private setupSettingsPanel;
private isAnyMenuOpen;
private hideControls;
/** How long the centre icon stays up after a play/pause: a short pop, then it
* HOLDS for a beat before fading. The pop alone was gone by the time a
* glance arrived — you'd toggle, look at the middle of the picture, and find
* nothing there. The hold is what makes it readable as "paused". */
private static readonly CENTER_FLASH_MS;
/** Keyframe offset where the flash stops holding and starts fading out.
* Must track the 0.68 offset in the keyframes below — everything before it
* sits at full opacity. */
private static readonly CENTER_FLASH_FADE_AT;
/** The other shape: a press on a button that is ALREADY on screen. Nothing to
* pop in, so the receipt is the press itself — in, past the resting size on
* the way up, and settled back ON it. The fade after that is for the one
* case where such a button does leave (the poster's play button, pressed);
* where it stays, settleCenterFlashSolid drops that tail and the press has
* already played out in full by then. */
private static readonly CENTER_FLASH_PRESS_MS;
/** Where the press has finished moving and the fade would start. The motion
* must be complete by here, because this is the boundary the settle logic
* cancels on. */
private static readonly CENTER_FLASH_PRESS_FADE_AT;
/** How long the glyph may be held across a seek before an unresolved resume
* is reported honestly — see _iconHeldForSeek. */
private static readonly SEEK_ICON_HOLD_MS;
private _centerFlashAnim;
/**
* Does the flash in flight own the centre GLYPH?
*
* Only the pop does. That shape is a receipt over the picture — it shows the
* ACTION taken, the pause bars when you paused, and it has to keep them for
* its whole run or the 250ms tick would reconcile them to the resulting state
* halfway through.
*
* The press does not. There the button is a persistent CONTROL, and a control
* says what pressing it will do NEXT — which is what the bar's icon does, at
* once. Pinning the action glyph there made the centre button lag the bar by
* the whole animation and then snap: measured on a phone, the bar read "play"
* 24ms after the tap while the centre button was still showing pause bars
* until 664ms, when the press ended. So the press leaves the glyph to
* updatePlayPauseIcon and animates the button around it.
*/
private _centerFlashOwnsGlyph;
private _centerFlashTimer;
/** When the flash in flight starts fading, in ms from its own start. Stored
* per flash because the two shapes hold for different lengths. */
private _centerFlashFadeAtMs;
private _centerFlashSettleTimer;
/**
* Pop the centre play/pause icon for a moment as the receipt for a toggle,
* then let it fade. It shows the action just taken (pause glyph when you
* paused), NOT the resulting state — the bar's icon covers state.
*
* On a mouse this is the only thing that puts the centre button on screen
* mid-playback: it never rides in with the chrome, and it never stays. On
* touch the button IS a control while the chrome is up, so there it presses
* in and springs back instead of flashing — see the early return below.
*
* Driven by the Web Animations API rather than a CSS class, because a class
* cannot survive this element: the 250ms UI tick, showControls and
* hideControls all rewrite its class attribute, and during a flash that
* churn was restarting the CSS animation ~80ms in and then cutting it off
* before it finished — the "it plays, jerks, and plays again" report. An
* animation object is owned here, immune to what anyone does to classList,
* and it tells us when it's actually done instead of us guessing with a
* timer.
*/
/**
* Drop an in-flight centre-icon flash. Its "cancel" handler restores
* pointer-events, so the button is immediately clickable again.
*/
private cancelCenterFlash;
/**
* The button is turning into a persistent control while a flash is mid-air.
*
* That flash was armed as a receipt, so it FADES at the end — at the moment
* of the press the button's resting state was hidden. Now it isn't, and the
* fade would take down a button the class says should be solid, then snap it
* back to full on the last frame (the animation has no fill). Cancelling
* outright is the old cure, and it costs the entire receipt: while a source
* is still loading _hasEverPlayed is false, so EVERY press took this path
* and the animation was killed at 0ms. Measured on an 850MB stream — "FLASH
* KILLED at 0 ms", press after press, the receipt drawing nothing at all
* while the button simply appeared.
*
* Only the fade is the problem. Up to it the flash holds full opacity, which
* is exactly what the class wants, so the pop and the hold can play out in
* full. Cancel at the fade boundary instead: the element drops to its
* resting style, which is solid by then, so there is no flicker and no lost
* receipt.
*/
private settleCenterFlashSolid;
private flashCenterIcon;
/**
* `themecolor` is one or two colours, separated by whitespace:
*
* themecolor="#8B5CF6" primary only
* themecolor="#8B5CF6 #22D3EE" primary secondary
*
* Splitting is paren-aware, so a functional colour keeps its inner spaces:
* `rgb(255 0 51) #22D3EE` is two colours, not four tokens. Anything that
* isn't exactly two values is taken whole as the primary — one odd-spaced
* colour must keep working, and three is a typo, not a theme.
*/
/**
* Re-read state into whatever settings surface is currently open.
*
* The panel and the context menu are both SNAPSHOTS — built once when opened.
* A keyboard shortcut (or the OS media keys, or a host calling the API)
* changes the same settings behind them, and the open menu then shows the
* previous state until it is closed and reopened, which looks like the
* shortcut didn't work.
*
* Called from the per-setting UI updaters, so every path that changes a
* setting goes through it without each one having to remember.
*/
private _refreshingSurfaces;
/** Update the open root panel without replacing its rows.
*
* A toggle changes state, not structure. Rebuilding root.innerHTML for it
* discarded hover/focus and restarted every entrance animation, making the
* whole panel look as though it had reopened. */
private syncSettingsRootState;
private refreshOpenSettingsSurfaces;
private doRefreshOpenSettingsSurfaces;
/** Copy the theme variables the PiP stylesheet reads into the PiP document. */
private syncPipTheme;
private applyThemeColor;
/**
* The first colour in a gradient, for everything that cannot take one.
*
* Reads the stops rather than the whole value: the first argument of a
* gradient is often a direction or an angle ("to right", "110deg", "in oklch
* longer hue"), and a stop itself may carry a position ("#674cff 20%"). What
* comes back is the colour alone, or null if nothing in there looks like one.
*/
private static firstColorOf;
/** Split on a separator that sits outside parentheses. */
private static splitTopLevelBy;
/** Split on whitespace that sits outside parentheses, so `rgb(255 0 51)` and
* `color-mix(in srgb, red 40%, blue)` survive as single values. */
private static splitTopLevel;
/**
* Theme colour as set — `"#8B5CF6"` or `"#8B5CF6 #22D3EE"`. An object is
* accepted for hosts that keep the two colours apart; it is written back as
* the same whitespace form, so the attribute always reads the way it would
* have been authored in markup.
*/
get themeColor(): string | null;
set themeColor(value: string | {
primary?: string;
secondary?: string;
} | null);
private updateControlsVisibility;
private startUIUpdates;
/**
* One pass of the in-page UI: the clock, the progress bar, the buffered
* range, the icons — and the frozen-video, stuck-playback and rung-restore
* watchdogs that ride along with them. Returns false when there is no player
* left to update, which stops whichever driver called it.
*
* Split out from its rAF loop because rAF is not always running. A tab that
* is not the foreground one has its animation frames paused, which is the
* normal state while watching in Document PiP — the video plays on in its own
* window and the page behind it froze mid-second: the time, the progress bar,
* every icon, and, less visibly, the throughput sampling and all three
* watchdogs. So PiP drives this from a timer as well; timers are throttled in
* a background tab but never stopped, and roughly a second is plenty for a
* 4Hz readout.
*/
private runUiTick;
/** ~4Hz. Time display, progress bar and icons do not need 60fps precision —
* at 60fps these DOM writes burn 30-50ms/sec of main thread on a low-end
* phone, enough to starve the media pipeline. Matches the cadence of
* HTMLMediaElement's own timeupdate. */
private static readonly UI_UPDATE_MIN_MS;
private _lastUiTickAt;
private _pipUiTimer;
/** Keep the page's own UI alive while the picture is in a PiP window. */
private setPipUiTimer;
/**
* Detect a frozen video: state is "playing" and the clock/audio is advancing,
* but no new frame is being presented — audio ran ahead over a stale/black
* frame (after a long background spell, or a seek that force-completed with no
* decodable frame; the case users unstick with a manual seek). A corrective
* seek to the current position re-flushes and re-decodes from there, re-aligning
* video to audio. Bounded; only judges a foreground tab (this rides the rAF UI
* loop, which is throttled when hidden — background legitimately skips decode).
*/
private _checkFrozenVideo;
/**
* Ask the player to bail out to its lowest rung because playback has actually
* stopped moving. Rate-limited: the switch needs a few seconds to prep and
* re-prime, and firing again inside that window would tear down the very
* rescue that is running. Returns true when a rescue was started (or one is
* still settling), so callers can hold off their own recovery.
*/
private _rescueRung;
/** Companion to _checkFrozenVideo for the NON-playing case: a seek or rebuffer
* that never completes. On a slow link the byte range being waited on may
* simply never arrive ("waitForData returned false"), which strands the
* player in seeking/buffering with the spinner up — after an ABR downshift,
* after a recovery recreate's resume-seek, or after any seek. The frozen
* watchdog only runs while "playing", so nothing caught this and the user had
* to seek manually to unstick it. Nudge forward onto data that IS arriving
* and (re)request play. Bounded, and inert while the buffer is still growing
* (a slow-but-working rebuffer must be left alone). */
private _checkStuckPlayback;
private addStyles;
private handleContextLost;
private handleContextRestored;
connectedCallback(): void;
/** The pre-`persist` OPFS restore, moved out whole so the guard above reads
* as one decision rather than wrapping a hundred lines. */
private restoreLegacySettings;
disconnectedCallback(): void;
attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void;
private _lastCanvasW;
private _lastCanvasH;
/**
* Keep the element from having to measure its own content to know how tall
* it is.
*
* `:host` is `height: 100%`, which against a parent of auto height resolves
* to auto — and the canvas inside is `height: 100%` of THAT, so it falls back
* to its intrinsic size: the backbuffer, in device pixels. The host then
* takes its content height from the canvas, updateCanvasSize measures the
* host, and sets a backbuffer bigger again. Measured on a player given a
* width and no height: 8,694px tall on the first sample and climbing ~840px
* a second, with the picture a sliver at the top of a canvas tens of
* thousands of pixels long — which reads as "it never played".
*
* An aspect ratio breaks the loop: the height comes from the width, and
* nothing downstream can feed back into it. Only when the author hasn't
* given a height of their own — with one, `height: 100%` is definite and CSS
* ignores `aspect-ratio` anyway, so this stays out of the way.
*/
/**
* `width` / `height` attributes, as a `` would answer them.
*
* On a replaced element those are presentational hints the browser turns into
* a CSS size. A custom element gets no such treatment: the attribute sits
* there meaning nothing, so markup carried over from a `` — or written
* by someone who reasonably expects HTML to behave like HTML — rendered at
* whatever width the container gave it. A page that said 620 got 1710.
*
* Written as inline style, and only over a size this element put there
* itself: a host that styles the player in its own CSS is saying something
* more specific than the attribute, and setting the attribute back to nothing
* takes the size away again.
*/
private applySizeAttributes;
private applyIntrinsicAspect;
/**
* Keep the two ends of the bottom row from running into each other.
*
* The row is a left cluster (transport + clock) and a right cluster
* (settings run), and neither can give way: the clock is nowrap text and the
* icons are fixed boxes. When they don't both fit, the left one overflows and
* the two draw on top of each other — a host's own control landing across
* "00:01 / 04:12" is the same collision as a 200px player having no room for
* the clock.
*
* A width breakpoint can't decide this. `addControl` lets the host put as
* many buttons in the row as it likes, and a 2-hour duration is wider than a
* 3-minute one, so what fits is a question about the CONTENT, not the
* viewport. Measure instead, and give up the clock in the order the parts are
* worth keeping: the duration first (the seek bar shows it, and it doesn't
* change), then the position.
*
* Cheap because it is rare — a resize, a duration arriving, a control being
* added — and it always measures from the un-hidden state, so a row that
* gains space takes its clock back.
*/
/**
* Hide any control capsule that has nothing visible left in it.
*
* The bar's capsules are pills with 2px of padding, and they are drawn by the
* container, not by the controls inside it. Take the controls away — a
* `controlslist` token, a `fastseek` mode, a source with no track to offer —
* and the container stays: 4px of rounded background sitting in the row as a
* dot with no meaning. It showed up between play and volume when the ±10s
* pair was off, and as the entire right-hand cluster when a host switched
* every control in it off.
*
* Emptiness is measured, not inferred: a child that lays out to nothing is
* gone whatever hid it, so this covers hiding done in the stylesheet, in an
* attribute and at runtime without having to know which.
*/
private syncEmptyCapsules;
/**
* Hide a host's divider that has nothing visible on one side of it, or only
* another divider — its neighbour is unavailable for this source, or was
* removed. A line at the end of a pill divides nothing and reads as a
* rendering fault.
*
* Neighbours are found by where things ARE, not by DOM siblings: the bar
* nests controls in display: contents wrappers (the mobile tray), so the
* control drawn beside a divider is often not its sibling. Only what shares
* the divider's own capsule counts — a control in the next capsule is on the
* other side of a gap already. Left alone while the row isn't laid out.
*/
private syncStrandedDividers;
private fitControlsRow;
private updateCanvasSize;
/**
* Toggle the LIVE indicator for live (dynamic) adaptive streams. Adds the
* `.movi-live` host class (CSS shows the badge + hides the meaningless
* wall-clock duration), and marks the badge `.behind` when the viewer has
* scrubbed back into the DVR window so it reads grey instead of pulsing red.
* Cheap + idempotent — safe to call on every timeupdate.
*/
private updateLiveState;
/**
* Paint embedded cover art (audio-only sources). Shows when:
* - the player has emitted a coverart bitmap, AND
* - there is no active video track (a real video would mask this anyway,
* but hiding upfront keeps unnecessary canvas work off the critical
* path for plain video files that happen to ship a thumbnail track).
*
* Layout: blurred fill of the artwork stretched over the full surface,
* with the sharp artwork centred and sized to ~60% of the smaller host
* dimension. Matches the YouTube Music / Apple Music aesthetic.
*/
/**
* For an audio-only source with a `poster` URL and no embedded album art,
* lazily load the poster into an ImageBitmap so updateCoverArtOverlay can
* paint it as album art (via the same blurred-backdrop cover-art canvas).
* Idempotent: loads once per URL, clears when no longer applicable (source
* became video, poster cleared, or embedded art arrived). Re-runs
* updateCoverArtOverlay once the bitmap is ready.
*/
private ensurePosterCoverArt;
/**
* The scrubber, drawn as a row of dots that move with the sound.
*
* Cover art leaves a whole surface with nothing happening on it: a plain
* line under a sleeve reads as a still image with a progress bar bolted on.
* The dots carry the position the same way — bright behind the playhead,
* dim ahead of it — and ripple with the level, so the picture is playing
* something. It is drawn over the real scrubber and never takes its clicks
* (pointer-events stay off), so seeking, keyboard and the handle are
* untouched.
*/
private startCoverEqualizer;
private stopCoverEqualizer;
private drawCoverEqualizer;
/** Downscale an album-art bitmap to a tiny JPEG data URL for the blurred
* backdrop. The backdrop is blurred to 54px, so ~96px is indistinguishable
* from full-res while keeping the URL small and the draw cheap. */
private makeCoverArtBgUrl;
/** Pull two restrained ambient colours from opposite halves of a sleeve.
* Using a tiny downsample keeps this off the hot path; weighting colourful
* midtones prevents a white title block or black border dominating it. */
private applyCoverArtPalette;
private updateCoverArtOverlay;
/** Two colours for a sleeve that has no picture to sample, derived from its
* title so a track keeps its colour from one visit to the next and two
* tracks in a queue don't look like the same record. Sets the same CSS
* hooks applyCoverArtPalette does, so the surround's glow follows. */
private applyDrawnSleevePalette;
/** The sleeve for a track with no art: its palette as a lit card with a
* note on it — plainly "no artwork", but composed like the real thing so
* the fullscreen view is the same view either way. Drawn into the
* caller's clip, so the rounding and the edge highlight come for free. */
private paintDrawnSleeve;
/**
* After an autoplay attempt, detect the browser blocking audio-with-sound
* (AudioContext stuck suspended) and fall back to muted playback so the
* video keeps rolling, then surface the "Tap to unmute" pill. No-op when
* the user already asked for muted, has deliberately unmuted this session,
* there's no audible source, or audio actually started. The AudioContext
* resume() kicked off inside play() is async, so we give it a couple of
* animation frames to settle before reading the state.
*/
/**
* Kick off autoplay: suppress the centre play overlay through the startup
* window, call play(), and run the muted-autoplay fallback. Shared by the
* initial load and the deferred (tab-was-hidden) path in _onVisibilityChange.
*/
private _startAutoplay;
private maybeFallbackToMutedAutoplay;
private updateMuted;
/**
* Show a "Tap to unmute" pill when the player is set up to autoplay
* muted (browsers block autoplay-with-sound) and the user has asked
* for controls. Hides as soon as the player isn't muted, one of the
* prerequisite attributes drops, or audio-track discovery proves
* there's no audio to surface — reusing the same hasAudibleSource()
* check that gates the volume button (see updateAudioTrackMenu).
* Before the player is initialized hasAudibleSource() can't be
* answered, so we optimistically show the pill on attrs alone and
* let updateAudioTrackMenu re-call this once tracks are known.
*/
private updateUnmuteOverlay;
/**
* @param silent Apply the volume without the OSD. For the paths that are not
* a viewer changing the volume — restoring a stored preference, replaying an
* attribute — where a toast reports a change nobody made.
*/
private updateVolume;
private updateSubtitleDelay;
private updatePlaybackRate;
private updateFitMode;
private posterObjectFit;
/**
* True when the current string src uses a scheme fetch() can't handle — a
* typo like "httpss://", or a missing scheme. new URL() accepts any scheme,
* so these don't parse-fail; they die later at fetch() with a generic
* "Failed to fetch", which reads as a network problem. Detecting the bad
* scheme lets both error paths (init-catch and the runtime "error" handler)
* blame the URL instead. Skipped when a custom source adapter INSTANCE is set
* (element.sourceAdapter) — it reads the bytes itself. A scheme registered
* via registerSourceAdapter("s3", …) is treated as openable too, so custom
* schemes aren't falsely flagged; only genuine typos fall through.
*/
private hasUnfetchableSrcScheme;
/**
* Automatically create and initialize MoviPlayer
*/
private initializePlayer;
/**
* Set up event handlers for the player
*/
private setupEventHandlers;
/**
* Repaint everything keyed to the active track set, and notify the host.
*
* Shared on purpose: the WASM player and the native fallback both publish
* their active video track through a TrackManager, so the fallback's Auto
* switches need this exact refresh — the quality badge, the menu's active row,
* and the `qualitychange` a host persists its pick from. It only ever ran for
* the WASM path before, which is why a native Auto switch changed the picture
* but left the badge (and the host) on the rung it started at.
*/
private handleTracksChange;
/** The rest of setupEventHandlers — split only so handleTracksChange could
* become a method the native fallback can reuse. */
private _setupRemainingEventHandlers;
/**
* Load the video source (automatic when src is set)
*/
/**
* Coalesce a source-affecting attribute change into a single microtask load
* so `src` + `headers` (+ any other in the same tick) all land before the
* fetch starts. See _attrLoadScheduled. Callers pass the same guards load()'s
* own trigger sites use (connected + has a source).
*/
private scheduleAttrLoad;
load(): Promise;
/**
* Play the video
*/
play(): Promise;
/** Start a play() that was held back behind a load — see play(). */
private startDeferredPlay;
/**
* Pause the video
*/
pause(): void;
/** Say `play` once per run of playback, and remember that we did. */
private announcePlay;
/** …and `pause` once, for a stop that is real. */
private announcePause;
/**
* Register OS Media Session action handlers once. These surface play/pause,
* skip-back/forward and scrub controls on the lock screen, notification
* shade, media-key hardware (headset / keyboard) and Bluetooth remotes, and
* route them back into the player. Registered once (guarded); the handlers
* close over `this` and read `this.player` lazily, so they keep working
* across source swaps that recreate the internal player.
*/
private setupMediaSession;
/**
* Refresh the lock-screen metadata (title + poster artwork) and the
* playing/paused indicator. Cheap to call on every state/load change.
*/
private updateMediaSession;
/**
* Capture the current decoded frame off the WebGL canvas as a data: URL for
* use as OS lock-screen artwork. A source with no explicit `poster` paints
* its poster/first frame straight onto the canvas (via the postertime /
* frame-0 seek) and never turns it into a URL — this is how that same still
* becomes the lock-screen thumbnail. Skipped when an explicit poster/cover
* URL already wins the artwork chain, or once a snapshot is already stored.
* The canvas uses preserveDrawingBuffer, so toDataURL returns the real frame.
* Cheap one-shot (mirrors the proven _lastFrameSnapshot capture) and only
* runs at moments a real frame is guaranteed on-canvas (poster seek / play).
*/
private captureMediaSessionArtwork;
/**
* Cheap synchronous test for whether the display canvas is still blank/black
* (renderer hasn't painted a real frame yet). Downscales the WebGL canvas to
* 16×16 on a throwaway 2D context and checks luminance spread — a near-flat
* result means no frame content. Used to defer artwork capture until a real
* frame lands. Returns true (treat as blank) on any failure, so capture just
* retries rather than storing garbage.
*/
private isCanvasBlank;
/**
* Use an extracted cover-art / thumbnail bitmap as the OS lock-screen artwork.
* The player surfaces embedded album art (and other thumbnails) asynchronously
* — after the initial metadata is seeded — so this refreshes the Media Session
* once it lands, instead of the lock screen being stuck with no image. The
* bitmap is rasterised to a bounded (≤512px) data: URL. Real art is
* authoritative, so it overwrites any earlier canvas-snapshot fallback; an
* explicit `poster`/cover URL still wins the chain in updateMediaSession().
*/
private setMediaSessionArtworkFromBitmap;
/** Best-effort MIME guess for poster artwork from its URL/extension. */
private artworkMime;
/**
* Push the current playhead / duration / rate into the OS scrubber. Guarded
* against live streams (Infinity), un-loaded state (NaN/0) and out-of-range
* positions, which would otherwise make setPositionState throw.
*/
private updateMediaSessionPosition;
/**
* Tear down the internal player and reset transient UI (time, title,
* subtitles, timeline) back to the initial, no-source state. Called
* internally on every src change so the next source starts clean. Safe to
* call when nothing is loaded.
*
* Note: we deliberately do NOT touch the canvas or the native video
* element — the canvas owns a WebGL2 context that the next renderer reuses,
* and resetting the can interfere with the DRM/HLS path.
*/
/**
* Aborted whenever the current source goes away — a new src, a dispose, a
* disconnect — and carried by every request the ELEMENT makes on its own
* behalf (the pre-play probe, the head-burst probe).
*
* These run before a player exists, so the player's own lifetime signal
* cannot cover them, and they are the largest of the lot: the pre-play probe
* alone asks for 2.9MB. The element already noticed when they were pointless
* — "Init superseded during the pre-play probe — dropping" — but it dropped
* the RESULT while the download carried on, on the link the replacement was
* trying to start on.
*/
private _sourceAbort;
/** Retire the current source's requests and open a fresh scope for the next. */
private abortSourceRequests;
dispose(): void;
get theme(): "dark" | "light";
set theme(value: "dark" | "light");
/**
* Get the internal canvas element
*/
getCanvas(): HTMLCanvasElement;
/**
* Re-read the tooltip for whatever the cursor is on.
*
* The click handler refreshes it a frame after the press, which is too early
* for play/pause: play() resolves later than that, so the label was rewritten
* from the state it was ABOUT to leave and then nothing touched it again —
* the bar said Pause over a paused video. State changes are the honest
* trigger, so the two labels that read state re-render from here.
*/
private refreshControlTip;
private updatePlayPauseIcon;
/** Target of the seek currently in flight, or -1. The player only moves its
* clock AFTER `demuxer.seek()` returns, which on a slow link blocks for as
* long as the byte range takes — so until then getCurrentTime() still reports
* the OLD position and the readout/scrubber snap backwards during loading,
* then jump to the target once it lands. Showing the target throughout is
* what the viewer asked for. */
private _uiSeekTarget;
/**
* Picture-in-Picture state change. Emits our own `pipchange` plus the two
* events HTMLVideoElement uses, so code written against a native
* (`enterpictureinpicture` / `leavepictureinpicture`) works unchanged.
*/
private _emitPipChange;
/** Last observed isHDRSupported(), so the UI tick can notice a flip. */
private _lastHdrSupported;
/** Buffered-end at the last `progress` dispatch. Reset on a new source. */
private _lastProgressBufferEnd;
/** Intrinsic size at the last `resize` dispatch. */
private _inTracksChange;
private _tracksChangedAgain;
private _lastVideoWidth;
private _lastVideoHeight;
/** `canplaythrough` is once-per-source. */
private _canPlayThroughFired;
/** Characters in the clock at the last fit — see fitControlsRow. */
private _lastClockLen;
/**
* Whether THIS source has ever resolved a video track. Lets the cover-art
* decision tell "the track list is being rebuilt" (quality switch) apart from
* "this really is an audio source". Must reset with the source.
*/
private _sourceHadVideoTrack;
/**
* `loadeddata` is dispatched from BOTH loadEnd and initializePlayer's finally
* (different pipelines reach one or the other), so a normal load fired it
* twice while its `loadedmetadata`/`canplay` neighbours fired once. A
* fires it once per load; this keeps the triple coherent.
*/
private _loadedDataFired;
/** The `` children in document order, and how far along them we are. */
private _childSourceUrls;
private _childSourceIndex;
/**
* `loadstart`, once per load.
*
* `el.src = url` announces the load and then writes the attribute, whose
* change callback announces it again — so a host counting loads, or arming a
* one-shot per `loadstart`, saw two for one source. The two run in the same
* task (setAttribute calls attributeChangedCallback synchronously), so a
* microtask-scoped latch collapses them while leaving a genuine later re-set
* — a different task — free to announce again.
*/
private _loadStartPending;
private announceLoadStart;
/** Dispatch the loaded/ready triple once per load, in native order. */
private _emitLoadedData;
/** Byte cursor / timer backing the `stalled` no-data window. */
private _lastStalledBytes;
private _stalledSince;
private _stalledFired;
/** Ownership token for _uiSeekTarget. Value equality is NOT enough: a single
* click fires BOTH the document-mouseup and the bar-click handler with the
* SAME target, so the first one's finally cleared the second one's still-live
* target and the readout/scrubber snapped back to the old position. */
private _uiSeekSeq;
/** A plain click on the progress bar fires BOTH the document "mouseup"
* handler (mousedown already set isDragging) AND the bar's own "click"
* handler, so ONE click issued TWO identical seeks. The first is instantly
* superseded and resolves in ~15ms; its teardown then stomped the live
* seek's UI state and the bar snapped back to the old position for the
* rest of the (multi-second) real seek. Release-seek claims the gesture
* here so the click handler skips it. */
private _seekHandledOnRelease;
/** Current time for the UI:
* - a seek is in flight → its target (don't snap back to the old position)
* - a recovery recreate → the resume target (the fresh player reads 0 until
* its resume-seek lands, which would flash 00:00 and then jump forward) */
private _uiCurrentTime;
/**
* Arm the UI's seek target AND repaint straight away. Letting the next UI
* tick pick it up left the time readout on the OLD position for ~200ms while
* the scrubber had already jumped to the target — a visible mismatch on the
* one interaction users watch most closely.
*/
private _armUiSeekTarget;
/** Retire the target only if OUR arming is still the live one. */
private _retireUiSeekTarget;
/** Time display shows the remainder ("-1:23") instead of elapsed. */
private _timeShowsRemaining;
/**
* Name the section the playhead is in, on the pill beside the clock.
*
* Most sources have no chapters, so the pill is absent rather than empty
* until one does — an unlabelled capsule in the bar is a control that does
* nothing. Absent in linear mode too: it opens the timeline, and the timeline
* works by seeking.
*/
/**
* Hand the crop setting to the renderer, and mirror what it finds back out.
*
* The renderer owns the pixels — it already mirrors every drawn frame for
* ambient, and that mirror is what the bars are found in — so the decision
* lives there and this only switches it on and reports.
*/
private applyBarCrop;
/**
* Crop the black bars that are part of the picture.
*
* A 2.39:1 film delivered in a 16:9 frame carries its letterbox as pixels, so
* "fill" and "zoom" scale the padding with the image and hand back the same
* letterbox, larger. With this on, the bars are taken off before any of that
* happens — so those fits mean what they say.
*/
get cropbars(): boolean;
set cropbars(value: boolean);
/**
* Let `autoplay` start even while the tab is hidden.
*
* By default a hidden tab parks the autoplay until it is shown. That is not
* politeness — a first play started behind another tab runs into a throttled
* rAF and a denied WakeLock, and the first seek can time out into a buffering
* state that only a manual pause→play unsticks. Opt in when the page knows
* that "hidden" doesn't mean "unwatched": background audio, a playlist that
* must keep advancing, a kiosk the browser reports as hidden.
*
* On a DESKTOP, playback already continues when a tab is hidden, so there
* this is only about starting there. On a PHONE it is more than that: hiding
* the tab normally pauses outright, because the OS throttles hidden tabs to
* the point where keeping playback alive is unreliable, and this opts out of
* that too. The return path still pauses for a tap if the AudioContext comes
* back stuck suspended. (Document PiP is exempt either way: the tab is
* hidden by definition and the picture is on screen regardless.)
*/
get backgroundplay(): boolean;
set backgroundplay(value: boolean);
/**
* Tie the sound and the picture together: either one stops, both stop.
*
* By default one side running out is only a stall if the other ran out with
* it, so each is free to carry on alone. The picture is the side that
* usually runs out on a slow link — it is by far the bigger stream — and the
* sound then plays on over a frozen frame, seconds ahead of the picture by
* the time it catches up. The sound is the side that runs out on a slow
* machine, where an expensive codec decodes behind realtime and the picture
* carries on over the holes.
*
* Bound is the default: whichever side empties buffers the other with it,
* and they resume together. The cost is that a shortfall you would otherwise
* have watched or listened through becomes a full stop — which is the honest
* thing to show, since a picture running seconds behind the sound is not
* playback anyone asked for.
*
* `bindav="false"` (or off/0/no) unbinds them. A bare boolean attribute
* cannot express an opt-out — absent has to mean on — so "off" is carried by
* the value, and the setter writes it rather than removing the attribute.
*/
get bindav(): boolean;
set bindav(value: boolean);
/** What is being cropped right now, as the fraction taken off each edge. */
getBarCrop(): {
top: number;
bottom: number;
left: number;
right: number;
};
private updateChapterPill;
/**
* Mark the tile the playhead is inside, and show how far through it we are.
*
* The strip was a row of equal thumbnails with no answer to "where am I" —
* the only highlight it had was the keyboard cursor, which is about what you
* are ABOUT to pick. Cheap enough for the UI tick: it only runs while the
* panel is open, and only writes when the tile changes.
*/
/** The tile the marker was last on, and the windows either side of a scroll
* we caused ourselves — see updateTimelineCurrent. */
private _timelineCurrentIndex;
/** The tile someone clicked, held until playback genuinely leaves it. */
private _timelinePickedStart;
private _timelineUserScrolledAt;
private _timelineScrollbarTimer;
/**
* Show the scroll bar for as long as the strip is moving, then take it away.
*
* A touchscreen has no hover to reveal it with, and a bar drawn permanently
* under the tiles is furniture — it is not grabbable with a finger, so all it
* ever did there was say "there is more this way", which is worth saying only
* while the strip is actually going that way.
*/
private flashTimelineScrollbar;
/**
* Turn each page arrow on only when there is strip left on that side.
*
* The 2px slack is not cosmetic: a scroll container's scrollLeft is a
* fractional CSS pixel on a fractional-DPR display, so `scrollLeft + client
* === scroll` is never exactly true at the end and the right arrow would stay
* lit on a strip that had nowhere left to go.
*/
private updateTimelineArrows;
/**
* Centre a tile in the strip by moving the strip's OWN scrollLeft.
*
* scrollIntoView() does not stop at the nearest scroll container: it walks up
* and scrolls every scrollable ancestor it finds. Only the strip was ever
* meant to move here, and the strip is the only thing that knows where its
* own tiles are — anything else it reaches is collateral. Writing scrollLeft
* cannot move anything but the strip, whatever sits above it.
*/
private centreTimelineTile;
private updateTimelineCurrent;
private updateTimeDisplay;
/**
* Update the seek slider's ARIA so screen readers announce the position as a
* real slider ("1:23 of 4:56"), not a bare number. Driven off timeUpdate so
* it stays current even while the visual chrome is auto-hidden.
*/
private updateSeekAria;
/** Push the visible caption's text into the off-screen aria-live region so
* screen readers announce it. Deduped so identical re-renders stay quiet. */
private mirrorCaption;
private startQoeHeartbeat;
private stopQoeHeartbeat;
/** Register a QoE analytics sink; returns an unsubscribe fn. Every event is
* also dispatched as a `movi-qoe` CustomEvent. */
addQoeSink(sink: QoESink): () => void;
removeQoeSink(sink: QoESink): void;
/** POST every QoE event to `url` via sendBeacon — shorthand for
* addQoeSink(beaconSink(url)). Returns an unsubscribe fn. */
setAnalyticsBeacon(url: string): () => void;
/** A rolled-up snapshot of the current QoE session. */
getQoeSession(): QoESession;
/**
* Render chapter markers on the progress bar (YouTube-style)
*/
/** Paint (or clear) the chapter gaps across the track's three painted
* layers. See the note at the call site for why the bar itself is spared. */
/**
* @param hole Percent range to cut out of the track ENTIRELY, on top of the
* chapter gaps. The raised chapter under the pointer paints its own copy of
* the three layers, and it is translucent white over the picture — so with
* the real track still painted underneath, the 4px it overlapped came out
* brighter than the 2px above and below it, and the section read as an
* outlined box rather than a thicker bar. Cutting the track there leaves
* exactly one layer of paint again.
*/
private applyChapterGaps;
/**
* Re-apply the chapter cuts for the bar's current size. The end-cap guard in
* applyChapterGaps is measured in pixels, so a player that changed width may
* now want a boundary it was hiding — or want to hide one it was showing.
*/
private refreshChapterGaps;
private renderChapterMarkers;
/**
* Raise the chapter under the pointer and paint its slice of the track.
*
* Pass a time to choose the section; pass nothing to lower whatever is up.
* The bar underneath keeps its own (thin) fill and buffer — this repaints
* only the raised section, because a taller strip drawn over the thin one
* would otherwise show a chapter-shaped hole in the progress.
*/
private paintChapterHover;
/**
* Convert a 0..1 seek-bar fraction to a media time. For live streams the bar
* spans the seekable DVR window [seekStart .. liveEdge], so the fraction maps
* into that window; for VOD it's the usual 0..duration.
*/
private barFractionToTime;
/**
* The inverse of barFractionToTime: where on the bar a media time sits, as a
* percentage. Live streams scale against the seekable DVR window, so a bare
* time/duration would put every live position in the wrong place.
*/
private barTimeToPercent;
/**
* Paint the stretch between the playhead and the pointer — what clicking
* here would skip forward over, or go back and replay. The bar already says
* where playback is and how far it has loaded; this says what the seek you
* are considering would COST, before you commit to it.
*
* Called with the hovered time, and with nothing to clear it. Never during a
* drag: there the fill itself follows the pointer, so the playhead and the
* pointer are the same place and the band would be a sliver chasing the
* handle.
*/
private paintSeekRangeHover;
private updateProgressBar;
private updateVolumeIcon;
/**
* Does the spinner take the centre button off the screen right now?
*
* Whenever it is up, yes. The two on screen together are the same statement
* made twice — and worse than that, a play triangle behind a spinner reads
* as a control that has stopped responding, when the truth is simply that
* data is on its way. One of them has to go and it is not the spinner: the
* spinner is the one saying what is happening.
*
* The cost is that on touch, where the centre button IS the play control,
* the opening load has no big button to press. Play/pause is on the bar
* throughout — the disable pass exempts it for exactly this reason.
*/
private centerHiddenBySpinner;
/**
* Is the bar the only play/pause control on screen right now?
*
* Exactly one moment answers yes: a ring up over the OPENING load. The
* centre button is off whenever a spinner is up (centerHiddenBySpinner says
* why), and before the first play there is no picture for the bar to be in
* the way of — so the auto-hide has nothing to protect and everything to
* take away. Measured on a ~8s open: the bar was never raised at all, so a
* viewer watching a spinner at 00:00 had nothing to press, and on touch,
* where a tap on the picture only toggles the chrome, that meant no way at
* all to stop a start they had changed their mind about. The press itself
* has worked since isStartPending() landed; there was simply no button.
*
* Keyed to the RING, not to `isLoading`: a load that resolves inside
* `spinnerdelay` never tells the viewer to wait, so it must not pop the
* chrome either — an autoplay embed that opens in 300ms still opens bare.
* And `_hasEverPlayed` keeps it to the opening load: a mid-play stall or a
* quality switch has a picture on screen and a bar the viewer already knows
* how to summon.
*
* Not under `autoplay`: there the start was the host's decision, not the
* viewer's, and a bar rising the moment a src is set is chrome nobody asked
* for. The viewer can still summon it — hover, or a tap on touch.
*/
private openingLoadHoldsControls;
/**
* How long the player has to keep saying "loading" before the viewer is
* told. `spinnerdelay`, in seconds; 0 — the default — is no wait at all.
*
* Every reason a spinner goes up already funnels through one decision
* (updateLoadingIndicator) and one switch (setSpinnerVisible), so a single
* timer in front of that switch covers all of them: the opening load, a
* seek, a mid-play stall, a rendition switch, judder, the software retry.
*
* Whether the wait is worth having is not ours to decide. A local file on a
* fast machine interrupts itself for a tenth of a second at a time and every
* one of those flashes a ring the viewer cannot read, which reads as a
* player in trouble; a phone on a thin link stalls for real, and hiding that
* for a second reads as a player that has died. Same code, opposite right
* answers — so it is a number the page sets, and it defaults to the
* behaviour that was there before it existed.
*
* This is a floor under the per-reason graces above it
* (SEEK_SPINNER_GRACE_MS, CATCHUP_SPINNER_GRACE_MS), not a replacement: they
* know which interruptions are ordinarily brief, and this knows how patient
* this particular page wants to be. Both have to pass.
*/
private spinnerDelayMs;
/**
* `spinnerdelay`, parsed. One number, which is the form this attribute has
* always taken, still means what it meant: the whole policy. Two split it —
* how long a mid-play stall has to last before it is reported, then how long
* an OPENING has.
*
* They are different waits, and a single number cannot be right for both. A
* video that has not started yet has its poster up and is doing exactly what
* a video that is starting does; a video that stops mid-picture has frozen,
* and the viewer is looking at a still that was moving a moment ago. The
* first can be left alone for a while, the second wants reporting quickly.
*
* Measured on a page that hit this: an ordinary cold open put the ring on
* screen for 925ms of a 1205ms startup — a whole second of "something is
* wrong" over a video that was merely beginning. Raising the single number
* far enough to cover that would have bought the silence by going quiet on
* real stalls too, so that host gave up on this attribute and re-implemented
* the spinner itself. Two numbers are what it actually needed.
*/
private static parseSpinnerDelay;
/** Pending "show the spinner if this is still true when it fires". */
private _spinnerDelayTimer;
/** The current run of wanting the spinner has already served its wait, so
* the next ask puts it up at once. Cleared by a real hide. */
private _spinnerEarned;
/**
* Ask for the spinner, or take it down — the ONLY way to do either.
*
* Asking is not showing. A show starts the delay and returns; the spinner
* appears only if nothing takes the ask back before the timer fires, which
* is the whole point — an interruption shorter than the delay is one the
* viewer never hears about. A hide is immediate and unconditional: it
* cancels a pending show, and it ends the run, so the NEXT interruption
* serves the wait again from the top rather than flashing on the strength of
* a wait some earlier stall paid for.
*
* Once a run has been shown it stays shown — repeated asks during it (a tick
* re-stating the same load) do not restart the timer, or a spinner would
* only ever appear in the gap between two ticks.
*/
private setSpinnerVisible;
/**
* Put the spinner on the screen or take it off — the raw half, past the
* delay. Call setSpinnerVisible instead unless you are the centre flash
* covering a spinner it has already earned.
*
* The host carries is-buffering for as long as the spinner is displayed, and
* CSS leans on that: it is what keeps the centre play button off the screen
* while the spinner is on it, which no amount of ordering between the two JS
* paths could guarantee on its own. Setting the display directly leaves the
* class behind, and a spinner the CSS cannot see is a spinner with a play
* triangle sitting inside it. Three call sites did exactly that.
*
* Strip mode reads the same class — it has no ring, and pulses its progress
* bar instead — so the delay reaches that surface for free.
*/
private applySpinnerVisible;
/**
* "Loading, ring withheld" — the state a `spinnerdelay` wait puts the player
* in, and the one thing the stylesheet needs to know about it: keep the
* centre play button off. It cannot be said with `is-buffering`, which means
* the ring is UP and which strip mode reads to pulse its progress bar; a wait
* that is holding the ring back has to hold that back too.
*/
private applySpinnerPending;
/** A spinner that wanted to come up while a centre flash was still running. */
private _spinnerDeferredByFlash;
/** Frames per second below which the picture counts as stopped rather than
* slow. A 25fps source manages 25; a 60fps one a machine can only half
* decode manages 30; a frozen one manages none. */
private static readonly MOVING_FPS;
/** Sampling window for the two readings below — long enough that a single
* late frame cannot read as a stall. */
private static readonly MOVING_SAMPLE_MS;
private _movingAt;
private _movingFrames;
private _movingTime;
private _moving;
/**
* Are sound and picture both actually advancing right now?
*
* Two readings, because either alone lies. Frames alone: a decoder can keep
* emitting frames of a stream whose audio has stopped dead. The clock alone:
* it runs from the audio, so it advances happily over a frozen picture.
* Together they are the only claim worth making — playback is happening.
*/
private playbackIsMoving;
private updateLoadingIndicator;
private formatTime;
/**
* fallback="native" — graceful degradation when the WASM/WebCodecs pipeline
* can't READ the source bytes (cross-origin + no-CORS, or a transient network
* failure) but the browser's own can still render them opaquely. Hands
* playback off to the native element instead of showing a hard error.
*
* This is the BROWSER decoding, not Movi: codecs the browser can't handle
* natively (e.g. AC-3/E-AC-3 audio, HEVC on Chrome) stay silent or fail, and
* Movi's canvas features (advanced HDR, 360, ambient, subtitle styling) are
* off. It's "play something instead of a network error", not a full recovery.
* If the native element can't play it either, the real error is surfaced.
*/
private engageNativeFallback;
/**
* The load error, held back because a native handoff is about to be tried.
*
* `error` is how a host is told the source failed, and most treat it as
* final — they tear the player down, or report it. Firing it and THEN
* recovering through `fallback="native"` (which reached playing state a few
* milliseconds later) told them the load had failed when it had not; the
* viewer saw a playing video while the page reported an error (issue #14).
* So it is deferred: released if the native element can't play it either,
* dropped the moment it can.
*/
private _deferredError;
/**
* Is a native handoff still to come for this source? Then the load
* hasn't finished failing yet.
*/
private willHandOffToNative;
/** Tell the host the source failed — unless a fallback may yet rescue it. */
private dispatchMediaError;
/** The message of the error just announced, while its double is still due. */
private _lastErrorAnnounced;
/**
* Is the origin answering at all? A no-cors HEAD gets an opaque response the
* page can't read, but resolving at all proves the server replied — which is
* the whole question. Rejects (or takes too long) → the network or the host
* is the problem, not permissions. Only used to word an error screen, so a
* slow host is treated as unreachable rather than kept waiting on.
*/
private serverAnswers;
private handleUnsupportedVideo;
/**
* Check if required security headers (COOP/COEP) are present
* These headers are required for SharedArrayBuffer support (needed by FFmpeg)
*/
private checkSecurityHeaders;
/**
* Record the wording an error screen is showing and tell the host about it.
*
* Every path that paints the screen goes through here, so `errorTitle` /
* `errorMessage` and the `errordisplay` event stay in step with what is
* actually on screen — including the WebAssembly-unavailable check, which
* never reaches handleUnsupportedVideo.
*/
private _publishErrorScreen;
/**
* Enable software decoding and reload the video.
*
* Public because a host that slots its own error screen has to be able to
* offer the same recovery the built-in "Try Software Decoding" button does
* — worth offering only when `errordisplay` reported canTrySoftware. The
* matching retry is `load()`.
*/
enableSoftwareDecoding(): Promise;
private updateControlsState;
/**
* Climb back to the rung a rescue took us off, once playback has been steady
* for a while.
*
* Only runs with Auto off, because that is the case nothing else covers: a
* crash or a stall drops the quality to get playing again, and with no ABR
* running the viewer simply stays there — a single WASM abort was enough to
* pin a fast connection to 144p for the rest of the video. One attempt; if
* the higher rung fails again the recovery path drops us back and we do not
* fight it.
*/
private _checkRungRestore;
/**
* Whether a context-menu entry still means something before anything is
* playing.
*
* Greying the whole menu out was indiscriminate: playback speed, aspect
* ratio, loop and stable volume are element state that applies the moment
* the video does start — the gear panel keeps every one of them live in
* exactly this state, so the menu saying otherwise was the two surfaces
* disagreeing about the same setting. The rest genuinely needs decoded
* media (a snapshot of nothing, PiP with no picture) and stays dark.
*/
/**
* The controls that stay live before there is anything to play.
*
* Preferences, all of them: things a viewer can decide up front and that the
* player remembers — how fast, how it fits, whether it loops, whether the
* volume is levelled, whether the glow is on, whether black bars get cropped.
* Everything else needs a source to act on and stays dim until there is one.
*
* Null means "the built-in list". A host can replace it with
* setInitialEnabledControls; see there for what that does and does not change.
*/
private static readonly DEFAULT_INITIAL_ENABLED_CONTROLS;
/**
* Context-menu action keys, in the names the rest of the API uses.
*
* The menu's own keys are markup detail — "fit", "loop-toggle" — while
* `controlslist`, isControlAvailable and isControlDisabled all speak of
* "aspect", "loop", "stableaudio". A host should only ever meet the second
* set, so the translation happens here rather than leaking a third
* vocabulary into the public surface.
*/
private static readonly ACTION_CONTROL_NAME;
private _initialEnabledControls;
/**
* Context-menu rows (and submenus, by their data-submenu key) that answer to
* a `controlslist` token — read by the menu's click handler, which refuses a
* switched-off one before any branch runs.
*
* Not ACTION_CONTROL_NAME: that one decides what is live before playback,
* and widening it would change that answer too. Two things are left out on
* purpose. `play-pause`, because `noplay` is the bar's button only — Space
* and a click on the picture still pause, and a host cannot take pausing
* away from a viewer. And the rows that open or close a place — PiP,
* fullscreen, stats, timeline, the shortcuts sheet — whose own toggles
* refuse to OPEN when switched off but still let the viewer out; gating
* them here as well would trap someone already inside.
*/
private static readonly MENU_ACTION_CONTROL;
/** The action keys that are ENABLED before playback — see
* setInitialEnabledControls for what that governs. */
getInitialEnabledControls(): string[];
/**
* Replace that list, or pass null to go back to the built-in one.
*
* This governs whether a control is OFFERED before a source exists — whether
* it reads as live in the settings panel and the context menu. It does not
* make an action possible: each one still checks for itself at the moment it
* is used, so listing "fullscreen" here gets you a lit row that still has
* nothing to go fullscreen with.
*/
setInitialEnabledControls(keys: string[] | null): void;
private worksBeforePlayback;
/**
* Clip the picture to the rounding the page asked for.
*
* border-radius alone is not enough on a canvas that the browser has already
* composited: Firefox keeps painting square corners inside the rounded frame
* until something rebuilds the layer — which is why the corners were square
* on load and came good the moment a quality switch reconfigured the canvas.
* A clip-path is applied by the compositor itself, so it holds from the first
* frame.
*
* The radius comes from the host's own computed style — whatever the page
* set, in whatever units — so this follows a themed or responsive value
* without the page having to tell us about it.
*/
/** The canvas renderer, when there is one — it owns the shader-side cut. */
private pictureRenderer;
/**
* Put the corner rounding back a few times over the next couple of seconds.
*
* Needed wherever the PICTURE is rebuilt, not just at connect. Two things are
* lost when a new player is built over the same element: the shader's own
* corner radius, which lives on the renderer that was just replaced, and —
* on Firefox — the CSS clip, which it only reads while building the canvas's
* compositing layer and which is therefore dropped when that layer is rebuilt
* and never re-read on its own.
*
* That is why a host that swaps sources on one element (a sidebar of videos,
* say) lost its rounded corners on Firefox from the second video on: the
* retries existed, but only ever ran at connect, and the element never
* disconnects.
*/
private scheduleRoundingReapply;
private syncPictureRounding;
private updateAmbientWrapperElement;
private updateAmbientMode;
private startAmbientColorSampling;
private stopAmbientColorSampling;
private sampleCanvasColors;
private updateAmbientBackground;
/** The URL actually being read, which on a quality ladder is the rung in
* play rather than the `src` that was declared. */
get currentSrc(): string;
/** Set alongside every `error` event, so a listener that arrives late can
* still ask what happened. Cleared by a fresh load. */
private _mediaError;
get error(): {
code: number;
message: string;
} | null;
/** Records the failure in MediaError's vocabulary. A source the engines all
* refused is SRC_NOT_SUPPORTED; anything raised mid-playback is a decode
* failure unless it came off the network. */
private noteMediaError;
/** NETWORK_EMPTY 0 · NETWORK_IDLE 1 · NETWORK_LOADING 2 · NETWORK_NO_SOURCE 3 */
get networkState(): number;
get seeking(): boolean;
/** One contiguous range, which is what this pipeline actually has: it reads
* forward from a single window rather than keeping scattered byte islands. */
get buffered(): TimeRanges;
/** Everything, unless the source refused range requests — then nothing, and
* the player is in linear mode with its timeline hidden. */
get seekable(): TimeRanges;
/** Accumulated by the UI tick — see notePlayed. */
private _playedRanges;
get played(): TimeRanges;
/** Extend the last played range, or open a new one after a seek. Called from
* the UI tick, so its resolution is that tick's — a quarter of a second. */
private notePlayed;
get videoWidth(): number;
get videoHeight(): number;
private _defaultMuted;
get defaultMuted(): boolean;
set defaultMuted(v: boolean);
private _defaultPlaybackRate;
get defaultPlaybackRate(): number;
set defaultPlaybackRate(v: number);
/**
* Always true, and settable only to true.
*
* The rate change runs through a time stretcher, so pitch is preserved by
* construction — there is no un-stretched path to fall back to. Saying so
* plainly beats accepting `false` and ignoring it.
*/
get preservesPitch(): boolean;
set preservesPitch(v: boolean);
private _srcObject;
/**
* A live stream has no container to demux and no byte range to seek, so the
* WASM pipeline has nothing to do with it. It goes to the native
* this element already keeps for its fallback path, and the chrome drives
* that instead — the same arrangement `fallback="native"` uses.
*/
get srcObject(): MediaStream | null;
set srcObject(stream: MediaStream | null);
/**
* A real TextTrackList, borrowed from the in the shadow tree.
*
* Subtitles are decoded and painted by the player, not by that element, but
* the browser's own TextTrack is the right data structure to hand back: it
* carries `mode`, `cues`, `activeCues` and fires `cuechange` on its own. One
* track is mirrored per declared subtitle track and `mode` writes are routed
* back into the player's own selection.
*/
get textTracks(): TextTrackList | null;
/**
* Native fires `cuechange` on the TextTrack whose active cues changed, so
* that is where this fires too — on the mirrored track for the language
* currently showing. The cue itself is carried along, so `activeCues` holds
* what is on screen rather than staying empty.
*/
private announceCueChange;
private _mirroredTextTracks;
private syncNativeTextTracks;
/** The audio tracks, in AudioTrackList's shape. `enabled` reads the player's
* current selection; writing it switches to that track. */
get audioTracks(): {
id: string;
kind: string;
label: string;
language: string;
/** The container's id for a muxed track; null for a declared one. */
trackId: number | null;
enabled: boolean;
}[] & {
getTrackById(id: string): {
id: string;
kind: string;
label: string;
language: string;
/** The container's id for a muxed track; null for a declared one. */
trackId: number | null;
enabled: boolean;
} | null;
};
/** The quality ladder, in VideoTrackList's shape. */
get videoTracks(): {
id: string;
kind: string;
label: string;
language: string;
selected: boolean;
width: number;
height: number;
}[] & {
getTrackById(id: string): {
id: string;
kind: string;
label: string;
language: string;
selected: boolean;
width: number;
height: number;
} | null;
};
/** Asked of the browser, which is the same authority consults. The
* WASM engine plays more than this reports, so a "" here is not a refusal
* from the player — only from the native decoders. */
canPlayType(type: string): CanPlayTypeResult;
/** Creates a real TextTrack on the element's own , exactly as native
* does, so cues added to it behave and fire `cuechange`. */
addTextTrack(kind: TextTrackKind, label?: string, language?: string): TextTrack | null;
/** The picture is drawn to a canvas, and a canvas can be captured. Audio is
* mixed in WebAudio and is not part of this stream. */
captureStream(frameRate?: number): MediaStream | null;
/** Frame counts from the canvas renderer, which is what presents here. */
getVideoPlaybackQuality(): {
creationTime: number;
droppedVideoFrames: number;
totalVideoFrames: number;
corruptedVideoFrames: number;
};
private _vfcHandlers;
private _vfcNextId;
/** Called once per presented frame, with the metadata object native supplies.
* Registered with the renderer's presentation loop rather than rAF, so it
* reports frames rather than repaints. */
requestVideoFrameCallback(cb: (now: number, metadata: unknown) => void): number;
cancelVideoFrameCallback(id: number): void;
/** Point the renderer's per-frame hook at this element, so
* requestVideoFrameCallback reports FRAMES rather than repaints. */
private attachFrameCallbackBridge;
/** Fired by the presentation loop for every frame that reaches the screen. */
private notifyVideoFrame;
/** Fullscreen on the element itself, which is what the player already drives
* from its own button. */
requestFullscreen(options?: FullscreenOptions): Promise;
requestPictureInPicture(): Promise;
/** The same routing `setAudioOutput` does, under the name native uses. */
setSinkId(sinkId: string): Promise;
/** Native's "approximately, and quickly". The player has fast-seek modes of
* its own; this asks for one for this seek. */
fastSeek(time: number): void;
/**
* Bring what the stylesheet cannot reach in step with a `controlslist` that
* changed after the player was built. The stylesheet hides buttons and rows
* on its own; these are the surfaces that were built from the old list, or
* that live outside the shadow tree.
*/
private applyControlsList;
/**
* Refuse Picture-in-Picture on the internal too, not only on the
* player's own paths. The browser offers PiP on any media element by itself
* — the global media controls, the video's own menu — and none of that goes
* through togglePiP. Mirrored the way disableRemotePlayback already is.
*/
private syncVideoPipBlock;
private _disablePip;
get disablePictureInPicture(): boolean;
set disablePictureInPicture(v: boolean);
private _disableRemote;
get disableRemotePlayback(): boolean;
set disableRemotePlayback(v: boolean);
get src(): string | File | null;
/**
* Separate audio source URL (for split video+audio files)
*/
get audioSrc(): string | null;
set audioSrc(value: string | null);
/**
* Get available audio language tracks
*/
getAudioLangs(): {
lang: string;
label: string;
active: boolean;
}[];
/**
* Switch audio to a different language
*/
selectAudioLang(lang: string): boolean;
/**
* Get available external subtitle tracks
*/
getSubtitleLangs(): {
lang: string;
label: string;
active: boolean;
pending?: boolean;
}[];
/**
* Select an external subtitle track by language (null to disable)
*/
selectSubtitleLang(lang: string | null): Promise;
set src(value: string | File | null);
/**
* Set a File object as the source (convenience method)
*/
setFile(file: File | null): void;
/**
* Custom SourceAdapter — feeds bytes from any protocol (WebSocket, WebRTC
* data channel, IndexedDB, custom encryption, etc.) without writing a
* SourceConfig branch. Set to `null` to clear and go back to `src`.
*
* Usage:
* ```ts
* const element = document.querySelector('movi-player');
* element.sourceAdapter = new MyWebSocketSource(url);
* ```
*
* Clearing src + setting adapter is mutually exclusive — the adapter wins.
*/
get sourceAdapter(): SourceAdapter | null;
set sourceAdapter(adapter: SourceAdapter | null);
/**
* Convenience method mirroring `setFile()` — same effect as assigning to
* the `sourceAdapter` property.
*/
setSourceAdapter(adapter: SourceAdapter | null): void;
/**
* Custom HTTP headers sent with every media network request — the adaptive
* manifest (.mpd/.m3u8) and all of its segments, plus progressive downloads.
* Use for auth tokens, signed headers, etc. The object form is the
* programmatic counterpart to the JSON `headers` attribute; objects can't go
* through attributes, so set non-trivial header maps via this property.
*
* player.headers = { Authorization: "Bearer " };
*/
get headers(): Record | null;
set headers(value: Record | null);
/**
* Audio-only (data-saver) mode. When true the player skips video decoding to
* save CPU and, for adaptive streams (HLS/DASH), fetches only audio
* renditions to save bandwidth; the UI shows the album-art / strip surface.
* Toggle via this property or the `audioonly` attribute.
*
* player.audioOnly = true; // data saver on
*/
/**
* Take the sound down a slope over `ms`, and leave it down.
*
* For a player that is about to go away — a desktop window closing, a page
* unloading. Sound that stops dead on whatever sample it was on is what
* makes closing an app feel like pulling a plug; a fifth of a second of
* slope is all it takes to feel like a door closing instead. There is no
* way back from it on purpose: whoever calls this is not expecting to play
* again.
*/
fadeOutAudio(ms?: number): void;
get audioOnly(): boolean;
set audioOnly(value: boolean);
/**
* Apply an audio-only toggle to the live player. Adaptive streams change what
* gets downloaded (Shaka ignores video at manifest-parse time), so they
* reload; the demuxer path flips video decode on/off in place.
*/
private applyAudioOnly;
/**
* Video.js-style source API
*
* Usage:
* // Single source as string
* player.source('video.mp4');
*
* // Single source as object
* player.source({ src: 'video.mp4', type: 'video/mp4' });
*
* // Multiple sources (first playable source wins)
* player.source([
* { src: 'video.mp4', type: 'video/mp4' },
* { src: 'video.webm', type: 'video/webm' },
* ]);
*
* // Separate video + audio (DASH-style split)
* player.source({
* video: { src: 'video-only.mp4', type: 'video/mp4' },
* audio: { src: 'audio.m4a', type: 'audio/mp4' },
* });
*
* // Multi-language audio
* player.source({
* video: { src: 'video.mp4', type: 'video/mp4' },
* audio: [
* { src: 'en.m4a', type: 'audio/mp4', lang: 'en', label: 'English' },
* { src: 'hi.m4a', type: 'audio/mp4', lang: 'hi', label: 'Hindi' },
* ],
* });
*
* // Get current source
* const current = player.source();
*/
source(value?: string | {
src: string;
type?: string;
} | {
src: string;
type?: string;
}[] | {
video: {
src: string;
type?: string;
};
audio?: {
src: string;
type?: string;
} | {
src: string;
type?: string;
lang: string;
label: string;
}[];
subtitles?: {
src: string;
lang: string;
label: string;
format?: string;
}[];
}): {
src: string | File | null;
type: string;
audioSrc?: string | null;
} | void;
/**
* Pick the first source whose MIME type the browser can play.
* Falls back to null if none are supported.
*/
private pickSource;
/**
* Guess MIME type from a URL string extension.
*/
private guessMediaType;
/**
* Load an encrypted video source (programmatic API)
*
* Usage:
* const player = document.querySelector('movi-player');
* player.loadEncrypted({
* videoUrl: '/api/video',
* tokenUrl: '/api/token',
* videoId: 'my-video',
* fingerprint: await generateFingerprint(),
* sessionToken: 'jwt-token',
* });
*/
loadEncrypted(config: {
videoUrl: string;
tokenUrl: string;
videoId: string;
fingerprint: string;
sessionToken: string;
tokenRefreshInterval?: number;
onAuthFailed?: (reason: string) => void;
}): Promise;
get autoplay(): boolean;
set autoplay(value: boolean);
get controls(): boolean;
set controls(value: boolean);
get loop(): boolean;
/**
* Play the queue in a random order. Off by default.
*
* Its own attribute rather than a `loop` token, because it is its own
* question: a queue can be shuffled and played through once, and it can be
* looped in the order it was given. They compose — `shuffle loop="all"` is
* the pair most people mean — but neither implies the other.
*/
get shuffle(): boolean;
set shuffle(value: boolean);
/**
* Draw a fresh order. No-op unless shuffle is on; the item playing now keeps
* its place, so this changes what comes next and nothing that is on screen.
*/
reshuffle(): void;
/** How many times the current source has looped, from 0. Resets with the
* source. The `loop` event carries the same number as it happens. */
get loopCount(): number;
set loop(value: boolean);
/**
* What `loop` is set to: "off", "one" (this item, over and over) or "all"
* (the queue — the next item plays, and the last leads back to the first).
*
* `loop` the boolean property is the "one" half of this, kept because that
* is what it has always meant.
*/
get loopMode(): "off" | "one" | "all";
set loopMode(mode: "off" | "one" | "all");
/**
* The root the context menu currently lives in. On desktop the menu portals
* to a body-level shadow root while open, so its items are NOT in this.shadowRoot
* at that moment — a toggle-state updater querying the shadow root would find
* nothing and silently no-op (the reason menu toggles didn't reflect their new
* state after a click). Query menu items through this so they're found whether
* the menu is portaled or still home in the shadow root.
*/
private contextMenuRoot;
/**
* The context menu's Shuffle item: on/off state, and whether it is there at
* all. Driven from refreshPlaylistUi, which every change to the queue and to
* shuffle itself already passes through — so there is no path that can move
* one without the other.
*
* Visibility is set here rather than by a :host() rule because the menu
* portals to a body-level shadow root while it is open, where this
* element's host selectors do not reach.
*/
private updateShuffleUI;
private updateLoopUI;
/** The context-menu row's On/Off, kept in step however the setting changed —
* the row, the settings panel, C, or the host writing the attribute. */
private updateCropUI;
private updateAmbientUI;
/** Setting name → the attribute that carries it. */
private static readonly PERSISTABLE;
/**
* The two settings that are remembered by LANGUAGE rather than by attribute.
*
* Everything in PERSISTABLE is a value the element already carries on an
* attribute, so remembering it is a matter of writing that attribute back.
* Track choices are not: a track number means nothing across files — track 2
* is Hindi in one and a commentary in the next — and there is no attribute
* for "the audio the viewer likes". What survives is the LANGUAGE, which is
* the actual preference; the track that carries it is found again per file.
*
* "off" is a real value for subtitles, and the only one that has to be
* stored as a word: an absent preference means "not chosen yet", and turning
* subtitles off is a choice.
*/
private static readonly PERSISTABLE_LANGS;
private _persistNames;
/** Namespaced, so two players on a page — or two apps on a domain — do not
* quietly share one viewer's preferences. */
private _prefKey;
private _prefRead;
private _prefWrite;
private _applyingPersisted;
/** Cleared on every source change: the remembered languages are re-applied
* once per file, when that file's tracks first arrive. */
private _persistedTracksApplied;
/**
* Remember a track choice, if the host asked for this one to be remembered.
*
* Called from the pick* wrappers rather than from the menus, because there
* are four surfaces that change tracks — the context menu, the settings
* panel's borrowed lists, the bar menus and the keyboard — and they all end
* up at the same four player calls.
*/
private noteTrackChoice;
/** A language tag worth storing, or null. "und" is Matroska's way of saying
* the field was never filled in, and matching on it in the next file picks
* whichever track was equally anonymous there. */
private static usableLang;
/**
* Two language tags for the same language, as far as a viewer is concerned.
*
* Files disagree about how to spell one: "en", "eng", "en-US". Exact first,
* so "es" never loses to a file that also has "est"; only when nothing
* matches exactly does the two-letter stem get to try, which is what makes
* "eng" find "en".
*/
private static langMatches;
/**
* Put the remembered audio / subtitle languages back, once this file's
* tracks are known.
*
* A remembered language the file does not have is left alone rather than
* forced: the file's own default is a better answer than nothing, and the
* preference survives for the next file that does carry it.
*/
private applyPersistedTracks;
private _audioRestored;
private _subsRestored;
/** What the always-on store had for the two languages, and whether it has
* answered yet. See applyPersistedTracks. */
private _legacySettingsLoaded;
private _legacyAudioLang;
private _legacySubtitleLang;
private pickAudioLang;
private pickAudioTrack;
private pickSubtitleLang;
private pickSubtitleTrack;
/** Called from attributeChangedCallback for every attribute that backs a
* setting the host opted into. */
private notePersistedAttribute;
/**
* Put a remembered fit back the way the viewer put it in.
*
* Under objectfit="control" the fit lives in _currentFit, not on the
* attribute — that is the whole of what "control" means, the viewer owning
* it rather than the page. Writing the attribute there would answer the
* question by removing it: the element would leave control mode and the
* host's own setting would be gone.
*/
private restoreFit;
/**
* Put the remembered settings back, before the first load.
*
* The stored value WINS over the markup for any setting the host opted into
* — that is what opting in means. A host that wants a value fixed leaves it
* out of `persist`.
*/
private applyPersistedSettings;
/**
* Is the element's own always-on settings store still in charge?
*
* SettingsStorage has been quietly remembering volume, muted, speed, stable
* volume, ambient and HDR in OPFS since long before `persist` existed —
* unconditionally, un-namespaced, with no way to turn it off. Two stores for
* one setting is worse than either: whichever applied last would win, and
* which one that is depends on how fast OPFS answers.
*
* So `persist` takes the whole decision. Present, and it is the only answer:
* the old store neither saves nor restores, and the host's list is exactly
* what is remembered. Absent, nothing changes for anyone — which is what
* every existing embed already depends on.
*/
private legacySettingsEnabled;
/**
* What the viewer's store says, per attribute, for the settings it owns.
*
* Filled when a stored value is restored, and updated whenever the viewer
* changes one. It exists so the element can tell the two kinds of attribute
* write apart: an integrator declaring a default, and a decision.
*/
private _storedChoice;
/** Remember what the store's answer looks like as an attribute. `null` means
* the attribute is absent, which for a boolean setting is the answer "off". */
private noteStoredChoice;
/**
* Put a stored answer back over a host that has just re-declared its default.
*
* "A remembered value always wins over the HTML default" is the rule the
* always-on store already documents, and in plain HTML it holds: the markup
* sets the attribute once, the store answers later, and that is the end of
* it. Under a framework it did not. React re-applies its props on every
* render, so a `stablevolume` in JSX was re-asserted seconds after the
* restore — and every render after that — which is how movi-tube ended up
* unable to remember stable audio being turned off while the player's own dev
* page, with the very same attribute in static HTML, remembered it fine.
*
* So the rule is enforced rather than assumed. Once a setting has been
* restored, an attribute write that disagrees with the stored answer is the
* host talking over the viewer, and is put back. The viewer's own changes go
* through the setters below, which update the record first, so those agree
* and pass straight through.
*/
private hostOverridingStoredChoice;
/** Every setting this element can remember — for a host building its own
* preferences UI, so the list lives in one place. */
static get persistableSettings(): string[];
/** Single keys the player's own shortcuts already claim. A custom hotkey is
* checked after them, so one of these can only ever be dead — said out loud
* at registration rather than left to be discovered. */
private static readonly RESERVED_HOTKEYS;
/** Built-in controls a custom one can be positioned against. */
private static readonly CONTROL_ANCHORS;
/**
* The capsules a host can join, by name.
*
* Two shapes behind one map: "seek", "volume" and "settings" are already
* containers, so a control joins by being appended to them. "play" and
* "time" are a button and a block of text that WEAR a capsule rather than
* being one, so joining those wraps them — see controlGroupTarget.
*/
private static readonly CONTROL_GROUPS;
/** The context menu's item-click delegate, kept so panels created after the
* menu was wired can be given it too. See setupContextMenu. */
private _menuItemClickHandler;
/** The context menu's own close, so callers outside setupContextMenu can
* close it PROPERLY — the desktop close is a portal dance (freeze the host,
* hide, move menu + panels home), and skipping it leaves the menu parked in
* the portal with the element's own state saying it is still open. */
private _hideContextMenu;
private _customControls;
/**
* Add a control of the host's own to the bar, the context menu, or both.
*
* Adding the same id twice replaces the first — so a host that re-runs its
* setup (a framework re-render, a source change) does not end up with two.
*/
addControl(spec: MoviControlSpec | MoviDividerSpec): void;
/** A divider is a control with nothing to press: registered in the same map
* so removeControl, controlslist and re-registration all treat it alike. */
private addDivider;
private addButtonControl;
/**
* Change a control that is already there. Only the fields present in the
* patch are touched, so `updateControl(id, { active: true })` is the way to
* reflect state the host owns without rebuilding anything.
*/
updateControl(id: string, patch: Partial): void;
/** Each of a control's nodes, and the sibling it currently follows. */
private customControlHomes;
/** Put freshly-rendered nodes back where the old ones were. */
private restoreCustomControlHomes;
/**
* Put the host's hotkeys in the Keyboard Shortcuts panel, beside the
* player's own.
*
* The panel is a static list, and it was the player's list — a host could
* register a hotkey and the one screen a viewer opens to find out what the
* keys ARE would not mention it. Rebuilt on open rather than on add, so a
* control that comes and goes with the source never leaves a row behind for
* a key that no longer does anything.
*/
/** Open or shut the Keyboard Shortcuts sheet — the ? key and the menu row.
* `noshortcuts` stops it opening; one already up still closes. */
private toggleShortcutsPanel;
private syncShortcutsPanel;
/** Take a custom control back down. Unknown ids are ignored. */
removeControl(id: string): void;
private _overlays;
private _overlayLayer;
/** The layer overlays live in, made on first use. */
private overlayLayer;
/**
* Put the host's own panel over the picture — an end screen, an up-next
* countdown, a paywall, a "you were watching" resume prompt.
*
* It has to live in HERE, not in the host's page, and fullscreen is the whole
* reason: the fullscreen element IS this player, so anything the host renders
* beside it stops existing the moment a viewer goes fullscreen. Everything
* else follows from that — the layer sits above the picture and BELOW the
* control bar, the way a video's end screen does, so the controls stay
* reachable while it is up.
*
* The look belongs to the host, exactly like addControl: hand over markup (a
* shipped