/** * MoviPlayer - Main public API for the streaming video library */ import type { PlayerConfig, SourceConfig, Track, PlayerState, PlayerEventMap, MediaInfo, VideoTrack, AudioTrack, SubtitleTrack, SubtitleRenderer } from "../types"; import { EventEmitter } from "../events/EventEmitter"; import { type SourceAdapter } from "../source"; import { TrackManager } from "./TrackManager"; import { LogLevel } from "../utils/Logger"; 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; private _splitAudioEof; /** 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 _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; 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 across reloads. * 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; /** * 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; /** …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; private _abrDrainStrikes; private _videoHoldingForKeyframe; private _lastSeekAt; private _blackFrameWatchdog; private _blackRecoverySeeks; private static readonly MAX_BLACK_RECOVERY_SEEKS; private _activeSubtitleLang; private _externalSubCues; 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; 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 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; private wasPlayingBeforeRebuffer; 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 _bufferingEntryTime; private _bufferingSelfInflicted; private _lastRateChangeAt; private _lastSeekResumeAt; /** 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; /** * 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; /** 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; /** 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 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. */ 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 _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. */ setAutoQuality(enabled: boolean): void; /** * 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; /** 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; /** 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; private static readonly DEMUX_TIMEOUT; private eofReached; private eofSince; /** * 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. */ 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; /** * 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; /** * 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 */ 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; /** * 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; }): 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; private initPreviewPipeline; /** Hand the shared thumbnail module back, if this player holds it. */ private releaseSharedThumbModule; 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 */ setSubtitleControlsPadding(padding: number): void; /** * Rotate video 90 degrees clockwise */ rotateVideo(): number; /** * The currently-displayed picture, or null — a decoded VideoFrame, or the *