import { TypedEmitter } from "./events.js"; import { probe } from "./probe/index.js"; import { classify } from "./classify/index.js"; import { Diagnostics } from "./diagnostics.js"; import { PluginRegistry } from "./plugins/registry.js"; import { registerBuiltins } from "./plugins/builtin.js"; import { discoverSidecars, attachSubtitleTracks, SubtitleResourceBag } from "./subtitles/index.js"; import { dbg } from "./util/debug.js"; import type { Classification, CreatePlayerOptions, DiagnosticsSnapshot, MediaContext, PlaybackSession, PlayerEventMap, PlayerEventName, StrategyName, TransportConfig, Listener, } from "./types.js"; import { AvbridgeError, ERR_PLAYER_NOT_READY, ERR_ALL_STRATEGIES_EXHAUSTED } from "./errors.js"; /** * Decoded-video-frame counter reader. Prefers the standard * `getVideoPlaybackQuality().totalVideoFrames` (all evergreen browsers); * falls back to the WebKit-prefixed `webkitDecodedFrameCount` for older * Safari. Returns 0 for non-video elements or when nothing exposes the * count — the caller treats 0 as "no signal" (constant across samples, * which is fine). */ export function readDecodedFrameCount(target: HTMLMediaElement): number { if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0; const vq = (target as HTMLVideoElement & { getVideoPlaybackQuality?: () => { totalVideoFrames: number } }).getVideoPlaybackQuality; if (typeof vq === "function") { try { return vq.call(target).totalVideoFrames; } catch { /* fall through */ } } const legacy = (target as HTMLVideoElement & { webkitDecodedFrameCount?: number }).webkitDecodedFrameCount; return typeof legacy === "number" ? legacy : 0; } /** * Pure decision function for the stall supervisor. Takes a snapshot of * the observable state and returns whether to escalate. Extracted so it * can be unit-tested without spinning up a real player / media element. * * - `time-stall`: `currentTime` hasn't moved for `timeStallThresholdMs` * despite the element being in a state where it should be playing. * - `silent-video`: the media has a video track, `currentTime` is * advancing (audio is playing), but the decoder has produced no new * frames for `frameStallThresholdMs`. Catches Firefox-style "MSE * reports codec supported but the decoder can't actually decode it". */ export function evaluateDecodeHealth(input: { hasVideoTrack: boolean; timeAdvanced: boolean; framesAdvanced: boolean; now: number; lastProgressTime: number; lastFrameProgressTime: number; timeStallThresholdMs?: number; frameStallThresholdMs?: number; }): { escalate: false } | { escalate: true; kind: "time-stall" | "silent-video" } { const timeThreshold = input.timeStallThresholdMs ?? 5000; const frameThreshold = input.frameStallThresholdMs ?? 3000; if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) { return { escalate: true, kind: "time-stall" }; } if ( input.hasVideoTrack && input.timeAdvanced && !input.framesAdvanced && input.now - input.lastFrameProgressTime > frameThreshold ) { return { escalate: true, kind: "silent-video" }; } return { escalate: false }; } export class UnifiedPlayer { private emitter = new TypedEmitter(); private session: PlaybackSession | null = null; private diag = new Diagnostics(); private timeupdateInterval: ReturnType | null = null; // Saved from bootstrap for strategy switching private mediaContext: MediaContext | null = null; private classification: Classification | null = null; // Stall detection private stallTimer: ReturnType | null = null; private lastProgressTime = 0; private lastProgressPosition = -1; /** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames` * (or `webkitDecodedFrameCount` fallback). Used by the silent-video * watchdog — catches cases where `currentTime` advances (audio plays) * but the decoder produces no frames, e.g. Firefox claiming `hev1.*` * via MSE when the decoder actually can't decode HEVC. */ private lastVideoFrameCount = 0; private lastVideoFrameProgressTime = 0; private errorListener: (() => void) | null = null; // Bound so we can removeEventListener in destroy(); without this the // listener outlives the player and accumulates on elements that swap // source (e.g. ). private endedListener: (() => void) | null = null; // Background tab handling. userIntent is what the user last asked for // (play vs pause) — used to decide whether to auto-resume on visibility // return. autoPausedForVisibility tracks whether we paused because the // tab was hidden, so we don't resume playback the user deliberately // paused (e.g. via media keys while hidden). private userIntent: "play" | "pause" = "pause"; private autoPausedForVisibility = false; private visibilityListener: (() => void) | null = null; // Serializes escalation / setStrategy calls private switchingPromise: Promise = Promise.resolve(); // Owns blob URLs created during sidecar discovery + SRT->VTT conversion. // Revoked at destroy() so repeated source swaps don't leak. private subtitleResources = new SubtitleResourceBag(); // Transport config extracted from CreatePlayerOptions. Threaded to probe, // subtitle fetches, and strategy session creators. Not stored on MediaContext // because it's runtime config, not media analysis. private readonly transport: TransportConfig | undefined; /** * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead. */ private constructor( private readonly options: CreatePlayerOptions, private readonly registry: PluginRegistry, ) { const { requestInit, fetchFn, cacheBytes } = options; if (requestInit || fetchFn || cacheBytes !== undefined) { this.transport = { requestInit, fetchFn, cacheBytes }; } } static async create(options: CreatePlayerOptions): Promise { const registry = new PluginRegistry(); registerBuiltins(registry); if (options.plugins) { for (const p of options.plugins) registry.register(p, /* prepend */ true); } const player = new UnifiedPlayer(options, registry); try { await player.bootstrap(); } catch (err) { (err as Error & { player?: UnifiedPlayer }).player = player; throw err; } return player; } private async bootstrap(): Promise { const bootstrapStart = performance.now(); try { dbg.info("bootstrap", "start"); const ctx = await dbg.timed("probe", "probe", 3000, () => probe(this.options.source, this.transport)); dbg.info("probe", `container=${ctx.container} video=${ctx.videoTracks[0]?.codec ?? "-"} ` + `audio=${ctx.audioTracks[0]?.codec ?? "-"} probedBy=${ctx.probedBy}`, ); this.diag.recordProbe(ctx); this.mediaContext = ctx; // Merge sidecar / explicit subtitles if (this.options.subtitles) { for (const s of this.options.subtitles) { ctx.subtitleTracks.push({ id: ctx.subtitleTracks.length, format: s.format ?? (s.url.endsWith(".srt") ? "srt" : "vtt"), language: s.language, sidecarUrl: s.url, }); } } if (this.options.directory && this.options.source instanceof File) { const found = await discoverSidecars(this.options.source, this.options.directory); for (const s of found) { // Track every blob URL we adopted from discovery so it gets // revoked at destroy() — otherwise repeated source changes leak. this.subtitleResources.track(s.url); ctx.subtitleTracks.push({ id: ctx.subtitleTracks.length, format: s.format, language: s.language, sidecarUrl: s.url, }); } } const decision = this.options.initialStrategy ? buildInitialDecision(this.options.initialStrategy, ctx) : classify(ctx); dbg.info("classify", `strategy=${decision.strategy} class=${decision.class} reason="${decision.reason}"` + (decision.fallbackChain ? ` fallback=${decision.fallbackChain.join("→")}` : ""), ); this.classification = decision; this.diag.recordClassification(decision); this.emitter.emitSticky("strategy", { strategy: decision.strategy, reason: decision.reason, }); // Try the primary strategy, falling through the chain on failure await this.startSession(decision.strategy, decision.reason); // Apply subtitles for all strategies. Native/remux render them via // the inner