/**
* 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 type { RendererType, SubtitleRenderer } from "../types";
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 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[];
};
};
/** 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 & {});
/** Where it appears. Default "bar". */
placement?: "bar" | "menu" | "both";
/** 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";
/** 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;
}
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;
private player;
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;
private _posterCoverBitmap;
private _posterCoverUrl;
private _posterCoverLoading;
private _coverArtBgEl;
private _coverArtBgUrl;
private _coverArtResolved;
private _lastStripDispatched;
private controlsTimeout;
private isOverControls;
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;
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 _stutterSeconds;
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;
/** Set when pause() lands before playback has started, so the pending
* autoplay/queued play doesn't fire once loading finishes. */
private _startCancelled;
/**
* 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;
private _pendingPlay;
private _preloadGateActive;
private _resumeDialogPending;
private _posterTime;
private _generatedPosterUrl;
private _posterGenId;
private _controls;
private _loop;
private _muted;
private _playsinline;
private _preload;
private _poster;
private _volume;
private _playbackRate;
private _subtitleDelay;
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;
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 _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;
/** 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;
private _resume;
/** 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;
/** 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;
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, audio-only, or where the Screen Orientation lock API is missing
* (e.g. iOS Safari, which rejects programmatic lock — caught and ignored).
*/
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;
private static readonly ASPECT_ICONS;
private updateAspectRatioIcon;
private static readonly TRACK_ICON_AUDIO;
private static readonly TRACK_ICON_SUBTITLE;
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.
*/
/** 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 _preserveDecodeCapOnce;
private _recoveryResumeTime;
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;
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;
/**
* 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;
/**
* 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;
private _judderSeconds;
private _juddering;
private resetStutterHint;
/** One stutter sample: compare presented FPS to the smooth-playback baseline. */
private sampleStutter;
/**
* Show On-Screen Display (OSD) notification
*/
private osdTimeout;
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;
/**
* 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.
*/
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;
/**
* 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.
*/
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 just below the anchor
* button using viewport-relative position:fixed. The strip CSS drops
* the host's `contain: paint` + `container-type` so fixed coords are
* actually viewport-relative again (default behaviour) and paint
* extends outside the 56px strip box.
*/
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;
/** 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;
/**
* 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. There is
* nothing to pop in, so it is all exit — a short hold, then the same fade.
* Shorter than the full flash because the hold is only there to let the
* glyph register, not to bring an icon into view. */
private static readonly CENTER_FLASH_EXIT_MS;
private static readonly CENTER_FLASH_EXIT_FADE_AT;
private _centerFlashAnim;
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;
private refreshOpenSettingsSurfaces;
private doRefreshOpenSettingsSurfaces;
/** Copy the theme variables the PiP stylesheet reads into the PiP document. */
private syncPipTheme;
private applyThemeColor;
/** 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;
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;
/** Downscale an album-art bitmap to a tiny JPEG data URL for the blurred
* backdrop. The backdrop is blurred to 40px, so ~96px is indistinguishable
* from full-res while keeping the URL small and the draw cheap. */
private makeCoverArtBgUrl;
private updateCoverArtOverlay;
/**
* 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;
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;
/**
* Pause the video
*/
pause(): void;
/**
* 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;
/**
* 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;
/** 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;
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;
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;
/**
* Put the spinner up or take it down — the ONLY way to do either.
*
* 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.
*/
private setSpinnerVisible;
/** 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;
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;
/** 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;
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;
}[];
/**
* 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
*/
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;
set loop(value: boolean);
/**
* 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;
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;
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): void;
/**
* 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.
*/
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