/** * 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 * `