/** * MoviPlayer - Main public API for the streaming video library */ import type { PlayerConfig, SourceConfig, Track, PlayerState, PlayerEventMap, MediaInfo, VideoTrack, AudioTrack, SubtitleTrack, SubtitleSourceEntry, SubtitleCue, SubtitleRenderer, PlaybackAssessment, PlaybackAssessmentOptions, PlaybackQuery, DecodeAudioOptions, DecodedAudioChunk } from "../types"; import { EventEmitter } from "../events/EventEmitter"; import { type SourceAdapter } from "../source"; import { TrackManager } from "./TrackManager"; import { LogLevel } from "../utils/Logger"; import { type StoryboardSpec, type StoryboardTile } from "../utils/Storyboard"; import { type VRView, type RenderSource } from "../render/CanvasRenderer"; export declare class MoviPlayer extends EventEmitter { private static readonly _isMobileDevice; private config; private source; private cache; private demuxer; private _audioTracks; private _activeAudioLang; private static _hlsSubtitleCache; private audioDemuxer; private audioSource; private audioAnimationFrameId; private audioDemuxInFlight; private _splitAudioTrackId; /** How long a split-audio track may fail every read before the player says * so. Long enough to step over a bad patch, short enough that nobody sits * through a silent film wondering. */ private static readonly SPLIT_AUDIO_GIVE_UP_MS; private _splitAudioEof; private _splitAudioDead; private _splitAudioErrorSince; /** Resolves once the video demuxer has produced mediaInfo — the only thing * the parallel split-audio open has to wait for. */ private _videoInfoReady; private _resolveVideoInfoReady; private _audioRecoveryInFlight; private _audioRecoveries; private static readonly MAX_AUDIO_RECOVERIES; private static readonly UNMUTE_RESUME_WAIT_MS; private _splitAudioStartTime; private _splitAudioSkipBefore; private _splitAudioFloorPending; private _splitAudioPtsDelta; private _audioSwitchInProgress; private _destroyed; /** * Aborted by destroy(), and the signal every request this player makes is * expected to carry. * * The sources already cancelled their own work; nothing else did, because * nothing else had a handle to cancel WITH. A probe built its AbortController * as a local and only ever aborted it on its own timer; the subtitle loaders * passed no signal at all. So a player torn down mid-startup kept pulling * megabytes for a video that was already gone — off the link the replacement * player was trying to start on. */ private _lifetimeAbort; /** The signal for anything fetched on this player's behalf. */ get lifetimeSignal(): AbortSignal; private _lastSplitAudioPts; private _subtitleTracks; /** * The demuxer-path source came from a manifest that declares encryption. It * plays for as long as the packager's clear lead lasts, then stops for good; * this is what turns that stop into "needs a licence" rather than a decoder * fault. Set when the fallback plan is taken, cleared on every load. */ private _sourceIsEncrypted; /** * Halt further reading without tearing the source down — the buffered part is * still good to seek around in, and only what comes next is unusable. */ private stopSourceStreaming; /** Reported once; either decoder may be the one that hits the wall first. */ private _encryptedGaveUp; /** Audio already given up on for this source — say it once, not per packet. */ private _encryptedAudioSilenced; /** * The clear lead of an encrypted source has run out. Whichever decoder * noticed, the answer is the same and there is no recovery to attempt. * * Stopping matters as much as reporting. Left running, the pipeline keeps * asking for frames it can never get, and the machinery built to rescue a * struggling stream turns on itself: audio empties, video is bound to it, the * stall detector fires, the element nudges a seek, and that repeats for as * long as anyone watches. Pausing ends that; halting the source stops pulling * bytes that cannot be used; the error names the cause. */ private encryptedSourceGaveUp; private _dashRenditions; private _activeDashRendition; private _switchIndicatorGen; private _decodeScreened; private _usingSharedThumbModule; /** * Resolutions this device has been shown not to sustain — learned from a * decode-bound stall or from MediaCapabilities, and kept for this tab's * session (see deviceDecodeBoundHeights). * Public because the pick happens BEFORE any player exists: the element's * pre-play speed test chooses the opening rung off a bandwidth measurement * alone, so without this a machine that cannot decode 8K still opened on it * and stepped down a moment later, in full view. */ static decodeBoundHeights(): string[]; /** Has THIS codec at THIS height been shown not to decode here? */ static isDecodeBound(codec: string | undefined, height: number): boolean; /** * Will this play smoothly HERE — on this device, in this browser, at this * speed — before committing to playing it? * * Answered with the same judgement the player itself acts on when it picks a * rendition (screenLadderForDecode), not a second opinion that could * disagree with it: * * 1. Is there a HARDWARE decoder for this exact configuration? Asked of * WebCodecs with `prefer-hardware`, because that is the API that decides * — MediaCapabilities is advisory, and Safari has been caught answering * "supported" for 4K AV1 that isConfigSupported then refused. * 2. Failing that, is there a browser SOFTWARE decoder? Failing that, the * player's own FFmpeg (WASM) takes it — which is why `playable` is true * for nearly anything with a video track. * 3. Without hardware, it comes down to cost: width × height × fps × the * codec's work factor × playback rate, against the budget for this class * of device — the same two constants the player's ABR uses, calibrated on * a phone that could not produce a frame of 1440p AV1 and the 1080p H.264 * it plays without complaint. * 4. MediaCapabilities gets a veto over "smooth": when the browser says a * configuration will not be smooth, that is believed. * * Audio is reported but never decides it. Every audio track goes through the * software decoder already, and audio decode is a rounding error next to * video's. * * @param input A URL or File to open and read (an isolated WASM instance, so * a player already playing is not disturbed), the tracks of something * already open, or a {@link PlaybackQuery} when there is nothing to open. */ static assessPlayback(input: string | File | Blob | Track[] | PlaybackQuery, options?: PlaybackAssessmentOptions): Promise; /** * The audio track of a URL or File, decoded to mono PCM and handed back in * chunks — without playing it, and alongside anything that IS playing. * * for await (const chunk of MoviPlayer.decodeAudio(file, { from: 60 })) { * // chunk.samples: Float32Array @ 16 kHz, chunk.start / chunk.end in seconds * } * * Built for work that needs the sound ahead of the playhead — speech * recognition for captions is the case it was written for. It decodes with * this player's own demuxer and audio decoder, so anything the player can * play it can read: MKV, Opus, TrueHD, DTS — not just what the browser's * decodeAudioData happens to understand, and never the whole file into * memory at once. * * Runs on its own WASM instance, like assessPlayback, so a player that is * playing is not disturbed, and yields to the event loop as it goes. Resampled * with an OfflineAudioContext, which band-limits properly — a speech model * fed aliased audio hears noise that is not in the file. * * Uses the first audio track. Stops, and releases everything, when the * signal aborts or the loop is broken out of. */ static decodeAudio(input: string | File | Blob, options?: DecodeAudioOptions): AsyncGenerator; /** The stream's own codec string, or null — never a throw on odd extradata. */ private static codecStringFor; /** Open a URL or File just long enough to read its tracks. */ private static probeTracks; /** * Ask the device about a ladder before there is a player to ask through. * * The opening rung is picked in the element, off a bandwidth probe, before * any of this class exists — so on a fresh device the FIRST rung it lands on * has never been screened, and a machine that cannot decode it finds out by * playing it badly. That one visible attempt is the whole complaint; every * load after it is covered by the persisted ceiling. * * Callable statically because the ceiling it fills is module-level too. * Rungs need their own codec (a `codec` on → `data-codec`); * without one there is nothing to ask, and the reactive path still covers it. */ static screenLadder(rungs: { height?: number; codec?: string; bandwidth?: number; }[], fps?: number): Promise; private _autoQuality; private _abrTimer; private _abrSwitchInProgress; private _abrProbeInFlight; private _startupSpeedTestRan; private _splitAudioRetries; private static readonly MAX_SPLIT_AUDIO_RETRIES; private _lastThroughputBps; private _lastAbrSwitchAt; private _playbackStartedAt; private _abrUpCandidate; private _abrUpConfirms; private _abrPrimed; private _lastBufferAhead; private _abrPenalizedBandwidth; private _abrPenaltyUntil; private static readonly ABR_PENALTY_MS; private static readonly ABR_DECODE_PENALTY_MS; private static readonly ABR_PENALTY_MAX_MS; private static readonly ABR_STRIKE_DECAY_MS; private static readonly ABR_UPSHIFT_MIN_BUFFER_S; private static readonly RENDITION_SWAP_LOOKBACK_S; /** How far past the playhead the incoming rendition must decode before a * seamless switch will commit. A quarter second is several frames of * runway — enough that the new decoder is never the thing being waited on * at the seam. */ private static readonly SEAMLESS_PRIME_LEAD_S; private static readonly SWITCH_FIRST_RANGE_BYTES; private static readonly ABR_POST_SEEK_HOLD_MS; private static readonly PICTURE_CLOCK_LEAD_S; /** …and the most it may prime, however much the outgoing queue holds. Whole * decoded frames are expensive at 4K and outrageous at 8K. */ private static readonly SEAMLESS_PRIME_MAX_AHEAD_S; private static readonly SEAMLESS_PRIME_MIN_FRAMES; /** Long enough to cross a GOP on a slow link, short enough that a switch * which cannot be made seamless falls back before the reason for switching * gets worse. */ private static readonly SEAMLESS_PRIME_BUDGET_MS; /** * Whole-prep bound for an in-place switch — open, seek and prime together. * * Every await in the prep was unbounded, and the one that hurts is the rung * that cannot be fed: an 8K AV1 rung is a 1.2GB file at 38.7Mbps, and on a * link that measures about the same the open alone can hang for as long as * it likes. Nothing rescues that, because `_abrSwitchInProgress` is held for * the whole call and it gates abrDecide, the decode downshift AND * abrEmergencyDownshift — so the ladder cannot step back down while the * stream it is stuck on saturates the link the player is starving for. * * Generous on purpose. A DOWNSHIFT comes through here too, and that is the * one switch a struggling player must always be able to finish; its target * is a smaller rung, so it is nowhere near this. What this cuts off is a * climb into a rung the link cannot carry, which is the only case that ever * runs this long. */ private static readonly SWITCH_PREP_BUDGET_MS; /** * The same bound for a step DOWN, which the note above assumed would never * come near 12s. It does, on exactly the link that asks for one: at 0.2 MB/s * the 480p rung's header, index and the GOP behind the swap point are ~2MB, * ten seconds before anything else waits. The downshift was abandoned at the * budget, decided again, abandoned again — four times, until the element * gave up on a video the lower rung would have carried. Abandoning a * downshift buys nothing: the rung being left has already run dry, and the * outgoing source is held for the prep (suspendNetwork), so there is no * picture to go back to. What still needs an end is a rung the link cannot * feed at all, and this is that end. */ private static readonly SWITCH_DOWN_PREP_BUDGET_MS; private _abrDrainStrikes; private _videoHoldingForKeyframe; private _lastSeekAt; private _blackFrameWatchdog; private _blackRecoverySeeks; private static readonly MAX_BLACK_RECOVERY_SEEKS; private _soundCarryingAlone; /** Every video packet handed to the decoder, ever. Differences only — see * armBlackFrameWatchdog, which uses it to tell a picture that will not * decode from one that has not arrived. */ private _videoPacketsFed; /** framesPresented when the sound took over, so a picture that comes back on * its own can end the hand-over even if the gate that watches for it was * cleared by something else. */ private _soundCarryingFrames; /** Set for the one seek that STARTS the hand-over, so seek() re-primes the * pipeline where the playhead is without cancelling it. */ private _carrySoundThroughNextSeek; /** Media time from which the file has audio but no video. */ private _videoTailStart; /** * Media time from which the file has video but no audio — the mirror of * _videoTailStart, and read the same way: from the audio track's own declared * duration, before playback starts. * * A container writes its streams to whatever lengths it likes. The player * already knew about a picture that stops before the file does and lets the * sound play on past the last frame. The other way round it did not: an audio * track that stops early left the master clock pinned at the last sample — it * is the audio that times playback — and everything downstream read the * silence as a fault. Measured on a 4K AV1 MKV whose Opus track ends at * 193.4s of a 243.8s film: playback stopped at 3:13 with fifty seconds left. * * Past here the picture carries playback on its own, exactly as the sound * does in the other tail. */ private _audioTailStart; /** pts of the newest video packet the demuxer has handed us. */ private _lastVideoPacketPts; private static readonly VIDEO_TAIL_GAP_S; /** pts of the newest audio packet the demuxer has handed us — the mirror of * _lastVideoPacketPts, and the runtime half of _audioTailStart for the * containers that declare no per-stream duration at all. This file's MKV * declares none: both tracks report undefined, so the up-front read can * never fire and only the cursors can tell. */ private _lastAudioPacketPts; private _videoPacketsSinceAudio; /** Media time the current continuous read pass began at — a load, or the * last seek. Tells the EOF check whether "no audio packet was seen" means * the sound has ended or merely that we started reading past it. */ private _audioReadPassStart; /** How far the picture may run past the newest audio packet before the * runtime detector calls it a tail. Same reasoning and same value as * VIDEO_TAIL_GAP_S: interleave is the container's business, deciding late * costs nothing, and a video packet arriving retracts nothing while an * audio packet does. */ private static readonly AUDIO_TAIL_GAP_S; private _activeSubtitleLang; private _externalSubCues; /** * Subtitle tracks whose cues were handed to us rather than fetched — see * appendSubtitleCues. Keyed by lang, each list kept sorted by start. The * array IS what the external renderer reads when that lang is active, so an * append shows up on the next tick without a reload. */ private _generatedSubCues; /** Generated tracks still waiting for their first cue — listed with a * working indicator rather than as an empty track that does nothing. */ private _pendingSubLangs; private _externalSubTimer; trackManager: TrackManager; private clock; private stateManager; private mediaInfo; private fileSize; private lastBufferedTime; private bufferedRangeStart; /** * Enable/disable seek-bar scrub previews on an already-constructed player. * Lets the `thumb` attribute be toggled at runtime (or applied after `src`, * whose callback creates the player first) without recreating the player. * The thumbnail pipeline stays lazy — it only spins up on the first hover. */ setPreviewsEnabled(enabled: boolean): void; /** * The longest edge a preview JPEG is worth encoding at. * * The card is about 160px wide. Encoding the frame at its own size — 1920, * or 3840 on a 4K source — spends the time on pixels the browser then throws * away scaling it down: measured at 519KB and ~45ms a frame on 4K60, per * hovered position, on a fast desktop. Downscaling first costs one draw. */ private static readonly PREVIEW_MAX_EDGE; /** * How big a frame the SOFTWARE preview decoder may be asked for. * * Only reached when WebCodecs refuses the source outright — 8K H.264 is * refused by every browser tested. The cost is the decode, which is the * source's own size whatever the scaler is then asked for: measured in WASM * on this machine, ~250-330ms of blocked main thread per 8K frame, and over * a second for the first. 4K UHD (8.3MP) sits just under the line and stays. */ private static readonly PREVIEW_SOFTWARE_MAX_PIXELS; /** * Is the source too big to preview in software? * * Asked in two places, because a browser can refuse the source at * configure() — Chrome does, for 8K H.264 — or accept it and then fail to * produce a frame, which is what a decoder that cannot really do this size * looks like from here. Both end at the same software decoder, and at that * size it stalls the page either way. */ private previewSoftwareTooBig; /** * The source's shape, shrunk to the size a preview is actually shown at. * * Everything on the preview path used to work at the source's own * resolution and shrink at the very end, one step before the JPEG. On an 8K * source that means a 7680x4320 RGBA buffer — 132MB — scaled by the decoder, * copied out of WASM, uploaded as a texture and drawn, per hover, for a * picture that is then thrown away and redrawn at 480px. Measured on 8K * H.264: 1.7-2.4s per preview with the main thread stalled for up to 2.1s of * it, which is the seek bar hanging the player while the keyboard seeks fine. * * The scaler has to resize the frame either way, so asking it for the size * the picture is wanted at costs nothing and saves all of the above. */ private static previewShape; private previewScaleCanvas; /** * Frames already made, kept by the second they belong to. * * Scrubbing asks for a new time every few pixels of pointer travel, and the * pipeline is one decoder: each request seeks, decodes and encodes from * scratch, so a viewer moving back and forth over the same stretch paid for * the same frames again and again — and the card sat on its loading state * every time. A second is finer than the strip can show, so quantising to it * costs nothing and makes the second pass over any stretch instant. * * Bounded and insertion-ordered: the oldest entry goes when it is full, which * for scrubbing is the part of the timeline the pointer has left behind. */ private static readonly PREVIEW_CACHE_MAX; private previewCache; /** * The same frames again, filed under the keyframe they actually came from. * * Outside precise mode a preview IS the keyframe at or before the hovered * time, so every position inside one GOP is the same picture — and on a * long-GOP source that is seconds of timeline. The time-keyed cache above * cannot know that: it files by where the pointer was, so a scrub across a * single GOP missed on every step of it and paid a full decode and JPEG * encode (~11ms of the ~16ms a preview costs) to arrive at pixels it already * had. * * This map is consulted AFTER the seek, because the seek is what says which * keyframe the time belongs to — 4ms rather than 16, with no guess about * where the GOP boundaries are and so no risk of showing the wrong frame. */ private previewByKeyframe; /** * How finely the preview cache tells one moment from another. * * It was a whole second — Math.round(time) — and a whole second is also how * often the picture could then change. Every hover inside it was answered * with the same remembered frame, so a scrub across ten seconds of a film * showed ten pictures however carefully the frame under the pointer had been * decoded. The precision the decode paid for never reached the screen. * * A fifth of a second is fine enough to read as continuous while dragging, * and still coarse enough that a pointer trembling on one spot is answered * from memory rather than re-decoded. */ private static readonly PREVIEW_CACHE_STEP_S; /** The cache key for a time, or null when the frame can't be reused. */ private previewKey; /** * Is this position's picture already in hand? * * Asked BEFORE a preview is dispatched, by the pacer that otherwise waits * for the pointer to settle. That wait exists to stop a scrub spending a * 2MB range fetch at every position it crosses — but a frame that is * already remembered costs nothing to produce, so waiting for it buys * nothing and is simply 180ms of delay. Synchronous on purpose: a pacer * that had to await this could not decide in the same tick as the move. */ hasPreviewFor(time: number, view?: VRView | null): boolean; private rememberPreview; /** The keyframe map's key for a decoded packet's pts, or null if unusable. */ /** * The thumbnail demuxer's current packet, in the same time base as everything * that asks it questions. * * It reports the packet's RAW pts — `pkt->pts * time_base`, with no * `start_time` taken off — while every hover, target and duration on this * side of the WASM boundary is media time from zero. On a source whose * stream starts at zero those are the same number and nothing shows; on one * that does not, they are apart by exactly that offset, and three separate * things quietly read the wrong answer. * * Measured on a 47.7s 4K HEVC MPEG-TS whose stream starts at 1.050044s * (LG-Daylight-4K): a hover at 12.5s took the keyframe at raw 11.878 — * 10.827 in media time, and the file's own keyframe list confirms 11.877522 * is a real raw pts, so the value is raw beyond doubt. The walk then stopped * at raw 12.545 because it breaks on `pts >= time`, which is media 11.495: * a preview one full second before the pointer, every time, sixty frames out * at this file's 59.94fps. Where the keyframe happened to land within the * offset of the hover, `timestamp < time` was false and the walk did not run * at all — precise mode falling back to the plain keyframe with nothing said. * And the renderer picks the frame nearest `targetSec`, which is media time, * against chunk timestamps that were raw — the same offset a third time. * * The C seek does normalise (it adds `start_time` to its target), so the * frame fetched was always the right one; only the arithmetic about it was * wrong. Correcting it here rather than in the WASM keeps the fix on the * side that owns the media-time convention, and needs no rebuild. */ private previewPacketPts; private keyframeKey; private rememberKeyframePreview; /** Drop remembered frames — the pictures behind them are no longer the file. */ private clearPreviewCache; /** * Pictures the source brought with it (see utils/Storyboard). * * When a source has a storyboard, a preview costs a crop out of an image the * browser already has, instead of a seek, a decode and an encode — and the * second WASM module the decode path needs is never opened at all. Set from * the element's `storyboard` attribute or property; a URL is a WebVTT * thumbnail track, an object is a tile spec. */ private storyboardSource; private storyboard; private storyboardLoad; /** Mosaics already fetched, by URL — one image serves dozens of previews. */ private storyboardImages; private storyboardCanvas; setStoryboard(source: string | StoryboardSpec | null): void; /** The board's mosaics in order, for a caller that paints them itself. */ getStoryboardSheets(): string[]; /** * The same list, but once the board has actually been parsed — for a caller * that wants to pull the pictures in before anybody hovers. * * getStoryboardSheets answers from what is in hand, so at load time (the one * moment worth warming at) it answers with nothing: a VTT track is still in * flight. Resolves to an empty list when there is no board, or when it turns * out not to have one — never rejects, because a storyboard is an * optimisation and its absence is not an error. */ whenStoryboardReady(): Promise; /** True while a storyboard is standing in for the decode path. */ hasStoryboard(): boolean; /** * The tile for a moment, RIGHT NOW — no promise, no crop, no encode. * * Cropping a tile into a blob costs a canvas draw, a JPEG encode and an * image decode on the other side, and the caller has to await all of it. A * caller that can paint the mosaic itself (the seek card, with a background * offset) needs none of that: it needs the rectangle, and it needs it in the * same frame the pointer moved. Returns null until the board is parsed — * asking starts that, so the next move is answered. */ getStoryboardTileSync(time: number): StoryboardTile | null; private ensureStoryboard; private loadStoryboardImage; /** Cut one tile out of its mosaic and hand it over as a preview. */ private cropStoryboardTile; /** * Encode what has been rendered into `src` as a preview JPEG, shrinking it * first when it is bigger than a preview needs to be. */ private encodePreviewBlob; /** * Walk forward from the keyframe to the frame the pointer is actually on. * * A preview is normally the keyframe at or before the hovered time, because * that is the one frame a seek can hand over for free. On a long-GOP source * — a WEB-DL with 5-10s between keyframes — that is a picture from seconds * away from where the viewer is pointing. Precise mode decodes the frames in * between and shows the one that belongs to the hovered second instead. * * Off by default: it is the same decode the player does for playback, on the * hover path, so it costs a run of frames per preview rather than one — and * the run is as long as the source's GOP, since a walk that gives up part * way is a keyframe preview wearing this mode's name. */ private precisePreviews; setPrecisePreviews(enabled: boolean): void; /** * Can a preview still cost a network read, or is every frame it will be * asked for already in hand? * * The seek bar asks before it decides how hard to push: a preview made from * bytes the machine already holds can be asked for as fast as the pointer * moves, but one that has to fetch cannot. The thumbnail reader pulls a 2MB * window per position, and a scrub is a run of positions — so asking for * every one of them puts the fetch for the frame the viewer actually stopped * on at the BACK of a queue of fetches for frames they only passed over. The * bar answers that by waiting for the pointer to settle first; this is how it * knows to. * * False for a local file (nothing to fetch, ever) and for an HTTP source * whose bytes are all in memory — a small file that has finished downloading * scrubs like a local one and must not be slowed down to match a link it is * no longer using. True for an adaptive stream, whose thumbnail tiles are * fetched per position by the wrapper. * * A source adapter from outside this file answers neither test and so counts * as fetching, which is the right way round to be wrong: a custom adapter is * a remote one until shown otherwise, and the cost of pacing one that turns * out to be local is a fraction of a second of settle, against a queue of * whole fetches the other way. */ previewsMayFetch(): boolean; private previewsAllowed; /** Proxy a stream wrapper's events + mirror its TrackManager onto the player. */ private wireStreamWrapper; private videoDecoder; private audioDecoder; private subtitleDecoder; private _customSubtitleRenderer; private _subtitleRenderRAF; private _subtitleDelaySec; private videoRenderer; private prefetchedSubtitleStream; private prefetchInFlight; private coverArt; private streamWrapper; private thumbnailBindings; /** The half-built pipeline, while it is still being built. It holds an * FFmpeg context from the moment it exists, so teardown has to be able to * find it even though callers must not — see initPreviewPipeline. */ private thumbnailBindingsPending; private thumbnailSource; private thumbnailRenderer; private thumbnailHDREnabled; private isPreviewGenerating; /** The generation currently in flight, for callers that must WAIT rather * than be turned away — see getPreviewFrame's `queue` argument. */ private previewInFlight; private audioRenderer; private _previewGeneration; private previewInitPromise; private previewInitAttempts; private _previewWarmTimer; private static readonly PREVIEW_WARM_DELAY_MS; private static readonly PREVIEW_WARM_RETRY_MS; private _thumbBufferIdleTimer; private static readonly THUMB_BUFFER_IDLE_MS; private _durationRescanTimer; private _durationRescanDone; private static readonly DURATION_RESCAN_DELAY_MS; private previewInitGaveUp; private disableAudio; private _audioOnly; private muted; /** * Bind the two streams: either one running out stops both. * * Off (the default) each side is allowed to carry on while the other is * short. That is asymmetric in practice, because the two run out for * different reasons. Video runs out on the WIRE — it is an order of * magnitude the bigger stream, so on a slow link it is always the one * refilling, and the sound sails on over a frozen frame until the picture * comes back seconds behind what you have already heard. Audio runs out on * the CPU — an expensive codec decoding slower than realtime — and there the * picture carries on over sound full of holes. * * Bound — the default — neither happens: whichever side empties, playback * buffers, both are suspended, and they start again together when both are * ready. The cost is that a shortfall you would previously have watched or * listened through becomes a full stop, which is the honest thing to show: * a picture running seconds behind the sound is not playback anyone asked * for. `bindav="false"` unbinds them for a caller who would rather have the * stutter. * * The one case that would suffer from this is a picture that is merely slow * to DECODE, and that is not this: the frames-presented check below restarts * the stall window whenever frames are still reaching the screen, so a source * decoding at half rate keeps stuttering along rather than turning into a * spinner. */ private _bindAV; /** * Does this source actually have a picture to wait for? * * Canvas mode builds a CanvasRenderer for EVERY source, audio-only included — * the strip a music file plays behind is that same renderer. So * `!!this.videoRenderer` says "we could draw", never "there is anything to * draw", and every place that read it as the latter was asking a video-less * file to produce a frame. Its queue is empty forever, which is * indistinguishable from a video source whose queue has just run dry. * * Measured on a FLAC-only .mka: the stall detector's `videoEmpty` was * permanently true and `_bindAV` defaults to true, so `videoStalled` held * from the first tick — and `bufferingForVideo`, also gated on the renderer * merely existing, lifted the audio ceiling for the whole of the buffering * that followed. The file played 0.51s, stalled, swallowed 141 SECONDS of * audio in one uninterrupted read, sat in buffering for 5.5s, resumed, and * did it again: five cycles in 5.6s of media. * * The honest question is whether the SOURCE carries video, so ask that. */ private get hasPicture(); private wasPlayingBeforeRebuffer; /** * Buffering is over and play() has been called, but the state is not * "playing" yet. * * Leaving buffering sets "paused" and then calls play(), which is async — it * resumes the AudioContext and re-anchors before it flips the state, ~60ms * later. The decoder does not wait for any of that: it is mid-burst on the * packets the prebuffer read, and onFrame bins every frame that arrives * outside "playing"/"buffering". Measured on a 20s 1080p30 video-only file, * first play: 33 frames closed between 0.300s and 1.400s, and the picture * then froze for 1.1s at 0.300s while the clock ran through the hole they * left. The frames were decoded, correct, and thrown away. * * So this says the resume is in flight, and the queue stays open across it. */ private _resumeToPlayPending; private _stallStartTime; /** performance.now() of the last frame decoded while a seek waited for sync — * the signal that the seek is still working. See the seek deadline. */ private _seekFrameProgressAt; /** How long the video decoder may go silent mid-seek before the seek is * declared stuck. One 4K AV1 frame is a few tens of ms even on a slow * machine, so a gap this long means it has stopped, not slowed. */ private static readonly SEEK_PROGRESS_IDLE_MS; /** …and the ceiling on extending, because a decoder emitting frames that * never reach the target is itself a failure to give up on. */ private static readonly SEEK_PROGRESS_CAP_MS; /** framesPresented when the current stall window opened — the baseline the * "is the picture still moving?" test measures against. */ private _stallStartFrames; /** Presented-frames-per-second at or above which an empty video queue is a * slow decoder, not a stall. Deliberately low: this is the line between * "watchable, if choppy" and "frozen", not a quality bar. A 60fps source * running at 30, or a 24fps one dropping half its frames, is comfortably * over it; a picture that has actually stopped presents nothing at all. */ private static readonly STALL_MOVING_FPS; private static readonly DECODE_ALIVE_WINDOW_MS; private _bufferingEntryTime; private _seekResumeQueueWait; private _bufferingSelfInflicted; private _lastRateChangeAt; /** When audio last changed language in place. A stall in the moments after * belongs to that swap — the renderer was reset and the decoder is cold — * not to the link, and the ABR must not read it as the rung failing. */ private _lastAudioSwitchAt; private _lastSeekResumeAt; /** * How long after a seek resumes the picture on screen may still be one from * BEFORE it, and how far apart the two have to be to say so. See the audio * desync guard — a gap of seconds this soon after a seek is a leftover * frame, not drift, and chasing it undoes the seek. */ private static readonly SEEK_STALE_PICTURE_MS; private static readonly SEEK_STALE_PICTURE_GAP_S; /** * How far the picture may outrun the sound and still count as a tail rather * than as content. A container writes the two tracks to slightly different * lengths all the time — a frame or two — and that tail can never come due * against a clock the audio pins. Beyond this it is not a rounding artefact, * it is film, and it gets played. See the EOF block. */ private static readonly AUDIO_TAIL_GRACE_S; /** When an in-place rendition swap actually LANDED, and when a picture * catch-up started. Both leave the video pipeline re-priming while the sound * plays on, so a stall in the moments after is ours, not the link's. */ private _lastRenditionSwapAt; private _lastVideoCatchUpAt; /** * How long after a swap its catch-up still owns a stall. * * The hard path deliberately lands RENDITION_SWAP_LOOKBACK_S behind the * playhead, so the new rendition has that much to fetch and decode before it * reaches the sound. At 4K60 that is not the blink it is at 720p, and the * stall it ends in was read as the rung failing — which dropped a rung, whose * own swap stalled the same way, all the way down the ladder. */ private static readonly POST_SWAP_CATCHUP_MS; /** * How little must be buffered ahead for a stall to be about the LINK. A link * that cannot carry the rung starves the buffer to nothing; anything above * this and the bytes were already there. */ private static readonly ABR_STALL_STARVED_S; /** How long after a rate change or a seek's resume an audio stall is still * attributable to the flush/re-anchor that operation performed itself. */ private static readonly SELF_INFLICTED_STALL_WINDOW_MS; /** The renderer queue depth below which the picture counts as having run * OUT, rather than merely being short — the point past which the audio * cushion's cap must not be what holds the demuxer shut. A quarter of the * queue's own cap, so it scales with the cap's own reasoning (VRAM on 8K, * wall-clock on mobile, deep on desktop), held between these two so a very * shallow cap cannot put the floor at the stall itself and a very deep one * cannot keep the exemption standing through ordinary playback. */ private static readonly PICTURE_FLOOR_MIN_FRAMES; private static readonly PICTURE_FLOOR_MAX_FRAMES; /** * Buffered ahead of the playhead, past which a stall is not about supply. * Comfortably more than the cushion the floor exists to rebuild, so a link * that is genuinely struggling never reads as fine. */ private static readonly SUPPLY_FINE_AHEAD_S; /** Set at stall entry: the bytes were already there, so only decode is short. */ private _bufferingSupplyIsFine; /** * The picture running behind the sound, and staying there. * * Frame selection normally makes this self-healing: the presentation loop * takes the LATEST due frame, so a hitch is absorbed the moment fresh frames * arrive. What it cannot absorb is a decoder that lost ground and only ever * decodes forward at ~1x — after a GC pause or a few starved seconds on a * small-memory device, every frame that arrives is already late by the same * amount, each one is dutifully presented, and the offset becomes permanent. * * Nothing else catches it. The frozen-picture watchdog reads advancing * frames as healthy, and the desync check further down only looks the other * way (audio behind video), so the only fix was the viewer seeking by hand. */ /** Set per seek() call: true while a seek nobody asked for is in flight. */ private _seekIsInternal; /** The seek target the audio-crossed-it line was last said for. */ private _audioTargetLoggedFor; private _videoLagSince; private _videoLagHealthySince; private _lastVideoLagResyncAt; private _videoLagResyncs; /** Lip-sync is long gone by here, and it matches the audio-side threshold. */ private static readonly VIDEO_LAG_S; /** Long enough that an ordinary hitch heals itself first. */ private static readonly VIDEO_LAG_SUSTAIN_MS; /** How long the video-only ending waits for the renderer's queue to run out * once the clock has armed it. Only ever runs at the very end of a file. */ private static readonly EOF_PICTURE_DRAIN_MS; /** How long the decoder must have emitted nothing before an empty renderer * queue is taken to mean the picture is finished rather than between * callbacks. Several frame intervals at any rate we play. */ private static readonly EOF_PICTURE_SETTLE_MS; /** * A speed-UP asked for but not applied yet, while the pipeline fills for it. * * Only ever set on the one path that stalls today. A rate change re-anchors * the audio, and when a healthy anchor survives that, setPlaybackRate skips * the corrective seek entirely and the change is already smooth — that case * is left exactly as it was. When the anchor is NOT healthy the seek runs: * decoders flushed, frame queue cleared, a keyframe waited for. That is the * "playback stops dead, lags, then runs fine at 2x" on a device that plays * 1080p perfectly well at 1x — it simply has no cushion to spare, and * doubling the rate halves what it has in wall-clock terms. * * So on that path, keep playing at the CURRENT rate for a moment and let the * loop fill against the new rate's targets instead. When the anchor comes * good the change applies and the seek is skipped; if it never does, it * applies anyway at the cap and nothing is worse than before. */ private _pendingRate; private _pendingRateSince; /** True while the deferred rate is being handed back to setPlaybackRate, so * it applies instead of deferring itself all over again. */ private _applyingPendingRate; /** Longest the pipeline may prepare before the rate is applied regardless. */ private static readonly RATE_PREPARE_MAX_MS; /** One-shot: the decoder is asked for its reorder tail once per EOF. */ private _eofFlushRequested; /** Which EOF flush has finished emitting — see maybeStartLoopPreroll. A * counter, not a flag, so a flush from before a seek cannot vouch for one * asked for after it. */ private _eofFlushSeq; private _eofFlushSettledSeq; /** When the video-only ending was armed (timeDone first true), so the drain * wait above is measured from then and not from a much earlier EOF. */ private _eofPictureDrainSince; /** * How long after a speed change the picture may trail the sound without us * reaching for a seek. Longer than the seek window above because a rate * change can BE a seek plus an audio re-anchor, and the pipeline it re-primes * is the whole of it. The sustain timer only starts once this is past, so the * earliest a catch-up can fire after a speed change is this plus * VIDEO_LAG_SUSTAIN_MS. */ private static readonly RATE_CHANGE_SETTLE_MS; /** Below this share of the source rate the picture is not merely late, it is * being outrun — and a catch-up cannot win that. Deliberately looser than * the renderer's own severe ratio: this only declines to make things worse, * where that one commits the whole pipeline to a downshift. */ private static readonly LAG_KEEPING_UP_RATIO; private static readonly VIDEO_LAG_COOLDOWN_MS; /** * A catch-up that keeps being needed isn't catching up. Past this the device * is simply behind, and more re-seeks would only cost it the read-ahead they * throw away — the FPS/DPR degrades and the ABR downshift own that case. The * budget comes back after a stretch of being genuinely in step. */ private static readonly MAX_VIDEO_LAG_RESYNCS; /** * Presented-frame history, for pictureKeepingUp(). * * The renderer's own decode-bound verdict is the right gate on the catch-up * and it is already wired below — but it needs four uninterrupted seconds of * presentation to reach, and `framesPresented` restarts at every seek. The * catch-up IS a seek, so each attempt wiped the evidence for the guard that * would have stopped the next one. Read off an 8K60 AV1 session in Firefox: * catch-ups at 6.3s, 12.3s and 18.4s, and the verdict only at 31.5s — once * the attempt limit had stopped the resets. Those 25 seconds are the stutter. * * So the player keeps its own reading, which nothing resets but time. */ private _lagFpsBase; private _lagFpsAt; private _lagFpsAchieved; private _lagSlowLogged; /** * Samples in the current reading's window, and how many of them found the * picture with nothing to work on: no frame waiting to be shown, nothing in * the decoder, nothing in the read-ahead stash. Frames not presented then * were never delivered, so they say nothing about the device. A slow * decoder still reads as one: its frame queue runs dry with packets still * waiting in the decoder. */ private _lagWindowSamples; private _lagWindowStarved; /** * When the reading last looked. It only looks while playing, so a long gap * means playback stopped in between — a stall — and a window stretched over * one divides a second of frames by the seconds spent waiting for bytes. * * From an 8K60 AV1 file streamed from Google Drive: the link could not * carry its 41 Mbps, every window spanned a stall and read ~13fps, and this * reading called the DEVICE the bottleneck — the one verdict that keeps the * link notice silent (deviceIsBottleneck). Pausing to buffer played it * smoothly, which a slow decoder would not have. Reproduced with 4K25 AV1 * over a throttled link: windows over stalls read 2, 5, 9fps on a decoder * doing 25, and deviceIsBottleneck was true for 17s of 70; with such * windows dropped, 2s of 90. So they are dropped, not recorded. */ private _lagLastSampleAt; private static readonly LAG_SAMPLE_GAP_MS; /** Above this share of starved samples, a window's frame rate is not a * reading of the device at all, and is discarded rather than recorded. */ private static readonly LAG_STARVED_RATIO; /** * How long a bound stall may hold before it gives up and resumes on whatever * it has. * * Generous on purpose. Under a binding the picture is frozen for the whole * wait either way — resuming early does not un-freeze it, it only lets the * sound walk off without it. So the only thing this protects against is a * video pipeline that is not slow but DEAD, and for that the decoder's own * error paths are the real answer; this is the backstop behind them. */ private static readonly BOUND_RESUME_ESCAPE_MS; /** * How much video, in seconds of queued frames, a bound stall wants back * before it lets go. One frame is not a picture that is running again — it is * a picture that will freeze one frame later, with the sound released for the * whole grace that follows. */ private static readonly BOUND_RESUME_CUSHION_S; private static readonly HEAVY_SOFTWARE_AUDIO; private static readonly SEEK_RESUME_QUEUE_ESCAPE_MS; /** The stall-detection grace given to a resume that came out of a stall * rather than out of a cold start. Long enough for the queue we just waited * for to start playing, short enough that the sound cannot walk. */ private static readonly BOUND_RESUME_GRACE_MS; /** How long the decoder may sit waiting for a keyframe, picture frozen, with * the stall detector suppressed, before a binding calls it a stall. Ends the * wait that never ends when the packets stop arriving. */ private static readonly BOUND_KEYFRAME_HOLD_MS; /** How long a superseded seek waits for whoever took its session to resolve * the "seeking" state before doing it itself. */ private static readonly ORPHANED_SEEK_BACKSTOP_MS; /** How long the picture gets to rejoin the sound in a video-only catch-up * before a binding stops the sound and waits for it. Long enough that the * ordinary case — a few hundred ms of already-buffered decode — passes * unnoticed; short enough that a catch-up going nowhere cannot run away. */ private static readonly RESYNC_HOLD_MS; private _playStartTime; /** * A seek settled while paused, so the demuxer cursor is past the picture. * * A seek's processLoop bursts packets while it hunts for the frame the * target lands on, and a seek that ends paused stops there: the one frame is * painted, the decoded read-ahead behind it is dropped (see the paused branch * of notifySeekCompletion), and the cursor is left wherever the hunt reached * — measured 2.5s past the target on a 1080p source. Nothing rewinds it, * because resuming from a pause is otherwise just a matter of restarting the * clock. So playback picked up from the cursor: the viewer scrubbed to 260s, * saw 260s on the card and on the picture, pressed play, and the film jumped * to 262.5s. * * play() answers this with the realignment the first-play path already does * for the identical reason (the poster seek reads ahead the same way). This * flag is what tells the two apart from an ordinary pause → play, which must * stay instant and must NOT re-seek. */ private _demuxerAheadOfClock; /** * Can the picture simply carry on from where the clock is, with what has * already been decoded? * * The flag above says the demuxer's cursor is past the clock. That is true * the moment ANY seek settles paused — including the one a click on the seek * bar issues, because the bar pauses for the drag. But a click is followed by * play() within a few tens of milliseconds, while the frames that seek just * decoded are still queued: playback runs straight out of them and the cursor * being ahead never costs anything. Rewinding there is pure harm — it throws * away those frames, leaves the PREVIOUS position's picture on screen while * it re-seeks and re-decodes, and restarts the audio a second time. Measured: * a click seeking to 162.45s showed the old frame at 286.28s for ~240ms * before snapping, against a clean -0.04s landing without the rewind. * * What made the paused-scrub-then-play case different is that by the time * play arrived those frames were gone — the queue measured empty — so * decoding resumed from the cursor and skipped everything between. So the * question is not whether the cursor is ahead; it is whether the frames that * cover the gap still exist. This asks that directly. */ private pictureCanResumeFromClock; /** performance.now() of the last buffering→playing resume (0 = never). */ private _stallResumeAt; private _primingAudio; private _decoderStuckSince; private _lastDesyncSeekTime; private animationFrameId; private backgroundIntervalId; private backgroundWorker; /** * True when the tab is hidden. * * Seeded from the document rather than starting false, because a player is * not always born in a visible tab: an auto-advance while the viewer is away * destroys one player and builds the next one, and that new instance never * saw the visibilitychange that put the old one in the background. It came up * believing it was on screen, waited for a video frame that nothing was * decoding, and sat in buffering — silent — until the viewer came back. Every * background-aware branch in this file depends on this flag being right from * the first tick. */ private isBackgrounded; /** * The page has said hidden does not mean unwatched — see MoviElement's * `backgroundplay`. Two things follow from it: an autoplay may START while * hidden (handled in the element), and hiding the tab does not pause on a * phone (below). */ private _backgroundPlay; private _foregroundRecoveryAt; private wakeLock; private seekingToKeyframe; private seekingToKeyframeStartTime; private static readonly KEYFRAME_SEEK_TIMEOUT; /** * Video packets that must have been scanned before the wall-clock timeout is * allowed to give up and accept a non-keyframe. The timeout exists for long-GOP * content where a keyframe is genuinely far away — a condition measured in * PACKETS, not seconds. On a slow link only a handful of packets arrive in 5s, * so the pure wall-clock check fired while the demuxer was merely starved, * handing the decoder a non-keyframe and painting black video. Below this * count the seek is waiting on bytes, not on a keyframe, so keep waiting. */ private static readonly SEEK_KEYFRAME_MIN_SCAN; /** Absolute ceiling so a permanently starved seek can still bail out. */ private static readonly KEYFRAME_SEEK_HARD_TIMEOUT; private seekKeyframeScanned; private seekCraSeen; private static readonly SEEK_IDR_WAIT_MS; private videoChainBrokenUntilKeyframe; private videoSkipRaslAfterChainCra; private static readonly PREBUFFER_AUDIO_SECONDS; private static readonly PREBUFFER_VIDEO_FRAMES; private static readonly PREBUFFER_VIDEO_SEARCH_PACKETS; private static readonly PREBUFFER_MAX_WALL_MS; private static readonly PREBUFFER_MAX_PACKETS; private seekTargetTime; /** * A video-only resume point, for when the picture has to catch up to sound * that never stopped (see resyncVideoToAudio). * * seekTargetTime serves both streams, and there it has to sit at the END of * the audio the renderer has already scheduled, or the re-demuxed packets * behind it get decoded a second time and the sound fast-forwards. That end * is seconds ahead of what anyone is hearing — measured at six — and holding * the PICTURE to it meant the frame the viewer asked for waited for a moment * that had not been reached yet. Video has no such history to protect: it * resumes where the sound actually IS. * * -1 means no video-only resume is in flight and the shared target applies. * Otherwise this owns the video gate for the rest of the catch-up, dropping * to -Infinity once the first frame lands — the frames AFTER that one are * still behind the audio schedule's end, and handing the gate back to the * shared target would drop every one of them until the sound caught up to a * point it had only buffered, not played. */ /** * The position a completed seek still owes the screen a picture for, or -1. * * A seek that finishes WITHOUT a frame — the forced completion, when the * demuxer ran out or the deadline fired before the walk reached the target — * leaves the player paused (or ended) with nothing new on the canvas. The * frames it was waiting for usually arrive a moment later, out of a decoder * that was still working, and the state gate below drops every one of them * because by then the state is "paused" and waitingForVideoSync is false. * Nothing else ever puts them up: the presentation loop is stopped while * paused, so the picture that was asked for is simply lost. * * Reported on a 1.33s single-GOP transport-stream segment (seg.ts, 40 frames, * one keyframe at 0). Every paused seek there decoded all 40 frames and * presented none: the file is short enough that EOF arrives before the * decoder has emitted the frames past the target, so the forced completion * always won the race. Playing the same file works, because "playing" holds * the gate open. */ private _pictureOwedFrom; private _videoResumeTarget; /** When the catch-up above began, so the UI can tell a hitch nobody notices * from a wait worth putting a spinner on. */ private _videoCatchUpStartedAt; private waitingForVideoSync; private pendingAudioPackets; private pendingPrebufferPackets; private _videoAheadStash; private _videoAheadStashBytes; private _videoAheadActive; private _readAheadDroppedForSeek; private _readAheadInvalidated; /** * The ONE way the read-ahead stash is emptied. Every caller goes through here * — the flush sites, the seek/poster/teardown resets, and processLoop's * point-of-use guard — because emptying it has a consequence that is easy to * forget at any single call site, and forgetting it is a decoder error two * seconds later with nothing nearby to blame. */ /** * Drop stashed video the picture has already gone past, up to the first * keyframe that is still ahead of it. Cheap: a scan of a queue that is only * ever a few hundred entries, and only while it is non-empty. */ private trimOvertakenReadAhead; private dropVideoReadAhead; private static readonly VIDEO_AHEAD_MAX_PACKETS; private static readonly VIDEO_AHEAD_TAIL_GUARD_S; private static readonly VIDEO_AHEAD_ENGAGE_AUDIO_S; private static readonly VIDEO_AHEAD_RELEASE_AUDIO_S; private static readonly VIDEO_AHEAD_MAX_BYTES; /** How much of the audio target must be in hand before the loop goes back to * handing stashed video to the decoder instead of reading for sound. */ private static readonly STASH_DRAIN_AUDIO_FRACTION; private _audioBatchPending; private justSeeked; private seekTime; private startTime; private seekKeyframeOffset; private static readonly POST_SEEK_THROTTLE_MS; private pauseBufferTimerId; private static readonly PAUSE_BUFFER_INTERVAL_MS; private static readonly PAUSE_BUFFER_MAX_PACKETS; private static readonly PAUSE_BUFFER_AUDIO_SECONDS; private static readonly PAUSE_BUFFER_VIDEO_FRAMES; constructor(config: PlayerConfig); /** * Load the media file */ load(sourceConfig?: SourceConfig): Promise; /** * Create source adapter from config */ private createSource; /** * Configure decoders for active tracks */ /** * A+ verify-then-swap quality switch for the demuxer fallback (HLS/DASH). * Instead of tearing the player down and reloading, prepare the new * rendition's demuxer (open + seek to the current position) WHILE the old one * keeps playing, then atomically swap the video source/demuxer and reconfigure * the video decoder. Audio, subtitles, the clock and the AudioContext are * never touched — only the video briefly freezes on the last frame (no black * flash, no audio gap, no loading). Uses one decode pipeline at a time (the * new demuxer opens on an isolated WASM module so it doesn't clash with the * old during prep, but the video decoder only reconfigures at swap-time), so * memory stays flat. Returns false (staying on the current rendition) if the * new one can't be prepared, so a failed switch never breaks playback. */ switchVideoRenditionInPlace(newRenditionUrl: string): Promise; /** * Clear a rendition-switch indicator once the new pipeline is not just * painting but painting SMOOTHLY. * * The first frame is the wrong moment to let go of it. clearQueue() empties * the renderer at the swap, so that frame arrives with nothing behind it — * the decoder is still filling, and what the viewer gets for the next few * hundred milliseconds is a frame here, a frame there. Hiding the indicator * on it just moves the unexplained stutter to right after the indicator * disappears. Wait for about a third of a second of frames AND a queue with * something in reserve, which together are what "it's running again" means. * * Bounded: a decoder that never gets there (a rung the machine can't handle, * a stalled fetch) must not leave the indicator up forever. The stall then * shows as ordinary buffering, which is what it is. */ private clearSwitchIndicatorOnResumedPlayback; /** * Enable/disable adaptive quality (ABR) on the in-place demuxer/premuxed * switch. When on, a timer estimates download throughput and switches to the * best rendition it can sustain — in-place, so it's smooth. Off pins the * current rendition. */ /** * `snap` is for the viewer choosing Auto from a fixed quality: go to the rung * the link carries now, the way the opening pick does, instead of starting * the ordinary climb from wherever the fixed pick left it. See * abrSnapToLink. The per-video re-apply of a remembered Auto preference does * not snap — the opening pick has already made that decision. */ setAutoQuality(enabled: boolean, opts?: { snap?: boolean; }): void; /** * Put Auto on the rung the link carries, now — the opening pick, re-run. * * Choosing Auto from a fixed quality used to hand the ladder to the ordinary * tick, which climbs one step and a cooldown at a time from wherever the * fixed pick left it: a viewer on 360p over a 40Mbps line picked Auto and * watched it walk up for a minute, when a fresh load of the same video opens * on the right rung before the first frame. So do what the fresh load does: * the same 55% of the best link reading we hold, the same device ceilings, * then read the chosen rung's own stream and re-pick from that — up or down, * twice at most — and switch once. A fixed pick above what the link carries * comes down the same way. * * Runs under the tick lock, so no ordinary decision races it. */ private abrSnapToLink; /** * One-shot startup speed test. Probes the SMALLEST rung's URL past the proxy * burst for a sustained reading, seeds the estimate, and re-evaluates so Auto * ramps straight to the affordable rung. Best-effort — a failure just leaves * Auto to measure passively from playback as before. */ private runStartupSpeedTest; isAutoQuality(): boolean; /** * One ABR decision: pick the best rendition the measured throughput can * sustain and switch to it in-place. Downshifts eagerly when the audio buffer * is starving. No-op while a switch is already running or bitrates are unknown. */ private abrTick; private _abrTickInFlight; private _pendingSwitchSource; private _pendingSwitchDemuxer; private _demuxerGeneration; /** * A rung the link just failed: stop the remembered rate claiming it, and * hold the ladder below it for an escalating while — see _abrDrainStrikes. */ private penalizeLeavingRung; /** Deepest the buffer has been since playback last settled on this rung — * the reference the early downshift measures "half used" against. */ private _abrBufferPeak; /** When the early downshift last asked and chose to stay. */ private _abrEarlyCheckAt; /** Below this share of its peak, a buffer the link is not refilling is * worth a question about the rung — see the early downshift. */ private static readonly ABR_EARLY_DOWN_AT; /** A peak shallower than this has no half worth protecting: the reactive * downshift's own thresholds (4-6s) are already that close. */ private static readonly ABR_EARLY_MIN_PEAK_S; private static readonly ABR_EARLY_RECHECK_MS; /** One ABR decision. Always through abrTick(), never called directly. */ private abrDecide; /** * Quick active speed test against a specific rung's file. Fetches a small * head slice and returns the measured throughput in BITS/s (or -1 if it can't * measure — a failure just falls back to the passive estimate). * * Probing the TARGET rung, not the current one, is the point: it's a file the * browser has never fetched, so it hits the network fresh rather than reading * a cache, and it directly answers "can the link carry THIS rung?". Only used * to gate an UPSHIFT — a downshift happens because the buffer is already * draining, and adding a probe fetch into a struggling link would only slow * the recovery, so downshift stays reactive. */ /** Last usable range-probe reading, and when it was taken. Reused to size the * upshift candidate while the buffer is deep — see the paced-link note in * abrTick. */ private _lastProbeBits; private _lastProbeAt; /** Last probe reading per rung URL — the second opinion an upshift needs * before the ladder moves into that rung. See the vote in abrTick. */ private _rungProbeBits; /** Rungs whose in-place climb could not be primed, and until when the * ladder aims below them. See the cap in abrDecide. */ private _primeRefusals; private static readonly PRIME_REFUSAL_MS; private static readonly PRIME_REFUSAL_MAX_MS; /** Probes of a rung that came back with no reading, and when the last one * did — the backoff before asking that rung again. */ private _rungProbeMisses; /** How long a probe reading stays fresh enough to size a candidate from. */ private static readonly ABR_PROBE_FRESH_MS; /** How long a rung's own refusal stands before it is worth spending another * 2.4MB to re-ask. Two ABR ticks and a little. */ private static readonly ABR_PROBE_MIN_GAP_MS; private probeRungThroughput; /** Perform an ABR switch and arm the anti-thrash cooldown/hysteresis. */ private abrCommit; private abrSwitchTo; /** * Relieve a DECODE/render bottleneck — the device can't sustain the CURRENT * rung's resolution even though the network is fine (buffer full, throughput * healthy), so frames are being dropped and playback stutters. The plain ABR * never sees this because it reads only network signals; this is driven off * the renderer's sustained frame-deficit detector instead. Drops ONE rung and * penalizes the one it leaves so the ABR won't climb straight back into a * resolution the device just proved it can't decode. A no-op unless Auto is on * and a lower rung exists — at the lowest rung (or with Auto off) the renderer's * FPS cap + software frame-skip remain the only levers. Re-fires naturally if * the lower rung is still too heavy: switchVideoRenditionInPlace reconfigures * the renderer, which re-arms its perf window for a fresh measurement. */ /** * Ask the device, BEFORE climbing, whether it can actually play a rung. * * The reactive path learns a ceiling by climbing into a rung, stuttering for * a perf window or two, and dropping back out — so every machine that cannot * do 8K tries 8K, on a good connection, every time. MediaCapabilities answers * beforehand: decodingInfo() reports `smooth` (real-time) and * `powerEfficient` (hardware) for a codec at a resolution and frame rate. * A rung that isn't smooth here goes into the same ceiling the decode-bound * detector fills, so the ABR simply never offers it. * * Screened once per ladder, and only above 4K. Below that, software decode is * a legitimate option plenty of machines sustain, and screening it out would * cost quality on devices that were coping fine. * * Above it, three verdicts bar a rung: unsupported, not smooth, and NOT * powerEfficient. The last one matters because `smooth` is optimistic — * measured on a Mac, Chrome answers 8K AV1 with smooth:true even where * playback stutters, while 8K H.264 comes back powerEfficient:false. At that * size powerEfficient:false means a software decoder, and software 8K is not * real-time on anything. Whatever this misses, the decode-bound detector * still catches — once, now that the ceiling survives a reload. */ private screenRungsForDecodeCapability; private abrDeviceDownshift; /** * Last resort: the video has stopped moving under running audio and the * normal ABR hasn't rescued it. Bail out to the LOWEST rung immediately. * * Everything the ordinary downshift weighs — the 5s settle, the 12s cooldown, * "is the source coasting", the throughput estimate — exists to keep quality * from flapping on a healthy link. None of it applies once playback has * actually stopped: the rung has been disproven by the outcome, so this * skips the lot and goes straight to the bottom of the ladder, whose small * file also preps fastest. * * Retried once, because the failure that gets here is usually a flaky link * and the rescue's own open has to cross the same one — a single fumbled * size probe was enough to leave a real session frozen until the viewer * seeked by hand. * * Auto only. A rung the viewer picked by hand is their decision, and this * does not get to overrule it: if the link can't carry 1080p, a viewer who * asked for 1080p gets buffering, not a quality they didn't choose. (It used * to override the pick once playback stopped, on the grounds that a stalled * picture helps nobody. The choice is the viewer's to make and to change.) * * Returns the label of the rung it moved to, or null when there was nothing * to switch to (Auto off, no ladder, already lowest) — then the caller falls * back to its own recovery. */ /** * How long the picture has been catching up to the sound, in ms — null when * it is not. The wait is normally under a tenth of a second, so the UI can * hold its spinner back for a moment rather than flashing one on every * return from a background tab. */ videoCatchUpElapsedMs(): number | null; /** * Milliseconds since a rendition switch last LANDED (Infinity if none has). * The picture is legitimately still for a moment after one — the queue was * emptied at the swap and the new decoder has not filled it yet — so the * element's frozen-video watchdog needs to know the difference between that * and a stall. */ msSinceRenditionSwitch(): number; abrEmergencyDownshift(reason: string): Promise; private configureDecoders; /** * Pre-read a small amount of media before reporting "ready" and stash the * packets for the normal demux loop to consume. On short videos the demux * burst can drain the file faster than the HTTP source delivers bytes, * tripping the stall detector the moment play() starts; reading ahead * gives the source layer more time to buffer bytes. * * We deliberately do NOT decode here — the video decoder's onFrame * callback drops frames whenever state !== "playing", and the audio * renderer starts AudioContext playback the moment samples arrive. Both * break if we decode during prebuffer. */ private prebuffer; /** * Start playback */ play(): Promise; /** * Pause playback */ pause(): void; /** * Flag to prevent concurrent async WASM operations */ private demuxInFlight; private demuxInFlightStartTime; /** * The DTS of the last video packet handed to the decoder, and the marks a * muxed rate-change rewind leaves behind: re-read video up to that DTS has * already been decoded and is dropped, audio before the rewind target is a * fraction of a second the renderer has just been reset past. DTS rather * than PTS because the demuxer delivers in decode order, and re-delivers the * identical sequence — so resuming at the packet after the last one fed * leaves the decoder's reference chain exactly as it was. */ private _lastFedVideoDts; /** Decoded picture, in seconds at the new rate, that makes an audio-only rewind safe. */ private static readonly REWIND_PICTURE_CUSHION_S; private static readonly REWIND_QUEUE_AT_CAP_FRACTION; private static readonly AUDIO_CARVEOUT_MAX_MULT; private _rewindVideoUntilDts; private _rewindAudioFrom; private _rewindAudioFloorPending; private static readonly DEMUX_TIMEOUT; private _loopEnabled; /** Turns this source has come back round, from 1. Reset when the source is. */ private _loopCount; private _loopPrerolling; private _loopPrerollFrames; /** This pass turns over on the SOUND's seam, not the picture's — see * maybeCompleteLoopWrap. */ private _loopWithSound; /** When the sound first ran dry with the seam still not reached, or 0. */ private _loopSeamDrySince; /** How long the sound may sit empty with no new pass in it before the * picture turns over without it. */ private static readonly LOOP_SEAM_DRY_MS; /** Enough to cover the handover; a cap because these are DECODED frames and * an 8K one is tens of megabytes of VRAM apiece. */ private static readonly LOOP_PREROLL_MAX_FRAMES; private eofReached; private eofSince; private _audioPlayedOutSince; /** * Route a decoded audio frame to the renderer, dropping any that predate the * active seek target. FFmpeg seeks to the keyframe BEFORE the target, so it * decodes pre-target packets, and a seek-flush can emit an in-flight frame * from the OLD position — both would let the renderer sync the clock back to * where playback was, which reads as "the first seek jumped back and audio * stopped" (a second seek then works because the pipeline is clean). The * demux-level skip in pumpSplitAudio only catches packets read after the * seek, not frames already in the decoder pipeline; this catches those. * Mirrors the video onFrame guard. */ private renderDecodedAudio; /** * Internal handler for seek completion when first target frame is found. * Clears the seek flag, synchronizes clock, and transitions to final state. */ /** * True when the playhead has reached the stretch of the file that has audio * but no video (see _videoTailStart). Callers ask this before treating an * empty video queue as a stall: past here the queue is empty because the * video track ENDED, so the right behaviour is the native one — hold the * last frame and let the sound play on to the end of the file. */ private isInAudioOnlyTail; /** * The mirror: true once the playhead has reached the stretch that has video * but no audio (see _audioTailStart). Callers ask this before treating an * empty audio buffer as a shortfall — past here it is empty because the * track ENDED, and the right behaviour is to let the picture play on to the * end of the file. */ isInVideoOnlyTail(at?: number): boolean; /** * Is there no sound left to wait for here? * * `isInVideoOnlyTail()` answers from `_audioTailStart`, and that is LEARNED — * at EOF, or from a container that declares per-track durations, which the * MKV this was measured on does not. On a source that has just loaded, * nothing has been learned yet, so a seek straight into a stretch with no * audio reads as "sound is on its way" and anything waiting for a cushion * waits for good. Measured on the 243.8s file whose Opus ends at 193.4s: * load it and seek to 194, 200, 220 or 240 and it sat in `buffering` with the * spinner up and the clock frozen — indefinitely, with 130 frames decoded and * queued behind it. Play the same file THROUGH the handover first and the * same seeks are fine, because by then the tail is known. * * So ask the demuxer's own read as well. A long run of video packets with no * audio between them means the sound has stopped in the file: audio packets * outnumber video ones on any ordinary interleave, and the bar is the * read-ahead bound, which is the deepest a healthy pipeline ever runs video * ahead of the audio buried in it. * * NOT for the starve verdict in processLoop. Standing that down early was * tried twice and breaks the tail handover outright — see `soundIsOver` * there, which stays on the settled-at-EOF signals on purpose. */ private noSoundLeftToWaitFor; /** * True when the picture is the whole of playback — there is no sound that * could carry it on its own. A file with no audio track, audio switched off, * or sound the browser refuses to start. * * It matters wherever the pipeline is willing to let playback run without * frames. With sound, that is a picture falling behind; without it, it is a * wall clock ticking over a frozen frame while nothing plays at all — the * timeline advancing past content the viewer never saw, because the frames * that arrive late are already behind the clock and get dropped. * * False where the picture is missing BY DESIGN — data-saver audio-only, a * hidden tab — since waiting for frames nobody is decoding never ends. */ private pictureIsPlayback; /** * Is there sound that could carry playback on its own right now? A track that * exists, isn't switched off, and isn't being dropped because the browser * won't start the context. (Split audio lives in its own demuxer, outside the * track manager, so it is counted separately.) */ private hasAudioThatCanPlay; private cancelBlackFrameWatchdog; /** * After a seek force-completes with no decodable video frame, watch for one to * actually land. If none does within a short window — the decoder can't produce * a picture at this point (open-GOP / a bad seek target), and the audio-driven * resume has flipped to "playing" over a BLACK screen — nudge the playhead onto * the next GOP by seeking a little forward. Escalates the jump each attempt and * stops after MAX_BLACK_RECOVERY_SEEKS so a genuinely undecodable stream doesn't * seek forever. This automates the manual seek users do to unstick a black * screen. Superseded silently if a newer seek (incl. the user's own) intervenes. */ private armBlackFrameWatchdog; private notifySeekCompletion; /** * Heavy lossless/complex software audio codecs (TrueHD/MLP, DTS/DCA) decode * sub-realtime for ~1-2s from a cold start. Every seek flushes the decoder, * so each post-seek resume is a cold start that needs the audio prime cushion * — not just the first play/replay. Hardware audio (AAC) and lightweight * software codecs (Opus/FLAC/AC-3/E-AC-3) decode faster than realtime even * cold and don't need it. (issue #11) */ private activeAudioNeedsColdPrime; /** * Should a post-seek resume hold for the frame queue to refill first? * * Only where there is a picture that is actually being decoded and presented: * data-saver audio-only and a hidden tab both skip video on purpose, so the * queue is empty for a reason that has nothing to do with the seek, and * waiting on it would hold the sound for a picture nobody asked for. Same * carve-outs the rebuffer gate's `pictureRunning` makes. */ private needsSeekResumeQueue; /** Whether the audio side has yet to reach a resumable cushion — the sound * half of the question needsSeekResumeQueue() asks about the picture. * Mirrors `audioReady` in the rebuffer check, at its lightest target: this * decides how long to keep a spinner hidden, not when to resume. */ private audioSideStillPriming; /** Frames the renderer should hold before playback restarts after a seek. */ private seekResumeQueueTarget; /** * The active audio is one of the codecs that genuinely decodes sub-realtime * from cold — the ones the prime and the 2s rebuffer cushion were built for. * * This used to be spelled `audioDecoder.usesSoftware && /truehd|mlp|dts|dca/` * in one place and plain `audioDecoder.usesSoftware` in the other, and the * two meant the same thing back when only the heavy codecs took the software * path. AudioDecoder.needsSoftwareDecoding() now returns true for everything * (see its note — one decoder, so the bitrate ladder isn't also a decoder * ladder), which silently turned the second spelling into "always". The * consequence was measurable: a plain 8-channel AAC file underran once, and * the buffering exit gate then demanded a 2s cushion — the TrueHD number — * before it would resume. It never got there, and the recovery came from * MoviElement's stuck watchdog seeking out of it rather than from the player. * * So ask the codec, not the code path, and ask it from one place. */ private activeAudioIsHeavySoftware; /** * Tell the demuxer to skip every audio track except the active one. A file * with a second, unused audio track (e.g. a dual-TrueHD source with ~933 * packets/s PER track) otherwise floods the read path with packets the loop * immediately throws away — roughly doubling the reads needed per second. On * a slower engine (Safari pulls ~half the packets/s of Chromium) that starved * the ACTIVE audio below realtime and stalled every few seconds (issue #11). * AVDISCARD_ALL makes av_read_frame skip them internally so each read returns * a useful packet. Only audio streams are touched — video and subtitles keep * their demuxer defaults (subtitles are read on demand). Re-applied on every * audio-track switch so the newly-selected track is re-enabled. */ private applyStreamDiscard; /** * Begin an audio prime: hold the AudioContext suspended (primeForBuffering, * which never issues a resume() so it can't drain/race), flush any pending * post-seek audio packets into the decoder so the cushion starts filling, and * flag _primingAudio so the buffering→resume gate waits for a real cushion * (2s) rather than the thin 0.1s default before resuming. */ private beginAudioPrime; /** * The mechanical half of a prime, without the 2s cushion demand: hold the * context suspended and hand the stashed post-seek packets to the decoder so * the buffer fills against a frozen clock. * * Any seek-completion branch that holds the picture back has to do this. The * packets are stashed precisely BECAUSE the renderer isn't playing yet * (render() drops AudioData while isPlaying is false), so a branch that * returns without calling this leaves the audio buffer at zero — and the * resume gate it just handed control to reads that as "audio not ready" and * waits out its escape every single time. */ private holdAudioForBuffering; /** * Hand a run of audio packets to the decoder in as few WASM round-trips as * possible. * * Only the software path batches — and that's where it matters: TrueHD/MLP * emits a 40-sample access unit (~0.8 ms), so priming a 2s cushion after a * seek means ~2400 packets, each otherwise costing its own send/receive/getter * round-trips and per-channel copies. Batched, the whole run crosses once. * WebCodecs codecs have no such cost and just replay one by one. * * A short `consumed` means the block ended at a format change or pts * discontinuity, so the remainder goes as a fresh batch — never flattened. */ private submitAudioPackets; /** * Main Playback Loop */ private processLoop; /** * Handle playback ended */ /** * Turn the loop on or off. Off discards anything primed — the frames are * decoded pictures and holding them for a loop that is no longer coming is * VRAM nobody asked for. */ setLoop(on: boolean): void; isLoopEnabled(): boolean; /** * The sound as levels, for drawing — `bars` bands, each 0..1, or null when * there is nothing playing to read. Used by the cover-art view's dotted * meter; any host can draw its own from the same numbers. */ getAudioLevels(bars: number): Float32Array | null; /** How many times this source has looped, from 0. */ getLoopCount(): number; /** * The file has come back round. One place for it, because there are two * routes to here — the gapless turn, and the restart a file with a * soundtrack still takes through `ended` — and a page counting loops should * not have to care which one it got. */ private noteLoopTurn; /** Let go of a primed pass: closing the frames, dropping the held packets, * and cancelling the seam the audio renderer was holding open. */ private discardLoopPreroll; /** * Start decoding the next pass, if this is the moment for it. * * Every condition here is about having somewhere free to do the work. The * demuxer is free because EOF means it has read everything. The decoder is * free because the EOF flush has emitted its reorder tail and its queue has * drained. And there is TIME to work in because the renderer still holds * picture — without that the priming would be racing the very freeze it * exists to remove, and we may as well take the old path. */ private maybeStartLoopPreroll; private startLoopPreroll; /** * The seam. Called once the outgoing pass has no picture left to show. * * Order matters: the sound is armed BEFORE any of the new pass is decoded, * so the first buffer of it is placed against a scheduler that already knows * where the media time wraps — and placed at `scheduledTime`, which is where * the outgoing pass's last sample ends. commitAudioBuffer never honours a * backward jump in the media timeline, so it appends rather than re-anchors: * no hole, no overlap, nothing to hear. */ private maybeCompleteLoopWrap; private handleEnded; /** * Seek to timestamp */ private seekSessionId; private seekArmedSessionId; private _liveSeekSession; private wasPlayingBeforeSeek; suppressSeekSpinner: boolean; /** * The failure the source will not come back from, if there has been one. * * An expired or revoked link answers every subsequent byte range the same * way, so HttpSource latches the refusal and re-throws it on every read * without touching the network again. That latch is the only honest signal * that the video is over — the demuxer cannot supply one, because a read * that FAILED and a read that reached the end of the file both reach C as * "no bytes". */ getSourceFailure(): Error | null; /** * What the server called this source, from a request it had already made. * * Asked after an open has failed, to tell the one case the URL cannot: a * manifest served from a path with no extension. Empty for a source that * never saw an HTTP header — a file, an adapter, a stream already playing. */ getSourceContentType(): string; /** The same question for a separately-fetched audio rendition, which has a * source of its own and therefore an expiry of its own. A split stream's * two URLs are signed together and die together, but the audio is the one * read on a tick of its own — see pumpSplitAudio. */ getSplitAudioFailure(): Error | null; /** * End playback on a failure nothing downstream can recover from. * * Pausing without this leaves the state machine in "buffering", which the * UI renders as a spinner that never resolves — the viewer is told the * video is loading when it is not coming at all. */ failFatally(error: Error): void; seek(seconds: number, opts?: { suppressSpinner?: boolean; preservePlaying?: boolean; /** * Machinery, not a request: rendering the first frame, priming a poster, * re-anchoring after a context loss. `seeking`/`seeked` stay quiet for * these — a media element repositioning itself internally says nothing * either, because those events answer for what a page asked for. */ internal?: boolean; }): Promise; /** * A seek that was superseded mid-flight, letting go. * * If a NEWER SEEK took the session there is nothing to do: it set "seeking" * itself and its own completion will resolve it. The other case is the one * this exists for. An in-place rendition swap, a subtitle prefetch and the * network-recovery re-seek all bump seekSessionId purely to invalidate an * in-flight seek — none of them is a seek, and none of them finishes what * this one started. What it leaves behind is a player in "seeking" with a * paused clock, a stopped split-audio pump, and (past the arming point) * waitingForVideoSync raised for a session that can never complete, so every * frame that arrives afterwards just re-enters notifySeekCompletion's * stale-session branch and bails. * * Read off the reported session: the network came back, the recovery seek * went out, the ABR swap that had been stuck on the dead link completed * half a second later and took the session — and the player sat frozen under * a spinner, first frame decoded and on screen, until the viewer dragged the * scrubber by hand. That manual seek was doing what this does here. */ private abandonSupersededSeek; /** * Hand the pipeline back to playback after the seek that owned it was taken * over by something that is not a seek. Called from both ends of that race: * the seek itself when it notices (abandonSupersededSeek), and the operation * that took the session — the in-place rendition swap — when the seek was * already past its last check and cannot. Whichever gets there first, the * other finds the state resolved and does nothing. */ private resumeAfterOrphanedSeek; /** * Check if seek target time falls within the already-buffered byte range. * Uses linear byte→time estimation (same as getBufferedTime). */ private isSeekTargetBuffered; /** * Initialize WebGL context for thumbnail rendering */ /** * Generates a preview frame for the given time using C-based FFmpeg software decoding. * Fast and doesn't block main playback. */ /** * Generates a preview frame for the given time using C for demuxing and WebCodecs for decoding. */ getPreviewFrame(time: number, view?: VRView | null, /** * Wait for a generation already in flight instead of returning null. * * The pipeline is a single decoder, so only one frame can be made at a * time, and a second caller is turned away. That is RIGHT for the seek bar: * a hover wants the frame for where the pointer is NOW, and a queue of * stale positions is worse than a dropped one. * * It is wrong for anything asking for a fixed list of times. The chapter * strip asks for one frame per chapter in a loop, and one preview already * running — a keyframe fetch is 2MB, which is seconds on a phone — turned * every one of those calls away instantly. Sixteen chapters resolved to * null in a few milliseconds and the panel came out empty, with the count * printed over it. */ queue?: boolean): Promise; /** * Generate timeline thumbnails at regular intervals * @param count Number of thumbnails to generate (default 8) * @param onProgress Callback for each generated thumbnail * @returns Array of { time, blob } objects */ generateTimeline(count?: number, onProgress?: (index: number, total: number, blob: Blob, time: number) => void): Promise>; /** * The rung to decode seek previews from, or null when there is no ladder to * choose from (a single file — there is nothing cheaper to read). * * Lowest rung that still has some detail: a preview is a ~160px-wide still, * so anything above ~240p is pixels nobody sees, paid for in bytes and in * decode time on the machine already decoding playback. Below 240p the * picture starts to read as mush at 2x DPR, so that is the floor — and if * the ladder's smallest rung is lower than that, it is still the cheapest * thing on offer and wins by default. * * …but never ABOVE the rung being played. That floor is about how a preview * LOOKS, and it only earns its cost while the picture beside it is better * still. A player that opened on 144p did so because the link could not carry * more, and pulling a 240p stream alongside it to draw hover stills is the * one machine on the one link spending more on the preview than on the video. * There the cheapest rung is also the honest one: a still can hardly be * blurrier than the picture it is previewing. */ private pickPreviewRendition; /** * Arm the deferred preview warm-up, and keep re-arming it until playback has * actually settled. * * The delay exists to keep the second WASM module + WebCodecs decoder out of * the first seconds of PLAYBACK. It used to be a single timer started when * load() finished, which quietly assumes play() follows immediately — and on * a local file it does not. A FileSource preload reads 20×2MB before play() * is even reachable, so on an 11 GB MKV the 12s grace was already spent by * the time the first frame went up: the warm-up landed ~4.5s into playback, * stood a second isolated WASM instance up, re-opened the same 11 GB file * through the same main-thread Asyncify reads, and the audio cushion the * (all-software) decoder was holding collapsed into a stall that then made * no progress at all for six seconds, until MoviElement's stuck watchdog * seeked out of it. * * So measure the grace against playback, not load, and never start the * warm-up while the pipeline is already struggling — buffering and seeking * are precisely when the competing decode is least affordable. A source that * is simply sitting paused is not competing with anything, so it still warms * on schedule; if the user scrubs before any of that, the seek path lazy-inits * on demand (initPreviewPipeline is idempotent). */ private schedulePreviewWarm; /** Goes at the preview rung before previews are drawn from the picture's own * file instead — see initPreviewPipeline. */ private static readonly PREVIEW_RUNG_TRIES; private _previewRungFailures; /** * Build the preview pipeline — off the small rung when there is one, and off * the rendition being played when that rung will not open. * * The rung is a separate file, and a separate file can fail where the one * playing does not: another host (a ladder's small rungs are often on a * second CDN), a header that host never sends, a link that has expired. The * rung was the only thing ever tried, so every retry — three, from the hover * path — went at the same failing file, and then previews were switched off * for the rest of the video while the picture itself played on fine. So: the * rung twice, and then the file already on screen, which is known to open. */ private initPreviewPipeline; /** What a build that threw left half-made: nothing of it is published. */ private abandonPreviewBuild; private buildPreviewPipeline; /** Hand the shared thumbnail module back, if this player holds it. */ private releaseSharedThumbModule; /** * Is the source playing without a duration anybody could find? * * True only once the media is actually open — during a load the duration is * legitimately unknown and the UI should stay blank, not pretend the file is * endless. Drives the seek bar's live-edge rendering and hides the 0:00 that * would otherwise sit where a total ought to be. */ hasUnknownDuration(): boolean; /** * Try once more for a missing duration, with playback already running. * * The scan inside open() has a load waiting on it, so it is capped in the * single seconds and gives up having read a few percent of anything but a * small file — a 291MB Matroska got 14MB in before its second was up. Off * the load path there is nothing to hold up, so the same pass can be given * the time it actually needs. * * It cannot run on the playing context: movi_scan_duration reads that * context to EOF and then seeks it back, which would take every packet out * of the demuxer's mouth. So it opens its own, over its own reader, exactly * as the preview pipeline does — and for the same reason. */ private scheduleDurationRescan; private rescanDurationInBackground; /** * Drop the preview reader's fetch window once hovering has stopped, so the * 2MB it holds isn't kept for the rest of the session — but not between two * hovers of the same scrub, which is what it exists for. */ private scheduleThumbBufferClear; /** * The seek bar is done asking: stop downloading the rest of the preview * reader's fetch window, keeping the bytes that have arrived. * * The window keeps streaming after its frame is made so a scrub's next hover * finds its bytes already here — worth it only while the pointer is still * travelling. The caller is the one that knows: the player sees requests, and * on a fetching source the seek bar deliberately holds those back for up to * 1.5s mid-drag, so a quiet spell here is not a pointer at rest. Ignored * while a frame is being made; its own reads need the window. */ stopPreviewFill(): void; private destroyPreviewPipeline; /** * Get all tracks */ getTracks(): Track[]; /** * DASH-fallback video Representations for the demuxer-mode quality menu * (best-first), and the one currently playing. Empty unless force-demuxing. */ getDashRenditions(): { url: string; label: string; id: string; bandwidth?: number; }[]; getActiveDashRendition(): string; /** * Externally supply the video renditions (with bitrate) for the ABR — used by * the premuxed multi-source path, which owns the quality list in the element. * * `activeUrl` seeds which rendition is *currently* playing. This matters: the * ABR compares its pick against `_activeDashRendition`, and until a swap sets * that it's "". With it empty the ABR can't recognise the file already on * screen, so it "switches" to the identical rendition — a pointless in-place * swap that reseeks the video and desyncs it against the still-running split * audio. Seed it once (only when unset, so a real swap's value isn't clobbered * by a later menu re-render passing the unchanged element src). */ setDashRenditions(renditions: { url: string; label: string; id: string; bandwidth?: number; height?: number; codec?: string; }[], activeUrl?: string): void; /** Current network throughput estimate (bytes/s). Hosts can persist this and * re-seed the next video (a fresh player) so the ABR sizes the starting * quality from a real number instead of climbing up from a cold estimate. */ getNetworkThroughputBps(): number; /** Seed the throughput estimate (bytes/s) before playback measures its own. * Only applied while no live measurement exists, so a real sample always * wins. Lets a fresh video pick the right rung on the first ABR tick. */ seedNetworkThroughputBps(bps: number): void; /** * Fold the source's live download speed into the throughput estimate. Meant to * be called frequently (every UI tick, ~250ms) — the ABR's own 4s tick is too * coarse to catch a small file that finishes downloading in under a second, so * a fully-cached video would otherwise leave the estimate stale/low and Auto, * thinking the link is slow, would sit stuck at the low starting rung. */ sampleThroughput(): void; /** * Get video tracks */ getVideoTracks(): VideoTrack[]; /** * Get audio tracks */ getAudioTracks(): AudioTrack[]; /** * Get subtitle tracks */ getSubtitleTracks(): SubtitleTrack[]; /** * Select audio track */ selectAudioTrack(trackId: number): boolean; /** * Route audio output to a specific device (AudioContext.setSinkId). * "" → system default. Returns false when unsupported / device gone. */ setAudioOutputDevice(deviceId: string): Promise; /** Current audio output device id ("" = system default). */ getAudioOutputDevice(): string; /** * Select subtitle track */ selectSubtitleTrack(trackId: number | null): Promise; /** * Get current playback time */ getCurrentTime(): number; /** * After a `postertime` seek has painted the poster frame on the canvas, * reset only the CLOCK/playhead bookkeeping back to the start — WITHOUT * flushing the decoder, re-seeking the demuxer, or clearing the renderer * queue. That keeps the poster frame (from ~postertime) visible on the * canvas while the seek bar/getCurrentTime() read 0, and lets the first * play() start cleanly from the beginning (play()'s first-play branch * re-seeks the demuxer to 0 itself). Pure time-math; touches no media state. */ resetClockToStartForPoster(): void; /** * Get duration */ getDuration(): number; /** * Get LRU cache statistics */ getCacheStats(): { utilization: number; sizeBytes: number; maxSizeBytes: number; entryCount: number; }; /** * Get cached time ranges for visualization * Converts cached byte ranges to time ranges * @returns Array of {start, end} time ranges in seconds */ getCachedTimeRanges(): Array<{ start: number; end: number; }>; /** * Get current state */ getState(): PlayerState; /** * Intended playback state, independent of transient interruptions. * * The raw state flips to "buffering"/"seeking" while the user is still * mid-playback (network stall, internal seek), which would otherwise make * the UI's play/pause icon flicker to "play" even though the user never * paused. This returns true whenever playback is meant to be running — * actually "playing", or interrupted by a buffer/seek that we entered * from a playing state (tracked via wasPlayingBeforeRebuffer/Seek). Use * this to drive the play/pause icon so it stays stable through stalls. */ isPlaybackIntended(): boolean; /** * Get media info */ /** * Load an encrypted video source * Reconfigures the player with an EncryptedHttpSource */ loadEncrypted(config: { videoUrl: string; tokenUrl: string; videoId: string; fingerprint: string; sessionToken: string; tokenRefreshInterval?: number; onAuthFailed?: (reason: string) => void; }): Promise; getMediaInfo(): MediaInfo | null; getContentDispositionFilename(): string | null; getMetadataTitle(): string | null; /** * Get HLS video element (DRM mode) for direct DOM insertion */ getHLSVideoElement(): HTMLVideoElement | null; /** Chapters supplied by the host, which win over anything in the container. * Null = none supplied, so the container's own chapters are used. */ private _externalChapters; /** * Get chapters: the host's list if it supplied one, otherwise the media's * own (MKV/MP4 chapter atoms, read by the demuxer). * * Ends are filled in HERE rather than when the list is set, because a host * typically has its chapters before the media is open — at which point the * duration is still 0 and the last chapter would be left ending where it * starts, i.e. zero-length. */ getChapters(): Array<{ title: string; start: number; end: number; image?: string; }>; /** * Supply chapters from outside the media file. Most streaming sources carry * them nowhere near the bytes — YouTube keeps them in the watch page, a CMS * in its own database — so a player that can only read container chapters * can't show them for the sources that use them most. * * Ends are derived where omitted: a chapter runs until the next one starts, * and the last to the end of the media. Pass null (or an empty list) to drop * back to the container's own chapters. */ setChapters(list: Array<{ title: string; start: number; end?: number; image?: string; }> | null | undefined): void; resizeCanvas(width: number, height: number): void; /** * Set HDR enabled state */ setHDREnabled(enabled: boolean): void; /** * Check if current media is HDR */ isHDRSupported(): boolean; /** * Set subtitle overlay element for HTML-based subtitle rendering */ setSubtitleOverlay(overlay: HTMLElement | null): void; /** * Set extra bottom padding for subtitles when controls are visible */ /** See CanvasRenderer.subtitleReservePx. */ getSubtitleReserve(): number; /** Hold the caption still while the viewer drags it. */ setSubtitleHeld(held: boolean): void; setSubtitleControlsPadding(padding: number): void; /** * Rotate video 90 degrees clockwise */ rotateVideo(): number; /** * The currently-displayed picture, or null — a decoded VideoFrame, or the *