/** * Custom HTML element for Movi Player * Usage: * * Note: Custom element names must contain a hyphen per HTML spec. * * Supports native video element properties: * - src, autoplay, controls, loop, muted, playsinline, preload, poster * - width, height, crossorigin * - volume, playbackRate, currentTime, duration, paused, ended */ import { MoviPlayer } from "../core/MoviPlayer"; import type { RendererType, SubtitleRenderer, PlaybackAssessment, PlaybackAssessmentOptions, PlaybackQuery, SubtitleCue, DecodeAudioOptions, DecodedAudioChunk } from "../types"; import type { StoryboardSpec } from "../utils/Storyboard"; import type { SourceAdapter } from "../source/SourceAdapter"; import { type QoESink, type QoESession } from "../utils/QoE"; /** * A control the HOST adds — to the bottom bar, to the context menu, or both. * * Described rather than constructed: the player builds the button and the menu * row from this, keeps them in step, and removes them together. See * MoviElement.addControl. * * ```ts * player.addControl({ * id: "autoplay", * label: "Autoplay", * icon: autoplaySvg, // markup or a node; omit for a plain word * side: "right", * before: "cc", // sits just left of the subtitles button * toggle: true, * active: true, * placement: "both", // bar AND context menu, one shared state * onSelect: (on) => setAutoplay(on), * }); * ``` */ /** * A panel the HOST puts over the picture — see MoviElement.showOverlay. * * `content` is trusted exactly as far as addControl's `icon` is: it comes from * the page embedding this player, not from the media or the network, and it is * inserted as markup. A host passing anything it did not write should sanitise * it first, or hand over an Element it built itself. */ export interface MoviOverlaySpec { /** Unique, and the handle for updateOverlay / hideOverlay. */ id: string; /** Markup, or an element to mount. Keep the element if you mean to mutate it. */ content: string | Element; /** "fill" covers the picture (an end screen), "center" floats in the middle, * "bottom-end" tucks above the controls on the right (an up-next card). * Default "fill". */ placement?: "fill" | "center" | "bottom-end"; /** False lets clicks through to the video. Default true — an overlay with * links in it needs them. */ interactive?: boolean; /** Take it away by itself when playback resumes, the viewer seeks, or Escape * is pressed. Nothing by default: an end screen should sit there. */ dismissOn?: Array<"play" | "seek" | "escape">; /** Called whenever it goes, with what took it: one of the dismissOn reasons, * or "host" for hideOverlay. */ onDismiss?: (reason: string, player: MoviElement) => void; } /** * One entry in a queue of things to play. * * `src` is the only part that must be there. The rest is what the PLAYER would * otherwise have to guess between items — the heading over the picture, the * still shown before the first frame, where in the video to come in — and each * is owned by the item rather than the element, so it goes away again when the * queue moves on to something that doesn't carry one. * * A bare string is accepted anywhere an item is, and means `{ src }`. */ export interface MoviPlaylistItem { /** * What to play. Optional, because a page whose sources are `` / * `` children — a quality ladder, per-language audio, subtitle tracks * — cannot say any of that in one URL, and neither can a page where each * item is its own route. Those queues leave `src` off, cancel `itemchange`, * and load the item themselves; see MoviElement.playlist. */ src?: string | File; /** The host's own name for this item, handed back on `itemchange`. The * element never reads it — it is there so a queue of src-less items has * something to be identified BY. */ id?: string; /** Heading over the picture, and the name the OS media session announces. */ title?: string; /** Still shown before this item's first frame. */ poster?: string; /** Where to come in, in seconds. */ startAt?: number; /** Anything else the host put on the item is carried through untouched. */ [key: string]: unknown; } /** One row of a custom control's submenu. */ export interface MoviControlItem { /** Handed back to onPick. */ id: string; /** The row's text. */ label: string; /** Right-hand text — a value, a hint, a duration. */ hint?: string; /** Children, for a nested list. A row with children OPENS rather than picks, * so it never reaches onPick; only leaves do. Any depth. */ items?: MoviControlItem[]; } export interface MoviControlSpec { /** Unique, and the handle for updateControl / removeControl. */ id: string; /** Accessible name, tooltip, and the text shown in the context menu. */ label: string; /** Inline SVG markup or an element to clone. Without one the label is drawn * as text, which is a perfectly good control. */ icon?: string | Element; /** Tooltip override. Pass null for no tooltip at all. */ title?: string | null; /** Which end of the bar. Default "right". */ side?: "left" | "right"; /** Position against a built-in: "play", "back10", "forward10", "volume", * "time", "audio", "cc", "quality", "speed", "stableaudio", "hdr", "loop", * "settings", "aspect", "pip", "fullscreen", "more". In the context menu, * the built-in's action name instead. Unknown or absent → the end. * * A LIST is tried in order, first match wins. That is not a nicety: half * the bar is conditional — the audio-track button exists only for a video * with dubs, HDR only for an HDR source, captions only when there are any — * so "the leftmost control in the right-hand capsule" cannot be named by a * single anchor. ["audio", "hdr", "cc"] says it once and keeps saying it * whichever of them the video turns out to have. */ before?: string | string[]; after?: string | string[]; /** Per-SURFACE placement, for when one pair cannot serve both. * * The bar and the context menu are different lists with different * neighbours: a control can belong at the left edge of the bar's settings * capsule and directly under Loop in the menu, and before/after above can * only describe one of those. Anything given here wins for that surface; * anything left out falls back to before/after, so a host that only cares * about one surface never has to mention the other. * * (The gear panel is not a surface a host control can be placed in — it * renders the player's own settings only. `placement` is bar, menu, or * both, and this mirrors it.) */ anchors?: { bar?: { before?: string | string[]; after?: string | string[]; }; menu?: { before?: string | string[]; after?: string | string[]; }; top?: { before?: string | string[]; after?: string | string[]; }; center?: { before?: string | string[]; after?: string | string[]; }; }; /** WHICH capsule it sits in — the bar draws one behind each group of * controls, and this says which group this one belongs to. * * A built-in group's name joins that capsule: "play", "seek", "volume", * "time", "settings". Any other string MAKES a capsule of that name, so two * host controls naming the same string share one. "none" means what it says * — no capsule, and none of the neighbours' either: the control stands on * the bar on its own, which is also how it gets OUT of the right-hand * capsule that every control on that side otherwise shares. * * Left off, a control on the left gets its own capsule and one on the right * joins the settings capsule it is already inside — the arrangement each * side already had. * * Separate from before/after on purpose: those say where it goes, this says * what it belongs WITH. A control can sit beside the subtitles button and * still be in nobody's capsule. */ group?: "play" | "seek" | "volume" | "time" | "settings" | "none" | (string & {}); /** Which surface (or surfaces) it appears on. Default "bar". * * "bar" the control bar along the bottom * "center" the MIDDLE of that bar — the free width between the left run * and the settings run, which the player itself never puts * anything in. Its own surface and not a side of "bar", because * it is not one: `side`, `group` and every built-in anchor * describe the two runs, and the middle is neither of them. A * control here is ordered only against the host's OWN other * centre controls, by id. * * The middle is also the first thing to go when the bar runs out * of room — the clusters grow towards each other and a control * drawn across the clock is worse than one that stepped aside — * so it suits a control that is an offer rather than a duty. * Paired with `screen: "fullscreen"` it is the "Suggestions" / * "Up next" affordance a full-screen player has the width for * and a 320px embed does not. * "top" the top-right corner, beside the three-dots button that opens * the context menu on touch — the corner a player puts the things * that are ABOUT the session rather than about playback: cast, * share, close. Shown on every device, not only touch: the * three-dots is a touch affordance (a mouse has right-click), the * corner is not. * "menu" a row in the context menu * "both" bar and menu, which is what it has always meant * * A LIST puts one control on several surfaces — ["top", "menu"] is the * arrangement a cast button wants, reachable in the corner while watching * and still named in the long list. "both" stays exactly what it was, so * nothing written against the old three values changes meaning. * * The corner is a row like the bar is, and it is ordered the same way: with * `before`/`after`, against a built-in, against another of the host's * controls by id, or against `"dots"` — the three-dots itself, which is one * member of that row and not a fixture at its end. A control that names no * anchor goes in front of the three-dots, so the corner still ENDS with it * unless someone asks for otherwise; `after: "dots"` is that asking. The * order holds on touch and on desktop alike — the three-dots is hidden where * there is a right-click to open the menu with, and the row simply closes up * around it. * * `group` and `side` describe the bar and are ignored there. Its tooltip is * the browser's, like the three-dots' own — the player's tooltip belongs to * the bar and points at it. */ placement?: "bar" | "center" | "menu" | "top" | "both" | Array<"bar" | "center" | "menu" | "top">; /** WHICH kind of media it belongs to. Default "both". * * The player collapses to an audio presentation when the media has no * picture — cover art in place of the canvas, or the compact strip — and the * built-ins that mean nothing there (captions, quality, aspect, PiP, * fullscreen) take themselves out of the bar and the menu. A host control is * no different: "Cast to TV" has no business on a podcast, and "Sleep timer" * is the one control an album view wants and a film does not. * * "video" only while a picture is playing * "audio" only in the audio presentation * "both" always (the default) * * Enforced in CSS against the host's audio class, so it follows a source * swap from video to audio with no work from the host. */ media?: "video" | "audio" | "both"; /** WHERE the player has to be for this control to exist. Default "both". * * "fullscreen" only while the player is fullscreen * "windowed" only while it is not * "both" always (the default) * * The same words `titlemode` uses, and for the same reason: fullscreen is a * different amount of room and a different amount of attention. A control * that would crowd a 360px embed has a whole screen to sit in once the * viewer has committed to watching, and a control that only makes sense * beside the page it came from should not follow them out of it. * * Every fullscreen route counts — native, a host-driven one (see * `setHostFullscreen`) and the iOS pseudo-fullscreen fallback — so the * control appears and disappears on its own with no work from the host. * Like `media`, a scoped-out control leaves the bar, the corner, the * context menu AND the shortcuts panel, and its hotkey stops firing: an * invisible control with a live key is worse than no control. */ screen?: "fullscreen" | "windowed" | "both"; /** A toggle carries state: pressed styling, On/Off in the menu, and the * boolean handed to onSelect. Without it, a plain button. */ toggle?: boolean; /** Starting state for a toggle. */ active?: boolean; /** A key that uses this control, e.g. "a", "shift+a", "ctrl+alt+p". Matched * case-insensitively against the physical key plus its modifiers. Checked * AFTER the player's own shortcuts, so a host cannot accidentally take over * space or the arrows; a collision is logged once at registration. Appears * on the control's menu row automatically. */ hotkey?: string; /** Right-hand text on the menu row for a non-toggle. Defaults to the hotkey * when one is set, which is what the built-in rows show. */ shortcutHint?: string; /** Turn the control's context-menu row into a submenu of choices — the shape * Speed and Aspect Ratio already use. The row opens the list instead of * doing anything itself, so `toggle` is ignored and onSelect never fires; * the pick arrives on `onPick` with the chosen item's id. Menu only: a bar * button has nowhere to put a list. */ items?: MoviControlItem[]; /** Called with the id of the submenu item the viewer chose. */ onPick?: (itemId: string, player: MoviElement) => void; /** Which submenu item is currently the chosen one, by id. Update it with * updateControl(id, { value }) when the host's own state moves. */ value?: string; /** Remember this toggle's state across loads and sessions, under the * element's `persistkey` namespace. The stored value wins over `active` at * registration — read it back with isControlActive(id) if the host keeps its * own copy. Ignored without `toggle`. */ persist?: boolean; /** Set false to stay silent when the control is used by its HOTKEY. On by * default: a key press has no other feedback, which is why every built-in * shortcut flashes the OSD. A click never flashes — the button itself is * the feedback. */ osd?: boolean; /** Called on every use, with the state AFTER the toggle flipped. Also * emitted as a "movi-control" event for hosts that prefer listeners. */ onSelect?: (active: boolean, player: MoviElement) => void; } /** * A divider the HOST adds — the same quiet line the player draws between its * track controls and its viewing controls, put wherever the host's own * controls want one. Passed to addControl like any control: * * ```ts * player.addControl({ id: "sep", divider: true, after: "cc" }); * player.addControl({ id: "sep2", divider: true, group: "extras" }); * ``` * * Placed, grouped, scoped and removed exactly as a control is — same anchors, * same `group` (so it can sit INSIDE a capsule), same `controlslist` token by * its id. On the bar, the centre or the corner it is a vertical line; in the * context menu, the menu's own horizontal rule. A divider left with nothing * visible on one side of it inside its row or capsule hides itself, so a * control that is unavailable for this source doesn't strand a line at the * end of a pill. Styled by --movi-divider-color / -width / -height / -gap and * ::part(controls-divider), which reach the built-in one too. */ export interface MoviDividerSpec { /** Unique; the handle for removeControl and the controlslist token. */ id: string; divider: true; side?: MoviControlSpec["side"]; before?: MoviControlSpec["before"]; after?: MoviControlSpec["after"]; anchors?: MoviControlSpec["anchors"]; group?: MoviControlSpec["group"]; placement?: MoviControlSpec["placement"]; media?: MoviControlSpec["media"]; screen?: MoviControlSpec["screen"]; } export declare class MoviElement extends HTMLElement { private canvas; private video; private subtitleOverlay; private _captionLive; private _captionObserver; private _sourceObserver; private _sourceSignature; private _sourceSwapQueued; private _lastCaptionText; private _qoe; private _qoeHeartbeat; /** * The engine behind the element: the `MoviPlayer` that loads, decodes and * plays the current source. For what the element does not forward — * `getPreviewFrame()`, `getMediaInfo()`, `getCacheStats()` and the rest of * the core API. * * Read it, don't assign it, and don't keep it: the element builds a new one * for every source (and on some recoveries) and destroys the old one, so a * reference held across a `loadstart` points at a player that is gone. Take * it after `loadeddata`. `null` before the first load and after `src` is * cleared. */ player: MoviPlayer | null; private isLoading; private _isUnsupported; private eventHandlers; private controlsContainer; private unmuteOverlay; private _userHasUnmuted; /** The viewer muted this deliberately — through the setter, which is where * the button, the M key and a slider dragged to zero all arrive. The * autoplay fallback mutes by writing `_muted` directly, so it never trips * this and its pill still appears. */ private _userChoseMute; private brokenIndicator; private _errorTitle; private _errorMessage; private emptyStateIndicator; /** Shown in the page while the canvas is living in a PiP window. */ private pipPlaceholder; private coverArtOverlay; private coverArtCanvas; private coverArtBitmap; /** The dotted level meter drawn along the scrubber in cover-art mode. */ private _eqCanvas; private _eqRaf; private _eqSmoothed; /** The loudest band seen lately — the meter is drawn relative to it. */ private _eqPeak; private _posterCoverBitmap; private _posterCoverUrl; private _posterCoverLoading; private _coverArtBgEl; private _coverPaletteBitmap; private _coverPalette; private _coverArtBgUrl; private _coverArtResolved; private _lastStripDispatched; private _lastAudioModeSeen; private controlsTimeout; private isOverControls; /** Document-level "you tapped somewhere else" — see where it is set up. */ private _outsideTapHandler; private _settingsOutsideHandler; /** The bar's post-touch re-show, pending — cancellable by a tap elsewhere. */ private _barTouchReshowTimer; private isSeeking; private pendingSeekTarget; private _pendingSeek; private isDragging; /** The chapter section currently raised under the pointer, so the playback * tick can keep its paint current without re-scanning every segment. */ private _hoveredChapter; /** Media time the pointer is over on the seek bar, or -1 when it is off it. * Kept so the tick can repaint the hover range against a moving playhead — * see paintSeekRangeHover. */ private _hoverBarTime; private isTouchDragging; private touchStartX; private touchStartY; private touchStartTime; private gesturePerformed; private clickTimer; private lastSeekTime; private lastSeekSide; private cumulativeSeekAmount; private _seekChainTarget; private _contextMenuVisible; private _contextMenuJustClosed; private _menuPortalHost; private _menuPortalRoot; private _menuHome; private lastTouchTime; private _holdSpeedTimer; private _holdSpeedActive; private _rateBeforeHold; private _openMenuViaGear; private _mediaSessionReady; private _mediaSessionLastPos; private _mediaSessionArtworkUrl; private _nerdStatsVisible; private _currentManualRotation; private _timelineGenerating; private _timelineCancelled; private _timelineComplete; private _timelineNextIndex; private nerdStatsInterval; private networkSpeedHistory; private _stutterInterval; private _stutterLastPresented; private _stutterHistory; private _stutterCooldown; private _stutterCooldownTimer; private _stutterGraceUntil; private static readonly STUTTER_GRACE_MS; private static readonly GRAPH_MAX_SAMPLES; private _src; private _sourceAdapter; private _headers; private _attrLoadScheduled; private _hasConnected; private _audioOnly; private _audioSrc; private _videoQualities; private _lastNotifiedQualityHeight; private _qualityHighWater; private _audioTracks; private _subtitleTracks; private _autoplay; private _autoplayStarting; /** Pending "did startup really stop?" check — see the paused branch in the * state handler. */ private _autoplayPausedTimer; /** Set when pause() lands before playback has started, so the pending * autoplay/queued play doesn't fire once loading finishes. */ private _startCancelled; /** "The viewer asked for this pause." Set by pause(), cleared by play() and * by dispose(), which every source change runs through — so it never * carries a decision about one video over to the next. The unmute pill * reads it: unmuting must not restart a video someone stopped on purpose. * Separate from _startCancelled, which the autoplay path owns. */ private _pauseWasAskedFor; /** * "A seek is under way and playback is meant to continue through it." * * The play/pause glyph reads intent, not raw state, so a stall cannot bounce * it — but intent is only reported for `playing`, `buffering` and `seeking`, * and a seek does not always land back on one of those. seekFromEvent has * known this all along: it checks whether the player came out of a seek NOT * playing and puts it back 100ms later. For those 100ms, plus however long * the resume itself takes, the state is `paused` or `ready` and every icon * flipped to "play" and back — a blink after every scrub, on the bar and on * the centre button together. * * Held from the seek that starts it until playback genuinely resumes, the * viewer asks for a pause, or the source ends. Backstopped by a timer, so a * resume that never comes leaves an honest "play" behind rather than a glyph * that lies for the rest of the session. */ private _iconHeldForSeek; private _iconHoldTimer; /** * Bumped by every initializePlayer(), and only there — a load() that bumps * on its own can invalidate a live init without starting one to replace it. * An init awaits twice — the * pre-play probe and player.load() — and load() clears `isLoading` on its * way through, so a source change landing inside either window starts a * SECOND init while the first is still running. Both then own a player and * a live source: the superseded one kept streaming its rendition in the * background, and when its demux failed it painted "Can't Play This" * over the load that was about to play fine. Each init captures the * generation it started with and drops itself the moment it no longer * matches — silently, since whoever superseded it owns the screen now. */ private _loadGeneration; /** The pre-play probe currently running, so a concurrent init awaits its * result instead of opening the seed rung. Null when none is in flight. */ private _startProbeInFlight; private _autoplayPendingVisible; private _autoMutedForAutoplay; private _hasEverPlayed; /** * Whether `play` has been announced and not yet answered by `pause` — the * element's own idea of "currently playing", which keeps the pair ordered * and stops a startup state change from reporting a pause nobody asked for. */ private _playFired; /** * The paused flag, in the sense a media element means it: not "is a frame * being painted right now" but "has playback been asked for and not * stopped". It flips on play() and pause(), and at the end of the media — * which is why buffering, seeking and a slow start all still read as * playing, exactly as they do on a