@oddin-gg/havik-player 2.9.0 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../src/core/errors.ts","../src/core/types.ts","../src/core/http.ts","../src/core/waitForLive.ts","../src/core/resolve.ts","../src/core/fullscreen.ts","../src/core/catalog.ts","../src/core/poll.ts","../src/core/events.ts","../src/core/drm/deviceId.ts","../src/core/drm/headers.ts","../src/core/drm/cdm.ts","../src/core/drm/support.ts","../src/core/manifest.ts","../src/core/env.ts","../src/core/stats.ts","../src/core/engine/native.ts","../src/core/engine/hls.ts","../src/core/analytics.ts","../src/core/environment.ts","../src/core/version.ts","../src/ui/theme.ts","../src/ui/styles.ts","../src/ui/icons.ts","../src/ui/controls.ts","../src/managed/index.ts","../src/embed/protocol.ts","../src/embed/host.ts","../src/embed/page.ts"],"sourcesContent":["// Typed error taxonomy mapped from the havik-streams playback outcome table.\n\nexport type PlaybackErrorCode =\n | 'INVALID_URN' // 400 — malformed match URN (terminal)\n | 'NOT_FOUND' // 404 — unknown URN OR not entitled, indistinguishable (terminal)\n | 'GONE' // 410 — ended past the catchup window (terminal)\n | 'TOO_EARLY' // 425 — upcoming, session not upserted yet (retry honoring Retry-After)\n | 'UNAVAILABLE' // 503 — should-be-live but origin failing (retry with backoff)\n | 'UNAUTHORIZED' // 401 — bad/absent key (terminal)\n | 'FORBIDDEN' // 403 — origin not allowed / DRM signature rejected (terminal)\n | 'DRM_CLIENT' // client-side DRM failure: key system unusable on this platform, CDM/EME error, or license transport exhausted — NOT a server entitlement denial (httpStatus carries the license status when there was one, else 0)\n | 'RATE_LIMITED' // 429 — per-IP limiter (back off Retry-After)\n | 'INTERNAL' // 5xx other / unexpected (terminal)\n | 'NETWORK' // fetch threw (transient — retryable in the live-poll loop)\n | 'TIMEOUT' // SDK gave up waiting for live (terminal)\n | 'ABORTED'; // caller aborted via AbortSignal (terminal)\n\nexport interface PlaybackErrorInit {\n retryAfterMs?: number;\n requestId?: string;\n /** The raw server-provided error code string, if any — forward-compatible with new server codes. */\n serverCode?: string;\n /**\n * Scheduled kickoff time (epoch ms), if the server included it on a TOO_EARLY\n * response body. The SDK uses this to widen poll cadence for far-future\n * matches without a separate catalog call.\n */\n liveStartsAtMs?: number;\n cause?: unknown;\n}\n\nexport class PlaybackError extends Error {\n readonly code: PlaybackErrorCode;\n readonly httpStatus: number;\n /** Present for TOO_EARLY / RATE_LIMITED / UNAVAILABLE when the server sent Retry-After. */\n readonly retryAfterMs?: number;\n /** Server request id, when surfaced — useful for correlating opaque 403/503 in server logs. */\n readonly requestId?: string;\n /** Raw server error code (e.g. \"TOO_EARLY\"), preserved even if `code` maps it to a known enum. */\n readonly serverCode?: string;\n /**\n * Scheduled kickoff time (epoch ms), if the server carried `liveStartsAt` on\n * a TOO_EARLY body. `pollToLive` reads this to widen poll cadence for\n * far-future matches.\n */\n readonly liveStartsAtMs?: number;\n\n constructor(\n code: PlaybackErrorCode,\n httpStatus: number,\n message: string,\n init?: PlaybackErrorInit,\n ) {\n super(message);\n this.name = 'PlaybackError';\n this.code = code;\n this.httpStatus = httpStatus;\n this.retryAfterMs = init?.retryAfterMs;\n this.requestId = init?.requestId;\n this.serverCode = init?.serverCode;\n this.liveStartsAtMs = init?.liveStartsAtMs;\n if (init?.cause !== undefined) {\n (this as { cause?: unknown }).cause = init.cause;\n }\n }\n}\n\n/** Map an HTTP status from /v1/playback to a typed code. */\nexport function classifyStatus(status: number): PlaybackErrorCode {\n switch (status) {\n case 400:\n return 'INVALID_URN';\n case 401:\n return 'UNAUTHORIZED';\n case 403:\n return 'FORBIDDEN';\n case 404:\n return 'NOT_FOUND';\n case 410:\n return 'GONE';\n case 425:\n return 'TOO_EARLY';\n case 429:\n return 'RATE_LIMITED';\n case 503:\n return 'UNAVAILABLE';\n default:\n return 'INTERNAL';\n }\n}\n\n/**\n * Map the HTTP status of a DRM license/certificate exchange to a typed code.\n * Only a genuine server auth/entitlement denial keeps the server taxonomy;\n * everything else (no response, transport exhausted, CDM/EME failure) is a\n * client-side DRM failure. Shared by both engines so they cannot drift.\n */\nexport function classifyDrmStatus(status: number): PlaybackErrorCode {\n return status === 401 ? 'UNAUTHORIZED' : status === 403 ? 'FORBIDDEN' : 'DRM_CLIENT';\n}\n\nconst WAITABLE: ReadonlySet<PlaybackErrorCode> = new Set<PlaybackErrorCode>([\n 'TOO_EARLY',\n 'UNAVAILABLE',\n 'RATE_LIMITED',\n 'NETWORK',\n]);\n\n/** True for codes the wait-for-live loop should keep polling on. */\nexport function isWaitable(code: PlaybackErrorCode): boolean {\n return WAITABLE.has(code);\n}\n","// Shared domain types for the Havik player SDK.\n\nexport type DrmSystem = 'widevine' | 'fairplay';\n\nexport interface WidevineInfo {\n licenseUrl: string;\n}\nexport interface FairPlayInfo {\n licenseUrl: string;\n certificateUrl: string;\n}\nexport interface DrmInfo {\n widevine?: WidevineInfo;\n fairplay?: FairPlayInfo;\n}\n\n/**\n * The per-match playback credential returned by `GET /v1/playback/{urn}`,\n * normalized for the SDK. `manifestUrl` is an unsigned HLS `.m3u8`; the DRM\n * license URLs are pre-signed by havik-streams (the signed query string IS the\n * token — never rewrite them).\n */\nexport interface StreamDescriptor {\n matchUrn: string;\n protocol: 'hls';\n /** Producer-set DRM decision. When false, init no EME/CDM at all. */\n drmEnabled: boolean;\n manifestUrl: string;\n /** Present only when drmEnabled. */\n drm?: DrmInfo;\n serverTime: string;\n /** Present only while the match is upcoming. */\n liveStartsAt?: string;\n /** Filled by the player once a media playlist with EXT-X-PART-INF is seen. */\n isLowLatency?: boolean;\n /**\n * Analytics session binding minted by havik-streams (present when the\n * server has analytics enabled). The sid is an opaque per-playback-session\n * id — non-secret by design (DRM remains the access gate) — echoed in CMCD\n * query params on CDN requests and carried by QoE beacons. It lives only in\n * memory for the duration of the session and is never persisted.\n */\n analytics?: { sid?: string };\n /** Any unrecognized top-level fields from the playback response (forward-compat). */\n extensions?: Record<string, unknown>;\n}\n\nexport interface Credential {\n /** A publishable api-key, e.g. `pk_live_…` / `pk_test_…`. */\n apiKey: string;\n}\n\n/**\n * A credential, or a function that produces one. The function form lets a host\n * plug in a future short-lived-token mint service without an API change; it is\n * invoked per request so refresh works transparently.\n */\nexport type CredentialSource = Credential | (() => Credential | Promise<Credential>);\n\n/** @internal */\nexport async function resolveCredential(source: CredentialSource): Promise<Credential> {\n const cred = typeof source === 'function' ? await source() : source;\n if (!cred || !cred.apiKey) {\n throw new Error('havik-player: credential is missing an apiKey');\n }\n return cred;\n}\n\nexport type MatchLiveStatus = 'upcoming' | 'live' | 'ended';\n\nexport interface MatchStatus {\n matchUrn: string;\n matchName?: string;\n status: MatchLiveStatus;\n datePlannedStart?: string;\n}\n","import { PlaybackError } from './errors';\nimport { resolveCredential, type CredentialSource } from './types';\n\n/**\n * Join a base URL and an absolute path into a validated absolute http(s) URL.\n * Rejects empty, protocol-relative (`//evil`), and non-http(s) bases up front so\n * an unvalidated baseUrl can never become the (credentialed) request origin.\n */\nexport function joinUrl(base: string, path: string): string {\n const trimmed = base.replace(/\\/+$/, '');\n let url: URL;\n try {\n url = new URL(trimmed + path);\n } catch {\n throw new PlaybackError('INTERNAL', 0, `invalid baseUrl: ${JSON.stringify(base)}`);\n }\n if (url.protocol !== 'https:' && url.protocol !== 'http:') {\n throw new PlaybackError('INTERNAL', 0, `baseUrl must be http(s), got: ${JSON.stringify(base)}`);\n }\n return url.toString();\n}\n\n/**\n * Parse an HTTP `Retry-After` header (delta-seconds or HTTP-date) into ms.\n * Returns undefined when absent/unparseable.\n */\nexport function parseRetryAfterMs(value: string | null): number | undefined {\n if (!value) return undefined;\n const secs = Number(value);\n if (Number.isFinite(secs)) return Math.max(0, Math.round(secs * 1000));\n const when = Date.parse(value);\n if (!Number.isNaN(when)) return Math.max(0, when - Date.now());\n return undefined;\n}\n\nexport interface FetchOptions {\n credential: CredentialSource;\n signal?: AbortSignal;\n method?: string;\n headers?: Headers;\n body?: BodyInit;\n /**\n * Extra headers for this request — and strictly extra: an entry whose\n * name the SDK already set (e.g. `If-None-Match` on conditional catalog\n * and status polls) is ignored rather than allowed to clobber it, and\n * `x-api-key` is applied after this map so the credential can never be\n * replaced either.\n */\n apiHeaders?: Record<string, string>;\n}\n\n/**\n * fetch() with the `x-api-key` header attached from the (possibly async)\n * credential source. Network failures and aborts are normalized to\n * PlaybackError so callers handle one error type. The credential is resolved\n * per call so a getCredential() hook can refresh transparently.\n */\nexport async function fetchWithCredential(url: string, opts: FetchOptions): Promise<Response> {\n const cred = await resolveCredential(opts.credential);\n const headers = opts.headers ?? new Headers();\n if (opts.apiHeaders) {\n for (const [name, value] of Object.entries(opts.apiHeaders)) {\n if (!headers.has(name)) headers.set(name, value);\n }\n }\n headers.set('x-api-key', cred.apiKey);\n try {\n return await fetch(url, {\n method: opts.method ?? 'GET',\n headers,\n body: opts.body,\n signal: opts.signal,\n cache: 'no-store',\n credentials: 'omit',\n mode: 'cors',\n });\n } catch (err) {\n if (opts.signal?.aborted) {\n throw new PlaybackError('ABORTED', 0, 'request aborted', { cause: err });\n }\n throw new PlaybackError('NETWORK', 0, `network request failed: ${describe(err)}`, {\n cause: err,\n });\n }\n}\n\nfunction describe(err: unknown): string {\n if (err instanceof Error) return err.message;\n return String(err);\n}\n","import { isWaitable, PlaybackError } from './errors';\n\nexport interface WaitForLiveOptions {\n /** Overall budget before giving up with a TIMEOUT error. Default: unbounded. */\n timeoutMs?: number;\n /** Minimum delay between polls. Floored at 1000ms (the server's min / limiter). Default: 1000. */\n floorMs?: number;\n /**\n * Maximum delay between polls in the IMMINENT window (≤ {@link imminentMs}\n * before kickoff). Far-from-kickoff polls are widened automatically using\n * `kickoffAt` (see below) up to {@link sanityFloorMs}, regardless of this\n * value — so this knob really controls only the near-kickoff cadence. Default: 30000.\n */\n ceilMs?: number;\n /**\n * Caller-supplied scheduled kickoff time (epoch ms, or an ISO string), used to\n * widen the long-tail poll cadence so the SDK does not hammer the per-viewer\n * `/v1/playback` endpoint when a viewer is armed hours early.\n *\n * Far-from-kickoff (time-to-kickoff > {@link imminentMs}) the poll interval is\n * stretched to `min(ttk / 10, sanityFloorMs)` (never tighter than the server's\n * `Retry-After`, never longer than {@link sanityFloorMs}, never longer than\n * the time remaining to kickoff). Inside the imminent window the cadence\n * collapses to the tight `[floorMs, ceilMs]` range so a 200 is caught fast.\n *\n * When omitted, the SDK also reads `liveStartsAt` off the 425 body if the\n * server returns it (`PlaybackError.liveStartsAtMs`), so newer servers tune\n * automatically without any client change.\n */\n kickoffAt?: number | string;\n /**\n * Threshold (ms) for \"imminent kickoff\" — inside this window the cadence\n * collapses to the tight `[floorMs, ceilMs]` range. Default: 10 minutes.\n */\n imminentMs?: number;\n /**\n * Absolute hard ceiling on any single wait, even when far from kickoff or\n * when the server returns a very large `Retry-After`. Sanity floor so an\n * early go-live or a schedule change is caught within bounded latency.\n * Default: 5 minutes (300_000ms).\n */\n sanityFloorMs?: number;\n /** Add up to +15% jitter to each delay (never reduces below the server's ask). Default: true. */\n jitter?: boolean;\n /** Called before each wait with the upcoming retry. */\n onState?: (state: WaitState) => void;\n}\n\nexport type WaitForLive = boolean | WaitForLiveOptions;\n\nexport interface WaitState {\n phase: 'tooEarly' | 'unavailable' | 'rateLimited' | 'network';\n /** How long until the next poll, in ms. */\n retryInMs: number;\n /** 1-based retry count. */\n attempt: number;\n /** Wall-clock ms since the wait started. */\n elapsedMs: number;\n /**\n * Time-to-kickoff (ms) used to compute this delay, if known. `undefined` when\n * no kickoff hint is available (caller didn't pass `kickoffAt` and the server\n * didn't carry `liveStartsAt` on the error).\n */\n ttkMs?: number;\n}\n\ninterface NormalizedWait {\n timeoutMs?: number;\n floorMs: number;\n ceilMs: number;\n imminentMs: number;\n sanityFloorMs: number;\n /** Caller-supplied kickoff (epoch ms), or undefined. */\n kickoffMs?: number;\n jitter: boolean;\n onState?: (state: WaitState) => void;\n}\n\nconst DEFAULT_FLOOR_MS = 1000;\nconst DEFAULT_CEIL_MS = 30_000;\nconst DEFAULT_IMMINENT_MS = 10 * 60 * 1000; // 10 min — collapse to tight cadence\nconst DEFAULT_SANITY_FLOOR_MS = 5 * 60 * 1000; // 5 min — hard cap on any single sleep\nconst SERVER_MIN_MS = 1000; // never poll /v1/playback faster than this (per-IP limiter)\n\nconst PHASE: Record<string, WaitState['phase']> = {\n TOO_EARLY: 'tooEarly',\n UNAVAILABLE: 'unavailable',\n RATE_LIMITED: 'rateLimited',\n NETWORK: 'network',\n};\n\nfunction normalize(cfg: WaitForLive): NormalizedWait {\n const o: WaitForLiveOptions = cfg === true || cfg === false ? {} : cfg;\n const floorMs = Math.max(SERVER_MIN_MS, o.floorMs ?? DEFAULT_FLOOR_MS);\n const ceilMs = Math.max(floorMs, o.ceilMs ?? DEFAULT_CEIL_MS);\n const imminentMs = Math.max(0, o.imminentMs ?? DEFAULT_IMMINENT_MS);\n const sanityFloorMs = Math.max(ceilMs, o.sanityFloorMs ?? DEFAULT_SANITY_FLOOR_MS);\n return {\n timeoutMs: o.timeoutMs,\n floorMs,\n ceilMs,\n imminentMs,\n sanityFloorMs,\n kickoffMs: parseKickoff(o.kickoffAt),\n jitter: o.jitter ?? true,\n onState: o.onState,\n };\n}\n\nfunction parseKickoff(v: number | string | undefined): number | undefined {\n if (v == null) return undefined;\n if (typeof v === 'number') return Number.isFinite(v) ? v : undefined;\n const t = Date.parse(v);\n return Number.isFinite(t) ? t : undefined;\n}\n\nfunction clamp(value: number, lo: number, hi: number): number {\n return Math.min(hi, Math.max(lo, value));\n}\n\nfunction addJitter(delay: number): number {\n // Only ADDS time, so we never poll faster than the server asked.\n return Math.round(delay * (1 + 0.15 * Math.random()));\n}\n\n/**\n * Compute the next poll delay, given the server's hint, an optional kickoff\n * time, and the caller's bounds.\n *\n * The rule:\n * 1. **Server `Retry-After` is the floor.** If the server says \"wait 2s,\" we\n * never poll sooner, regardless of how far away kickoff is. This preserves\n * the server's authority to tighten cadence near kickoff.\n * 2. **Inside the imminent window** (`ttk ≤ imminentMs`, or no ttk known) the\n * delay is the classic `clamp(retryAfter, floor, ceil)`.\n * 3. **Far from kickoff** (`ttk > imminentMs`) the delay is widened to\n * `min(ttk / 10, sanityFloorMs)`, but **never below** the server's\n * `Retry-After` and **never longer than** the time remaining to kickoff\n * (so we don't sleep past it).\n * 4. **Sanity floor caps everything** so an early go-live or a schedule slip\n * is caught within `sanityFloorMs` (default 5 min).\n *\n * Returns `{ delay, ttkMs }` so the caller can surface `ttk` to UI.\n */\nexport function nextDelayMs(\n retryAfterMs: number | undefined,\n now: number,\n o: Pick<NormalizedWait, 'floorMs' | 'ceilMs' | 'imminentMs' | 'sanityFloorMs' | 'kickoffMs'>,\n kickoffOverrideMs?: number,\n): { delay: number; ttkMs: number | undefined } {\n const kickoffMs = kickoffOverrideMs ?? o.kickoffMs;\n const ttkMs = kickoffMs != null ? Math.max(0, kickoffMs - now) : undefined;\n const serverFloor = Math.max(o.floorMs, retryAfterMs ?? 0);\n\n // Imminent (or no ttk known): use the classic tight band.\n if (ttkMs == null || ttkMs <= o.imminentMs) {\n return { delay: clamp(retryAfterMs ?? o.floorMs, serverFloor, o.ceilMs), ttkMs };\n }\n\n // Far from kickoff: widen to ~10% of remaining time, capped by sanity floor\n // and by ttk itself (don't sleep past kickoff).\n const widened = Math.min(Math.floor(ttkMs / 10), o.sanityFloorMs, ttkMs);\n // Server can always tighten with a smaller Retry-After (treated as floor).\n return { delay: Math.max(serverFloor, widened), ttkMs };\n}\n\nfunction sleep(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new PlaybackError('ABORTED', 0, 'wait-for-live aborted'));\n return;\n }\n const onAbort = () => {\n clearTimeout(timer);\n reject(new PlaybackError('ABORTED', 0, 'wait-for-live aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\n/**\n * Poll `attempt` until it resolves, retrying on waitable PlaybackErrors\n * (TOO_EARLY / UNAVAILABLE / RATE_LIMITED / NETWORK). Each retry's delay is\n * computed by {@link nextDelayMs} from (a) the server's `Retry-After` (always a\n * floor) and (b) the time-to-kickoff from `kickoffAt` and/or\n * `PlaybackError.liveStartsAtMs`. Far from kickoff the cadence widens (to\n * `~ttk/10`, capped at `sanityFloorMs`) so the SDK does not hammer the\n * per-viewer `/v1/playback` endpoint when a viewer is armed hours early; inside\n * the imminent window it collapses to the tight `[floorMs, ceilMs]` band so a\n * 200 — i.e. the instant the match goes live — is caught fast. Terminal errors\n * (404 / 410 / 400 / 401 / 403 / INTERNAL) reject straight away.\n */\nexport async function pollToLive<T>(\n attempt: () => Promise<T>,\n cfg: WaitForLive,\n signal?: AbortSignal,\n): Promise<T> {\n const o = normalize(cfg);\n const start = Date.now();\n const deadline = o.timeoutMs != null ? start + o.timeoutMs : Infinity;\n let attemptNo = 0;\n // Cache the latest kickoff hint we've learned from a 425 body. Re-read every\n // loop (don't snapshot once) so a schedule change visible to the server is\n // honored — see the four correctness guards in the design note.\n let lastKickoffHintMs: number | undefined;\n\n for (;;) {\n if (signal?.aborted) throw new PlaybackError('ABORTED', 0, 'wait-for-live aborted');\n try {\n return await attempt();\n } catch (err) {\n if (!(err instanceof PlaybackError) || !isWaitable(err.code)) throw err;\n attemptNo += 1;\n\n // If the server now carries a kickoff hint (newer havik-streams), refresh\n // it. Caller-supplied `kickoffAt` still wins (set in normalize()).\n if (err.liveStartsAtMs != null && Number.isFinite(err.liveStartsAtMs)) {\n lastKickoffHintMs = err.liveStartsAtMs;\n }\n\n const now = Date.now();\n const computed = nextDelayMs(err.retryAfterMs, now, o, lastKickoffHintMs);\n const ttkMs = computed.ttkMs;\n let delay = computed.delay;\n if (o.jitter) delay = addJitter(delay);\n\n if (now + delay > deadline) {\n throw new PlaybackError(\n 'TIMEOUT',\n err.httpStatus,\n `timed out after ${o.timeoutMs}ms waiting for the stream to go live`,\n { retryAfterMs: err.retryAfterMs, cause: err },\n );\n }\n\n o.onState?.({\n phase: PHASE[err.code] ?? 'unavailable',\n retryInMs: delay,\n attempt: attemptNo,\n elapsedMs: now - start,\n ttkMs,\n });\n\n await sleep(delay, signal);\n }\n }\n}\n","import { classifyStatus, PlaybackError } from './errors';\nimport { fetchWithCredential, joinUrl, parseRetryAfterMs } from './http';\nimport { type CredentialSource, type StreamDescriptor } from './types';\nimport { pollToLive, type WaitForLive } from './waitForLive';\n\nexport interface ResolveOptions {\n /**\n * Base URL of the havik-streams API — `https://feed.oddin-video.gg`\n * (production) or `https://feed-dev.oddin-video.gg` (integration). Prefer\n * resolving it from the endpoint directory at runtime over hard-coding it;\n * see Service URL discovery in the API reference.\n */\n baseUrl: string;\n matchUrn: string;\n credential: CredentialSource;\n /**\n * When set, resolve retries internally on 425 TOO_EARLY honoring Retry-After\n * and resolves the instant the stream is live. See WaitForLive.\n */\n waitForLive?: WaitForLive;\n signal?: AbortSignal;\n /**\n * Extra headers attached to every request this call makes to {@link baseUrl}\n * — and ONLY there: never to CDN manifest/segment hosts or DRM license\n * endpoints. For integrations whose API front door wants its own auth\n * signal on top of the publishable key (gateways, operator consoles).\n * `x-api-key` cannot be overridden.\n */\n apiHeaders?: Record<string, string>;\n}\n\ninterface RawPlayback {\n matchUrn?: string;\n protocol?: string;\n drmEnabled?: boolean;\n manifestUrl?: string;\n drm?: {\n widevine?: { licenseUrl?: string };\n fairplay?: { licenseUrl?: string; certificateUrl?: string };\n };\n serverTime?: string;\n liveStartsAt?: string;\n analytics?: { sid?: string };\n}\n\n/**\n * Single GET /v1/playback/{urn}. Throws a typed PlaybackError on non-2xx.\n * @internal Use resolveStream (which adds the live-pickup loop); this is the SDK's internal one-shot.\n */\nexport async function resolvePlaybackOnce(opts: ResolveOptions): Promise<StreamDescriptor> {\n if (!opts.matchUrn || typeof opts.matchUrn !== 'string') {\n throw new PlaybackError('INVALID_URN', 0, 'resolveStream: a non-empty matchUrn is required');\n }\n const url = joinUrl(opts.baseUrl, `/v1/playback/${encodeURIComponent(opts.matchUrn)}`);\n const res = await fetchWithCredential(url, {\n credential: opts.credential,\n signal: opts.signal,\n apiHeaders: opts.apiHeaders,\n });\n if (!res.ok) throw await toPlaybackError(res);\n const raw = (await res.json()) as RawPlayback;\n return toDescriptor(raw, opts.matchUrn);\n}\n\n/**\n * Resolve a match into a playback descriptor (manifest URL + DRM config). With\n * `waitForLive`, blocks (politely, server-paced) until the stream is live.\n */\nexport function resolveStream(opts: ResolveOptions): Promise<StreamDescriptor> {\n const attempt = () => resolvePlaybackOnce(opts);\n return opts.waitForLive ? pollToLive(attempt, opts.waitForLive, opts.signal) : attempt();\n}\n\nasync function toPlaybackError(res: Response): Promise<PlaybackError> {\n const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));\n const requestId =\n res.headers.get('x-request-id') ?? res.headers.get('x-amzn-requestid') ?? undefined;\n let message = res.statusText || `HTTP ${res.status}`;\n let serverCode: string | undefined;\n let liveStartsAtMs: number | undefined;\n try {\n const body = (await res.json()) as {\n error?: { code?: string; message?: string };\n liveStartsAt?: string;\n };\n if (body?.error?.message) message = body.error.message;\n if (body?.error?.code) serverCode = body.error.code;\n // On a 425 TOO_EARLY body, newer havik-streams carries `liveStartsAt` so the\n // SDK can widen far-future poll cadence without a separate catalog call.\n if (typeof body?.liveStartsAt === 'string') {\n const t = Date.parse(body.liveStartsAt);\n if (Number.isFinite(t)) liveStartsAtMs = t;\n }\n } catch {\n // non-JSON / empty body — keep the status-text message\n }\n return new PlaybackError(classifyStatus(res.status), res.status, message, {\n retryAfterMs,\n requestId,\n serverCode,\n liveStartsAtMs,\n });\n}\n\nconst KNOWN_PLAYBACK_KEYS = new Set([\n 'matchUrn',\n 'protocol',\n 'drmEnabled',\n 'manifestUrl',\n 'drm',\n 'serverTime',\n 'liveStartsAt',\n 'expiresAt',\n 'analytics',\n]);\n\n// Reject obviously-wrong URL schemes before any of these strings reach a live\n// sink (video.src, hls.loadSource, the EME license XHR). The streams API is the\n// trust anchor, so this is defense-in-depth against a malformed/compromised\n// response — not a substitute for server-side trust. httpStatus 0 marks an\n// SDK-side (non-transport) error, matching NETWORK/ABORTED.\nfunction assertHttpUrl(label: string, value: string): string {\n let parsed: URL;\n try {\n parsed = new URL(value);\n } catch {\n throw new PlaybackError('INTERNAL', 0, `playback ${label} is not a valid URL`);\n }\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {\n throw new PlaybackError(\n 'INTERNAL',\n 0,\n `playback ${label} has a disallowed scheme: ${parsed.protocol}`,\n );\n }\n return value;\n}\n\nfunction toDescriptor(raw: RawPlayback, fallbackUrn: string): StreamDescriptor {\n if (!raw.manifestUrl) {\n throw new PlaybackError('INTERNAL', 0, 'playback response is missing manifestUrl');\n }\n const drmEnabled = raw.drmEnabled ?? Boolean(raw.drm);\n const descriptor: StreamDescriptor = {\n matchUrn: raw.matchUrn ?? fallbackUrn,\n protocol: 'hls',\n drmEnabled,\n manifestUrl: assertHttpUrl('manifestUrl', raw.manifestUrl),\n serverTime: raw.serverTime ?? '',\n };\n if (raw.liveStartsAt) descriptor.liveStartsAt = raw.liveStartsAt;\n if (typeof raw.analytics?.sid === 'string' && raw.analytics.sid) {\n descriptor.analytics = { sid: raw.analytics.sid };\n }\n\n if (drmEnabled && raw.drm) {\n const drm: NonNullable<StreamDescriptor['drm']> = {};\n if (raw.drm.widevine?.licenseUrl) {\n drm.widevine = {\n licenseUrl: assertHttpUrl('widevine licenseUrl', raw.drm.widevine.licenseUrl),\n };\n }\n if (raw.drm.fairplay?.licenseUrl && raw.drm.fairplay.certificateUrl) {\n drm.fairplay = {\n licenseUrl: assertHttpUrl('fairplay licenseUrl', raw.drm.fairplay.licenseUrl),\n certificateUrl: assertHttpUrl('fairplay certificateUrl', raw.drm.fairplay.certificateUrl),\n };\n }\n if (drm.widevine || drm.fairplay) descriptor.drm = drm;\n }\n\n // Forward-compat: carry any unrecognized top-level fields the server adds (e.g.\n // a future token TTL, CDN hint, or variant) so Mode-B consumers aren't blind to them.\n const extensions: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {\n if (!KNOWN_PLAYBACK_KEYS.has(k)) extensions[k] = v;\n }\n if (Object.keys(extensions).length > 0) descriptor.extensions = extensions;\n\n return descriptor;\n}\n","// Cross-platform fullscreen for a playing <video>. Exported for Mode B\n// (bring-your-own-player) integrators and shared by the managed player API\n// and the custom control bar, so the platform quirks live in exactly one\n// place.\n//\n// Capability ladder, because the element Fullscreen API is not universal:\n// 1. Standard element fullscreen on `container` (or the video itself) — a\n// container keeps custom DOM controls/overlays on screen.\n// 2. Prefixed element fullscreen — older iPad/macOS Safari (< 16.4) ships\n// only webkitRequestFullscreen / webkitExitFullscreen.\n// 3. Video-native fullscreen — iPhone Safari has NEVER shipped the element\n// API in any form (`requestFullscreen` is undefined on every element),\n// so the only fullscreen there is the AVPlayer-style\n// `video.webkitEnterFullscreen()` with NATIVE controls (the YouTube\n// behavior). Custom DOM is not visible inside it, it requires loaded\n// media (a pre-play call is a no-op), and it must run from a user\n// gesture.\n\ntype FullscreenTarget = HTMLElement & {\n requestFullscreen?: () => Promise<void>;\n webkitRequestFullscreen?: () => void;\n};\n\ntype FullscreenDocument = Document & {\n fullscreenElement?: Element | null;\n exitFullscreen?: () => Promise<void>;\n webkitFullscreenElement?: Element | null;\n webkitExitFullscreen?: () => void;\n};\n\ntype WebkitVideo = HTMLVideoElement & {\n webkitSupportsFullscreen?: boolean;\n webkitDisplayingFullscreen?: boolean;\n webkitEnterFullscreen?: () => void;\n webkitExitFullscreen?: () => void;\n};\n\nfunction ownerDoc(video: HTMLVideoElement): FullscreenDocument {\n return (video.ownerDocument ?? document) as FullscreenDocument;\n}\n\n/** The element currently in element fullscreen (unprefixed or prefixed), if any. */\nfunction fullscreenElement(doc: FullscreenDocument): Element | null {\n return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;\n}\n\n/**\n * True while this video's fullscreen presentation is active: element\n * fullscreen on `container` or the video itself, or iPhone's video-native\n * fullscreen. Pass the same `container` you pass to enterVideoFullscreen.\n */\nexport function videoFullscreenActive(video: HTMLVideoElement, container?: HTMLElement): boolean {\n const el = fullscreenElement(ownerDoc(video));\n if (el != null && (el === video || (container != null && el === container))) return true;\n return (video as WebkitVideo).webkitDisplayingFullscreen === true;\n}\n\n/**\n * Enter fullscreen for `video`, walking the capability ladder above.\n * `container` is the element to present in element fullscreen (defaults to\n * the video) — pass your player wrapper to keep your own controls visible on\n * platforms that support it. Returns false when NO mechanism exists (some\n * webviews) so the caller can hide or disable its button; rejections from a\n * denied request (permissions policy) propagate.\n *\n * Call from a user gesture: every branch of the ladder requires one.\n */\nexport async function enterVideoFullscreen(\n video: HTMLVideoElement,\n container?: HTMLElement,\n): Promise<boolean> {\n const target = (container ?? video) as FullscreenTarget;\n if (typeof target.requestFullscreen === 'function') {\n await target.requestFullscreen();\n return true;\n }\n if (typeof target.webkitRequestFullscreen === 'function') {\n target.webkitRequestFullscreen();\n return true;\n }\n const v = video as WebkitVideo;\n if (v.webkitSupportsFullscreen && typeof v.webkitEnterFullscreen === 'function') {\n v.webkitEnterFullscreen();\n return true;\n }\n return false;\n}\n\n/**\n * Leave whichever fullscreen presentation `video` is in: iPhone's video-native\n * fullscreen first (it reports only through the video, never the document),\n * then element fullscreen via the document (unprefixed or prefixed). Safe to\n * call when not fullscreen — and scoped to THIS video's presentation: an\n * unrelated element's fullscreen (a lightbox, another player) is left alone,\n * mirroring the ownership check exitPictureInPicture makes on\n * pictureInPictureElement. Pass the same `container` you entered with.\n */\nexport async function exitVideoFullscreen(\n video: HTMLVideoElement,\n container?: HTMLElement,\n): Promise<void> {\n const v = video as WebkitVideo;\n if (v.webkitDisplayingFullscreen) {\n v.webkitExitFullscreen?.();\n return;\n }\n const doc = ownerDoc(video);\n const el = fullscreenElement(doc);\n if (el != null && (el === video || (container != null && el === container))) {\n if (typeof doc.exitFullscreen === 'function') await doc.exitFullscreen();\n else doc.webkitExitFullscreen?.();\n }\n}\n","import { classifyStatus, PlaybackError } from './errors';\nimport { fetchWithCredential, joinUrl } from './http';\nimport { type CredentialSource, type MatchLiveStatus } from './types';\n\nexport interface CatalogMatch {\n matchUrn: string;\n matchName: string;\n status: MatchLiveStatus;\n datePlannedStart?: string;\n tournamentUrn: string;\n tournamentName: string;\n sport: string;\n}\n\nexport interface CatalogTournament {\n urn: string;\n name: string;\n sport: string;\n isOffline: boolean;\n matches: CatalogMatch[];\n}\n\nexport interface Catalog {\n tournaments: CatalogTournament[];\n /** ETag for a cheap conditional refresh next time. */\n etag?: string;\n /** True when the server returned 304 (the prior etag is still fresh); tournaments is empty. */\n notModified: boolean;\n}\n\nexport interface FetchCatalogOptions {\n baseUrl: string;\n credential: CredentialSource;\n signal?: AbortSignal;\n /** Prior ETag for a conditional GET (304 when unchanged). */\n etag?: string;\n status?: MatchLiveStatus | MatchLiveStatus[];\n sport?: string;\n /**\n * Extra headers attached to every request to {@link baseUrl} — and only\n * there: never to CDN or license hosts. `x-api-key` cannot be overridden.\n */\n apiHeaders?: Record<string, string>;\n}\n\ninterface RawCatalog {\n tournaments?: Array<{\n urn?: string;\n name?: string;\n sport?: string;\n isOffline?: boolean;\n matches?: Array<{\n matchUrn?: string;\n matchName?: string;\n status?: string;\n datePlannedStart?: string;\n }>;\n }>;\n}\n\n/** GET /v1/catalog — metadata only (no manifest/DRM). Supports conditional refresh. */\nexport async function fetchCatalog(opts: FetchCatalogOptions): Promise<Catalog> {\n const params = new URLSearchParams();\n if (opts.status) {\n params.set('status', Array.isArray(opts.status) ? opts.status.join(',') : opts.status);\n }\n if (opts.sport) params.set('sport', opts.sport);\n const qs = params.toString();\n const url = joinUrl(opts.baseUrl, '/v1/catalog' + (qs ? `?${qs}` : ''));\n\n const headers = new Headers();\n if (opts.etag) headers.set('If-None-Match', opts.etag);\n\n const res = await fetchWithCredential(url, {\n credential: opts.credential,\n signal: opts.signal,\n headers,\n apiHeaders: opts.apiHeaders,\n });\n\n if (res.status === 304) {\n return { tournaments: [], etag: opts.etag, notModified: true };\n }\n if (!res.ok) {\n throw new PlaybackError(\n classifyStatus(res.status),\n res.status,\n `catalog request failed (HTTP ${res.status})`,\n );\n }\n\n const raw = (await res.json()) as RawCatalog;\n return {\n tournaments: parseCatalog(raw),\n etag: res.headers.get('etag') ?? undefined,\n notModified: false,\n };\n}\n\nfunction parseCatalog(raw: RawCatalog): CatalogTournament[] {\n return (raw.tournaments ?? []).map((t) => {\n const urn = t.urn ?? '';\n const name = t.name ?? '';\n const sport = t.sport ?? '';\n return {\n urn,\n name,\n sport,\n isOffline: Boolean(t.isOffline),\n matches: (t.matches ?? []).map((m) => ({\n matchUrn: m.matchUrn ?? '',\n matchName: m.matchName ?? '',\n status: (m.status as MatchLiveStatus) ?? 'upcoming',\n datePlannedStart: m.datePlannedStart,\n tournamentUrn: urn,\n tournamentName: name,\n sport,\n })),\n };\n });\n}\n\nexport interface FetchTournamentOptions {\n baseUrl: string;\n credential: CredentialSource;\n /** Tournament URN, e.g. `od:tournament:42`. */\n urn: string;\n signal?: AbortSignal;\n /** Prior ETag for a conditional GET (304 when unchanged) — only effective once havik-streams ETags `/v1/tournaments/{urn}` (parity with `/v1/matches/{urn}`). */\n etag?: string;\n /**\n * Extra headers attached to every request to {@link baseUrl} — and only\n * there: never to CDN or license hosts. `x-api-key` cannot be overridden.\n */\n apiHeaders?: Record<string, string>;\n}\n\nexport interface TournamentResult {\n /** Present unless the tournament is unknown (404) or the server returned 304. */\n tournament?: CatalogTournament;\n /** ETag for a cheap conditional refresh next time, when the server emits one. */\n etag?: string;\n /** True when the server returned 304 (the prior etag is still fresh); `tournament` is undefined. */\n notModified: boolean;\n /** True when the URN is not in the catalog (or unknown to the caller's brand). */\n notFound: boolean;\n}\n\ninterface RawTournament {\n urn?: string;\n name?: string;\n sport?: string;\n isOffline?: boolean;\n matches?: Array<{\n matchUrn?: string;\n matchName?: string;\n status?: string;\n datePlannedStart?: string;\n }>;\n}\n\n/**\n * `GET /v1/tournaments/{urn}` — single-tournament metadata (matches included,\n * never any signed/per-viewer URLs; those live on `/v1/playback/{urn}`).\n *\n * Returns `notFound: true` on 404 (unknown URN OR caller-not-entitled — the\n * server intentionally collapses both so existence isn't leaked). Other waitable\n * statuses (5xx) reject as a typed `PlaybackError`. Supports ETag/304.\n */\nexport async function fetchTournament(opts: FetchTournamentOptions): Promise<TournamentResult> {\n const url = joinUrl(opts.baseUrl, `/v1/tournaments/${encodeURIComponent(opts.urn)}`);\n const headers = new Headers();\n if (opts.etag) headers.set('If-None-Match', opts.etag);\n\n const res = await fetchWithCredential(url, {\n credential: opts.credential,\n signal: opts.signal,\n headers,\n apiHeaders: opts.apiHeaders,\n });\n\n if (res.status === 304) {\n return { etag: opts.etag, notModified: true, notFound: false };\n }\n if (res.status === 404) {\n return { notModified: false, notFound: true };\n }\n if (!res.ok) {\n throw new PlaybackError(\n classifyStatus(res.status),\n res.status,\n `tournament request failed (HTTP ${res.status})`,\n );\n }\n\n const raw = (await res.json()) as RawTournament;\n return {\n tournament: parseTournament(raw),\n etag: res.headers.get('etag') ?? undefined,\n notModified: false,\n notFound: false,\n };\n}\n\nfunction parseTournament(raw: RawTournament): CatalogTournament {\n const urn = raw.urn ?? '';\n const name = raw.name ?? '';\n const sport = raw.sport ?? '';\n return {\n urn,\n name,\n sport,\n isOffline: Boolean(raw.isOffline),\n matches: (raw.matches ?? []).map((m) => ({\n matchUrn: m.matchUrn ?? '',\n matchName: m.matchName ?? '',\n status: (m.status as MatchLiveStatus) ?? 'upcoming',\n datePlannedStart: m.datePlannedStart,\n tournamentUrn: urn,\n tournamentName: name,\n sport,\n })),\n };\n}\n\n/** Flatten a Catalog to a single match list (handy for grids). */\nexport function flattenMatches(catalog: Catalog): CatalogMatch[] {\n return catalog.tournaments.flatMap((t) => t.matches);\n}\n","import { classifyStatus, PlaybackError } from './errors';\nimport { fetchWithCredential, joinUrl } from './http';\nimport { type CredentialSource, type MatchLiveStatus, type MatchStatus } from './types';\n\nexport interface WatchStatusOptions {\n baseUrl: string;\n matchUrn: string;\n credential: CredentialSource;\n /** Fired whenever the status changes (and once on first read). */\n onChange: (status: MatchStatus) => void;\n /** Fired on a poll error; polling continues. */\n onError?: (err: PlaybackError) => void;\n /** Poll interval in ms. Floored at 2000. Default: 10000 (catalog max-age). */\n intervalMs?: number;\n /**\n * Extra headers attached to every request to {@link baseUrl} — and only\n * there: never to CDN or license hosts. `x-api-key` cannot be overridden.\n */\n apiHeaders?: Record<string, string>;\n}\n\nexport interface StatusWatcher {\n stop(): void;\n}\n\n/**\n * Poll `GET /v1/matches/{urn}` for the live status (there is no push channel),\n * firing onChange on transitions. Uses ETag/If-None-Match so unchanged polls\n * are cheap 304s. This drives discovery; actual readiness is the /v1/playback\n * 200 (see resolveStream + waitForLive).\n */\nexport function watchStatus(opts: WatchStatusOptions): StatusWatcher {\n const interval = Math.max(2000, opts.intervalMs ?? 10_000);\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n let etag: string | undefined;\n let lastStatus: MatchLiveStatus | undefined;\n let stopped = false;\n\n const schedule = () => {\n if (!stopped) timer = setTimeout(tick, interval);\n };\n\n const tick = async () => {\n try {\n const url = joinUrl(opts.baseUrl, `/v1/matches/${encodeURIComponent(opts.matchUrn)}`);\n const headers = new Headers();\n if (etag) headers.set('If-None-Match', etag);\n const res = await fetchWithCredential(url, {\n credential: opts.credential,\n signal: controller.signal,\n headers,\n apiHeaders: opts.apiHeaders,\n });\n if (res.status !== 304) {\n if (!res.ok) {\n throw new PlaybackError(\n classifyStatus(res.status),\n res.status,\n `match status request failed (HTTP ${res.status})`,\n );\n }\n etag = res.headers.get('etag') ?? etag;\n const m = (await res.json()) as {\n matchUrn?: string;\n matchName?: string;\n status?: string;\n datePlannedStart?: string;\n };\n const status = (m.status as MatchLiveStatus) ?? 'upcoming';\n if (status !== lastStatus) {\n lastStatus = status;\n opts.onChange({\n matchUrn: m.matchUrn ?? opts.matchUrn,\n matchName: m.matchName,\n status,\n datePlannedStart: m.datePlannedStart,\n });\n }\n }\n } catch (err) {\n if (!controller.signal.aborted) {\n opts.onError?.(\n err instanceof PlaybackError ? err : new PlaybackError('NETWORK', 0, String(err)),\n );\n }\n } finally {\n schedule();\n }\n };\n\n void tick();\n\n return {\n stop() {\n stopped = true;\n controller.abort();\n if (timer) clearTimeout(timer);\n },\n };\n}\n","import { joinUrl } from './http';\nimport { resolveCredential, type CredentialSource } from './types';\n\n/**\n * Coarse, player-facing live state pushed by havik-streams over SSE. Mirrors\n * the `/v1/playback` outcome ladder so a pushed event and a follow-up\n * `/v1/playback` call always agree:\n * live ↔ 200 OK (a serveable session exists — play now)\n * upcoming ↔ 425 Too Early (scheduled, not serving yet)\n * ended ↔ 503 (was live; media stopped, still in catchup)\n * gone ↔ 410 Gone (ended past the catchup window)\n */\nexport type LiveState = 'live' | 'upcoming' | 'ended' | 'gone';\n\n/**\n * Why a stream ended (havik-streams #191). `ended` alone cannot tell a viewer\n * whether the MATCH finished or only its stream dropped — two situations that\n * deserve opposite messaging:\n *\n * - `interrupted` — the match is still running; no session is serving (an\n * ingest outage that outlived the bridge's reconnect grace). Play is\n * expected to resume, so surface \"reconnecting\", never \"this has ended\".\n * - `match_ended` — the match itself finished. A real end.\n *\n * Absent on every other state, and absent entirely from servers predating the\n * field — so treat `undefined` as \"unknown, assume a real end\" (what clients\n * did before it existed).\n */\nexport type EndedReason = 'interrupted' | 'match_ended';\n\nexport interface LiveStateEvent {\n matchUrn: string;\n state: LiveState;\n /** Set only on `ended`; see {@link EndedReason}. */\n reason?: EndedReason;\n /** RFC3339 server timestamp the transition was stamped at. */\n serverTime: string;\n}\n\nexport interface SubscribeLiveStateOptions {\n /** Base URL of the SSE endpoint, e.g. `https://events.feed.oddin-video.gg`. */\n eventsBaseUrl: string;\n matchUrn: string;\n credential: CredentialSource;\n /** Fired for the initial snapshot on connect and every subsequent transition. */\n onState: (ev: LiveStateEvent) => void;\n /**\n * Fired on a non-fatal subscription problem (network drop, HTTP error,\n * server lifetime-cap close). The client keeps retrying with backoff; this\n * is observational only — it never tears playback down.\n */\n onError?: (err: unknown) => void;\n /**\n * Fired on EVERY received frame, including heartbeat comments — a liveness\n * tick. Lets a caller treat the subscription as healthy (e.g. suspend\n * polling) while frames keep arriving, and fall back when they stop.\n */\n onAlive?: () => void;\n /** Reconnect backoff floor / ceiling (ms). Defaults: 1000 / 15000. */\n minBackoffMs?: number;\n maxBackoffMs?: number;\n /**\n * How long a request may stay silent before it is treated as dead and\n * reconnected. Applies to both phases: waiting for response headers, and\n * waiting for the next byte on an open connection. Default 45000 — two\n * missed 20s server heartbeats. Set `0` to disable the watchdog (a silent\n * connection then stays open forever, which is what this subscription did\n * before the watchdog existed).\n */\n idleTimeoutMs?: number;\n}\n\n/** Two missed 20s server heartbeats. See {@link SubscribeLiveStateOptions.idleTimeoutMs}. */\nconst DEFAULT_IDLE_TIMEOUT_MS = 45_000;\n\nexport interface LiveStateSubscription {\n /** Stop the subscription and abort the in-flight request. Idempotent. */\n close(): void;\n}\n\n/**\n * Subscribe to a match's live-state stream (`GET /v1/events/{urn}`).\n *\n * Uses `fetch` rather than the native `EventSource` because the endpoint\n * authenticates with the `x-api-key` HEADER, which `EventSource` cannot set.\n * The body is a `text/event-stream` we parse incrementally. The connection is\n * long-lived (the server caps it at ~30min and sends heartbeats); on any\n * close/error the client reconnects with exponential backoff and re-syncs from\n * the initial snapshot the server replays on connect. A connection that stops\n * delivering without closing is caught by the idle watchdog\n * (`idleTimeoutMs`) — see the note on it in `readStream`. A subscription\n * failure is always non-fatal to playback — callers keep the HLS staleness\n * watchdog as the layer-fallback.\n */\nexport function subscribeLiveState(opts: SubscribeLiveStateOptions): LiveStateSubscription {\n const minBackoff = Math.max(250, opts.minBackoffMs ?? 1000);\n const maxBackoff = Math.max(minBackoff, opts.maxBackoffMs ?? 15_000);\n // Not floored: a caller that asks for a very short window (a test) gets it —\n // the reconnect rate is bounded by minBackoff either way. 0/negative/NaN\n // disable the watchdog.\n const idleRaw = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;\n const idleMs = Number.isFinite(idleRaw) && idleRaw > 0 ? idleRaw : 0;\n const ctrl = new AbortController();\n let closed = false;\n\n const sleep = (ms: number): Promise<void> =>\n new Promise((resolve) => {\n if (ctrl.signal.aborted) return resolve();\n const t = setTimeout(resolve, ms);\n ctrl.signal.addEventListener(\n 'abort',\n () => {\n clearTimeout(t);\n resolve();\n },\n { once: true },\n );\n });\n\n const dispatch = (frame: string): void => {\n opts.onAlive?.(); // any frame (incl. a heartbeat comment) is a liveness tick\n let event = 'message';\n const data: string[] = [];\n for (const raw of frame.split('\\n')) {\n const line = raw.endsWith('\\r') ? raw.slice(0, -1) : raw;\n if (line === '' || line.startsWith(':')) continue; // blank / comment (heartbeat)\n const i = line.indexOf(':');\n const field = i < 0 ? line : line.slice(0, i);\n let value = i < 0 ? '' : line.slice(i + 1);\n if (value.startsWith(' ')) value = value.slice(1);\n if (field === 'event') event = value;\n else if (field === 'data') data.push(value);\n }\n if (event !== 'state' || data.length === 0) return;\n try {\n const parsed = JSON.parse(data.join('\\n')) as LiveStateEvent;\n if (parsed && typeof parsed.state === 'string') opts.onState(parsed);\n } catch {\n /* ignore a malformed frame; the next one re-syncs state */\n }\n };\n\n // SSE frame separator = a blank line. Accept LF, CRLF, and lone-CR servers\n // (per the spec, lines may end in \\n, \\r\\n, or \\r). Non-global so .exec always\n // scans from 0; it yields both the boundary index AND its length. A trailing\n // partial boundary (e.g. a chunk ending mid-\"\\r\\n\\r\\n\") simply stays buffered\n // until the next chunk completes it.\n const FRAME_BOUNDARY = /\\r\\n\\r\\n|\\n\\n|\\r\\r/;\n\n const readStream = async (\n body: ReadableStream<Uint8Array>,\n onData: () => void,\n onIdle: () => void,\n ): Promise<void> => {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n let gotData = false;\n let idleTimer: ReturnType<typeof setTimeout> | undefined;\n // Idle watchdog. `reader.read()` below parks until the body yields, ends,\n // or errors — and a black-holed socket (laptop sleep, NAT rebind, proxy\n // half-close) does none of those: no FIN ever reaches us, so the read\n // never wakes and the subscription is permanently dead AND silent (no\n // onError, no reconnect). The server's ~30min lifetime cap only helps when\n // its close actually arrives. So: arm on open (this also covers\n // headers-then-no-body-ever), rearm on every chunk, and on expiry tear the\n // attempt down so run()'s loop reconnects.\n const rearmIdle = (): void => {\n if (!idleMs) return;\n clearTimeout(idleTimer);\n idleTimer = setTimeout(() => {\n onIdle(); // flags the attempt and aborts the request (socket teardown)\n // The abort alone is not enough: the body stream is already handed to\n // this reader, and cancel() is what resolves the parked read() as\n // `done` and unblocks the loop.\n void reader.cancel().catch(() => {});\n }, idleMs);\n };\n try {\n rearmIdle();\n for (;;) {\n const { value, done } = await reader.read();\n if (done) return;\n if (value && value.length > 0) {\n rearmIdle();\n if (!gotData) {\n gotData = true;\n onData(); // first bytes flowed — the connection is genuinely healthy\n }\n }\n buf += decoder.decode(value, { stream: true });\n let m: RegExpExecArray | null;\n while ((m = FRAME_BOUNDARY.exec(buf)) !== null) {\n dispatch(buf.slice(0, m.index));\n buf = buf.slice(m.index + m[0].length);\n }\n if (closed) return;\n }\n } finally {\n clearTimeout(idleTimer);\n await reader.cancel().catch(() => {});\n }\n };\n\n const run = async (): Promise<void> => {\n let backoff = minBackoff;\n while (!closed) {\n // Per-attempt abort handle. The subscription-wide `ctrl` must stay\n // unaborted across reconnects (the catch below returns on it, and a\n // later fetch would be handed an already-aborted signal), so the idle\n // watchdog needs its own handle to tear down just THIS request. Chained\n // so close() still kills the in-flight one; the relay is removed in the\n // finally, or one closure would leak per reconnect on a long session.\n const attempt = new AbortController();\n const relayAbort = (): void => attempt.abort();\n ctrl.signal.addEventListener('abort', relayAbort, { once: true });\n // Set by whichever watchdog tore this attempt down, and reported after\n // the try/finally. Also suppresses the AbortError the teardown raises —\n // that error IS the teardown, and these messages name the cause.\n let idleReason: string | null = null;\n let connectTimer: ReturnType<typeof setTimeout> | undefined;\n const onIdle = (): void => {\n idleReason = `no data for ${idleMs}ms`;\n attempt.abort();\n };\n try {\n const cred = await resolveCredential(opts.credential);\n if (closed) return;\n const url = joinUrl(opts.eventsBaseUrl, `/v1/events/${encodeURIComponent(opts.matchUrn)}`);\n // Connect watchdog. `fetch` has no timeout of its own, so a socket that\n // black-holes during the HANDSHAKE (dropped SYN, captive portal, a NAT\n // that lost the mapping) parks this await forever — the same silent\n // death readStream's watchdog closes, one step earlier and not reachable\n // from there, since that one is only armed once a body exists. The two\n // belong together: a reconnect triggered BY the stream watchdog goes out\n // over a network that just changed under us, so it is exactly the\n // request most likely to hit a black hole. (A `credential` provider that\n // never settles is the caller's own promise and out of scope — aborting\n // cannot unpark the await above it.)\n if (idleMs) {\n connectTimer = setTimeout(() => {\n idleReason = `no response headers within ${idleMs}ms`;\n attempt.abort();\n }, idleMs);\n }\n const res = await fetch(url, {\n method: 'GET',\n headers: { 'x-api-key': cred.apiKey, accept: 'text/event-stream' },\n signal: attempt.signal,\n cache: 'no-store',\n credentials: 'omit',\n mode: 'cors',\n });\n clearTimeout(connectTimer); // headers are in; readStream arms its own\n if (closed) return;\n if (!res.ok || !res.body) {\n opts.onError?.(new Error(`live-state subscribe failed: HTTP ${res.status}`));\n } else {\n // Reset the backoff only once data actually flows (readStream's onData),\n // NOT on bare accept — an accept-then-immediate-close server would\n // otherwise reconnect in a tight loop with no backoff growth.\n await readStream(\n res.body,\n () => {\n backoff = minBackoff;\n },\n onIdle,\n );\n }\n } catch (err) {\n if (closed || ctrl.signal.aborted) return;\n // A watchdog teardown reports itself below. The AbortError it raises\n // here is the teardown, not a second failure — and this IS the usual\n // path in a real browser, where aborting errors the body stream and\n // rejects the parked read() before cancel() can resolve it as `done`.\n if (!idleReason) opts.onError?.(err);\n } finally {\n clearTimeout(connectTimer);\n ctrl.signal.removeEventListener('abort', relayAbort);\n attempt.abort(); // leave no request dangling behind a finished attempt\n }\n if (closed) return;\n if (idleReason) {\n opts.onError?.(new Error(`live-state subscribe idle: ${idleReason} — reconnecting`));\n }\n await sleep(backoff);\n backoff = Math.min(maxBackoff, backoff * 2);\n }\n };\n\n void run();\n\n return {\n close() {\n if (closed) return;\n closed = true;\n ctrl.abort();\n },\n };\n}\n\n/**\n * Derive the SSE endpoint base from the API base by prefixing the host with\n * `events.` — matching the deployed convention (`feed.<domain>` →\n * `events.<domain>`, served ALB-direct because CloudFront can't stream SSE).\n * Returns `undefined` if `baseUrl` can't be parsed.\n */\nexport function deriveEventsBaseUrl(baseUrl: string): string | undefined {\n try {\n const u = new URL(baseUrl);\n u.hostname = `events.${u.hostname}`;\n u.pathname = '';\n u.search = '';\n return u.origin;\n } catch {\n return undefined;\n }\n}\n","// Persistent per-device id required by the DRM license POST (X-Device-Id).\n// havik-drm rejects a missing id (400 missing_device_id) and validates the\n// format against ^[a-zA-Z0-9\\-_.@:]+$ — a UUID passes. We persist it in\n// localStorage so it is stable across reloads, falling back to an in-memory id\n// when storage is unavailable (private mode), which is still valid per request.\n\nconst STORAGE_KEY = 'havik.player.deviceId';\nlet cached: string | undefined;\n\nexport function getDeviceId(): string {\n if (cached) return cached;\n try {\n const stored = globalThis.localStorage?.getItem(STORAGE_KEY);\n if (stored) {\n cached = stored;\n return stored;\n }\n } catch {\n // storage blocked — fall through to generate\n }\n const id = generateUuid();\n cached = id;\n try {\n globalThis.localStorage?.setItem(STORAGE_KEY, id);\n } catch {\n // best-effort persistence\n }\n return id;\n}\n\n/**\n * Clear the persisted device id (localStorage + in-memory cache). The next\n * getDeviceId() generates a fresh one. Provide this to viewers as a privacy /\n * \"reset my device identifier\" control — the device id is sent as X-Device-Id\n * on every DRM license request and persists across sessions.\n */\nexport function clearDeviceId(): void {\n cached = undefined;\n try {\n globalThis.localStorage?.removeItem(STORAGE_KEY);\n } catch {\n // storage unavailable — the in-memory cache is already cleared\n }\n}\n\nfunction generateUuid(): string {\n const c = globalThis.crypto as Crypto | undefined;\n if (c && typeof c.randomUUID === 'function') return c.randomUUID();\n\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') {\n c.getRandomValues(bytes);\n } else {\n for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);\n }\n bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));\n return (\n hex.slice(0, 4).join('') +\n '-' +\n hex.slice(4, 6).join('') +\n '-' +\n hex.slice(6, 8).join('') +\n '-' +\n hex.slice(8, 10).join('') +\n '-' +\n hex.slice(10, 16).join('')\n );\n}\n","import { type Credential, type StreamDescriptor } from '../types';\nimport { getDeviceId } from './deviceId';\n\nexport interface LicenseHeaderOptions {\n /** Override the persistent device id (defaults to getDeviceId()). */\n deviceId?: string;\n /** Optional viewer id, forwarded as X-User-Id for billing/tracking. */\n userId?: string;\n}\n\n/**\n * Headers a host player must attach to the DRM license POST. The license URL\n * itself must be sent VERBATIM (its signed query string is the token) — these\n * headers are the rest of the contract:\n * - x-api-key : the SAME key used to resolve playback (cid binding)\n * - X-Device-Id : required, persistent\n * - X-Match-Urn : advisory (the signed `m` param is authoritative)\n * - X-User-Id : optional\n * Body must be the raw EME challenge bytes (application/octet-stream).\n */\nexport function licenseRequestHeaders(\n descriptor: StreamDescriptor,\n credential: Credential,\n opts: LicenseHeaderOptions = {},\n): Record<string, string> {\n const headers: Record<string, string> = {\n 'x-api-key': credential.apiKey,\n 'X-Match-Urn': descriptor.matchUrn,\n 'X-Device-Id': opts.deviceId ?? getDeviceId(),\n 'Content-Type': 'application/octet-stream',\n };\n if (opts.userId) headers['X-User-Id'] = opts.userId;\n return headers;\n}\n","/**\n * Per-element CDM reuse, so rebuilding the player never has to give a\n * `<video>` a second ContentDecryptionModule — which Chrome forbids outright.\n *\n * ## The browser rule\n *\n * Once a media element has both a MediaKeys and a media player, Chrome\n * refuses to change or remove that CDM. BOTH of these reject:\n *\n * video.setMediaKeys(null) // remove\n * video.setMediaKeys(otherKeys) // replace\n *\n * with `InvalidStateError: Failed to execute 'setMediaKeys' on\n * 'HTMLMediaElement': The existing ContentDecryptionModule object cannot be\n * removed at this time.`\n *\n * Measured on Chromium 152 (`org.w3.clearkey`, secure context): removal is\n * allowed while the element has no `src`, and starts failing the moment a\n * MediaSource is attached — with `readyState` still 0, nothing buffered,\n * nothing played, and no encrypted media involved. So the bar is not \"a\n * decoder holds the key\"; it is \"the element has a player\". Every DRM rebuild\n * crosses it.\n *\n * ## Why hls.js's own mitigation cannot help\n *\n * hls.js serialises CDM teardown through a static `EMEController`\n * `CDMCleanupPromise`: the destroyed instance's `_clear()` calls\n * `setMediaKeys(null)` and the next instance waits on it before setting its\n * own keys. On Chrome that clear is exactly as forbidden as the set it is\n * meant to make room for. `_clear()` swallows the rejection as a NON-FATAL\n * `KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR`, the cleanup promise resolves anyway,\n * and the new instance then fails its own `setMediaKeys` — that one fatally,\n * as `KEY_SYSTEM_NO_KEYS`. Ordering was never the problem, so no amount of\n * queueing fixes it. (Verified against the bundled 1.7.2: `_clear` at\n * `dist/hls.js:27580`, the swallowed catch at `:27604`.)\n *\n * ## What we do instead\n *\n * Hand every hls.js instance on a given element the SAME MediaKeys, via the\n * `requestMediaKeySystemAccessFunc` config hook. Nothing then tries to\n * replace the CDM:\n *\n * - `EMEController.onMediaAttached` seeds `this.mediaKeys` from\n * `media.mediaKeys` (`dist/hls.js:27568`), and `attemptSetMediaKeys`\n * early-returns when the keys it is asked to set are already the element's\n * (`:27026`) — so the forbidden call is never made; and\n * - even if that early-return is missed, `setMediaKeys(sameObject)` is\n * permitted by the browser (measured), so the fallback is benign.\n *\n * Two independent layers, which is what makes this safe to rest on across\n * hls.js upgrades: the first is an hls.js implementation detail, the second is\n * the platform's own behaviour.\n *\n * This is not a compromise on freshness. Once an element has a CDM, that CDM\n * is the only one it can ever have — the alternative is not \"a fresh CDM\", it\n * is \"a fresh `<video>`\", and the element belongs to the host application\n * (`createPlayer({ video })`), which may hold its own reference, listeners and\n * styling on it. Reuse is what lets a rebuild stay invisible to the host.\n *\n * What a rebuild actually needs is fresh *hls.js* state — the wedged\n * fragment pipeline, the orphaned key-load promise — not a fresh CDM, and it\n * still gets that. Fresh key SESSIONS also still work: closing a session and\n * opening another on a reused MediaKeys is permitted (measured), which is what\n * the license retry depends on.\n *\n * Scope: reuse covers keys this module created for the element. An element\n * that already carries a MediaKeys set by the host is not adopted — it is not\n * a case the SDK creates, and adopting a CDM whose key system we cannot\n * confirm would trade a clear error for a confusing one.\n */\n\n/** Shape of the hls.js `requestMediaKeySystemAccessFunc` hook. */\ntype MediaKeyRequest = (\n keySystem: string,\n supportedConfigurations: MediaKeySystemConfiguration[],\n) => Promise<MediaKeySystemAccess>;\n\n/**\n * element → key system → its one MediaKeys, as a promise so concurrent\n * requests (hls.js can attempt several key systems) share a single creation.\n * Weak on the element so a discarded `<video>` takes its CDM entry with it.\n */\nconst keysByElement = new WeakMap<HTMLMediaElement, Map<string, Promise<MediaKeys>>>();\n\nfunction mediaKeysFor(media: HTMLMediaElement, access: MediaKeySystemAccess): Promise<MediaKeys> {\n let bySystem = keysByElement.get(media);\n if (!bySystem) {\n bySystem = new Map();\n keysByElement.set(media, bySystem);\n }\n const cached = bySystem.get(access.keySystem);\n if (cached) return cached;\n // Evict on failure: a transient createMediaKeys() rejection must not pin a\n // permanently-rejected promise to the element for the rest of the page.\n const created = access.createMediaKeys().catch((err: unknown) => {\n if (bySystem.get(access.keySystem) === created) bySystem.delete(access.keySystem);\n throw err;\n });\n bySystem.set(access.keySystem, created);\n return created;\n}\n\n/**\n * Whether this page can request key-system access at all. False in a\n * non-secure context (EME is secure-context-only, so plain `http://` embeds\n * see no API), in a non-browser runtime, and in an embedder that strips it.\n *\n * The engine installs the hook below only when this holds, so that when it does\n * NOT hold hls.js falls back to its own null `requestMediaKeySystemAccessFunc`\n * and reports the cause — including the specific \"not available over insecure\n * protocol\" message (`dist/hls.js:26637`), which is the likeliest way an\n * integrator meets this. Installing our hook unconditionally would shadow that\n * diagnostic with an opaque TypeError.\n */\nexport function emeAvailable(): boolean {\n return (\n typeof navigator !== 'undefined' && typeof navigator.requestMediaKeySystemAccess === 'function'\n );\n}\n\n/**\n * Video robustness rungs appended below whatever the caller asked for, weakest\n * last. Widevine's ladder continues to '' below this, and that rung is\n * deliberately absent: '' means \"no requirement\", which would turn a precise\n * `keySystemNoAccess` at access time into an opaque decrypt failure much later,\n * and no measured CDM has needed it. Audio is not laddered — the engine already\n * asks for the weakest named audio level (SW_SECURE_CRYPTO).\n */\nconst VIDEO_ROBUSTNESS_FALLBACKS = ['SW_SECURE_DECODE', 'SW_SECURE_CRYPTO'] as const;\n\ntype VideoRobustness = (typeof VIDEO_ROBUSTNESS_FALLBACKS)[number];\n\n/** True for the Widevine key system, the only one whose robustness we ladder. */\nfunction isWidevine(keySystem: string): boolean {\n return keySystem.startsWith('com.widevine');\n}\n\n/**\n * Expand each configuration into an ordered preference list: the caller's own\n * request first, then the same request at successively weaker VIDEO robustness.\n *\n * `requestMediaKeySystemAccess` takes candidates in descending preference and\n * returns the FIRST supported one, so this can only widen — a CDM that accepts\n * today's request still matches on entry 0 and is granted exactly what it is\n * granted now. It is the CDMs that accept NONE of our pins that change: they\n * get a weaker but working configuration instead of a hard rejection.\n *\n * Measured (Android 15 emulator, Chrome 124, Widevine L3, 2026-09-16): that\n * CDM supports video robustness SW_SECURE_CRYPTO and '' only. The engine's\n * SW_SECURE_DECODE — and every HW_* level — rejected with `NotSupportedError:\n * Unsupported keySystem or supportedConfigurations.`, which hls.js reports as a\n * fatal keySystemNoAccess and the SDK surfaces as an unplayable stream. With\n * the ladder the same call resolves at SW_SECURE_CRYPTO, keeping cbcs, and\n * createMediaKeys() succeeds.\n *\n * Exported for tests; the hook below is the only production caller.\n */\nexport function withVideoRobustnessFallbacks(\n keySystem: string,\n configurations: MediaKeySystemConfiguration[],\n): MediaKeySystemConfiguration[] {\n if (!isWidevine(keySystem)) return configurations;\n\n /** True when `rung` is strictly weaker than every level this config asks for. */\n const weakens = (config: MediaKeySystemConfiguration, rung: VideoRobustness): boolean => {\n const video = config.videoCapabilities;\n // Nothing to weaken: a config that pins no video robustness already\n // imposes no video requirement, so a rung below it would be identical.\n if (!video?.length || !video.some((cap) => cap.robustness)) return false;\n return video.every((cap) => {\n const asked = VIDEO_ROBUSTNESS_FALLBACKS.indexOf(cap.robustness as VideoRobustness);\n // An unrecognised level (HW_*, or a future name) sorts above the whole\n // ladder: every rung here is weaker, so all of them are offered.\n return asked === -1 || VIDEO_ROBUSTNESS_FALLBACKS.indexOf(rung) > asked;\n });\n };\n\n // EVERY caller configuration comes before ANY weakening we synthesized.\n // Interleaving per-config (config, its rungs, next config, its rungs) would\n // let a weakened first configuration outrank a later one the caller asked\n // for outright: given [strictA, strictB] on a CDM that rejects strictA but\n // accepts both weakA and strictB, the browser returns the FIRST match — it\n // would pick weakA and silently drop to a lower protection level while the\n // caller's own strictB was available. Fallbacks are ours, not the caller's,\n // so they rank last; within them, rung-major order so the strongest\n // remaining protection is offered first across all configurations.\n const fallbacks = VIDEO_ROBUSTNESS_FALLBACKS.flatMap((robustness) =>\n configurations\n .filter((config) => weakens(config, robustness))\n .map((config) => ({\n ...config,\n videoCapabilities: config.videoCapabilities?.map((cap) => ({ ...cap, robustness })),\n })),\n );\n return [...configurations, ...fallbacks];\n}\n\n/**\n * Build the `requestMediaKeySystemAccessFunc` for an element: a real access\n * request (so the caller's `supportedConfigurations` are honoured and a\n * genuinely unsupported system still rejects), wrapped so `createMediaKeys()`\n * resolves to the element's one MediaKeys.\n */\nexport function reuseMediaKeysFor(media: HTMLMediaElement): MediaKeyRequest {\n return (keySystem, supportedConfigurations) => {\n // hls.js calls this hook and returns its result straight into a promise\n // chain, so a synchronous throw would escape its error handling. Keep the\n // contract the default hook has: always a promise, rejected on failure.\n if (!emeAvailable()) {\n return Promise.reject(\n new Error(\n 'navigator.requestMediaKeySystemAccess is unavailable — EME needs a secure context (https, or localhost)',\n ),\n );\n }\n return navigator\n .requestMediaKeySystemAccess(\n keySystem,\n withVideoRobustnessFallbacks(keySystem, supportedConfigurations),\n )\n .then((access) => ({\n keySystem: access.keySystem,\n getConfiguration: () => access.getConfiguration(),\n createMediaKeys: () => mediaKeysFor(media, access),\n }));\n };\n}\n\n// Deliberately no way to drop an element's cached CDM. There is no correct\n// caller: engine teardown runs on every rebuild, which is exactly when the\n// entry must survive, and player teardown does not help either — if the host\n// reuses the element for another player, that player still cannot be given a\n// different CDM. The WeakMap covers the only case that matters, an element\n// going out of scope.\n","/**\n * DRM capability detection — answers \"can protected playback work in this\n * browser at all?\" so a host can show an accurate message instead of a black\n * player (e.g. iOS Chrome/Firefox/Edge: WKWebView exposes the EME API surface\n * but grants no CDM — Widevine does not exist on iOS and WebKit withholds\n * FairPlay from third-party browsers).\n *\n * Design rules (each encodes a real failure mode):\n * - PROBE, don't UA-sniff: the probe is ground truth for playability. UA\n * strings are spoofable, iPadOS masquerades as macOS, and in-app WebViews\n * carry no browser token. Hosts may still use the UA to WORD a message —\n * never to gate playback.\n * - `requestMediaKeySystemAccess` resolving is NOT proof: some platforms\n * resolve access and then fail `createMediaKeys()` — both must succeed.\n * - Bounded: some WebView builds never settle the access promise; without a\n * timeout the caller's UI hangs on \"checking…\".\n * - Lazy by contract: the EME spec allows the call to have user-visible\n * effects (consent prompts, CDM provisioning downloads). Only call this\n * once you know the content is DRM-protected — never on page load.\n * - Per-system configs must mirror what the engine will actually request:\n * Widevine uses the SW_SECURE_* pins; FairPlay must NOT (WebKit's\n * supportedRobustnesses() for FPS is { '' } — a Widevine-shaped probe\n * false-negatives on Safari).\n * - `SecurityError` is reported separately: a cross-origin iframe missing\n * allow=\"encrypted-media\" (or an https embed in an http parent) is an\n * embedding misconfiguration, not an unsupported browser.\n */\nimport type { DrmSystem } from '../types';\nimport { withVideoRobustnessFallbacks } from './cdm';\n\nexport type DrmSupportVerdict =\n /** At least one probed system produced working MediaKeys. */\n | 'supported'\n /** EME exists but no probed system yields a CDM (e.g. iOS third-party browsers). */\n | 'no-cdm'\n /** Not a secure context — EME is unavailable by spec (http://<LAN-IP> dev, etc.). */\n | 'insecure-context'\n /** SecurityError: permissions-policy / iframe allow=\"encrypted-media\" missing. */\n | 'blocked-by-policy'\n /** The access promise never settled within timeoutMs (some WebViews). */\n | 'probe-timeout';\n\nexport interface DrmSupportResult {\n verdict: DrmSupportVerdict;\n /** The first system that probed as working (when verdict is 'supported'). */\n system?: DrmSystem;\n /** Per-system outcome, for diagnostics/support bundles. */\n detail: Partial<Record<DrmSystem, string>>;\n}\n\nexport interface DetectDrmSupportOptions {\n /** Systems to probe, in order. Default: both. */\n systems?: DrmSystem[];\n /** OVERALL budget for the whole call before concluding 'probe-timeout' —\n * shared across every probed system/stage, not per probe. Default 5000ms. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_PROBE_TIMEOUT_MS = 5_000;\n\nconst VIDEO_MP4 = 'video/mp4; codecs=\"avc1.42E01E\"';\nconst AUDIO_MP4 = 'audio/mp4; codecs=\"mp4a.40.2\"';\n\n/** Key-system strings per DRM system. EXACTLY what the hls.js engine\n * configures — the probe predicts the ENGINE, not abstract capability, so a\n * key system the engine never requests (e.g. legacy `com.apple.fps.1_0`,\n * which hls.js has no fallback to) must not produce a 'supported' verdict. */\nconst KEY_SYSTEMS: Record<DrmSystem, string[]> = {\n widevine: ['com.widevine.alpha'],\n fairplay: ['com.apple.fps'],\n};\n\n/** One config per system, mirroring the engine's EFFECTIVE access request\n * (hls.ts drmSystemOptions → hls.js builds a single configuration):\n * Widevine carries the SW_SECURE_* pins, FairPlay the empty robustness\n * WebKit requires, and BOTH pin encryptionScheme cbcs exactly as the engine\n * does. (Not literally byte-for-byte: initDataTypes ordering and the\n * canonical codec strings differ from hls.js's manifest-derived ones, and\n * spec-default fields are omitted — none of which can flip a verdict.) A\n * WEAKER probe config (pin-less, scheme-less) would report 'supported' on\n * CDMs the engine's stricter request then fails on — the exact\n * optimistic-message-then-black-player this API exists to prevent. */\nfunction probeConfigs(system: DrmSystem): MediaKeySystemConfiguration[] {\n if (system === 'fairplay') {\n return [\n {\n initDataTypes: ['sinf', 'skd', 'cenc'],\n videoCapabilities: [{ contentType: VIDEO_MP4, robustness: '', encryptionScheme: 'cbcs' }],\n audioCapabilities: [{ contentType: AUDIO_MP4, robustness: '', encryptionScheme: 'cbcs' }],\n },\n ];\n }\n // Laddered through the same helper the engine's access hook uses, so the\n // probe keeps mirroring the EFFECTIVE request. Without this the probe would\n // be STRICTER than the engine and report 'no-cdm' for a device the engine\n // then plays on — the same false verdict this file exists to prevent, only\n // inverted.\n return withVideoRobustnessFallbacks('com.widevine.alpha', [\n {\n initDataTypes: ['cenc'],\n videoCapabilities: [\n { contentType: VIDEO_MP4, robustness: 'SW_SECURE_DECODE', encryptionScheme: 'cbcs' },\n ],\n audioCapabilities: [\n { contentType: AUDIO_MP4, robustness: 'SW_SECURE_CRYPTO', encryptionScheme: 'cbcs' },\n ],\n },\n ]);\n}\n\nfunction withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const t = setTimeout(() => reject(new Error('probe-timeout')), ms);\n p.then(\n (v) => {\n clearTimeout(t);\n resolve(v);\n },\n (e) => {\n clearTimeout(t);\n reject(e);\n },\n );\n });\n}\n\n/**\n * Probe whether DRM playback can work here. Resolves — never rejects.\n *\n * Call it lazily (only for content whose descriptor says `drmEnabled`), CACHE\n * the result yourself (probes are not memoized, and the EME spec allows each\n * call to show consent prompts / trigger CDM downloads), and treat\n * 'supported' as necessary-not-sufficient: the licence server, CORS\n * onboarding and content packaging still have to cooperate.\n */\nexport async function detectDrmSupport(\n opts: DetectDrmSupportOptions = {},\n): Promise<DrmSupportResult> {\n const systems = opts.systems ?? (['widevine', 'fairplay'] as DrmSystem[]);\n const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;\n const detail: Partial<Record<DrmSystem, string>> = {};\n\n if (typeof window !== 'undefined' && window.isSecureContext === false) {\n return { verdict: 'insecure-context', detail };\n }\n const rmksa =\n typeof navigator !== 'undefined'\n ? navigator.requestMediaKeySystemAccess?.bind(navigator)\n : undefined;\n if (!rmksa) {\n // Secure context but no EME entry point at all (very old browser, or an\n // embedder stripped it) — indistinguishable from no CDM for the caller.\n return { verdict: 'no-cdm', detail };\n }\n\n let sawTimeout = false;\n let sawSecurityError = false;\n // One WALL-CLOCK deadline for the whole call: stages consume the remaining\n // budget, so N systems × 2 stages can never stack into N×2 × timeoutMs of\n // \"checking…\" UI hang.\n const deadline = Date.now() + timeoutMs;\n const remaining = (): number => Math.max(1, deadline - Date.now());\n\n for (const system of systems) {\n const keySystems = KEY_SYSTEMS[system];\n if (!keySystems) {\n // Untyped callers can pass arbitrary strings — record, don't reject\n // (the documented contract is \"resolves, never rejects\").\n detail[system] = 'unknown system';\n continue;\n }\n for (const keySystem of keySystems) {\n if (Date.now() >= deadline) {\n sawTimeout = true;\n detail[system] ??= `${keySystem}: probe-timeout`;\n break;\n }\n try {\n const access = await withTimeout(rmksa(keySystem, probeConfigs(system)), remaining());\n // Access alone is not proof — the CDM must actually instantiate.\n await withTimeout(access.createMediaKeys(), remaining());\n detail[system] = `ok (${keySystem})`;\n return { verdict: 'supported', system, detail };\n } catch (err) {\n // Read name/message structurally: DOMException is not `instanceof\n // Error` in every runtime (jsdom, older WebKit) and the distinction\n // matters — SecurityError must not degrade into 'no-cdm'. Prefer the\n // message when the name is the generic 'Error' so a timeout is\n // legible in support bundles.\n const e = err as { name?: string; message?: string } | undefined;\n const name = (e?.name && e.name !== 'Error' ? e.name : e?.message) || String(err);\n detail[system] = `${keySystem}: ${name}`;\n if (e?.message === 'probe-timeout') sawTimeout = true;\n if (e?.name === 'SecurityError') sawSecurityError = true;\n // NotSupportedError (no CDM for this key system) → try the next one.\n }\n }\n }\n\n if (sawSecurityError) return { verdict: 'blocked-by-policy', detail };\n if (sawTimeout) return { verdict: 'probe-timeout', detail };\n return { verdict: 'no-cdm', detail };\n}\n","// LL-HLS detection from a playlist body. LL-HLS is per-distributor and OFF by\n// default, and the tags live on the *media* playlist — so we detect rather than\n// assume. The managed player uses this on level updates to report isLowLatency.\n\nconst LOW_LATENCY_TAG = /^#EXT-X-(PART-INF|PART|PRELOAD-HINT)[:\\s]/m;\n\nexport function isLowLatencyManifest(playlistText: string): boolean {\n return LOW_LATENCY_TAG.test(playlistText);\n}\n","// Named Oddin environments and the API base URL each one means.\n//\n// WHY THESE ARE PINNED HERE, since pinning an endpoint into a published\n// package is normally the wrong instinct: a hand-written `baseUrl` is\n// invisible when it is wrong. A typo, a stale env var, or the wrong\n// environment produces a player that either fails in a way that looks like an\n// outage, or — worse — works while quietly losing everything derived from the\n// host. The beacons endpoint is derived from `baseUrl` by hostname convention\n// (`feed[-dev].<domain>` → `beacons[-dev].<domain>`), so an off-convention\n// baseUrl means QoE analytics is off; an integrator who selects `env` instead\n// cannot land in that state at all, because the base is a feed host by\n// construction.\n//\n// AND WHY `baseUrl` SURVIVES ANYWAY. Pinning couples the endpoint to a\n// release, and this SDK is consumed by integrators who self-host a pinned\n// build and upgrade rarely. If these constants were the only way to reach the\n// platform, a domain change would strand every one of them. `baseUrl` is\n// therefore not deprecated: it is the escape hatch for that, for a customer\n// whose own CDN or gateway fronts the streams API, and for the CN plane,\n// whose ICP-registered host is not under this domain and rotates.\n//\n// Exactly one of the two is the right answer for any given integration, which\n// is why the callers reject being given both rather than picking a winner.\n\nimport { PlaybackError } from './errors';\n\n/**\n * A named Oddin environment.\n *\n * - `integration` — the shared pre-production stack. Where customers build\n * and test against real matches.\n * - `production` — the production stack. A separate distribution, not an\n * alias of integration.\n */\nexport type HavikEnv = 'integration' | 'production';\n\nconst BASE_URLS: Readonly<Record<HavikEnv, string>> = {\n integration: 'https://feed-dev.oddin-video.gg',\n production: 'https://feed.oddin-video.gg',\n};\n\n/**\n * The API base URL for a named environment.\n *\n * Mode A takes {@link HavikEnv} directly (`createPlayer({ env: 'production' })`).\n * Mode B and Mode C still take a `baseUrl`, so call this to get one rather\n * than copying a hostname into your own config:\n *\n * ```ts\n * resolveStream({ baseUrl: baseUrlForEnv('production'), matchUrn, credential });\n * ```\n *\n * Throws on an unrecognized value rather than returning undefined — callers\n * reach this from plain JS where the type gives no protection, and a silent\n * undefined becomes a relative URL against the embedding page.\n */\nexport function baseUrlForEnv(env: HavikEnv): string {\n // Own-property lookup, not a bare index. A bare BASE_URLS['constructor']\n // resolves an INHERITED Object.prototype property and returns a FUNCTION,\n // which is truthy — so the guard below would pass it through, createPlayer\n // would carry a function as its base URL, and joinUrl would die on\n // `base.replace is not a function`: a TypeError from deep inside the SDK\n // instead of the typed error naming the two valid values. `toString` and\n // `__proto__` do the same. Verified before fixing: all three returned\n // without throwing.\n //\n // hasOwnProperty.call, not Object.hasOwn — the global build targets\n // firefox91/safari15 (vite.global.config.ts) and Object.hasOwn needs\n // Firefox 92 / Safari 15.4, which Vite downlevels syntax for but does not\n // polyfill. Same guard and same reasoning as core/environment.ts.\n const url = Object.prototype.hasOwnProperty.call(BASE_URLS, env) ? BASE_URLS[env] : undefined;\n if (!url) {\n throw new PlaybackError(\n 'INTERNAL',\n 0,\n `unknown env ${JSON.stringify(env)} — expected 'integration' or 'production'`,\n );\n }\n return url;\n}\n","export type PlayerState =\n | 'idle'\n | 'waiting' // armed, polling for go-live (waitForLive)\n | 'loading'\n | 'playing'\n | 'buffering'\n | 'paused'\n | 'ended'\n | 'error';\n\nexport interface PlaybackStats {\n droppedFrames: number;\n /**\n * TOTAL frames this session — the denominator dropped frames need, and it\n * ALREADY INCLUDES the dropped ones (it is\n * `VideoPlaybackQuality.totalVideoFrames`). So the ratio is\n * `droppedFrames / decodedFrames`, with nothing added to the bottom.\n *\n * The name says \"decoded\" because it is the wire field's name and a\n * contract rename is not worth a migration (plan D10). Read it as \"total\".\n */\n decodedFrames?: number;\n /** Live-edge latency in seconds (hls.js engine only). */\n latencySeconds?: number;\n /** The live-sync target latency hls.js is converging to, in seconds. Compare\n * against latencySeconds to see drift. */\n targetLatencySeconds?: number;\n /** Estimated bandwidth in kbps (hls.js engine only). */\n bandwidthKbps?: number;\n /** Height of the current rendition, when known. */\n levelHeight?: number;\n /** Whether the stream is being played in low-latency mode. */\n lowLatency?: boolean;\n /** Whether the current media playlist is live. */\n isLive?: boolean;\n /** Whether playback is at/near the live edge (within the live sync window). */\n atLiveEdge?: boolean;\n /** Cumulative media segment bytes fetched this session (hls.js engine only). */\n bytesLoaded?: number;\n /** Cumulative manifest/playlist bytes fetched this session (hls.js engine only). */\n playlistBytesLoaded?: number;\n /** Cumulative ABR rendition switches this session (hls.js engine only). */\n renditionSwitches?: number;\n /** Bitrate of the current rendition in kbps, when known. */\n renditionBitrateKbps?: number;\n /** Duration of the most recent DRM license acquisition in ms (0 = none yet). */\n licenseTimeMs?: number;\n\n // ---- Contract v2 (QoE plane). Cumulative where the beacon layer takes\n // deltas; a snapshot otherwise. Engines fill what they can observe.\n /** RFC 6381 codec strings of the current rendition. */\n videoCodec?: string;\n audioCodec?: string;\n /** The offered rendition ladder, ascending heights, and its top bitrate. */\n ladderHeights?: number[];\n ladderTopBitrateKbps?: number;\n /** DRM the engine negotiated for the session. */\n keySystem?: 'widevine' | 'fairplay' | 'none';\n /** Robustness requested/granted, e.g. Widevine L3 (software) on the web. */\n drmSecurityLevel?: string;\n /** The viewer pinned a quality; ABR is not deciding. */\n qualityPinned?: boolean;\n /** Cumulative ABR switch direction split (renditionSwitches = both). */\n upshifts?: number;\n downshifts?: number;\n /** Cumulative media requests completed / failed, and the durations of the\n * most recent ones (ms, newest last, bounded) for interval percentiles. */\n requestCount?: number;\n requestErrors?: number;\n requestTimesMs?: number[];\n /** Host of the most recent media request — host only, never the path. */\n cdnHost?: string;\n}\n\nexport const EMPTY_STATS: PlaybackStats = { droppedFrames: 0 };\n\n/**\n * Host of a media URL, without the path. The beacon plane records WHICH CDN\n * served the session — so region and vendor come from the client with no IP\n * anywhere in the chain — never the path, which on a signed slot carries the\n * token.\n *\n * Shared by both engines on purpose: two copies would be two answers to\n * \"which CDN was this\", and the column is a LowCardinality string where\n * `CDN.example` and `cdn.example` would become two entries for one CDN. The\n * URL parser lowercases for us. `host`, not `hostname`: the port is signal.\n */\nexport function hostOf(url: string): string {\n try {\n return new URL(url).host;\n } catch {\n return '';\n }\n}\n\n/** Media URLs, playlists included. The playlist matters more than the\n * segments here — see mediaHostSince. */\nconst MEDIA_URL = /\\.(m3u8|ts|m4s|mp4|cmfv|cmfa)(\\?|$)/i;\n\n/**\n * The CDN host for an engine that does its own fetching, read back out of\n * Resource Timing.\n *\n * WebKit's native HLS player exposes NO per-segment network detail to script,\n * which is why the native engine reports no request timings. It does however\n * leave the media load in the resource timeline, so the host is recoverable\n * even though the transfer is not.\n *\n * Measured on iOS 26 (real WebKit, cross-origin stream), 2026-09-17:\n *\n * master.m3u8 1 entry, initiatorType 'video'\n * segments 0 entries, over 22 s of confirmed playback\n *\n * So this finds the PLAYLIST, not a segment, and two things follow:\n *\n * - It works cross-origin with no `Timing-Allow-Origin` anywhere. TAO gates\n * the TIMING fields, never `name` — the entry arrives with responseStart\n * and transferSize zeroed and its URL intact, and the URL is all we want.\n * - The host is fixed at session start. hls.js re-reads it on every\n * fragment precisely to catch a mid-session CDN failover; this engine\n * cannot see one. Where a deployment serves playlists and segments from\n * different hostnames, this would name the playlist's.\n *\n * `since` is a performance.now() mark taken when the engine was built, so a\n * stream played EARLIER on the same page cannot donate its host to this\n * session.\n */\nexport function mediaHostSince(since: number): string {\n if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') {\n return '';\n }\n let best = '';\n let bestStart = -1;\n // getEntriesByType is typed as PerformanceEntry[]; the 'resource' list is\n // PerformanceResourceTiming, which is where initiatorType lives.\n const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];\n for (const e of entries) {\n if (e.startTime < since) continue;\n // initiatorType 'video' is what WebKit attributes the native media load\n // to; the extension test carries any engine that attributes differently.\n if (e.initiatorType !== 'video' && e.initiatorType !== 'audio' && !MEDIA_URL.test(e.name)) {\n continue;\n }\n if (e.startTime >= bestStart) {\n const host = hostOf(e.name);\n if (host) {\n best = host;\n bestStart = e.startTime;\n }\n }\n }\n return best;\n}\n\n/** A selectable video rendition. */\nexport interface QualityLevel {\n /** Index into getQualityLevels() — pass to setQuality(). */\n index: number;\n width?: number;\n height?: number;\n bitrate: number;\n codecs?: string;\n}\n\n/** A selectable audio rendition. */\nexport interface AudioTrackInfo {\n id: number;\n name: string;\n lang?: string;\n default: boolean;\n}\n\n/** A selectable text (caption/subtitle) track. */\nexport interface TextTrackInfo {\n id: number;\n name: string;\n lang?: string;\n}\n\n/**\n * Frame counts via the standard media quality API (engine-agnostic). Both\n * numbers come from ONE call: a drop count without its denominator says\n * nothing — 300 dropped frames is a broken session at 30fps and a rounding\n * error over an hour.\n *\n * 🔑 `totalFrames`, NOT `decoded`. It is `VideoPlaybackQuality.totalVideoFrames`,\n * which the spec defines as every frame that WOULD have been presented —\n * dropped frames included. So `dropped / totalFrames` is already dropped over\n * total, and the denominator needs no correction.\n *\n * The old name was `decoded`, which reads as \"successfully decoded\" and\n * therefore as \"excluding dropped\". That invites a reader to fix a ratio that\n * was never broken, by switching the denominator to `decoded + dropped` and\n * double-counting every dropped frame. The maths was right; only the name was\n * wrong. Pinned by test/frames.test.ts.\n *\n * The WIRE field keeps its name (`decoded_frames_*`): renaming a contract\n * field to fix a comment is not worth a migration.\n */\nexport function readFrameStats(video: HTMLVideoElement): { dropped: number; totalFrames: number } {\n const q = (\n video as HTMLVideoElement & { getVideoPlaybackQuality?: () => VideoPlaybackQuality }\n ).getVideoPlaybackQuality?.();\n return { dropped: q?.droppedVideoFrames ?? 0, totalFrames: q?.totalVideoFrames ?? 0 };\n}\n\n/**\n * Map raw <video> media events to player states. Engine-agnostic: both the\n * hls.js and native engines drive the same state machine through this.\n */\nexport function attachMediaStateListeners(\n video: HTMLVideoElement,\n onState: (state: PlayerState) => void,\n): () => void {\n const handlers: Array<[keyof HTMLMediaElementEventMap, () => void]> = [\n ['playing', () => onState('playing')],\n ['waiting', () => onState('buffering')],\n ['stalled', () => onState('buffering')],\n [\n 'pause',\n () => {\n if (!video.ended) onState('paused');\n },\n ],\n ['ended', () => onState('ended')],\n ];\n for (const [ev, fn] of handlers) video.addEventListener(ev, fn);\n return () => {\n for (const [ev, fn] of handlers) video.removeEventListener(ev, fn);\n };\n}\n","import { classifyDrmStatus, PlaybackError } from '../errors';\nimport {\n mediaHostSince,\n readFrameStats,\n type AudioTrackInfo,\n type PlaybackStats,\n type QualityLevel,\n type TextTrackInfo,\n} from '../stats';\nimport { type EngineDeps, type PlaybackEngine } from './types';\n\n// Non-standard `video.audioTracks` (Safari) — minimal structural shape.\ninterface NativeAudioTrack {\n enabled: boolean;\n label: string;\n language: string;\n}\ninterface NativeAudioTrackList {\n readonly length: number;\n [index: number]: NativeAudioTrack;\n}\n\n/**\n * Pull the 32-char hex content IV out of Safari's FairPlay initData.\n *\n * WebKit hands `encrypted` the playlist key URI verbatim — `skd://<kid>:<iv>`\n * as UTF-8 — which is also Axinom's documented asset ID. Returns undefined\n * for any other shape so a malformed URI sends no header at all rather than a\n * stale or wrong one.\n */\nfunction extractSkdIV(initData: ArrayBuffer): string | undefined {\n try {\n const uri = new TextDecoder('utf-8').decode(new Uint8Array(initData));\n return /^skd:\\/\\/[0-9a-fA-F-]{36}:([0-9a-fA-F]{32})$/.exec(uri)?.[1];\n } catch {\n return undefined;\n }\n}\n\n/** True when the browser plays HLS natively (Safari / iOS). */\nexport function nativeHlsSupported(video?: HTMLVideoElement): boolean {\n const el =\n video ?? (typeof document !== 'undefined' ? document.createElement('video') : undefined);\n return Boolean(el?.canPlayType('application/vnd.apple.mpegurl'));\n}\n\n/**\n * True when the media stack is WebKit's — the only one that decrypts FairPlay\n * through native HLS.\n *\n * `nativeHlsSupported` cannot answer this. Chromium returns `\"maybe\"` for\n * `canPlayType('application/vnd.apple.mpegurl')` (measured 2026-08-07) while\n * being unable to play HLS natively at all, so gating the FairPlay engine on\n * it alone routes Chrome into the native engine and fails every playback with\n * an opaque 'native HLS playback error'.\n *\n * Both signals are present on Safari and on iOS third-party browsers (all\n * WebKit), and absent on Chromium — which reports `vendor: \"Google Inc.\"` and\n * exposes neither legacy FairPlay entry point. OR rather than AND so a future\n * WebKit that drops one of them still reaches the engine that works.\n */\nexport function webkitMediaStack(): boolean {\n if (typeof window === 'undefined') return false;\n const legacyFairPlay =\n 'WebKitMediaKeys' in window ||\n (typeof HTMLMediaElement !== 'undefined' && 'webkitSetMediaKeys' in HTMLMediaElement.prototype);\n const appleVendor = typeof navigator !== 'undefined' && /Apple/i.test(navigator.vendor ?? '');\n return legacyFairPlay || appleVendor;\n}\n\n/**\n * Native-HLS engine — plays via `video.src`, and drives FairPlay EME itself.\n *\n * This is the FairPlay engine on Apple platforms. The previous note here said\n * FairPlay was \"implemented on the hls.js engine, which 'auto' prefers\n * everywhere hls.js is supported (including modern Safari via\n * ManagedMediaSource)\". That was wrong, and it cost a lot of debugging: the\n * hls.js/MSE route reaches a 'usable' key and still renders nothing, because\n * WebKit binds FairPlay keys through the fragment `sinf` box rather than a\n * synthesized skd asset ID. Measured on macOS Safari 26.5 against live DRM\n * matches (2026-08-06): hls.js+MSE 1 pass / 6 attempts, this engine 4 / 4 at\n * 1280px (Widevine on Chrome 3/3 as the control).\n *\n * Widevine is not available here — that stays on the hls.js engine.\n */\nexport class NativeHlsEngine implements PlaybackEngine {\n readonly name = 'native' as const;\n private readonly deps: EngineDeps;\n private onLoaded?: () => void;\n private onError?: () => void;\n private onEncrypted?: (ev: Event) => void;\n private fairplayLicenseUrl: string | undefined;\n /** Kept so teardown can close the CDM session. */\n private keySession: MediaKeySession | null = null;\n /** Set synchronously on the first `encrypted` event; see armFairPlay. */\n private fairplayArmed = false;\n /** Content IV (32-char hex) parsed from Safari's skd:// initData. */\n private fairplayKeyIV: string | undefined;\n /** False while a deferLoad engine waits for startLoad(); see load(). */\n private loadStarted: boolean;\n /** True once load() rewrote the element's preload; destroy() restores it. */\n private preloadTouched = false;\n /** The preload attribute before load() rewrote it (null = not set). */\n private savedPreload: string | null = null;\n /** CDN host, recovered from Resource Timing once and kept; see getStats. */\n private cdnHost = '';\n /** Resource-timeline mark taken at construction, so a stream played EARLIER\n * on this page cannot donate its host to this session. */\n private readonly builtAt: number;\n\n constructor(deps: EngineDeps) {\n this.deps = deps;\n this.loadStarted = deps.deferLoad !== true;\n this.builtAt = typeof performance !== 'undefined' ? performance.now() : 0;\n }\n\n async load(): Promise<void> {\n const { video, descriptor, emit } = this.deps;\n\n // deferLoad (autoplay-off pre-play): preload 'none' keeps the native\n // pipeline from fetching the manifest, media, or FairPlay keys until the\n // viewer presses play. 'ready' (loadedmetadata) then arrives after the\n // first play() starts fetching — the managed player does not wait on it.\n if (!this.loadStarted) {\n this.preloadTouched = true;\n this.savedPreload = video.getAttribute('preload');\n video.preload = 'none';\n }\n\n if (descriptor.drmEnabled) {\n if (!descriptor.drm?.fairplay) {\n // DRM required but no FairPlay material (e.g. a Widevine-only stream): the\n // native engine cannot decrypt it. Fail fast with a clear, typed error\n // instead of an opaque 'native HLS playback error' downstream.\n emit({\n type: 'error',\n error: new PlaybackError(\n 'DRM_CLIENT',\n 0,\n 'native HLS engine cannot satisfy this DRM (Widevine requires the hls.js engine); use engine \"auto\"/\"hls\" on a Widevine-capable browser',\n ),\n fatal: true,\n });\n return;\n }\n this.armFairPlay();\n }\n\n // deferLoad: the managed layer owns the pre-play 'ready' for this engine\n // (emitted after analytics/session wiring, a macrotask after commit — an\n // emit here would fire inside load(), before the analytics session exists\n // and before the host can hold listeners). loadedmetadata can't fire\n // until play() starts fetching under preload=\"none\", so the engine-side\n // emit is suppressed entirely for a deferred instance — it would only\n // duplicate the managed one after startLoad() + play().\n const deferred = !this.loadStarted;\n this.onLoaded = deferred ? undefined : () => emit({ type: 'ready' });\n this.onError = () =>\n emit({\n type: 'error',\n error: new PlaybackError('INTERNAL', 0, 'native HLS playback error'),\n fatal: true,\n });\n if (this.onLoaded) video.addEventListener('loadedmetadata', this.onLoaded, { once: true });\n video.addEventListener('error', this.onError);\n video.src = withCmcdSid(descriptor.manifestUrl, descriptor.analytics?.sid);\n video.load();\n }\n\n startLoad(): void {\n if (this.loadStarted) return;\n this.loadStarted = true;\n // Putting the host's own preload back is enough: the play() that follows\n // drives the fetch regardless of the attribute's value, and forcing\n // 'auto' would override a host-set \"metadata\" for the rest of the\n // session. An explicit video.load() here would abort that play()'s\n // pending promise (the media load algorithm rejects it with AbortError).\n this.restorePreload();\n }\n\n /** Undo the deferLoad preload rewrite so the element reads as the host set\n * it. Idempotent; a no-op when load() never rewrote it. */\n private restorePreload(): void {\n if (!this.preloadTouched) return;\n this.preloadTouched = false;\n const { video } = this.deps;\n if (this.savedPreload !== null) video.setAttribute('preload', this.savedPreload);\n else video.removeAttribute('preload');\n }\n\n /**\n * Drive FairPlay EME on the native HLS pipeline.\n *\n * This is how FairPlay is meant to be deployed on Apple platforms, and it\n * is the path that actually decrypts. Safari raises `encrypted` with\n * initDataType 'skd' and the playlist key URI as initData — the full\n * `skd://<kid>:<iv>` string, UTF-8, 75 bytes — which is exactly Axinom's\n * documented asset ID. We never synthesize or reshape it.\n *\n * The hls.js/MSE route cannot do this: WebKit binds a FairPlay key to\n * media through the fragment `sinf` box, so a session built from a\n * synthesized skd asset ID is never associated with the stream (key\n * reports 'usable', element sits at waitingforkey, black), and driving it\n * from the sinf instead loses the IV that only the URI carries. Measured\n * on macOS Safari 26.5 against live DRM matches (2026-08-06): hls.js+MSE\n * 1 pass / 6, this path 4 passes / 4 at 1280px, with Widevine on Chrome\n * 3/3 as the control.\n *\n * Requires drm-proxy to put the content IV on every inline key of the\n * entitlement message (`content_keys_source.inline[].iv`) — Axinom rejects\n * a FairPlay request without one unless the asset ID supplies it.\n */\n private armFairPlay(): void {\n const { video, descriptor, credential, deviceId, userId, emit } = this.deps;\n const fp = descriptor.drm?.fairplay;\n if (!fp) return;\n this.fairplayLicenseUrl = fp.licenseUrl;\n\n // Only a server auth/entitlement refusal is UNAUTHORIZED/FORBIDDEN;\n // certificate/CDM/transport failures are DRM_CLIENT so callers can tell\n // \"not allowed\" from \"broke on this device\".\n const fail = (message: string, status: number, fatal: boolean) =>\n emit({\n type: 'error',\n error: new PlaybackError(classifyDrmStatus(status), status, message),\n fatal,\n });\n\n this.onEncrypted = (event: Event) => {\n const ev = event as MediaEncryptedEvent;\n // Safari raises `encrypted` several times in quick succession (once per\n // track / init segment). The guard must flip BEFORE the first await or\n // every event enters the async block and races to build its own session.\n if (!ev.initData || this.fairplayArmed) return;\n this.fairplayArmed = true;\n // Safari's initData IS the playlist key URI: skd://<kid>:<iv>, UTF-8.\n // Axinom needs that IV on every inline key of the entitlement message,\n // and drm-proxy has no other source for it, so pass it along.\n this.fairplayKeyIV = extractSkdIV(ev.initData);\n void (async () => {\n try {\n const access = await navigator.requestMediaKeySystemAccess('com.apple.fps', [\n {\n initDataTypes: [ev.initDataType],\n videoCapabilities: [{ contentType: 'application/vnd.apple.mpegurl', robustness: '' }],\n distinctiveIdentifier: 'not-allowed',\n persistentState: 'not-allowed',\n sessionTypes: ['temporary'],\n },\n ]);\n const mediaKeys = await access.createMediaKeys();\n // drm-proxy requires the api key on the certificate GET.\n const certRes = await fetch(fp.certificateUrl, {\n headers: { 'x-api-key': credential.apiKey },\n });\n if (!certRes.ok) {\n // Carry the real status so a certificate 401/403 keeps the server\n // taxonomy instead of collapsing to DRM_CLIENT/0 in the catch below.\n this.fairplayArmed = false; // allow a retry on the next `encrypted`\n fail(`FairPlay certificate request failed: ${certRes.status}`, certRes.status, true);\n return;\n }\n await mediaKeys.setServerCertificate(await certRes.arrayBuffer());\n await video.setMediaKeys(mediaKeys);\n\n const session = mediaKeys.createSession();\n this.keySession = session;\n session.addEventListener('message', (msgEvent) => {\n void (async () => {\n try {\n // Read the URL fresh: setLicenseUrls may have refreshed the\n // signed URL since the session was created (10-min TTL).\n const res = await fetch(this.fairplayLicenseUrl ?? fp.licenseUrl, {\n method: 'POST',\n body: msgEvent.message,\n headers: {\n 'x-api-key': credential.apiKey,\n 'X-Match-Urn': descriptor.matchUrn,\n 'X-Device-Id': deviceId,\n ...(userId ? { 'X-User-Id': userId } : {}),\n ...(this.fairplayKeyIV ? { 'X-Key-Iv': this.fairplayKeyIV } : {}),\n },\n });\n if (!res.ok) {\n fail(`FairPlay license request failed: ${res.status}`, res.status, true);\n return;\n }\n await session.update(new Uint8Array(await res.arrayBuffer()));\n } catch (e) {\n fail(`FairPlay license request failed: ${String(e)}`, 0, true);\n }\n })();\n });\n await session.generateRequest(ev.initDataType, ev.initData as BufferSource);\n } catch (e) {\n this.fairplayArmed = false; // allow a retry on the next `encrypted`\n fail(`FairPlay key session failed: ${String(e)}`, 0, true);\n }\n })();\n };\n video.addEventListener('encrypted', this.onEncrypted);\n }\n\n setLicenseUrls(urls: { widevine?: string; fairplay?: string }): void {\n // Keep the refreshed signed FairPlay URL for the next license request;\n // Widevine is not reachable on this engine.\n if (urls.fairplay) this.fairplayLicenseUrl = urls.fairplay;\n }\n\n // Native HLS handles ABR internally and does not expose a rendition ladder.\n getQualityLevels(): QualityLevel[] {\n return [];\n }\n getCurrentQuality(): number {\n return -1;\n }\n setQuality(_index: number): void {}\n setMaxBitrate(_bitrate: number | null): void {}\n\n getAudioTracks(): AudioTrackInfo[] {\n const list = (this.deps.video as HTMLVideoElement & { audioTracks?: NativeAudioTrackList })\n .audioTracks;\n if (!list) return [];\n return Array.from({ length: list.length }, (_v, id) => {\n const t = list[id];\n return {\n id,\n name: t.label || t.language || `Audio ${id + 1}`,\n lang: t.language || undefined,\n default: t.enabled,\n };\n });\n }\n setAudioTrack(id: number): void {\n const list = (this.deps.video as HTMLVideoElement & { audioTracks?: NativeAudioTrackList })\n .audioTracks;\n if (!list) return;\n for (let i = 0; i < list.length; i += 1) list[i].enabled = i === id;\n }\n\n getTextTracks(): TextTrackInfo[] {\n const list = this.deps.video.textTracks;\n return Array.from({ length: list.length }, (_v, id) => {\n const t = list[id];\n return { id, name: t.label || t.language || `Text ${id + 1}`, lang: t.language || undefined };\n });\n }\n setTextTrack(id: number): void {\n const list = this.deps.video.textTracks;\n for (let i = 0; i < list.length; i += 1) list[i].mode = i === id ? 'showing' : 'disabled';\n }\n\n seekToLive(): void {\n const v = this.deps.video;\n if (v.seekable.length > 0) v.currentTime = v.seekable.end(v.seekable.length - 1);\n }\n\n getStats(): PlaybackStats {\n const v = this.deps.video;\n const frames = readFrameStats(v);\n const stats: PlaybackStats = {\n droppedFrames: frames.dropped,\n decodedFrames: frames.totalFrames,\n };\n // Request timing, ABR direction and codecs stay invisible to this engine:\n // WebKit fetches the media itself and reports no per-segment network\n // detail to script. That is plan gate G8, not a gap to fill here.\n //\n // The CDN host is the exception, and it is worth the exception: without\n // it every Safari and iOS session lands in the empty cohort of a per-CDN\n // breakdown, which on a Safari-heavy audience is most of the traffic.\n // Resource Timing still carries the media load even though the transfer\n // detail is gone — see mediaHostSince, which documents what WebKit does\n // and does not expose, and why this names the PLAYLIST's host.\n //\n // Cached on first sight: the host cannot change for this engine (there is\n // no failover to observe), the entry can be evicted once the resource\n // buffer fills, and re-scanning the timeline on every heartbeat for a\n // value that never moves is waste.\n if (!this.cdnHost) this.cdnHost = mediaHostSince(this.builtAt);\n if (this.cdnHost) stats.cdnHost = this.cdnHost;\n if (this.deps.descriptor.drmEnabled) stats.keySystem = 'fairplay';\n if (v.duration === Infinity) {\n stats.isLive = true;\n if (v.seekable.length > 0) {\n stats.atLiveEdge = v.currentTime >= v.seekable.end(v.seekable.length - 1) - 2;\n }\n }\n return stats;\n }\n\n stop(): void {\n // Native HLS: pause to halt fetching; keep src so the last frame stays up.\n try {\n this.deps.video.pause();\n } catch {\n // ignore\n }\n }\n\n destroy(): void {\n const { video } = this.deps;\n if (this.onLoaded) video.removeEventListener('loadedmetadata', this.onLoaded);\n if (this.onError) video.removeEventListener('error', this.onError);\n if (this.onEncrypted) video.removeEventListener('encrypted', this.onEncrypted);\n // Release the CDM session; a stale one would keep the key alive past\n // teardown and block a fresh arm on the next load().\n void this.keySession?.close().catch(() => {});\n this.keySession = null;\n this.fairplayArmed = false;\n this.fairplayKeyIV = undefined;\n try {\n video.pause();\n } catch {\n // ignore\n }\n video.removeAttribute('src');\n this.restorePreload(); // a still-deferred engine dies with 'none' applied\n try {\n video.load();\n } catch {\n // ignore\n }\n }\n}\n\n/**\n * Append the CMCD session id to the manifest URL (CTA-5004 query mode).\n * The native pipeline fetches segments itself with no request hook, so the\n * sid can only ride the manifest request — the known partial-attribution\n * cohort of plan gate G8. No-op without a sid.\n */\nfunction withCmcdSid(manifestUrl: string, sid: string | undefined): string {\n if (!sid) return manifestUrl;\n const sep = manifestUrl.includes('?') ? '&' : '?';\n return `${manifestUrl}${sep}CMCD=${encodeURIComponent(`sid=\"${sid}\"`)}`;\n}\n","import Hls, {\n CapLevelController,\n type ErrorData,\n type HlsConfig,\n type LevelLoadedData,\n} from 'hls.js';\nimport { emeAvailable, reuseMediaKeysFor } from '../drm/cdm';\nimport { classifyDrmStatus, PlaybackError } from '../errors';\nimport { nativeHlsSupported, webkitMediaStack } from './native';\nimport {\n hostOf,\n readFrameStats,\n type AudioTrackInfo,\n type PlaybackStats,\n type QualityLevel,\n type TextTrackInfo,\n} from '../stats';\nimport { type EngineDeps, type PlaybackEngine } from './types';\n\nexport function hlsSupported(): boolean {\n return Hls.isSupported();\n}\n\nconst MAX_MEDIA_RECOVERIES = 3;\nconst MAX_NETWORK_RECOVERIES = 5;\n// hls.js has no license-request retry policy (there's certLoadPolicy/keyLoadPolicy\n// but no licenseLoadPolicy), and KEY_SYSTEM_LICENSE_REQUEST_FAILED is fatal by\n// default. A transient transport failure of the license POST is recoverable by\n// rebuilding the instance (fresh key session → fresh license request).\nconst MAX_LICENSE_RECOVERIES = 3;\nconst RECOVERY_RESET_MS = 30_000;\n// End-of-stream watchdog (live only). The live playlist not advancing for this\n// long — with no #EXT-X-ENDLIST — means the stream ended: Tencent's origin\n// freezes the manifest at end (HTTP 200, media sequence static) instead of\n// publishing ENDLIST, so hls.js would otherwise buffer forever. 12s is safely\n// above the worst observed edge refresh hiccup (~8s).\nconst END_STALE_MS = 12_000;\nconst END_WATCH_INTERVAL_MS = 3_000;\n// Stale-playlist chase suspension. A frozen live manifest keeps hls.js's\n// LatencyController extrapolating the live edge with wall clock, so measured\n// latency grows 1s/s; once it crosses liveMaxLatencyDuration — which happens\n// ceiling−target ≈ 9-10s after the freeze with our defaults, BEFORE the 12s\n// watchdog concludes — the controller force-seeks into data that doesn't\n// exist and the viewer sees a repeat-frame/reset loop until 'ended' lands.\n// The server can't cover this window: the SSE ended push may trail the\n// freeze by up to the bridge's 120s ingest-teardown debounce (feed-dies-\n// first path), so the client suspends chasing itself once the playlist has\n// been stale this long, and resumes the moment it advances again (a genuine\n// origin hiccup keeps full drift protection afterwards). Must stay below\n// ceiling−target (10s at the default target, 9s at liveLatencyTarget 3) and\n// above the 3s watch tick + normal playlist cadence.\nconst CHASE_SUSPEND_STALE_MS = 6_000;\n// Pause suspension. hls.js keeps chasing the live edge while the element is\n// PAUSED: synchronizeToLiveEdge runs on every playlist reload with no\n// `media.paused` check, so once the frozen playhead falls liveMaxLatencyDuration\n// (12s here) behind the edge it is reset to the live-sync position and fresh\n// segments load from there — forever. A paused 1080p viewer pulled the stream\n// at live rate (80–105 MB/min, latency sawing 4.5→12s, portal session\n// 2026-09-19). The pre-play case was already closed by deferLoad (AV-202); this\n// closes the post-play one: after this grace the loader is stopped, and the\n// next play() restarts it at the paused position (hls.js startLoad(-1) resumes\n// at lastCurrentTime; its next playlist reload re-syncs to live if far behind —\n// the same seek as before, now only when the viewer asked to play). A pause\n// shorter than the grace resumes from the untouched buffer. Must stay below\n// ceiling−target (10s) so the loader is quiet before the first forced re-sync.\nconst PAUSE_SUSPEND_MS = 5_000;\n// Video-starvation watchdog (live only). Kept as defence in depth: the wedge\n// below is fixed in the pinned 1.7.2 (upstream #7874 moved key loading behind\n// the part/hint selection, and we backported it to the 1.6.x line as #7976),\n// but the detection is generic — it catches any silently-dropped video load,\n// not only this mechanism, and it is the only thing that noticed this one.\n// The original wedge, on hls.js 1.6.x and encrypted LL-HLS: when a part of the\n// forward-looking `fragmentHint` fragment is selected while that\n// fragment's key-loading promise is in flight, `_doFragLoad` reassigns\n// `fragCurrent` to the hint fragment, the key promise's context check then\n// sees a \"changed\" fragment and resolves null, and the load is silently\n// dropped with the state machine parked in FRAG_LOADING (a sibling path parks\n// it in KEY_LOADING). No loader is in flight, so no load-policy timeout ever\n// fires: the level playlist keeps polling, audio keeps playing, and no video\n// fragment is ever requested again. Reproduced 2026-08-12 against feed-dev\n// with the fullscreen/PiP resize storm from Josef's report — the resize makes\n// `capLevelToPlayerSize` flap levels, which corrupts targetBufferTime past the\n// playlist's fragment end and steers loading onto the hint fragment.\n// Detection: the video element starved (readyState < HAVE_FUTURE_DATA) while\n// the live playlist still advances and hls.js has issued no video fragment\n// load for a while — that combination is never a plain rebuffer (rebuffering\n// keeps FRAG_LOADING events coming) and never stream end (the playlist would\n// be stale, which the end watchdog owns). Recovery: stopLoad() + startLoad(-1)\n// rebuilds the stream-controller state machine at the live edge; media stays\n// attached so EME key sessions survive and playback resumes in ~1-2s.\nconst STARVATION_FRAG_QUIET_MS = 4_000;\n/** Consecutive starved watchdog ticks (3s apart) before kicking. */\nconst STARVATION_TICKS = 2;\nconst MAX_STARVATION_KICKS = 5;\n// Escalation, for the wedges the kick provably cannot clear. A kick keeps the\n// media element attached — deliberately, so EME key sessions survive — but that\n// is exactly why it fails against a wedge held in hls.js's KEY-loader cache: a\n// `keyInfo.keyLoadPromise` that never settles is only discarded by detach() or\n// destroy(), so stopLoad()/startLoad() re-await the same dead promise and park\n// again within a second. Observed 2026-09-03 at join, when a client clock\n// running behind the licence server produced a first key status of `expired`:\n// hls.js renewed the key session and orphaned the promise the fragment loads\n// were waiting on, and all five kicks re-parked (the viewer's own recovery was\n// a page reload, which destroys the instance). Fixed at the source in\n// havik-drm (licence start_datetime is back-dated) and structurally in hls.js\n// 1.7.x (the EME path no longer caches that promise) — this is the client-side\n// net for the next key-status hiccup, and for whatever else the kick cannot\n// reach: if the pipeline is still starved this long after a kick, the instance\n// is unrecoverable in place and gets rebuilt, which is what a reload did.\n// Grace after a kick for main-track media to buffer before rebuilding, counted\n// in watchdog ticks rather than wall-clock milliseconds. Ticks only accrue\n// while the tab is visible and the wedge signature still holds, so the grace\n// measures ACTIVE starvation: a tab backgrounded right after a kick cannot burn\n// it through throttled or suspended timers and come back eligible for an\n// immediate rebuild. Same reason STARVATION_TICKS is a tick count.\nconst STARVATION_KICK_GRACE_TICKS = 2;\n/** Rebuilds per healthy-playback window. Two, because a rebuild that doesn't\n * take won't start taking: a fresh instance re-requests the licence, so the\n * second attempt covers a transient failure and further ones just churn. */\nconst MAX_STARVATION_REBUILDS = 2;\n// SDK-managed player-size capping (replaces hls.js's `capLevelToPlayerSize`\n// poller). hls.js re-reads the player's pixel box every 1s and applies a new\n// cap IMMEDIATELY, so a fullscreen enter/exit — which animates through\n// intermediate sizes for a few hundred ms — produces 1-2 ABR switches per\n// transition. At the LL live-sync target the forward buffer IS ~2s, and each\n// switch costs a cold rendition-playlist fetch at the CDN edge plus a decoder\n// re-init (slowest through Windows hardware-DRM pipelines), so every switch is\n// a visible ~1s stall right after the transition (the fullscreen-exit stutter\n// follow-up to Josef's freeze report: 1-2 stalls per exit, worst on Edge).\n// The SDK owns the cap instead: identical level selection — hls.js's own\n// CapLevelController.getMaxLevelByMediaSize on the DPR-scaled element box —\n// but a change only applies once the box has been STABLE for a settle window,\n// so mid-animation intermediate sizes never reach ABR. Raises settle briefly:\n// entering fullscreen should still upgrade quickly. (Known trade vs upstream,\n// VOD only: upstream flushed the forward buffer on a cap raise, so deep-buffer\n// VOD upgraded within ~a fragment; here the raise shows once buffered media\n// plays out. On live-LL the flush was a no-op anyway — hls.js skips it below\n// 2x target duration of buffer, which our latency target guarantees.)\nconst CAP_RAISE_SETTLE_MS = 2_000;\n// A shrink needs no switch to look right — the higher rendition simply\n// downscales into the smaller box; a down-cap only saves bandwidth. Applied\n// lazily, so a fullscreen exit never pays a switch stall at the transition and\n// a re-enter inside the window cancels the pending down-cap outright.\nconst CAP_SHRINK_LAZY_MS = 30_000;\n\n/**\n * Default live-edge target (seconds behind the edge) when the caller sets none.\n *\n * 2s, not the bleeding `PART-HOLD-BACK` edge (~1.5s = 3x part duration), so the\n * playhead parks where Tencent's edge has already settled the parts — chasing\n * hold-back on a low-traffic stream hits the cold per-object origin-pull flap\n * (parts/.m3u8 404<->200) and rebuffers continuously.\n *\n * Was 3s. Lowered after the operator Demo Player (havik-ops\n * streams-player.component.ts) ran 2s from 2026-07-22 to 2026-08-03 with no\n * regression, on srt-bridge rc.34+ (encoder-latency pins) where the cold-edge\n * part flap no longer reproduces. 2s keeps ~0.35s of settle margin over\n * hold-back instead of ~1.35s. If low-traffic streams start rebuffering again,\n * that view is the canary — restore 3 there first.\n */\nconst LIVE_SYNC_DURATION_S = 2;\n/** Recovery band above the target; the drift ceiling is target + this (min 12s). */\nconst LIVE_LATENCY_MARGIN_S = 9;\n/**\n * How far past the live-sync position a seek may land before it's clamped back.\n * Slack so hls.js's own forward nudges — the ≤1.5x catch-up overshooting by a\n * frame, a maxBufferHole gap-skip — never trip the clamp; a scrub to the end of\n * the DVR bar overshoots by seconds, not tenths.\n */\nconst OVER_SEEK_TOLERANCE_S = 0.5;\n/** Recent segment download durations kept for the beacon layer's per-interval\n * percentiles. One heartbeat spans ~7 segments at a 2s target; 64 leaves room\n * for LL-HLS part-level traffic without unbounded growth. */\nconst MAX_REQUEST_SAMPLES = 64;\n\n/**\n * Resolve the live-edge target (`liveSyncDuration`) + drift ceiling\n * (`liveMaxLatencyDuration`) on the final, caller-merged config, keeping it legal\n * for hls.js — mutates `config` in place.\n *\n * Why a target + ceiling at all: `lowLatencyMode` otherwise pins playback to\n * `PART-HOLD-BACK` (the bleeding edge), where Tencent's newest parts aren't\n * reliably published yet → constant rebuffering; and after a stall, latency\n * drifts unbounded toward the DVR window (~90s seen in the field). The target\n * parks ~2s back where parts are settled; the ceiling force-seeks back once\n * latency exceeds it, capping the drift. (`maxLiveSyncPlaybackRate` recovers\n * smoothly inside the band first.) Live/LL only — ignored on VOD.\n *\n * Why normalize rather than hard-code: hls.js THROWS at construction if the\n * Count and Duration variants are mixed, or if `liveMaxLatencyDuration <=\n * liveSyncDuration`. The `hlsConfig` escape hatch lets a caller pass either, so:\n * - caller opted into the Count variants → defer entirely (never inject seconds);\n * - otherwise default the target to 3 and keep the ceiling strictly above it,\n * repairing a one-sided or illegal Duration override instead of throwing.\n */\nfunction normalizeLiveSync(config: Partial<HlsConfig>): void {\n if (config.liveSyncDurationCount != null || config.liveMaxLatencyDurationCount != null) {\n // Caller chose count-based live sync — leave it; never mix in seconds keys.\n delete config.liveSyncDuration;\n delete config.liveMaxLatencyDuration;\n return;\n }\n if (config.liveSyncDuration == null) config.liveSyncDuration = LIVE_SYNC_DURATION_S;\n const ceiling = Math.max(12, config.liveSyncDuration + LIVE_LATENCY_MARGIN_S);\n if (\n config.liveMaxLatencyDuration == null ||\n config.liveMaxLatencyDuration <= config.liveSyncDuration\n ) {\n config.liveMaxLatencyDuration = ceiling;\n }\n deriveAbrReaction(config, config.liveSyncDuration);\n}\n\n/**\n * Scale ABR's DOWN-switch reaction to the live buffer we actually have.\n *\n * hls.js ships these sized for a multi-second forward buffer:\n * `maxStarvationDelay` 4 and `maxLoadingDelay` 4 (the starvation budget alone\n * is larger than our whole buffer, so ABR's own accounting still reads \"fine\"\n * while the media element is already stalling) and `abrEwmaFastLive` 3 (a\n * bandwidth drop takes 3s+ to move the estimate at all). On a live stream the\n * forward buffer IS `liveSyncDuration`, so at our target there is no runway:\n * a viewer whose link degrades rebuffers at the top rung for seconds instead\n * of dropping a rung. (Reported on the ops demo player, same config family:\n * throttled link → 1080p buffering → manual 720p fixed it.)\n *\n * LL-HLS also blunts hls.js's other rescue — `_abandonRulesCheck` aborts an\n * in-flight load and drops a rung, but it projects the overage over the\n * current PART (sub-second of media), so the overage looks small and the abort\n * rarely fires. These budgets are therefore the primary defence.\n *\n * Derived from the resolved target rather than hard-coded so responsiveness\n * stays consistent if the target moves (the 3s → 2s rollout). Caller values\n * always win — this only fills gaps. Skipped for count-based live sync, where\n * the target isn't expressible in seconds (documented escape hatch).\n */\nfunction deriveAbrReaction(config: Partial<HlsConfig>, syncS: number): void {\n const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n // Half the buffer: ABR must decide while there is still media to play.\n if (config.maxStarvationDelay == null) {\n config.maxStarvationDelay = clamp(syncS / 2, 0.5, 4);\n }\n // A fragment load that eats the whole buffer is already too slow.\n if (config.maxLoadingDelay == null) {\n config.maxLoadingDelay = clamp(syncS, 1, 4);\n }\n // The estimate has to move inside the runway, not after it.\n if (config.abrEwmaFastLive == null) {\n config.abrEwmaFastLive = clamp(syncS / 2, 1, 3);\n }\n}\n\n/** hls.js adapter — LL-HLS + Widevine/FairPlay EME, ported from the proven Demo Player config. */\nexport class HlsEngine implements PlaybackEngine {\n readonly name = 'hls' as const;\n private hls: Hls | null = null;\n private readonly deps: EngineDeps;\n /** Current signed Widevine license URL; mutable so it can be refreshed mid-session. */\n private licenseUrl: string | undefined;\n /** FairPlay application-certificate URL (public but auth'd — unsigned, stable). */\n /** Whether the live media playlist actually carries LL-HLS tags (detected, not requested). */\n private detectedLowLatency = false;\n // Consumption counters for the analytics heartbeat (plan A2/§3.1): the\n // beacon layer samples these cumulative totals via getStats() and computes\n // per-interval deltas itself, so the engine never needs a beacon cadence.\n private bytesLoaded = 0;\n private playlistBytesLoaded = 0;\n private renditionSwitches = 0;\n private licenseTimeMs = 0;\n /** A licence request has actually been issued. The descriptor flag only\n * says DRM was CONFIGURED; a session that dies before EME does anything\n * should not report a key system it never negotiated. */\n private licenseRequested = false;\n // Contract v2 counters. Switch DIRECTION splits renditionSwitches (which\n // stays the total): a session that only ever shifts down is a starved\n // viewer, one that oscillates is an unstable estimate, and the two are\n // indistinguishable from the total alone.\n private upshifts = 0;\n private downshifts = 0;\n private lastLevelIndex = -1;\n // Request timing and outcome. requestTimesMs is a ring of the most recent\n // durations; the beacon layer takes the tail its interval added.\n private requestCount = 0;\n private requestErrors = 0;\n private readonly requestTimesMs: number[] = [];\n private cdnHost = '';\n // The offered ladder, cached at MANIFEST_PARSED — recomputing it on every\n // 2s stats poll would walk every level for a value that cannot change.\n private ladderHeights: number[] | undefined;\n private ladderTopBitrateKbps = 0;\n // Recovery budgets are windowed: they reset after a stretch of healthy playback\n // (FRAG_BUFFERED), so a long live session isn't permanently disabled by a few\n // unrelated transient glitches spread across hours.\n private mediaRecoveries = 0;\n private networkRecoveries = 0;\n private licenseRecoveries = 0;\n private recoveryResetTimer: ReturnType<typeof setTimeout> | null = null;\n private networkRetryTimer: ReturnType<typeof setTimeout> | null = null;\n private licenseRetryTimer: ReturnType<typeof setTimeout> | null = null;\n /** visibilitychange handler — snaps back to the live edge after a backgrounded tab. */\n private onVisibility: (() => void) | null = null;\n /** seeking handler — clamps a seek past the live-sync position back to it. */\n private onSeeking: (() => void) | null = null;\n // Pause suspension state (see PAUSE_SUSPEND_MS).\n private onPause: (() => void) | null = null;\n private onPlay: (() => void) | null = null;\n private pauseSuspendTimer: ReturnType<typeof setTimeout> | null = null;\n /** Loading is stopped for a lingering pause; the next play() restarts it. */\n private loadSuspended = false;\n /** stop() was called (end of stream): a later `play` event must not restart loading. */\n private stopped = false;\n // End-of-stream watchdog state (armed once the stream is seen LIVE).\n private sawLive = false;\n private lastEndSN = -1;\n private lastAdvanceMs = 0;\n private endWatchdog: ReturnType<typeof setInterval> | null = null;\n private endedEmitted = false;\n // Video-starvation watchdog state (see STARVATION_* constants).\n private lastFragLoadingMs = 0;\n private starvedTicks = 0;\n private starvationKicks = 0;\n /** A kick is awaiting proof it worked; cleared by main-track FRAG_BUFFERED. */\n private kickPending = false;\n /** Active starved ticks observed since that kick (see STARVATION_KICK_GRACE_TICKS). */\n private kickTicks = 0;\n private starvationRebuilds = 0;\n // Stale-playlist chase suspension (see CHASE_SUSPEND_STALE_MS).\n private chaseSuspended = false;\n private savedLiveMaxLatency: number | undefined;\n private savedMaxSyncRate: number | undefined;\n // SDK-managed player-size cap state (see the CAP_* constants).\n private capObserver: ResizeObserver | null = null;\n private capTimer: ReturnType<typeof setTimeout> | null = null;\n /** Size-derived cap currently applied (level index); null = none applied yet. */\n private sizeCapLevel: number | null = null;\n /** Cap derived from setMaxBitrate (-1 = none); combined with the size cap via min. */\n private bitrateCapLevel = -1;\n /** False when the caller opted back into hls.js's own capLevelToPlayerSize poller. */\n private sizeCapManaged = true;\n // DPR watcher: ResizeObserver only tracks the CSS box, so a devicePixelRatio\n // change with an unchanged layout (window dragged to a different-DPI\n // monitor, OS scale change) would never re-evaluate the cap. The media query\n // pins the CURRENT ratio, so any change fires once; the handler re-pins.\n private dprMedia: MediaQueryList | null = null;\n private onDprChange: (() => void) | null = null;\n\n /** False while a deferLoad engine waits for startLoad(); guards the config\n * key on a license-retry rebuild and makes startLoad() idempotent. */\n private loadStarted: boolean;\n\n constructor(deps: EngineDeps) {\n this.deps = deps;\n this.licenseUrl = deps.descriptor.drm?.widevine?.licenseUrl;\n this.loadStarted = deps.deferLoad !== true;\n }\n\n async load(): Promise<void> {\n const { descriptor, snapToLiveOnRefocus, video, emit } = this.deps;\n\n // Fail fast on a DRM-required stream with no system USABLE ON THIS\n // PLATFORM, instead of loading with EME off (or a definitely-rejecting\n // drmSystems) and surfacing an opaque KEY_SYSTEM error after the manifest\n // parses. The platform signal is WebKit-ness: WebKit has FairPlay and\n // never Widevine; everything else hls.js runs on has Widevine and never\n // FairPlay. So a FairPlay-only descriptor on Chrome AND a Widevine-only\n // descriptor on Safari both fail here, with a message that names the\n // actual mismatch.\n //\n // WebKit-ness is NOT native-HLS capability, though the two look alike:\n // Chromium answers canPlayType('application/vnd.apple.mpegurl') with\n // \"maybe\" (measured 2026-08-07) while playing no HLS natively. Reading\n // that as WebKit made `widevineUsable` false on every Chrome session\n // carrying FairPlay material, and this guard then refused a stream\n // hls.js plays fine over Widevine.\n //\n // Known heuristic edge: some OEM/smart-TV browsers report native-HLS\n // capability AND ship Widevine (Tizen/WebOS builds). There a\n // Widevine-ONLY descriptor would fail fast although it might have\n // played. Accepted: havik-streams always emits both systems (a\n // both-systems descriptor loads fine there — FairPlay rejects at access\n // and hls.js falls back to Widevine), and those UAs are outside the\n // supported matrix. Revisit if a widevine-only + smart-TV case appears.\n const webkit = nativeHlsSupported(video) && webkitMediaStack();\n const widevineUsable = Boolean(descriptor.drm?.widevine?.licenseUrl) && !webkit;\n // FairPlay is NOT usable on this engine. WebKit binds FairPlay keys through\n // the fragment `sinf` box, so an MSE session built from a synthesized skd\n // asset ID reaches a 'usable' key and still renders nothing (macOS Safari\n // 26.5, live DRM: 1 pass / 6 attempts vs 4 / 4 on the native engine).\n // 'auto' routes FairPlay-on-Apple to NativeHlsEngine; only an explicit\n // engine:'hls' lands here, and it must fail loudly rather than play black.\n if (descriptor.drmEnabled && !widevineUsable) {\n const fairplayOnly = Boolean(descriptor.drm?.fairplay?.licenseUrl) && webkit;\n emit({\n type: 'error',\n error: new PlaybackError(\n 'DRM_CLIENT',\n 0,\n fairplayOnly\n ? 'FairPlay cannot be played on the hls.js engine (WebKit binds keys via the media sinf box); use engine \"auto\" or \"native\"'\n : 'DRM is required but none of the stream’s key systems is usable on this platform (WebKit takes FairPlay only; other browsers take Widevine only)',\n ),\n fatal: true,\n });\n return;\n }\n\n this.startHls();\n\n // A backgrounded tab throttles timers, so the live-sync controller can't\n // chase the edge and latency balloons while hidden. On refocus, if we're\n // well past the controller's max-latency ceiling, snap straight to live\n // rather than catch-up-playing through minutes of backlog. Opt out via\n // snapToLiveOnRefocus: false. Registered once here (not in startHls) so a\n // license-retry rebuild doesn't stack duplicate visibilitychange listeners.\n if (snapToLiveOnRefocus !== false && typeof document !== 'undefined') {\n this.onVisibility = () => {\n const h = this.hls;\n if (document.visibilityState !== 'visible' || !h) return;\n const pos = h.liveSyncPosition;\n if (typeof pos !== 'number') return;\n // maxLatency is Infinity unless a count-based max is configured (we\n // deliberately don't — it's the illegal-config trap), so key the snap\n // off the sync target instead of h.maxLatency.\n const target =\n typeof h.targetLatency === 'number' && h.targetLatency > 0 ? h.targetLatency : 3;\n if (pos - video.currentTime > Math.max(target * 3, 8)) video.currentTime = pos;\n };\n document.addEventListener('visibilitychange', this.onVisibility);\n }\n\n // Over-seek clamp. Registered here (not in startHls) for the same reason as\n // the visibility listener: a license-retry rebuild must not stack duplicates.\n this.onSeeking = () => {\n this.clampOverSeek();\n // A seek while suspended — the scrubber, the LIVE button, the refocus\n // snap above — asks for a frame the stopped loader cannot fetch, so the\n // picture would stay frozen until play(). Load it, then re-arm: five\n // seconds of loading per seek, not an open tap.\n if (this.loadSuspended) {\n this.resumeLoading();\n this.schedulePauseSuspend();\n }\n };\n video.addEventListener('seeking', this.onSeeking);\n\n // Pause suspension (see PAUSE_SUSPEND_MS). Same registration rule: once,\n // here, so a rebuild cannot stack the handlers.\n this.onPause = () => this.schedulePauseSuspend();\n this.onPlay = () => this.resumeLoading();\n video.addEventListener('pause', this.onPause);\n video.addEventListener('play', this.onPlay);\n\n // SDK-managed player-size cap (see the CAP_* constants). Registered once\n // here so a license-retry rebuild doesn't stack observers; each rebuilt\n // instance gets the cap re-applied by its MANIFEST_PARSED handler. Guarded:\n // SSR/older engines without ResizeObserver just play uncapped, exactly as\n // a caller-disabled cap would.\n if (this.sizeCapManaged && typeof ResizeObserver !== 'undefined') {\n this.capObserver = new ResizeObserver(() => this.onMediaResize());\n this.capObserver.observe(video);\n this.watchDprChanges();\n }\n }\n\n /**\n * A `pause` event: arm the suspension grace. Only once media loading has\n * been asked for (a deferLoad engine before its first play() fetches nothing\n * to suspend), never after stop() (the stream is over; the loader is already\n * down and must stay down), and never twice.\n */\n private schedulePauseSuspend(): void {\n if (this.pauseSuspendTimer || this.loadSuspended || this.stopped || !this.loadStarted) return;\n this.pauseSuspendTimer = setTimeout(() => {\n this.pauseSuspendTimer = null;\n const { video } = this.deps;\n // Re-checked at fire time: a play() inside the grace clears the timer,\n // but a stop() or an `ended` element between arm and fire does not.\n if (!video.paused || video.ended || this.stopped || !this.hls) return;\n try {\n this.hls.stopLoad();\n } catch {\n return; // not suspended: the loader is still running, so the watchdogs must keep watching it\n }\n this.loadSuspended = true;\n }, PAUSE_SUSPEND_MS);\n }\n\n /**\n * A `play` event (or a seek while suspended): cancel a pending suspension,\n * and if the loader was stopped, restart it. startLoad(-1) resumes at\n * hls.js's lastCurrentTime (the paused or seeked position); the next\n * playlist reload then re-syncs to live if the position is past the drift\n * ceiling — the ordinary go-live seek.\n */\n private resumeLoading(): void {\n if (this.pauseSuspendTimer) {\n clearTimeout(this.pauseSuspendTimer);\n this.pauseSuspendTimer = null;\n }\n if (!this.loadSuspended) return;\n this.loadSuspended = false;\n if (this.stopped || !this.hls) return;\n // The playlist was deliberately not reloaded while suspended: restart the\n // staleness and starvation clocks so the quiet window never reads as a\n // dead stream or a wedged pipeline.\n this.lastAdvanceMs = Date.now();\n this.lastFragLoadingMs = 0;\n this.starvedTicks = 0;\n // A pause/resume restarts the pipeline the same way a kick does, so a\n // kick still awaiting proof must not escalate to a rebuild on pre-pause\n // evidence (same reset stop() makes).\n this.kickPending = false;\n this.kickTicks = 0;\n try {\n this.hls.startLoad(-1);\n } catch {\n /* ignore */\n }\n }\n\n startLoad(): void {\n if (this.loadStarted) return;\n this.loadStarted = true;\n // The first play() of an autoplay-off player kicks the loader and then\n // calls video.play(). If that play is blocked (a gestureless probe) the\n // element stays paused with the loader running, MANIFEST_PARSED is long\n // gone and no pause event will ever come — so this is the third entry\n // point that must arm the grace. A play that goes through clears it.\n if (this.deps.video.paused) this.schedulePauseSuspend();\n // -1 = hls.js default start position: the live-sync point on live\n // playlists (the same target the armed go-live path lands on), 0 on VOD.\n // The poster card renders before MANIFEST_PARSED, so this often lands\n // pre-manifest: hls.js then sets its forceStartLoad flag and re-starts at\n // config.startPosition (hls.js default -1) once the manifest arrives —\n // identical here, but a caller hlsConfig.startPosition override would be\n // honored on the pre-manifest path only.\n this.hls?.startLoad(-1);\n }\n\n /**\n * Re-evaluate the cap when devicePixelRatio changes without a layout change\n * (see the dprMedia field). Re-armed after every fire because the query pins\n * the ratio that was current when it was built. Guarded feature-detection:\n * without matchMedia (SSR) or MQL addEventListener (legacy WebKit) the cap\n * simply doesn't track monitor moves — the pre-existing ResizeObserver\n * behavior, never an error.\n */\n private watchDprChanges(): void {\n if (typeof matchMedia !== 'function') return;\n this.unwatchDprChanges();\n const dpr = (typeof self !== 'undefined' && self.devicePixelRatio) || 1;\n const mql = matchMedia(`(resolution: ${dpr}dppx)`);\n if (typeof mql.addEventListener !== 'function') return;\n const handler = () => {\n this.onMediaResize();\n this.watchDprChanges();\n };\n mql.addEventListener('change', handler);\n this.dprMedia = mql;\n this.onDprChange = handler;\n }\n\n private unwatchDprChanges(): void {\n if (this.dprMedia && this.onDprChange) {\n this.dprMedia.removeEventListener('change', this.onDprChange);\n }\n this.dprMedia = null;\n this.onDprChange = null;\n }\n\n /**\n * Pull a seek that landed ahead of the live-sync position back onto it.\n *\n * Dragging the scrub bar (or a native control) to the far right lands the\n * playhead on `seekable.end()` — the bleeding edge of the live playlist, where\n * latency reads <1s and Tencent's newest parts aren't reliably published yet.\n * The forward buffer is then permanently empty: each ~0.5s part plays out\n * before the next exists, so playback advances a few frames, starves,\n * advances again. hls.js will NOT recover from this on its own — both of its\n * live-sync mechanisms only correct being too far BEHIND:\n * `maxLiveSyncPlaybackRate` rebases to 1.0x once `latency - targetLatency`\n * goes negative and never slows below it, and `liveMaxLatencyDuration`'s\n * force-seek (stream-controller's synchronizeToLiveEdge) skips out entirely\n * while `currentTime >= liveSyncPosition`. Nothing pulls the playhead back, so\n * the stutter persists for the rest of the session. This clamp is that missing\n * near-side counterpart to the drift ceiling.\n *\n * Clamping on `seeking` (not `seeked`) means the bleeding edge is never even\n * fetched. `liveSyncPosition` is the same spot seekToLive() targets, so an\n * over-seek reads as \"go live\" rather than as a broken seek.\n *\n * Skipped while chase suspension is active: a stale playlist is exactly the\n * state #39 stopped seeking in (the extrapolated edge points at data that\n * isn't there), and the policy there is to play out the buffer and hold the\n * last frame until 'ended' lands. An over-seek costs nothing then — there's no\n * live edge left to stutter against.\n *\n * Live only: hls.js still reports a non-null `liveSyncPosition` on VOD (it's\n * derived from liveSyncDuration, which has no VOD meaning), so gating on\n * `details.live` is what keeps this from capping VOD seeks at duration − 3s.\n *\n * Terminates without a re-entrancy flag: the clamped position is at or below\n * the limit, and the limit only moves forward, so the follow-up `seeking`\n * event this assignment queues returns at the tolerance check.\n */\n private clampOverSeek(): void {\n const hls = this.hls;\n if (!hls || this.chaseSuspended) return;\n const level = hls.levels[hls.currentLevel] ?? hls.levels[hls.loadLevel];\n if (level?.details?.live !== true) return;\n const limit = hls.liveSyncPosition;\n if (typeof limit !== 'number' || !Number.isFinite(limit)) return;\n const { video } = this.deps;\n if (video.currentTime <= limit + OVER_SEEK_TOLERANCE_S) return;\n video.currentTime = limit;\n }\n\n /**\n * Build (or rebuild) the hls.js instance, wire its events, and start loading.\n * Split out of load() so a transient license-request failure can re-drive EME\n * with a fresh instance (new key session → new license request) without\n * re-adding the one-time visibility listener.\n */\n private startHls(): void {\n const { video, descriptor, credential, deviceId, userId, lowLatency, debug, hlsConfig, emit } =\n this.deps;\n\n // Per-instance watchdog state. A rebuilt instance has issued no kick and\n // has loaded no fragment, so carrying either across would let the first\n // tick after a rebuild escalate again on the previous instance's evidence.\n this.kickPending = false;\n this.kickTicks = 0;\n this.starvedTicks = 0;\n this.lastFragLoadingMs = 0;\n // The end-of-stream stamps are per-instance for a sharper reason: trackEnd\n // only restamps lastAdvanceMs when endSN CHANGES, so a rebuilt instance\n // whose first playlist load repeats the endSN the old one last saw would\n // keep ageing an advance stamp from before the rebuild, and can cross\n // CHASE_SUSPEND_STALE_MS — or END_STALE_MS, reporting a healthy stream as\n // ended. Zeroing them gives the new instance a fresh liveness window:\n // checkEndStale returns early while lastAdvanceMs is 0, and checkStarvation\n // treats the playlist as not-yet-alive, so both watchdogs stay disarmed\n // until this instance sees its own first load. The ENDLIST path\n // (live→false) and the server's SSE push still catch a real end meanwhile.\n this.lastEndSN = -1;\n this.lastAdvanceMs = 0;\n\n const drmEnabled =\n descriptor.drmEnabled &&\n Boolean(descriptor.drm?.widevine?.licenseUrl || descriptor.drm?.fairplay?.licenseUrl);\n // FairPlay is only offered where WebKit can actually take it (Safari /\n // iOS — the same signal as native-HLS capability). This also scopes\n // drmSystemOptions below: the option object is GLOBAL in hls.js (applied\n // to every key system's requestMediaKeySystemAccess), and WebKit's\n // supportedRobustnesses() for FairPlay is { '' } — handing it the\n // Widevine SW_SECURE_* pins makes the FairPlay access request reject.\n\n const config: Partial<HlsConfig> = {\n // hls.js internal logging (fragment/part scheduling, ABR switches, EME\n // key sessions + license traffic) — opt-in, for diagnosing a playback\n // problem with support. The operator Demo Player (havik-ops\n // streams-player.component.ts) hardcodes this on; it is the one key the\n // two configs are meant to differ on, which is why it is an option here\n // rather than a constant.\n debug: debug === true,\n // Workers are disabled in low-latency mode: on hls.js 1.6.x the\n // LL-HLS partial-segment append path hits a bufferAppendError race\n // (\"SourceBuffer is still processing an appendBuffer/remove\") that\n // reproduces ~100% with workers ON (video-dev/hls.js#7321). Disabling\n // the worker is the documented mitigation; standard (non-LL) playback\n // keeps workers for off-main-thread parsing. Re-enable once a hls.js\n // release fixes the worker append race.\n enableWorker: !lowLatency,\n lowLatencyMode: lowLatency,\n backBufferLength: 90,\n // Audio-hole bridge. Tencent's LL-HLS audio packager cuts a ~0.45s\n // \"remainder\" part at each segment boundary; the hls.js default\n // maxBufferHole (0.1s) is smaller than the hole, so the player stalls\n // and gap-skips ~0.5s every few seconds. 0.5 bridges it as a\n // sub-perceptible hitch instead of a rebuffer.\n maxBufferHole: 0.5,\n // Live-edge target (liveSyncDuration) + drift ceiling (liveMaxLatencyDuration)\n // are applied by normalizeLiveSync() AFTER the caller's hlsConfig is merged\n // below — so a raw override (incl. the Count variants) can never produce an\n // illegal pair that throws at construction. See that helper for the why.\n // Catch up at up to 1.5x (ramped by how far behind) to recover drift inside\n // the target..ceiling band before the hard seek. Internal — not a public\n // knob (1.0 silently re-introduces drift); overridable via raw hlsConfig.\n maxLiveSyncPlaybackRate: 1.5,\n // ABR start quality — start conservative, then ramp. Seeding high (~4 Mbps\n // → a ~720p first rendition) and relying on \"drop fast\" to recover was still\n // rebuffering in the field on constrained links: the high first grab stalls\n // to fill the buffer before ABR can drop. Per the root-cause investigation\n // (docs/llhls-buffering-investigation.md §3), seed ~1 Mbps so the first\n // rendition is one a modest link sustains (~360p) and ABR ramps up within a\n // few seconds when real bandwidth allows — trading a slightly softer first\n // ~2s for not stalling on startup (the first-20s buffering we were chasing).\n // The SDK-managed size cap still bounds the session to the player's\n // pixel box (below). Caller startLevel / maxBitrate still override.\n abrEwmaDefaultEstimate: 1_000_000,\n // Player-size capping is SDK-managed (see the CAP_* constants): hls.js's\n // own poller applies a new cap the instant the box changes, which turns\n // every fullscreen enter/exit into immediate ABR switches — each a\n // visible ~1s stall at LL buffer depth. Explicit false (the hls.js\n // default) so the two managers never fight over autoLevelCapping; a\n // caller hlsConfig `capLevelToPlayerSize: true` opts back into the\n // hls.js poller and stands the SDK manager down.\n capLevelToPlayerSize: false,\n // deferLoad (autoplay-off pre-play): parse the manifest (MANIFEST_PARSED\n // → 'ready', track lists) but fetch no level playlists or fragments —\n // and so no EME/license traffic — until startLoad(). Keyed off\n // loadStarted, not deps.deferLoad, so a license-retry rebuild after\n // playback began loads eagerly again. The hlsConfig escape hatch (spread\n // below) wins here like everywhere else — an explicit autoStartLoad:\n // true is the \"warm the buffer, wait for the gesture\" pattern;\n // loadStarted is re-synced to the final merged config after the spread.\n ...(this.loadStarted ? {} : { autoStartLoad: false }),\n ...(drmEnabled\n ? {\n emeEnabled: true,\n drmSystems: {\n ...(this.licenseUrl ? { 'com.widevine.alpha': { licenseUrl: this.licenseUrl } } : {}),\n },\n // FairPlay is NOT configured here — it lives on NativeHlsEngine.\n // WebKit binds FairPlay keys through the fragment `sinf` box, so an\n // MSE session built from a synthesized skd asset ID reaches a\n // 'usable' key and still renders nothing. load() fails fast above\n // rather than reaching this config.\n drmSystemOptions: {\n videoRobustness: 'SW_SECURE_DECODE',\n audioRobustness: 'SW_SECURE_CRYPTO',\n videoEncryptionScheme: 'cbcs',\n audioEncryptionScheme: 'cbcs',\n },\n // Reuse this element's CDM across rebuilds. Chrome refuses to\n // replace OR remove a media element's ContentDecryptionModule once\n // the element has a player, so every rebuildInstance() below —\n // starvation, license retry, or a managed reload() building a fresh\n // engine on the same <video> — would otherwise die on\n // `setMediaKeys`: \"The existing ContentDecryptionModule object\n // cannot be removed at this time\", surfaced as a fatal\n // KEY_SYSTEM_NO_KEYS. hls.js's CDMCleanupPromise cannot help; its\n // cleanup call is forbidden too. See core/drm/cdm.ts.\n //\n // Only when EME exists: with no key-system API at all (a plain-http\n // embed — EME is secure-context-only), leaving the hook unset lets\n // hls.js report that cause itself rather than us shadowing it.\n ...(emeAvailable()\n ? { requestMediaKeySystemAccessFunc: reuseMediaKeysFor(video) }\n : {}),\n // EME license requests route through licenseXhrSetup — NOT the\n // segment-side xhrSetup (a hard-won Demo Player lesson; putting the\n // key in xhrSetup makes every license 401). Open the XHR ourselves\n // before setting any headers (setRequestHeader throws if the XHR\n // isn't OPENED) so we never depend on hls.js's internal open-ordering;\n // opening against the CURRENT license URL also picks up a refreshed\n // signed URL (hls.js keeps passing the construction-time one).\n // Widevine only — FairPlay lives on the native engine.\n licenseXhrSetup: (xhr: XMLHttpRequest, url: string) => {\n xhr.open('POST', this.licenseUrl ?? url, true);\n xhr.setRequestHeader('x-api-key', credential.apiKey);\n xhr.setRequestHeader('X-Match-Urn', descriptor.matchUrn);\n xhr.setRequestHeader('X-Device-Id', deviceId);\n if (userId) xhr.setRequestHeader('X-User-Id', userId);\n // License-acquisition timing for the QoE plane — fleet-wide DRM\n // regressions (WAF license 403s, the canPlayType trap) were\n // invisible without it.\n this.licenseRequested = true;\n const t0 = performance.now();\n xhr.addEventListener('loadend', () => {\n this.licenseTimeMs = Math.max(1, Math.round(performance.now() - t0));\n });\n },\n }\n : {}),\n // CMCD in query mode (plan D1/D2): the per-session sid rides every CDN\n // request as the attribution key the edge-log plane joins on. Query\n // mode, never headers — header mode would force a CORS preflight per\n // segment. Only when streams minted a sid (server-side analytics flag).\n ...(descriptor.analytics?.sid\n ? {\n cmcd: {\n sessionId: descriptor.analytics.sid,\n contentId: descriptor.matchUrn,\n useHeaders: false,\n },\n }\n : {}),\n ...hlsConfig,\n };\n // The FairPlay certificate GET goes through hls.js's GENERIC loader (not\n // licenseXhrSetup), and drm-proxy requires the api key on it (its router\n // test asserts 401 without). Strictly URL-scoped: manifest/segment\n // requests are public CDN fetches and must not carry auth. hls.js hands\n // xhrSetup an UNOPENED xhr — open it first or setRequestHeader throws.\n // COMPOSED with any caller hlsConfig.xhrSetup (applied post-spread): the\n // escape hatch must not silently disable certificate auth on WebKit.\n // ORDER MATTERS: the caller runs FIRST — it gets the unopened xhr hls.js\n // hands out (the documented contract, and many hooks call open()\n // themselves; the XHR spec EMPTIES author headers on open(), so auth set\n // before a caller's open() would be silently wiped). Our cert branch then\n // opens only if still UNSENT and sets the api key on the final, opened xhr.\n // Resolve the live-edge target/ceiling on the FINAL merged config so a raw\n // hlsConfig override can't produce an illegal hls.js pair (see helper).\n normalizeLiveSync(config);\n // The escape hatch wins: a caller that re-enabled hls.js's own player-size\n // poller gets exactly that, and the SDK manager stands down (two writers on\n // autoLevelCapping would fight, the 1s poller always winning).\n this.sizeCapManaged = config.capLevelToPlayerSize !== true;\n // Sync loadStarted with the FINAL config: a caller hlsConfig.autoStartLoad\n // may have overridden the deferLoad key above, and a stale flag would make\n // startLoad() re-kick a loader that is already running (or believe a\n // caller-stopped one had started).\n this.loadStarted = config.autoStartLoad !== false;\n\n const hls = new Hls(config);\n this.hls = hls;\n\n hls.on(Hls.Events.MANIFEST_PARSED, () => {\n this.mediaRecoveries = 0;\n this.networkRecoveries = 0;\n const heights = hls.levels.map((l) => l.height).filter((h): h is number => !!h);\n this.ladderHeights = heights.length ? [...new Set(heights)].sort((a, b) => a - b) : undefined;\n this.ladderTopBitrateKbps = hls.levels.length\n ? Math.round(Math.max(...hls.levels.map((l) => l.bitrate)) / 1000)\n : 0;\n // Initial size cap, applied immediately (no settle window): the first\n // fetch of a small/embedded player must not grab 1080p.\n this.applySizeCap();\n // An instance that starts paused — autoplay still pending, autoplay\n // BLOCKED (the play card, with hls.js chasing live behind it forever:\n // no `pause` event ever fires for an element that never played), or a\n // rebuild while suspended (the new instance loads regardless) — gets\n // the same grace as a pause. Pending autoplay resolves long before it\n // fires, and the fire-time re-check sees the element playing.\n this.loadSuspended = false;\n if (video.paused) this.schedulePauseSuspend();\n emit({ type: 'ready' });\n });\n // After a stretch of uninterrupted buffering, refund the recovery budget so a\n // healthy long session isn't permanently capped. A fresh error cancels the\n // pending refund (see clearRecoveryReset in onError), preserving tight-loop\n // protection. Main-track fragments only: the starvation wedge keeps audio\n // buffering while video is dead, and audio frags must not refund the kicks.\n hls.on(Hls.Events.FRAG_BUFFERED, (_e, data) => {\n if (data?.frag?.type !== 'main') return;\n // Main-track media buffered, so the last kick did its job: cancel the\n // pending escalation. This is the only place the grace is cleared, which\n // is what makes the escalation fire on exactly the case it is for — a\n // kick that produced no video, rather than one that merely took a while.\n this.kickPending = false;\n this.kickTicks = 0;\n if (\n (this.mediaRecoveries > 0 ||\n this.networkRecoveries > 0 ||\n this.licenseRecoveries > 0 ||\n this.starvationKicks > 0 ||\n this.starvationRebuilds > 0) &&\n !this.recoveryResetTimer\n ) {\n this.recoveryResetTimer = setTimeout(() => {\n this.mediaRecoveries = 0;\n this.networkRecoveries = 0;\n this.licenseRecoveries = 0;\n this.starvationKicks = 0;\n this.starvationRebuilds = 0;\n this.recoveryResetTimer = null;\n }, RECOVERY_RESET_MS);\n }\n });\n // Starvation-watchdog liveness input: a healthy or merely rebuffering\n // pipeline keeps issuing main-track fragment loads; a wedged one goes quiet.\n hls.on(Hls.Events.FRAG_LOADING, (_e, data) => {\n if (data?.frag?.type === 'main') this.lastFragLoadingMs = Date.now();\n });\n // Consumption accounting: media segment payload bytes (all tracks — the\n // dashboard-grade half of D3; edge logs stay the billing truth).\n hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {\n const stats = data?.frag?.stats;\n const loaded = stats?.loaded;\n if (typeof loaded === 'number') this.bytesLoaded += loaded;\n // Per-request delivery timing: the viewer-side view of how the CDN is\n // serving this session, which the edge plane's own latency cannot show\n // (it never sees the last mile).\n const loading = stats?.loading;\n if (loading && loading.end > 0 && loading.start > 0) {\n this.requestCount += 1;\n this.requestTimesMs.push(Math.max(0, Math.round(loading.end - loading.start)));\n if (this.requestTimesMs.length > MAX_REQUEST_SAMPLES) this.requestTimesMs.shift();\n }\n const url = data?.frag?.url;\n if (url) {\n const host = hostOf(url);\n // Re-read every fragment on purpose: a mid-session CDN failover is\n // exactly the event this column exists to make visible.\n if (host) this.cdnHost = host;\n }\n });\n hls.on(Hls.Events.LEVEL_LOADED, (_e, data: LevelLoadedData) => {\n // Playlist chatter is nontrivial at 2s LL refresh windows — counted\n // separately from media bytes (plan §3.1).\n const loaded = data?.stats?.loaded;\n if (typeof loaded === 'number') this.playlistBytesLoaded += loaded;\n if (this.detectedLowLatency) return;\n const details = data.details;\n const isLL = Boolean(details?.partList?.length) || (details?.partTarget ?? 0) > 0;\n if (isLL) {\n this.detectedLowLatency = true;\n emit({ type: 'lowLatencyDetected', value: true });\n }\n });\n // End-of-stream detection (separate handler so the LL-detection early-return\n // above never short-circuits it). ENDLIST flips details.live→false; a frozen\n // manifest is caught by the staleness watchdog interval below.\n hls.on(Hls.Events.LEVEL_LOADED, (_e, data: LevelLoadedData) => this.trackEnd(data));\n // startHls() also runs on a rebuild, and assigning over a live handle\n // leaks the previous interval: both keep firing, so every checkEndStale /\n // checkStarvation tick doubles up. That already applied to the\n // license-retry rebuild (up to MAX_LICENSE_RECOVERIES extra intervals);\n // it would also break the starvation escalation, whose grace is measured\n // between ticks — a doubled tick rate re-stamps the kick before the grace\n // can ever elapse.\n if (this.endWatchdog) clearInterval(this.endWatchdog);\n this.endWatchdog = setInterval(() => {\n this.checkEndStale();\n this.checkStarvation();\n }, END_WATCH_INTERVAL_MS);\n hls.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => {\n // Rendition is only sampled at heartbeats; without this counter, ABR\n // churn between samples is invisible (plan §3.1).\n this.renditionSwitches += 1;\n // Direction by BITRATE, not by index: level order is a property of\n // the manifest, not a contract, and a reordered reload would invert\n // every switch this session reports.\n const from = this.lastLevelIndex >= 0 ? hls.levels[this.lastLevelIndex] : undefined;\n const to = hls.levels[data.level];\n if (from && to && to.bitrate !== from.bitrate) {\n if (to.bitrate > from.bitrate) this.upshifts += 1;\n else this.downshifts += 1;\n }\n this.lastLevelIndex = data.level;\n emit({ type: 'qualitychange', index: data.level });\n });\n hls.on(Hls.Events.AUDIO_TRACK_SWITCHED, (_e, data) =>\n emit({ type: 'audiotrackchange', id: data.id }),\n );\n hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (_e, data) =>\n emit({ type: 'texttrackchange', id: data.id }),\n );\n hls.on(Hls.Events.ERROR, (_e, data: ErrorData) => this.onError(data));\n\n hls.loadSource(descriptor.manifestUrl);\n hls.attachMedia(video);\n }\n\n getQualityLevels(): QualityLevel[] {\n if (!this.hls) return [];\n return this.hls.levels.map((l, index) => ({\n index,\n width: l.width || undefined,\n height: l.height || undefined,\n bitrate: l.bitrate,\n codecs: l.videoCodec || l.codecSet || undefined,\n }));\n }\n\n getCurrentQuality(): number {\n if (!this.hls) return -1;\n return this.hls.autoLevelEnabled ? -1 : this.hls.currentLevel;\n }\n\n setQuality(index: number): void {\n if (this.hls) this.hls.currentLevel = index; // -1 re-enables ABR\n }\n\n setMaxBitrate(bitrate: number | null): void {\n const hls = this.hls;\n if (!hls) return;\n if (bitrate == null) {\n this.bitrateCapLevel = -1;\n } else {\n // Cap ABR at the highest level whose bitrate fits the budget.\n let cap = -1;\n hls.levels.forEach((l, i) => {\n if (l.bitrate <= bitrate) cap = i;\n });\n this.bitrateCapLevel = cap;\n }\n // Routed through pushCap so clearing the bitrate budget no longer wipes the\n // player-size cap (both used to write autoLevelCapping directly, last one\n // winning until hls.js's 1s poller re-imposed the size cap).\n this.pushCap();\n }\n\n /**\n * Level index the player's current pixel box caps ABR to, or null when it\n * can't be computed (no levels yet, or a zero-size/hidden box — upstream\n * skips those the same way). Selection is hls.js's own\n * CapLevelController.getMaxLevelByMediaSize, on the element's bounding box\n * scaled by the same DPR rules the upstream poller uses\n * (ignoreDevicePixelRatio / maxDevicePixelRatio from the merged config), so\n * flipping between the two managers can never change WHICH level is chosen —\n * only WHEN it is applied.\n */\n private computeSizeCapTarget(): number | null {\n const hls = this.hls;\n if (!hls || hls.levels.length === 0) return null;\n const { video } = this.deps;\n const rect = video.getBoundingClientRect();\n let width = rect.width;\n let height = rect.height;\n if (!width && !height) {\n // Mirror upstream getDimensions(): a media element that isn't laid out\n // (equivalent to not being in the DOM) falls back to its width/height\n // attributes — without this, off-DOM playback would never receive an\n // initial cap, since ResizeObserver doesn't fire for detached elements.\n width = video.width || 0;\n height = video.height || 0;\n }\n if (!(width > 0) || !(height > 0)) return null;\n const cfg: Partial<HlsConfig> = hls.config ?? {};\n let scale = 1;\n if (!cfg.ignoreDevicePixelRatio && typeof self !== 'undefined' && self.devicePixelRatio > 0) {\n scale = self.devicePixelRatio;\n }\n scale = Math.min(scale, cfg.maxDevicePixelRatio ?? Number.POSITIVE_INFINITY);\n return CapLevelController.getMaxLevelByMediaSize(hls.levels, width * scale, height * scale);\n }\n\n /**\n * ResizeObserver callback: debounce cap changes so only a SETTLED box ever\n * reaches ABR. Every size event restarts the pending window, so a fullscreen\n * transition animating through intermediate sizes keeps deferring until the\n * box stops moving — and a box that settles back onto the applied cap (exit\n * + prompt re-enter) cancels the pending change without ever touching ABR.\n */\n private onMediaResize(): void {\n if (!this.sizeCapManaged) return;\n const target = this.computeSizeCapTarget();\n if (target == null) return;\n if (this.sizeCapLevel == null) {\n // Nothing applied yet (manifest parsed while the box was hidden/zero):\n // this is the initial cap, not a change — apply immediately.\n this.applySizeCap();\n return;\n }\n if (target === this.sizeCapLevel) {\n if (this.capTimer) {\n clearTimeout(this.capTimer);\n this.capTimer = null;\n }\n return;\n }\n if (this.capTimer) clearTimeout(this.capTimer);\n const delay = target > this.sizeCapLevel ? CAP_RAISE_SETTLE_MS : CAP_SHRINK_LAZY_MS;\n this.capTimer = setTimeout(() => {\n this.capTimer = null;\n // The box can move between the last observed event and this firing\n // (ResizeObserver delivery is frame-aligned, timers aren't): apply only\n // if the box still selects the level this window was scheduled FOR;\n // otherwise re-debounce in the fresh direction — a raise window must\n // never be the door a shrink walks through.\n if (this.computeSizeCapTarget() === target) this.applySizeCap();\n else this.onMediaResize();\n }, delay);\n }\n\n /** Recompute the size cap from the CURRENT box and apply it (recomputing at\n * timer fire, not schedule time, so a stale pending value is never applied). */\n private applySizeCap(): void {\n if (!this.sizeCapManaged) return;\n if (this.capTimer) {\n clearTimeout(this.capTimer);\n this.capTimer = null;\n }\n const target = this.computeSizeCapTarget();\n if (target == null) return;\n this.sizeCapLevel = target;\n this.pushCap();\n }\n\n /** Combine the size cap and the setMaxBitrate cap (min wins) into hls.js's\n * single autoLevelCapping knob. */\n private pushCap(): void {\n const hls = this.hls;\n if (!hls) return;\n const size = this.sizeCapLevel ?? -1;\n const rate = this.bitrateCapLevel;\n hls.autoLevelCapping = size < 0 ? rate : rate < 0 ? size : Math.min(size, rate);\n }\n\n getAudioTracks(): AudioTrackInfo[] {\n if (!this.hls) return [];\n return this.hls.audioTracks.map((t) => ({\n id: t.id,\n name: t.name,\n lang: t.lang || undefined,\n default: Boolean(t.default),\n }));\n }\n\n setAudioTrack(id: number): void {\n if (this.hls) this.hls.audioTrack = id;\n }\n\n getTextTracks(): TextTrackInfo[] {\n if (!this.hls) return [];\n return this.hls.subtitleTracks.map((t) => ({\n id: t.id,\n name: t.name,\n lang: t.lang || undefined,\n }));\n }\n\n setTextTrack(id: number): void {\n const hls = this.hls;\n if (!hls) return;\n if (id < 0) {\n hls.subtitleTrack = -1;\n hls.subtitleDisplay = false;\n } else {\n hls.subtitleTrack = id;\n hls.subtitleDisplay = true;\n }\n }\n\n seekToLive(): void {\n const hls = this.hls;\n if (hls && typeof hls.liveSyncPosition === 'number') {\n this.deps.video.currentTime = hls.liveSyncPosition;\n }\n }\n\n setLicenseUrls(urls: { widevine?: string; fairplay?: string }): void {\n // Refresh only what the caller has — licenseXhrSetup reads this field per\n // request, so an in-flight session picks the new signed URL up on its next\n // license POST without an instance rebuild. FairPlay is ignored here: it\n // runs on NativeHlsEngine, which keeps its own refreshed URL.\n if (urls.widevine) this.licenseUrl = urls.widevine;\n }\n\n getStats(): PlaybackStats {\n const hls = this.hls;\n const frames = readFrameStats(this.deps.video);\n const stats: PlaybackStats = {\n droppedFrames: frames.dropped,\n decodedFrames: frames.totalFrames,\n lowLatency: this.detectedLowLatency,\n bytesLoaded: this.bytesLoaded,\n playlistBytesLoaded: this.playlistBytesLoaded,\n renditionSwitches: this.renditionSwitches,\n licenseTimeMs: this.licenseTimeMs,\n upshifts: this.upshifts,\n downshifts: this.downshifts,\n requestCount: this.requestCount,\n requestErrors: this.requestErrors,\n // A copy: PlaybackStats is public surface, and a consumer that\n // mutated this array would corrupt the engine's own accounting.\n requestTimesMs: [...this.requestTimesMs],\n ladderHeights: this.ladderHeights,\n ladderTopBitrateKbps: this.ladderTopBitrateKbps || undefined,\n };\n if (this.cdnHost) stats.cdnHost = this.cdnHost;\n // Widevine with the robustness this engine requests (SW_SECURE_*) is\n // software-path by definition: L3. A hardware-backed L1 is not reachable\n // through hls.js/MSE here, so reporting the requested level is honest.\n if (this.licenseRequested) {\n stats.keySystem = 'widevine';\n stats.drmSecurityLevel = 'L3';\n }\n if (hls) {\n stats.qualityPinned = !hls.autoLevelEnabled;\n if (typeof hls.latency === 'number') stats.latencySeconds = hls.latency;\n if (typeof hls.targetLatency === 'number' && hls.targetLatency > 0) {\n stats.targetLatencySeconds = hls.targetLatency;\n }\n if (typeof hls.bandwidthEstimate === 'number') {\n stats.bandwidthKbps = Math.round(hls.bandwidthEstimate / 1000);\n }\n const level = hls.currentLevel >= 0 ? hls.levels[hls.currentLevel] : undefined;\n if (level) stats.levelHeight = level.height;\n if (level?.videoCodec) stats.videoCodec = level.videoCodec;\n if (level?.audioCodec) stats.audioCodec = level.audioCodec;\n if (level && typeof level.bitrate === 'number') {\n stats.renditionBitrateKbps = Math.round(level.bitrate / 1000);\n }\n if (typeof level?.details?.live === 'boolean') {\n stats.isLive = level.details.live;\n if (typeof hls.liveSyncPosition === 'number') {\n stats.atLiveEdge = this.deps.video.currentTime >= hls.liveSyncPosition - 1.5;\n }\n }\n }\n return stats;\n }\n\n /**\n * Track live-playlist progress for end detection. Once the stream has been\n * seen LIVE, a transition to `details.live === false` means the origin\n * published #EXT-X-ENDLIST (clean end); otherwise the staleness watchdog\n * (checkEndStale) catches a frozen-but-200 manifest.\n */\n private trackEnd(data: LevelLoadedData): void {\n const d = data.details;\n if (!d) return;\n if (d.live === true) {\n this.sawLive = true;\n const sn = d.endSN ?? -1;\n if (sn !== this.lastEndSN) {\n this.lastEndSN = sn;\n this.lastAdvanceMs = Date.now();\n // The playlist is moving again — restore normal live-edge chasing\n // before the stale window that suspended it is forgotten.\n if (this.chaseSuspended) this.resumeChasing();\n }\n return;\n }\n // VOD that was never live is not an \"end\" — only treat live→false as ENDLIST.\n if (this.sawLive) this.emitEnded('endlist');\n }\n\n private checkEndStale(): void {\n if (this.endedEmitted || !this.sawLive || this.lastAdvanceMs === 0) return;\n // Chrome's intensive throttling (muted tab hidden >5 min) batches ALL timers\n // to ~1/min — including hls.js's playlist-reload timer — so advance stamps\n // gap to ~60s on a healthy stream (this check wakes in the same batch as the\n // reload, before its XHR completes). Re-stamp instead of evaluating while\n // hidden: staleness detection resumes with a fresh window on refocus, and\n // the SSE live-state push still catches a real end immediately. Mirrors the\n // Demo Player guard (havik-ops#68).\n if (typeof document !== 'undefined' && document.hidden) {\n this.lastAdvanceMs = Date.now();\n return;\n }\n // Same re-stamp while loading is suspended for a pause: no playlist is\n // being reloaded, so \"not advancing\" is by design, not a frozen origin.\n if (this.loadSuspended) {\n this.lastAdvanceMs = Date.now();\n return;\n }\n const staleMs = Date.now() - this.lastAdvanceMs;\n if (!this.chaseSuspended && staleMs > CHASE_SUSPEND_STALE_MS) this.suspendChasing();\n if (staleMs > END_STALE_MS) this.emitEnded('stalled');\n }\n\n /**\n * Detect and heal a wedged hls.js video pipeline (see the STARVATION_*\n * constants for the mechanism). All three conditions must hold across\n * consecutive ticks:\n * - the element is starved (readyState < HAVE_FUTURE_DATA) while playing;\n * - the live playlist is still advancing (a stale playlist is stream end —\n * the end watchdog and chase suspension own that path);\n * - hls.js has issued no main-track fragment load recently (a plain\n * rebuffer keeps FRAG_LOADING events flowing; the wedge goes silent).\n * Hidden tabs are skipped for the same timer-throttling reason as\n * checkEndStale. The kick budget is windowed like the other recoveries\n * (refunded after RECOVERY_RESET_MS of healthy buffering).\n */\n private checkStarvation(): void {\n const hls = this.hls;\n const { video } = this.deps;\n if (!hls || this.endedEmitted || !this.sawLive || this.chaseSuspended) return;\n if ((typeof document !== 'undefined' && document.hidden) || this.loadSuspended) {\n this.starvedTicks = 0;\n return;\n }\n const playlistAlive =\n this.lastAdvanceMs !== 0 && Date.now() - this.lastAdvanceMs < CHASE_SUSPEND_STALE_MS;\n const fragQuiet =\n this.lastFragLoadingMs !== 0 &&\n Date.now() - this.lastFragLoadingMs > STARVATION_FRAG_QUIET_MS;\n const starved = video.readyState < HTMLMediaElement.HAVE_FUTURE_DATA && !video.paused;\n if (!(starved && playlistAlive && fragQuiet)) {\n this.starvedTicks = 0;\n return;\n }\n // Reached only on a visible tick that still shows the wedge signature, so\n // this is the grace's clock: active starvation, not elapsed wall time.\n if (this.kickPending) this.kickTicks += 1;\n if (++this.starvedTicks < STARVATION_TICKS) return;\n this.starvedTicks = 0;\n\n // A kick that has had its grace and still produced no main-track media did\n // not reach the wedge. Escalate to a rebuild before spending the rest of\n // the kick budget re-parking the same instance.\n if (this.kickPending && this.kickTicks >= STARVATION_KICK_GRACE_TICKS) {\n this.kickPending = false;\n this.kickTicks = 0;\n if (this.starvationRebuilds < MAX_STARVATION_REBUILDS) {\n this.starvationRebuilds += 1;\n console.warn(\n 'havik-player: restarting the load did not resume video (the wedge is in hls.js state that only a rebuild clears) — rebuilding the player',\n );\n this.rebuildInstance('starvation rebuild');\n return;\n }\n }\n\n if (this.starvationKicks >= MAX_STARVATION_KICKS) return;\n this.starvationKicks += 1;\n this.kickPending = true;\n this.kickTicks = 0;\n console.warn(\n 'havik-player: video starved while the live playlist advances (wedged hls.js fragment pipeline) — restarting the load',\n );\n try {\n hls.stopLoad();\n hls.startLoad(-1);\n } catch (err) {\n // The kick is best-effort: a throw from a wedged hls.js must not escape\n // the watchdog interval and take the page down with it.\n console.warn('havik-player: starvation kick failed', err);\n }\n }\n\n /**\n * Freeze live-edge chasing on a stale playlist. Both knobs are read\n * dynamically by hls.js's LatencyController on every tick, so mutating the\n * live config takes effect immediately: an effectively-infinite latency\n * ceiling disables the force-seek (which would land in data that doesn't\n * exist), and a 1.0 sync rate stops the catch-up speedup — the video simply\n * plays out its buffer and holds the last frame until 'ended' (or fresh\n * bytes) arrive.\n */\n private suspendChasing(): void {\n const hls = this.hls;\n if (!hls) return;\n this.chaseSuspended = true;\n this.savedLiveMaxLatency = hls.config.liveMaxLatencyDuration;\n this.savedMaxSyncRate = hls.config.maxLiveSyncPlaybackRate;\n hls.config.liveMaxLatencyDuration = Number.MAX_SAFE_INTEGER;\n hls.config.maxLiveSyncPlaybackRate = 1;\n }\n\n private resumeChasing(): void {\n this.chaseSuspended = false;\n const hls = this.hls;\n if (!hls) return;\n // Restore UNCONDITIONALLY — including `undefined`. Count-based caller\n // configs (liveSyncDurationCount) have their seconds keys deleted by\n // normalizeLiveSync, so the saved value is legitimately undefined; a\n // guarded restore would leave the suspension's MAX_SAFE_INTEGER ceiling\n // in place forever (hls.js prefers the seconds key over the count key),\n // silently disabling the drift ceiling for the rest of the session.\n hls.config.liveMaxLatencyDuration = this.savedLiveMaxLatency as number;\n hls.config.maxLiveSyncPlaybackRate = this.savedMaxSyncRate as number;\n }\n\n private emitEnded(reason: 'endlist' | 'stalled'): void {\n if (this.endedEmitted) return;\n this.endedEmitted = true;\n if (this.endWatchdog) {\n clearInterval(this.endWatchdog);\n this.endWatchdog = null;\n }\n this.deps.emit({ type: 'ended', reason });\n }\n\n stop(): void {\n if (this.endWatchdog) {\n clearInterval(this.endWatchdog);\n this.endWatchdog = null;\n }\n // A deliberate stop is not a wedge: drop any pending escalation so a\n // later resume can't rebuild on evidence from before the stop.\n this.kickPending = false;\n this.kickTicks = 0;\n // And it outranks the pause suspension: the `pause` this stop fires must\n // not arm a grace, and a later `play` on the ended element (the control\n // bar stays clickable) must not restart the loader.\n this.stopped = true;\n this.loadSuspended = false;\n if (this.pauseSuspendTimer) {\n clearTimeout(this.pauseSuspendTimer);\n this.pauseSuspendTimer = null;\n }\n try {\n this.hls?.stopLoad();\n } catch {\n /* ignore */\n }\n try {\n this.deps.video.pause();\n } catch {\n /* ignore */\n }\n }\n\n destroy(): void {\n this.clearRecoveryReset();\n if (this.capTimer) {\n clearTimeout(this.capTimer);\n this.capTimer = null;\n }\n if (this.capObserver) {\n this.capObserver.disconnect();\n this.capObserver = null;\n }\n this.unwatchDprChanges();\n if (this.endWatchdog) {\n clearInterval(this.endWatchdog);\n this.endWatchdog = null;\n }\n if (this.onVisibility && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this.onVisibility);\n this.onVisibility = null;\n }\n if (this.onSeeking) {\n this.deps.video.removeEventListener('seeking', this.onSeeking);\n this.onSeeking = null;\n }\n if (this.onPause) {\n this.deps.video.removeEventListener('pause', this.onPause);\n this.onPause = null;\n }\n if (this.onPlay) {\n this.deps.video.removeEventListener('play', this.onPlay);\n this.onPlay = null;\n }\n if (this.pauseSuspendTimer) {\n clearTimeout(this.pauseSuspendTimer);\n this.pauseSuspendTimer = null;\n }\n if (this.networkRetryTimer) {\n clearTimeout(this.networkRetryTimer);\n this.networkRetryTimer = null;\n }\n if (this.licenseRetryTimer) {\n clearTimeout(this.licenseRetryTimer);\n this.licenseRetryTimer = null;\n }\n if (this.hls) {\n try {\n this.hls.destroy();\n } catch (e) {\n // Surface a failed teardown rather than silently leaking the worker/EME session.\n console.warn('havik-player: hls.js destroy() threw during teardown', e);\n }\n this.hls = null;\n }\n }\n\n private clearRecoveryReset(): void {\n if (this.recoveryResetTimer) {\n clearTimeout(this.recoveryResetTimer);\n this.recoveryResetTimer = null;\n }\n }\n\n /**\n * A transient transport failure of the EME license (or service-certificate)\n * request — no response, timeout, 429, or 5xx — is recoverable: hls.js has no\n * license-retry policy and reports it as a fatal KEY_SYSTEM error, so we\n * rebuild the instance (a fresh key session re-issues the request), bounded\n * and backed off. A real auth/entitlement denial (401/403 or any other 4xx)\n * is NOT retried. Returns true iff a retry was scheduled.\n */\n private scheduleLicenseRetry(data: ErrorData): boolean {\n const requestFailure =\n data.details === Hls.ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED ||\n data.details === Hls.ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED;\n if (!requestFailure) return false;\n // 0 = network/CORS/timeout (the POST never got a response); 408/429/5xx are\n // transient server-side. Any other 4xx (esp. 401/403) is a real denial.\n const status = data.response?.code ?? 0;\n const transient = status === 0 || status === 408 || status === 429 || status >= 500;\n if (!transient || this.licenseRecoveries >= MAX_LICENSE_RECOVERIES) return false;\n this.licenseRecoveries += 1;\n const delay = Math.min(8000, 500 * 2 ** (this.licenseRecoveries - 1));\n this.licenseRetryTimer = setTimeout(() => {\n this.licenseRetryTimer = null;\n this.rebuildInstance('license retry'); // fresh key session re-issues the request\n }, delay);\n return true;\n }\n\n /**\n * Tear the hls.js instance down and build a fresh one, which is the only\n * recovery that reaches state the instance owns privately: destroy() runs\n * KeyLoader.detach(), and that is what discards a cached `keyInfo` whose\n * key-load promise will never settle (`isCommonEncryption` entries go even\n * when their session context is null — the wedged shape). Equivalent to the\n * page reload viewers resort to, minus the page.\n *\n * Deliberately not a stopLoad()/startLoad(): those keep the media element\n * attached and re-await the same cached promise.\n *\n * Callers own their own budget — this is the mechanism, not the policy.\n */\n private rebuildInstance(reason: string): void {\n if (!this.hls) return;\n try {\n this.hls.destroy();\n } catch (e) {\n console.warn(`havik-player: hls.js destroy() threw during ${reason}`, e);\n }\n this.hls = null;\n this.startHls();\n }\n\n private onError(data: ErrorData): void {\n // Counted BEFORE the non-fatal early return: hls.js retries a failed\n // segment internally, so every request error a viewer actually suffers\n // is invisible to the QoE plane unless it is tallied here.\n //\n // Fragment failures only. requestCount counts fragment loads, so\n // folding manifest and playlist failures in here would let a\n // manifest-flapping session report an error ratio above 100% against a\n // denominator those errors were never part of.\n if (\n data.type === Hls.ErrorTypes.NETWORK_ERROR &&\n String(data.details ?? '').startsWith('frag')\n ) {\n this.requestErrors += 1;\n }\n if (!data.fatal) return; // hls.js recovers non-fatal gaps internally\n const hls = this.hls;\n if (!hls) return;\n\n // A fatal error cancels any pending recovery-budget refund so a tight\n // recover-loop still trips the caps.\n this.clearRecoveryReset();\n\n // A transient transport failure of the license/cert request is recoverable\n // by rebuilding hls.js; a real auth/entitlement denial and other key-system\n // errors are not.\n if (data.type === Hls.ErrorTypes.KEY_SYSTEM_ERROR) {\n if (this.scheduleLicenseRetry(data)) return;\n this.deps.emit({\n type: 'error',\n error: this.toError(data, 'DRM'),\n fatal: true,\n detail: data.details,\n });\n return;\n }\n if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {\n // Transient origin/segment/license network failure: restart the loader with\n // exponential backoff, but cap retries so a persistently-failing origin\n // eventually surfaces a fatal error instead of restarting forever.\n if (this.networkRecoveries < MAX_NETWORK_RECOVERIES) {\n this.networkRecoveries += 1;\n const delay = Math.min(8000, 1000 * 2 ** (this.networkRecoveries - 1));\n this.networkRetryTimer = setTimeout(() => {\n this.networkRetryTimer = null;\n if (this.hls) this.hls.startLoad();\n }, delay);\n return;\n }\n this.deps.emit({\n type: 'error',\n error: this.toError(data, 'NETWORK'),\n fatal: true,\n detail: data.details,\n });\n return;\n }\n if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {\n if (this.mediaRecoveries < MAX_MEDIA_RECOVERIES) {\n this.mediaRecoveries += 1;\n hls.recoverMediaError();\n return;\n }\n this.deps.emit({\n type: 'error',\n error: this.toError(data),\n fatal: true,\n detail: data.details,\n });\n return;\n }\n this.deps.emit({ type: 'error', error: this.toError(data), fatal: true, detail: data.details });\n }\n\n private toError(data: ErrorData, kind = 'PLAYBACK'): PlaybackError {\n const status = data.response?.code ?? 0;\n const detail = data.details ?? data.type;\n const reason = data.reason ?? data.error?.message ?? '';\n const code =\n kind === 'DRM'\n ? classifyDrmStatus(status)\n : status === 401\n ? 'UNAUTHORIZED'\n : status === 403\n ? 'FORBIDDEN'\n : 'INTERNAL';\n return new PlaybackError(\n code,\n status,\n `${kind} error: ${detail}${reason ? ` (${reason})` : ''}`,\n );\n }\n}\n","// QoE beacon emitter — plan A2 (havik-streams docs/plans/havik-analytics-plan.md).\n//\n// One AnalyticsSession per playback session (per sid). Wire format is the\n// proto3 JSON mapping of `havik_analytics.BeaconBatch` (protocol-buffers\n// repo): camelCase keys, enums by NAME, and — the one contract landmine —\n// StateChange uses `fromState`/`toState` (`from` is a Python keyword; the\n// contract renamed the pair for every generator).\n//\n// Privacy posture (plan D11, asserted by test): the sid arrives in the\n// playback response, lives in this object, and dies with it. This module\n// never touches localStorage, sessionStorage, cookies, or IndexedDB.\n//\n// Delivery is fire-and-forget (plan D3/D9): beacons are dashboard-grade, the\n// CDN edge-log plane is the billing truth, so a lost batch is dropped —\n// never retried into a stampede, never allowed to affect playback.\n\nimport { type SessionEnvironment } from './environment';\nimport { type PlaybackStats, type PlayerState } from './stats';\n\n/** Beacon cadence — plan §9 settled at review time to 15s. */\nconst DEFAULT_HEARTBEAT_MS = 15_000;\n/**\n * How long a session may sit PAUSED before it closes itself, and the\n * suspension gap (no timer fired for this long: OS sleep, a frozen tab) after\n * which it does the same on waking.\n *\n * Without this a session had exactly four ends — pagehide, stream end,\n * destroy(), fatal error — so a viewer who paused and forgot the tab, closed\n * the lid, or had the tab discarded left a session \"still open\" for hours\n * (portal, 2026-09-19: paused heartbeats from 16:13 on, no end), and a slept\n * laptop booked its whole sleep as playing time on waking. Ten minutes is\n * longer than any pause that is still \"the same viewing\" (an ad break, a\n * phone call) and shorter than half-time; a viewer who comes back after it\n * starts a NEW session (the player reloads on the next play()), which is\n * what it is. Hidden-but-playing is NOT idle: audio keeps playing in a\n * background tab.\n */\nconst DEFAULT_IDLE_MS = 10 * 60_000;\n/**\n * The suspension detector's floor, whatever idleMs is. A gap between two of\n * our ticks is only a suspension when no legitimate cadence explains it: a\n * hidden tab's throttled timers gap to a minute (Chrome, after 5 min\n * hidden), and a host-configured idleMs below the heartbeat interval is a\n * PAUSE limit, not a licence to close a playing session on its first\n * ordinary heartbeat. Five minutes is above every throttled cadence seen and\n * below any sleep worth closing over.\n */\nconst MIN_SUSPENSION_GAP_MS = 5 * 60_000;\n/** Queue bound: past this the oldest non-lifecycle beacons drop first. */\nconst MAX_PENDING = 100;\n/** Startup stages report >=1ms when reached; 0 means never reached. */\nconst MIN_STAGE_MS = 1;\n/**\n * How long SessionStart waits for the autoplay verdict before going out.\n *\n * The contract puts the autoplay result on SessionStart, but the engine is\n * committed (and this session constructed) BEFORE play() is ever called, so\n * at enqueue time the verdict does not exist yet. Holding the first flush\n * for a beat lets the real value ride the beacon it belongs on; nothing is\n * lost if the verdict misses the window, and any earlier flush (an error,\n * end, pagehide) sends immediately regardless.\n */\nconst FIRST_FLUSH_DELAY_MS = 1_000;\n/** A stall or error this close to the end of a session is what the viewer\n * left over — beyond it, the two are unrelated events in one session. */\nconst ABANDON_WINDOW_MS = 30_000;\n/**\n * How long after a seek a stall is still the seek's.\n *\n * Was an inline 1_000 in causeOf; named because the rebuffer counters now\n * share it. A seek re-buffers from a new position, so the gap is bounded by\n * a segment fetch rather than by anything the viewer did — one second covers\n * it at any realistic segment duration without swallowing a genuine stall\n * that merely happens to follow a seek.\n */\nconst SEEK_STALL_WINDOW_MS = 1_000;\n\n/** Contract enum names (proto3 JSON encodes enums by name). */\ntype WirePlayer = 'PLAYER_HLSJS' | 'PLAYER_SAFARI_NATIVE';\ntype WireState =\n | 'PLAYER_STATE_UNSPECIFIED'\n | 'PLAYER_STATE_IDLE'\n | 'PLAYER_STATE_WAITING'\n | 'PLAYER_STATE_LOADING'\n | 'PLAYER_STATE_PLAYING'\n | 'PLAYER_STATE_BUFFERING'\n | 'PLAYER_STATE_PAUSED'\n | 'PLAYER_STATE_ENDED'\n | 'PLAYER_STATE_ERROR';\ntype WireKeySystem = 'KEY_SYSTEM_NONE' | 'KEY_SYSTEM_WIDEVINE' | 'KEY_SYSTEM_FAIRPLAY';\ntype WirePresentation = 'PRESENTATION_INLINE' | 'PRESENTATION_FULLSCREEN' | 'PRESENTATION_PIP';\ntype WireCause =\n | 'TRANSITION_CAUSE_USER'\n | 'TRANSITION_CAUSE_SEEK'\n | 'TRANSITION_CAUSE_NETWORK_STALL'\n | 'TRANSITION_CAUSE_ERROR'\n | 'TRANSITION_CAUSE_AUTO';\ntype WireErrorStage =\n | 'ERROR_STAGE_RESOLVE'\n | 'ERROR_STAGE_MANIFEST'\n | 'ERROR_STAGE_DRM'\n | 'ERROR_STAGE_FIRST_SEGMENT'\n | 'ERROR_STAGE_SEGMENT'\n | 'ERROR_STAGE_DECODE'\n | 'ERROR_STAGE_OTHER';\nexport type WireAutoplay =\n | 'AUTOPLAY_RESULT_ALLOWED'\n | 'AUTOPLAY_RESULT_ALLOWED_MUTED'\n | 'AUTOPLAY_RESULT_BLOCKED'\n | 'AUTOPLAY_RESULT_NOT_ATTEMPTED';\n/** Presentation the viewer is watching in, as the player reports it. */\nexport type Presentation = 'inline' | 'fullscreen' | 'pip';\nconst PRESENTATION_TO_WIRE: Record<Presentation, WirePresentation> = {\n inline: 'PRESENTATION_INLINE',\n fullscreen: 'PRESENTATION_FULLSCREEN',\n pip: 'PRESENTATION_PIP',\n};\n\ntype WireEndReason =\n | 'END_REASON_ENDED'\n | 'END_REASON_PAGEHIDE'\n | 'END_REASON_STOPPED'\n | 'END_REASON_FATAL_ERROR'\n | 'END_REASON_IDLE';\n\nconst KEY_SYSTEM_TO_WIRE: Record<'widevine' | 'fairplay' | 'none', WireKeySystem> = {\n widevine: 'KEY_SYSTEM_WIDEVINE',\n fairplay: 'KEY_SYSTEM_FAIRPLAY',\n none: 'KEY_SYSTEM_NONE',\n};\n\n/**\n * Nearest-rank percentile over an ascending slice; 0 for an empty one.\n *\n * Nearest rank, not interpolation: the value returned is one the session\n * actually observed. The rank is ceil(p/100 * N), so p50 of four samples\n * is the second, not the third — worth stating because the two conventions\n * differ only at exact boundaries and a future reader is otherwise likely\n * to \"fix\" one into the other.\n */\nfunction percentile(ascending: number[], p: number): number {\n if (ascending.length === 0) return 0;\n const rank = Math.min(ascending.length, Math.max(1, Math.ceil((p / 100) * ascending.length)));\n return ascending[rank - 1] ?? 0;\n}\n\n/** Codes raised by /v1/playback before any media is fetched. */\nconst RESOLVE_CODES = new Set([\n 'INVALID_URN',\n 'NOT_FOUND',\n 'GONE',\n 'TOO_EARLY',\n 'UNAVAILABLE',\n 'UNAUTHORIZED',\n 'FORBIDDEN',\n 'RATE_LIMITED',\n 'TIMEOUT',\n]);\n\nconst STATE_TO_WIRE: Record<PlayerState, WireState> = {\n idle: 'PLAYER_STATE_IDLE',\n waiting: 'PLAYER_STATE_WAITING',\n loading: 'PLAYER_STATE_LOADING',\n playing: 'PLAYER_STATE_PLAYING',\n buffering: 'PLAYER_STATE_BUFFERING',\n paused: 'PLAYER_STATE_PAUSED',\n ended: 'PLAYER_STATE_ENDED',\n error: 'PLAYER_STATE_ERROR',\n};\n\ninterface WireSessionStart extends Partial<SessionEnvironment> {\n player: WirePlayer;\n sdkVersion: string;\n autoplay?: WireAutoplay;\n}\n\ninterface WireBeacon {\n sid: string;\n clientTime: string;\n seq: number;\n sessionStart?: WireSessionStart;\n mediaInfo?: {\n videoCodec?: string;\n audioCodec?: string;\n keySystem?: WireKeySystem;\n drmSecurityLevel?: string;\n ladderHeights?: number[];\n ladderTopBitrateKbps?: number;\n lowLatency?: boolean;\n };\n heartbeat?: Record<string, number | string | boolean>;\n stateChange?: {\n fromState: WireState;\n toState: WireState;\n positionMs?: number;\n renditionHeight?: number;\n cause?: WireCause;\n presentation?: WirePresentation;\n };\n error?: {\n code: string;\n detail?: string;\n fatal?: boolean;\n httpStatus?: number;\n recoveryAttempts?: number;\n recovered?: boolean;\n positionMs?: number;\n stage?: WireErrorStage;\n cdnHost?: string;\n };\n sessionEnd?: Record<string, number | string>;\n}\n\n/** What the player hands the session at each heartbeat. */\nexport type AnalyticsSample = PlaybackStats & {\n state: PlayerState;\n positionMs: number;\n /** Forward buffer depth, which only the player can measure. */\n bufferedMs?: number;\n /** >1 means the engine is catching up toward the live edge. */\n playbackRate?: number;\n};\n\nexport interface AnalyticsSessionOptions {\n /** Beacons ingest base URL — POSTs go to `<endpoint>/v1/beacons`. */\n endpoint: string;\n /** The per-session id minted by havik-streams at resolve time. */\n sid: string;\n player: WirePlayer;\n sdkVersion: string;\n /**\n * Called at each heartbeat for the current cumulative stats; the session\n * computes per-interval deltas itself.\n */\n sample: () => AnalyticsSample;\n /**\n * Where the session runs (contract v2): form factor, device model, OS\n * version, screen, connection, embedding host. Collected once by the\n * player. Model and OS version come from UA client hints and are absent\n * on browsers that do not implement them; no IP and no raw user agent\n * ever leave the client (see core/environment).\n */\n environment?: SessionEnvironment;\n heartbeatMs?: number;\n /**\n * Idle limit; see DEFAULT_IDLE_MS. `0` disables the idle close entirely.\n * The suspension detector never fires below MIN_SUSPENSION_GAP_MS,\n * whatever this is set to.\n */\n idleMs?: number;\n /**\n * Called once, after the session ended itself for inactivity\n * (END_REASON_IDLE) — the player latches \"reload on the next play()\" on it,\n * because this sid is finished and whatever plays next is a new session.\n */\n onIdle?: () => void;\n /** Test seams. */\n now?: () => number;\n sendBeacon?: (url: string, body: string) => boolean;\n fetchFn?: typeof fetch;\n}\n\n/**\n * Derive the beacons ingest base from the streams API base by the platform\n * hostname convention (`feed[-dev].<domain>` → `beacons[-dev].<domain>`),\n * mirroring how the SSE endpoint derives `events.<domain>`. Returns\n * undefined when the convention doesn't apply — the caller must then pass\n * an explicit endpoint for beacons to flow.\n */\nexport function deriveBeaconsBaseUrl(baseUrl: string): string | undefined {\n try {\n const url = new URL(baseUrl);\n const [first, ...rest] = url.hostname.split('.');\n if (!first || rest.length === 0) return undefined;\n if (first !== 'feed' && !first.startsWith('feed-')) return undefined;\n url.hostname = [first.replace(/^feed/, 'beacons'), ...rest].join('.');\n url.pathname = '';\n url.search = '';\n return url.origin;\n } catch {\n return undefined;\n }\n}\n\n/**\n * One playback session's QoE beacon emitter. Construct after engine\n * selection (player identity is only truthful then), call lifecycle hooks\n * from the player, and end() exactly once — later calls are no-ops.\n */\nexport class AnalyticsSession {\n private readonly opts: Required<Pick<AnalyticsSessionOptions, 'heartbeatMs' | 'idleMs'>> &\n AnalyticsSessionOptions;\n private readonly url: string;\n private readonly now: () => number;\n\n private seq = 0;\n private pending: WireBeacon[] = [];\n private timer: ReturnType<typeof setInterval> | null = null;\n private endedFlag = false;\n\n // Delta bases for the cumulative counters sampled at each heartbeat.\n private lastBytes = 0;\n private lastPlaylistBytes = 0;\n private lastDropped = 0;\n private lastSwitches = 0;\n\n // Wall-clock accounting: playing/hidden time accumulate between their\n // state flips; totals close out at end().\n private playingSinceMs: number | null = null;\n private hiddenSinceMs: number | null = null;\n private playingTotalMs = 0;\n private hiddenTotalMs = 0;\n private lastHbPlayingMs = 0;\n private lastHbHiddenMs = 0;\n private lastHbRebufferMs = 0;\n private lastHbRebufferCount = 0;\n private lastHbActiveMs = 0;\n\n // Session totals for SessionEnd.\n private rebufferCount = 0;\n private rebufferTotalMs = 0;\n private bufferingSinceMs: number | null = null;\n // Rebuffer, like active watch, is a visible-only window: a backgrounded tab\n // is throttled into 'buffering' and would otherwise book minutes of stall the\n // viewer never saw. isBuffering is the state; bufferingSinceMs is the open\n // window, held only while isBuffering && !isHidden (see syncBuffering).\n private isBuffering = false;\n private errorCount = 0;\n private seekCount = 0;\n private pauseCount = 0;\n\n // ---- Contract v2 accounting.\n // Active watch = playing AND visible AND unmuted. Kept as its own window\n // rather than derived at read time, because the three flip independently\n // and a server can only ever reconstruct a lower bound from the deltas.\n private isPlaying = false;\n private isHidden = false;\n private isMuted = false;\n private activeSinceMs: number | null = null;\n private activeTotalMs = 0;\n // Muted time is counted only WHILE PLAYING, so it is subtractable from\n // playing time; a muted paused tab is not watch time to discount.\n private mutedSinceMs: number | null = null;\n private mutedTotalMs = 0;\n private lastHbMutedMs = 0;\n private volumeChanges = 0;\n private muteChanges = 0;\n // Presentation: which surface the viewer watched in, and for how long.\n private presentation: Presentation = 'inline';\n private presentationSinceMs: number;\n private fullscreenTotalMs = 0;\n private pipTotalMs = 0;\n // Armed-before-live time: a viewer who waited five minutes for kickoff had\n // a different session from one who joined a running match.\n private waitingSinceMs: number | null = null;\n private waitingTotalMs = 0;\n // Startup and abandon signals.\n private firstAudioMs = 0;\n private timeToFirstStallMs = 0;\n private lastStallMs = 0;\n private lastErrorMs = 0;\n // −1 means \"no seek yet\": 0 is a real seek time when `now` starts at 0.\n private lastSeekMs = -1;\n /** True while the CURRENT stall is the seek's rather than the network's. */\n private stallIsSeekInduced = false;\n private lastErrorStage: WireErrorStage | null = null;\n private sawFirstBytes = false;\n private mediaInfoSignature = '';\n // Delta bases for the v2 cumulative counters.\n private lastUpshifts = 0;\n private lastDownshifts = 0;\n private lastDecoded = 0;\n private lastRequestCount = 0;\n private lastRequestErrors = 0;\n // The un-flushed SessionStart, held briefly so the autoplay verdict can\n // land on it (see FIRST_FLUSH_DELAY_MS).\n private pendingStart: WireSessionStart | null = null;\n private firstFlushTimer: ReturnType<typeof setTimeout> | null = null;\n // Idle close (see DEFAULT_IDLE_MS): the pause timer, and the last instant a\n // timer of ours actually ran — the suspension detector's reference.\n private idleTimer: ReturnType<typeof setTimeout> | null = null;\n private lastTickMs = 0;\n\n // Startup breakdown (0 = stage never reached, plan contract).\n private readonly bornMs: number;\n /**\n * What the startup ladder measures FROM: the moment the viewer asked for\n * playback, not the moment the player was constructed.\n *\n * Defaults to bornMs, which is correct for autoplay — intent and\n * construction coincide there. It moves once, on the first play(), for the\n * autoplay-off path, where the player is built with the manifest parsed and\n * then sits behind a poster for as long as the viewer takes to click. That\n * dwell was being reported as startup LATENCY: a viewer who read the page\n * for thirty seconds produced a thirty-second \"time to first frame\", and\n * ops' own help text has always described the figure this now computes\n * (\"from the player being asked to play to the first frame\").\n *\n * Every stage shares it, or they stop summing: the ladder is\n * manifest -> first segment -> first frame measured from one origin.\n */\n private startupAnchorMs: number;\n private startupTimeMs = 0;\n private manifestLoadMs = 0;\n private firstFragLoadMs = 0;\n\n private readonly onPagehide = () => this.end('END_REASON_PAGEHIDE');\n /**\n * Page Lifecycle `freeze`: the browser is about to stop running this page\n * (a backgrounded tab after ~5 min in Chrome, ahead of a discard). Nothing\n * runs after it — a discard fires no pagehide — so what is queued goes out\n * now, by sendBeacon, the only delivery a stopping page guarantees. Not an\n * end: the page may `resume`, and the suspension check there decides.\n */\n private readonly onFreeze = () => {\n if (this.endedFlag) return;\n this.flush(true);\n };\n /** Page Lifecycle `resume`: the page runs again; was it away past the idle limit? */\n private readonly onResume = () => this.checkSuspension(this.now());\n private readonly onVisibility = () => {\n if (typeof document === 'undefined') return;\n if (document.visibilityState === 'hidden') {\n if (this.hiddenSinceMs === null) this.hiddenSinceMs = this.now();\n this.isHidden = true;\n } else {\n if (this.hiddenSinceMs !== null) {\n this.hiddenTotalMs += this.now() - this.hiddenSinceMs;\n this.hiddenSinceMs = null;\n }\n this.isHidden = false;\n }\n this.syncActive();\n this.syncBuffering();\n };\n\n constructor(opts: AnalyticsSessionOptions) {\n // ?? at the use point, not object spread: a caller passing an explicit\n // `heartbeatMs: undefined` must not clobber the default into a 0ms\n // busy-loop interval.\n this.opts = {\n ...opts,\n heartbeatMs: opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,\n idleMs: opts.idleMs ?? DEFAULT_IDLE_MS,\n };\n this.url = `${opts.endpoint.replace(/\\/$/, '')}/v1/beacons`;\n this.now = opts.now ?? (() => Date.now());\n this.bornMs = this.now();\n // Not a startup stage: presentation time is fullscreen/PiP accounting and\n // is measured over the session's whole life, so it keeps bornMs.\n this.presentationSinceMs = this.bornMs;\n this.startupAnchorMs = this.bornMs;\n }\n\n /** Emit SessionStart (always the session's first beacon) and arm timers. */\n start(): void {\n if (this.endedFlag) return;\n const sessionStart: WireSessionStart = {\n player: this.opts.player,\n sdkVersion: this.opts.sdkVersion,\n ...this.opts.environment,\n };\n this.pendingStart = sessionStart;\n this.enqueue({ sessionStart });\n // Not flushed here: the autoplay verdict does not exist yet. See\n // FIRST_FLUSH_DELAY_MS — noteAutoplay, an error, a heartbeat or end all\n // send earlier if they happen first.\n this.firstFlushTimer = setTimeout(() => this.flush(), FIRST_FLUSH_DELAY_MS);\n this.lastTickMs = this.now();\n this.timer = setInterval(() => this.heartbeat(), this.opts.heartbeatMs);\n if (typeof window !== 'undefined') {\n window.addEventListener('pagehide', this.onPagehide);\n }\n if (typeof document !== 'undefined') {\n document.addEventListener('visibilitychange', this.onVisibility);\n document.addEventListener('freeze', this.onFreeze);\n document.addEventListener('resume', this.onResume);\n this.onVisibility(); // seed hidden accounting if born in a hidden tab\n }\n }\n\n /**\n * Did the page stop running for at least the idle limit? Timers do not\n * fire through OS sleep or a frozen tab, so the gap between two of our own\n * ticks is the measure: a heartbeat that finds the clock jumped past the\n * limit is the first tick after a suspension, and the session it wakes\n * into is over — nothing was watched, and every open accounting window\n * (playing, active, muted…) would otherwise book the whole gap. Ends as\n * IDLE; the next play() starts a new session.\n */\n private checkSuspension(t: number): boolean {\n if (this.endedFlag || !this.opts.idleMs) return false;\n const gap = Math.max(this.opts.idleMs, MIN_SUSPENSION_GAP_MS);\n if (this.lastTickMs > 0 && t - this.lastTickMs >= gap) {\n this.endIdle(this.lastTickMs);\n return true;\n }\n this.lastTickMs = t;\n return false;\n }\n\n /** Arm (paused) or disarm (anything else) the idle close. */\n private syncIdle(state: PlayerState): void {\n if (state === 'paused' && this.opts.idleMs) {\n if (this.idleTimer === null) {\n this.idleTimer = setTimeout(() => {\n this.idleTimer = null;\n this.endIdle();\n }, this.opts.idleMs);\n }\n } else if (this.idleTimer !== null) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n }\n\n private endIdle(at?: number): void {\n if (this.endedFlag) return;\n this.end('END_REASON_IDLE', at);\n this.opts.onIdle?.();\n }\n\n /** Player state transition — also drives rebuffer/watch-time accounting. */\n noteStateChange(\n from: PlayerState,\n to: PlayerState,\n positionMs: number,\n renditionHeight?: number,\n ): void {\n if (this.endedFlag || from === to) return;\n const t = this.now();\n\n if (to === 'playing') {\n if (this.playingSinceMs === null) this.playingSinceMs = t;\n if (this.startupTimeMs === 0) {\n this.startupTimeMs = Math.max(MIN_STAGE_MS, t - this.startupAnchorMs);\n }\n if (this.firstAudioMs === 0 && !this.isMuted) {\n // The closest a browser gets to \"first audible frame\": playback\n // running with the element neither muted nor at zero volume.\n this.firstAudioMs = Math.max(MIN_STAGE_MS, t - this.startupAnchorMs);\n }\n if (this.isMuted && this.mutedSinceMs === null) this.mutedSinceMs = t;\n } else if (this.playingSinceMs !== null) {\n this.playingTotalMs += t - this.playingSinceMs;\n this.playingSinceMs = null;\n if (this.mutedSinceMs !== null) {\n this.mutedTotalMs += t - this.mutedSinceMs;\n this.mutedSinceMs = null;\n }\n }\n this.isPlaying = to === 'playing';\n this.syncActive();\n\n // Time spent armed before the match went live, closed on the way out of\n // 'waiting' so it never includes the playback that followed.\n if (to === 'waiting') this.waitingSinceMs = t;\n else if (this.waitingSinceMs !== null) {\n this.waitingTotalMs += t - this.waitingSinceMs;\n this.waitingSinceMs = null;\n }\n\n if (to === 'buffering' && from === 'playing') {\n // Two kinds of stall are not the delivery path's fault and are excluded\n // from every stall figure:\n //\n // hidden — a backgrounded tab is throttled into 'buffering'; the\n // viewer saw nothing.\n // seek — the buffering that follows a seek is the seek's. The\n // state machine is driven by media events that carry no\n // reason, so this is inferred, and causeOf has always\n // inferred it for the LABEL; now the counters agree.\n //\n // (A stall that begins visible and later goes hidden is still one\n // stall: the count runs once here; syncBuffering only pauses its time.)\n this.stallIsSeekInduced = this.seekInduced(t);\n const counts = !this.isHidden && !this.stallIsSeekInduced;\n if (counts) this.rebufferCount += 1;\n this.isBuffering = true;\n this.lastStallMs = t;\n // Gated identically: \"time to first stall\" is a delivery figure, and a\n // seek is not a delivery event. Leaving it ungated would let a session\n // report a first stall it never counted.\n if (counts && this.timeToFirstStallMs === 0) {\n // Playback delivered before the first interruption — a session that\n // stalls at 3s and one that stalls at 30 minutes read identically\n // through the stall count alone.\n this.timeToFirstStallMs = Math.max(MIN_STAGE_MS, Math.round(this.playingTotalMs));\n }\n } else if (from === 'buffering') {\n this.isBuffering = false;\n this.stallIsSeekInduced = false;\n }\n // Open/close the rebuffer window against the new buffering state; visibility\n // changes come through onVisibility, which also calls syncBuffering.\n this.syncBuffering();\n if (to === 'paused') this.pauseCount += 1;\n this.syncIdle(to);\n\n const change: NonNullable<WireBeacon['stateChange']> = {\n fromState: STATE_TO_WIRE[from] ?? 'PLAYER_STATE_UNSPECIFIED',\n toState: STATE_TO_WIRE[to] ?? 'PLAYER_STATE_UNSPECIFIED',\n presentation: PRESENTATION_TO_WIRE[this.presentation],\n };\n const cause = this.causeOf(from, to, t);\n if (cause) change.cause = cause;\n if (positionMs > 0) change.positionMs = Math.round(positionMs);\n if (renditionHeight) change.renditionHeight = renditionHeight;\n this.enqueue({ stateChange: change });\n }\n\n /**\n * Was the stall starting at `t` the seek's rather than the network's?\n *\n * ONE definition, shared by the transition cause and by the rebuffer\n * accounting, because they are the same question: if the label said SEEK\n * while the counter said network stall, the beacon would contradict itself\n * inside a single session.\n *\n * The `lastSeekMs >= 0` guard is load-bearing, not defensive. Without it,\n * any stall within the first second of a clock that starts near zero reads\n * as seek-induced — true of an injected `now`, and the first stall of a\n * session is exactly the one a join-time analysis cares about most. The\n * \"no seek yet\" sentinel is −1 rather than 0 because 0 is a real seek\n * time on such a clock: a seek at the epoch must still own the\n * buffering that follows it.\n */\n private seekInduced(t: number): boolean {\n return this.lastSeekMs >= 0 && t - this.lastSeekMs < SEEK_STALL_WINDOW_MS;\n }\n\n /**\n * Why a transition happened, as far as the player can tell. Inferred, not\n * reported: the state machine is driven by media events that carry no\n * reason, so buffering right after a seek is the seek's, and buffering\n * otherwise is the network running dry — which is what the state means.\n */\n private causeOf(from: PlayerState, to: PlayerState, t: number): WireCause | undefined {\n if (to === 'error') return 'TRANSITION_CAUSE_ERROR';\n if (to === 'buffering') {\n return this.seekInduced(t) ? 'TRANSITION_CAUSE_SEEK' : 'TRANSITION_CAUSE_NETWORK_STALL';\n }\n if (to === 'paused' || (to === 'playing' && from === 'paused')) {\n return 'TRANSITION_CAUSE_USER';\n }\n return undefined;\n }\n\n /** Open or close the active-watch window after any of its three inputs moved. */\n private syncActive(at = this.now()): void {\n const on = this.isPlaying && !this.isHidden && !this.isMuted;\n if (on && this.activeSinceMs === null) this.activeSinceMs = at;\n else if (!on && this.activeSinceMs !== null) {\n this.activeTotalMs += at - this.activeSinceMs;\n this.activeSinceMs = null;\n }\n }\n\n /**\n * Open or close the rebuffer window after buffering or visibility moved.\n * Mirrors syncActive: a hidden tab is throttled into 'buffering', and that\n * stall time is not something the viewer experienced, so rebuffer time\n * accrues only while isBuffering && !isHidden.\n */\n private syncBuffering(): void {\n const on = this.isBuffering && !this.isHidden && !this.stallIsSeekInduced;\n if (on && this.bufferingSinceMs === null) this.bufferingSinceMs = this.now();\n else if (!on && this.bufferingSinceMs !== null) {\n this.rebufferTotalMs += this.now() - this.bufferingSinceMs;\n this.bufferingSinceMs = null;\n }\n }\n\n /** Bank the time spent in the current presentation and restart the clock. */\n private closePresentation(t = this.now()): void {\n const spent = t - this.presentationSinceMs;\n if (this.presentation === 'fullscreen') this.fullscreenTotalMs += spent;\n else if (this.presentation === 'pip') this.pipTotalMs += spent;\n this.presentationSinceMs = t;\n }\n\n /**\n * The autoplay verdict, once play() has resolved (or was never attempted).\n * Lands on SessionStart when it arrives inside the first-flush window,\n * which is the normal case; a slow manifest can miss it, and the field is\n * then simply absent rather than wrong.\n */\n noteAutoplay(result: WireAutoplay): void {\n if (this.pendingStart) this.pendingStart.autoplay = result;\n }\n\n /**\n * Environment facts that only resolve asynchronously (the high-entropy UA\n * client hints). Same one-shot window as the autoplay verdict: patched\n * onto SessionStart while it is still held, ignored once it has gone.\n */\n noteEnvironment(extra: Partial<SessionEnvironment>): void {\n if (this.pendingStart) Object.assign(this.pendingStart, extra);\n }\n\n /**\n * The audio state a session STARTS in. Counts no change: a player that\n * begins muted (the autoplay-friendly default) has had nothing done to\n * it, and reporting that as a mute change would put a phantom 1 on every\n * session and destroy the \"did the viewer touch the volume\" signal.\n */\n seedVolume(muted: boolean, volume: number): void {\n if (this.endedFlag) return;\n this.isMuted = muted || volume === 0;\n if (this.isMuted && this.isPlaying && this.mutedSinceMs === null) {\n this.mutedSinceMs = this.now();\n }\n this.syncActive();\n }\n\n /** Volume or mute changed on the media element. */\n noteVolume(muted: boolean, volume: number): void {\n if (this.endedFlag) return;\n const silent = muted || volume === 0;\n if (silent === this.isMuted) {\n // Same audibility, different level: a volume change, not a mute.\n this.volumeChanges += 1;\n return;\n }\n this.muteChanges += 1;\n const t = this.now();\n if (silent) {\n // Only while playing: a muted pause is not discountable watch time.\n if (this.isPlaying && this.mutedSinceMs === null) this.mutedSinceMs = t;\n } else {\n if (this.mutedSinceMs !== null) {\n this.mutedTotalMs += t - this.mutedSinceMs;\n this.mutedSinceMs = null;\n }\n // Unmuting mid-playback is when a muted-autoplay session first\n // becomes audible — the only moment that stage can be observed for\n // the dominant startup path, which no state change reports.\n if (this.firstAudioMs === 0 && this.isPlaying) {\n this.firstAudioMs = Math.max(MIN_STAGE_MS, t - this.startupAnchorMs);\n }\n }\n this.isMuted = silent;\n this.syncActive();\n }\n\n /** Fullscreen / picture-in-picture / inline transition. */\n notePresentation(next: Presentation): void {\n if (this.endedFlag || next === this.presentation) return;\n this.closePresentation();\n this.presentation = next;\n }\n\n /**\n * The viewer asked for playback. Re-anchors the startup ladder here, once.\n *\n * Only the autoplay-off path calls this, and only on the first play(): an\n * autoplay session already has intent at construction, and a SECOND call\n * after playback began would rewrite a finished measurement.\n *\n * A stage that already closed is re-stamped PRE-PAID rather than left\n * alone or allowed to go negative. In the deferred path the engine parses\n * the manifest while fetching no media, so manifestLoadMs is genuinely\n * already done when the viewer clicks — it cost them nothing, and\n * MIN_STAGE_MS is how this contract says \"reached, immeasurably fast\".\n * Leaving the old value would keep the poster dwell inside the ladder by\n * another route.\n */\n notePlayIntent(): void {\n if (this.endedFlag || this.startupTimeMs > 0) return;\n this.startupAnchorMs = this.now();\n if (this.manifestLoadMs > 0) this.manifestLoadMs = MIN_STAGE_MS;\n if (this.firstFragLoadMs > 0) this.firstFragLoadMs = MIN_STAGE_MS;\n }\n\n /** Engine 'ready' — closes the manifest-load startup stage. */\n noteReady(): void {\n if (this.manifestLoadMs === 0) {\n this.manifestLoadMs = Math.max(MIN_STAGE_MS, this.now() - this.startupAnchorMs);\n }\n }\n\n /** First media bytes observed — closes the first-segment startup stage. */\n noteFirstBytes(): void {\n this.sawFirstBytes = true;\n if (this.firstFragLoadMs === 0) {\n this.firstFragLoadMs = Math.max(MIN_STAGE_MS, this.now() - this.startupAnchorMs);\n }\n }\n\n noteSeek(): void {\n if (this.endedFlag) return;\n this.seekCount += 1;\n // Remembered so the buffering that usually follows is attributed to the\n // seek rather than reported as a network stall.\n this.lastSeekMs = this.now();\n }\n\n /** Playback error (code from src/core/errors.ts — strings, no lockstep). */\n noteError(\n code: string,\n detail: string | undefined,\n fatal: boolean,\n httpStatus: number,\n positionMs: number,\n /** The ENGINE's own name for the failure, when it supplied one. */\n engineDetail?: string,\n ): void {\n if (this.endedFlag) return;\n this.errorCount += 1;\n this.lastErrorMs = this.now();\n const err: NonNullable<WireBeacon['error']> = { code };\n if (detail) err.detail = detail;\n if (fatal) err.fatal = true;\n if (httpStatus > 0) err.httpStatus = httpStatus;\n if (positionMs > 0) err.positionMs = Math.round(positionMs);\n const stage = this.stageOf(code, detail, engineDetail);\n err.stage = stage;\n this.lastErrorStage = stage;\n const host = this.safeSample()?.cdnHost;\n if (host) err.cdnHost = host;\n this.enqueue({ error: err });\n // Errors shouldn't wait up to 15s to surface fleet-wide.\n this.flush();\n }\n\n /**\n * Which step of the pipeline an error interrupted. Resolve-time codes come\n * from the playback API before any media is touched; the rest are read off\n * the engine's own detail string, which is why the contract carries the\n * stage as an enum instead of leaving every reader to parse that string.\n */\n private stageOf(\n code: string,\n detail: string | undefined,\n engineDetail: string | undefined,\n ): WireErrorStage {\n // The ENGINE's own name for the failure when it gave one. The player's\n // message is composed prose — \"NETWORK error: fragLoadError (...)\" —\n // and staging by pattern-matching that prose is how the single most\n // common fatal error, a fragment load giving up, was staged as OTHER:\n // no anchored pattern can match a string that opens with the error\n // KIND. It also settles DRM: a licence 401 arrives with the code\n // UNAUTHORIZED, which is indistinguishable from a resolve failure by\n // code alone, but the engine calls it keyLoadError.\n const engine = engineDetail ?? '';\n if (engine !== '') {\n // fragLOAD only: a fragment that arrived and would not parse\n // (fragParsingError, fragDecryptError) is a decode failure, not a\n // delivery one, and staging it as a segment problem would send\n // someone to look at the CDN for a codec bug.\n if (/^fragLoad/i.test(engine)) {\n return this.sawFirstBytes ? 'ERROR_STAGE_SEGMENT' : 'ERROR_STAGE_FIRST_SEGMENT';\n }\n if (/^(manifest|level|audioTrack|subtitleTrack)/i.test(engine)) {\n return 'ERROR_STAGE_MANIFEST';\n }\n if (/key|licen[cs]e/i.test(engine)) return 'ERROR_STAGE_DRM';\n if (/parsing|append|buffer|remux|decrypt|media/i.test(engine)) return 'ERROR_STAGE_DECODE';\n return 'ERROR_STAGE_OTHER';\n }\n\n // No engine detail: the failure did not come from a running engine, so\n // the resolve stage is reachable here and nowhere else.\n if (code === 'DRM_CLIENT') return 'ERROR_STAGE_DRM';\n if (RESOLVE_CODES.has(code)) return 'ERROR_STAGE_RESOLVE';\n const d = detail ?? '';\n if (/frag|segment/i.test(d)) {\n return this.sawFirstBytes ? 'ERROR_STAGE_SEGMENT' : 'ERROR_STAGE_FIRST_SEGMENT';\n }\n if (/manifest|level|playlist/i.test(d)) return 'ERROR_STAGE_MANIFEST';\n if (/key|licen[cs]e/i.test(d)) return 'ERROR_STAGE_DRM';\n if (/buffer|decode|media|append/i.test(d)) return 'ERROR_STAGE_DECODE';\n return 'ERROR_STAGE_OTHER';\n }\n\n /** The 'ended' player state maps here with reason ENDED. */\n noteEnded(): void {\n this.end('END_REASON_ENDED');\n }\n\n /** Player torn down by the embedding page. */\n noteDestroyed(): void {\n this.end('END_REASON_STOPPED');\n }\n\n /** Fatal error path. */\n noteFatal(): void {\n this.end('END_REASON_FATAL_ERROR');\n }\n\n private heartbeat(): void {\n if (this.endedFlag) return;\n const t = this.now();\n // The first tick after a suspension ends the session instead of\n // reporting an interval that spans the whole gap.\n if (this.checkSuspension(t)) return;\n const s = this.opts.sample();\n\n // Close open accounting windows into the totals without ending them.\n let playing = this.playingTotalMs;\n if (this.playingSinceMs !== null) playing += t - this.playingSinceMs;\n let hidden = this.hiddenTotalMs;\n if (this.hiddenSinceMs !== null) hidden += t - this.hiddenSinceMs;\n // Stall, per interval. bufferingSinceMs is opened and closed by\n // syncBuffering, which holds it ONLY while the stall is visible and not\n // seek-induced — so both exclusions come for free here and, more to the\n // point, cannot drift from the session totals that share that window.\n // A stall spanning two heartbeats is split across them and counted once.\n let rebuffer = this.rebufferTotalMs;\n if (this.bufferingSinceMs !== null) rebuffer += t - this.bufferingSinceMs;\n // Active watch (playing AND visible AND unmuted), per interval, off the\n // window syncActive already maintains. MEASURED, not derived: the server\n // has been reconstructing this as playing - hidden - muted, and those\n // three overlap, so an interval that was both hidden and muted is\n // subtracted twice. The overlap is not recoverable after the fact, which\n // is why it has to be observed here rather than computed there.\n let active = this.activeTotalMs;\n if (this.activeSinceMs !== null) active += t - this.activeSinceMs;\n\n const hb: Record<string, number | string | boolean> = {};\n const bytes = s.bytesLoaded ?? 0;\n if (bytes > this.lastBytes) hb.bytesDownloadedDelta = bytes - this.lastBytes;\n this.lastBytes = bytes;\n const pl = s.playlistBytesLoaded ?? 0;\n if (pl > this.lastPlaylistBytes) hb.playlistBytesDelta = pl - this.lastPlaylistBytes;\n this.lastPlaylistBytes = pl;\n const dropped = s.droppedFrames ?? 0;\n if (dropped > this.lastDropped) hb.droppedFramesDelta = dropped - this.lastDropped;\n this.lastDropped = dropped;\n const switches = s.renditionSwitches ?? 0;\n if (switches > this.lastSwitches) hb.renditionSwitchesDelta = switches - this.lastSwitches;\n this.lastSwitches = switches;\n if (playing > this.lastHbPlayingMs)\n hb.playingMsDelta = Math.round(playing - this.lastHbPlayingMs);\n this.lastHbPlayingMs = playing;\n if (hidden > this.lastHbHiddenMs) hb.hiddenMsDelta = Math.round(hidden - this.lastHbHiddenMs);\n this.lastHbHiddenMs = hidden;\n if (rebuffer > this.lastHbRebufferMs) {\n hb.rebufferMsDelta = Math.round(rebuffer - this.lastHbRebufferMs);\n }\n this.lastHbRebufferMs = rebuffer;\n // The COUNT is already gated on the same two exclusions where it is\n // incremented, so this is a plain watermark diff. A stall that opens in\n // one interval and closes in a later one counts in the interval it\n // OPENED, which is where the viewer felt it.\n if (this.rebufferCount > this.lastHbRebufferCount) {\n hb.rebufferCountDelta = this.rebufferCount - this.lastHbRebufferCount;\n }\n this.lastHbRebufferCount = this.rebufferCount;\n // Deltas cover FULL heartbeat intervals only — end() emits no final\n // heartbeat, so a sub-interval tail after the last tick is carried by\n // the sessionEnd totals alone. sum(activeMsDelta) therefore reconciles\n // with activeWatchTimeMs through the last tick; the remainder is always\n // < heartbeatMs. Same policy as every other per-interval figure above.\n if (active > this.lastHbActiveMs) hb.activeMsDelta = Math.round(active - this.lastHbActiveMs);\n this.lastHbActiveMs = active;\n\n if (typeof s.latencySeconds === 'number') hb.latencyMs = Math.round(s.latencySeconds * 1000);\n if (typeof s.targetLatencySeconds === 'number') {\n hb.targetLatencyMs = Math.round(s.targetLatencySeconds * 1000);\n }\n if (typeof s.bandwidthKbps === 'number') hb.bandwidthEstKbps = s.bandwidthKbps;\n if (typeof s.levelHeight === 'number') hb.renditionHeight = s.levelHeight;\n if (typeof s.renditionBitrateKbps === 'number') {\n hb.renditionBitrateKbps = s.renditionBitrateKbps;\n }\n if (s.atLiveEdge === true) hb.atLiveEdge = true;\n if (typeof s.bufferedMs === 'number' && s.bufferedMs > 0) {\n hb.bufferedMs = Math.round(s.bufferedMs);\n }\n hb.playerState = STATE_TO_WIRE[s.state] ?? 'PLAYER_STATE_UNSPECIFIED';\n\n // ---- Contract v2.\n let muted = this.mutedTotalMs;\n if (this.mutedSinceMs !== null) muted += t - this.mutedSinceMs;\n if (muted > this.lastHbMutedMs) hb.mutedMsDelta = Math.round(muted - this.lastHbMutedMs);\n this.lastHbMutedMs = muted;\n\n const up = s.upshifts ?? 0;\n if (up > this.lastUpshifts) hb.upshiftsDelta = up - this.lastUpshifts;\n this.lastUpshifts = up;\n const down = s.downshifts ?? 0;\n if (down > this.lastDownshifts) hb.downshiftsDelta = down - this.lastDownshifts;\n this.lastDownshifts = down;\n const decoded = s.decodedFrames ?? 0;\n if (decoded > this.lastDecoded) hb.decodedFramesDelta = decoded - this.lastDecoded;\n this.lastDecoded = decoded;\n const reqErrors = s.requestErrors ?? 0;\n if (reqErrors > this.lastRequestErrors) {\n hb.requestErrorsDelta = reqErrors - this.lastRequestErrors;\n }\n this.lastRequestErrors = reqErrors;\n\n // Request timing: the engine keeps a ring of recent durations and a\n // cumulative count, so the tail this interval added is exactly the\n // slice to summarize — no per-request beacon, no unbounded array.\n const reqCount = s.requestCount ?? 0;\n const reqDelta = reqCount - this.lastRequestCount;\n this.lastRequestCount = reqCount;\n if (reqDelta > 0) {\n hb.requestCountDelta = reqDelta;\n const times = s.requestTimesMs ?? [];\n const recent = times.slice(Math.max(0, times.length - reqDelta)).sort((a, b) => a - b);\n if (recent.length > 0) {\n hb.requestTimeP50Ms = percentile(recent, 50);\n hb.requestTimeP95Ms = percentile(recent, 95);\n }\n }\n\n if (typeof s.latencySeconds === 'number' && typeof s.targetLatencySeconds === 'number') {\n // Signed: behind the target is positive, ahead of it negative.\n hb.driftMs = Math.round((s.latencySeconds - s.targetLatencySeconds) * 1000);\n }\n if (typeof s.playbackRate === 'number' && s.playbackRate > 0) {\n hb.playbackRate = s.playbackRate;\n }\n if (s.qualityPinned === true) hb.qualityPinned = true;\n if (s.cdnHost) hb.cdnHost = s.cdnHost;\n\n this.maybeEmitMediaInfo(s);\n this.enqueue({ heartbeat: hb });\n this.flush();\n\n if (bytes > 0) this.noteFirstBytes();\n }\n\n /**\n * MediaInfo once per session, as soon as the engine has negotiated what it\n * will play. SessionStart cannot carry this: it is emitted at engine\n * selection, before the manifest is parsed or a DRM session exists.\n */\n private maybeEmitMediaInfo(s: AnalyticsSample): void {\n const known = s.videoCodec ?? s.keySystem ?? s.ladderHeights;\n if (!known) return;\n const info: NonNullable<WireBeacon['mediaInfo']> = {};\n if (s.videoCodec) info.videoCodec = s.videoCodec;\n if (s.audioCodec) info.audioCodec = s.audioCodec;\n if (s.keySystem) info.keySystem = KEY_SYSTEM_TO_WIRE[s.keySystem];\n if (s.drmSecurityLevel) info.drmSecurityLevel = s.drmSecurityLevel;\n if (s.ladderHeights?.length) info.ladderHeights = s.ladderHeights;\n if (s.ladderTopBitrateKbps) info.ladderTopBitrateKbps = s.ladderTopBitrateKbps;\n if (s.lowLatency) info.lowLatency = true;\n // The contract says MediaInfo is emitted again if the negotiated facts\n // change (an audio-track switch changes the codec; a rebuilt engine can\n // change the ladder), so this compares rather than firing once. A\n // session whose media never changes still sends exactly one.\n const signature = JSON.stringify(info);\n if (signature === this.mediaInfoSignature) return;\n this.mediaInfoSignature = signature;\n this.enqueue({ mediaInfo: info });\n }\n\n /**\n * End the session exactly once: totals beacon + final flush. On pagehide\n * the flush rides navigator.sendBeacon — the only delivery a closing page\n * guarantees.\n */\n end(reason: WireEndReason, at?: number): void {\n if (this.endedFlag) return;\n this.endedFlag = true;\n // `at`: the instant the session is closed AS OF. Only the suspension\n // path passes one — the last tick before the page stopped — so a slept\n // laptop closes every open window (playing, active, muted, hidden) there\n // and books none of the sleep.\n const t = at ?? this.now();\n if (this.playingSinceMs !== null) {\n this.playingTotalMs += t - this.playingSinceMs;\n this.playingSinceMs = null;\n }\n if (this.hiddenSinceMs !== null) {\n this.hiddenTotalMs += t - this.hiddenSinceMs;\n this.hiddenSinceMs = null;\n }\n if (this.bufferingSinceMs !== null) {\n this.rebufferTotalMs += t - this.bufferingSinceMs;\n this.bufferingSinceMs = null;\n }\n if (this.mutedSinceMs !== null) {\n this.mutedTotalMs += t - this.mutedSinceMs;\n this.mutedSinceMs = null;\n }\n if (this.waitingSinceMs !== null) {\n this.waitingTotalMs += t - this.waitingSinceMs;\n this.waitingSinceMs = null;\n }\n this.isPlaying = false;\n // At `t`, like every window above: the active-watch and presentation\n // windows are the two that read the clock themselves.\n this.syncActive(t);\n this.closePresentation(t);\n if (this.firstFlushTimer) {\n clearTimeout(this.firstFlushTimer);\n this.firstFlushTimer = null;\n }\n if (this.timer) {\n clearInterval(this.timer);\n this.timer = null;\n }\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n if (typeof window !== 'undefined') {\n window.removeEventListener('pagehide', this.onPagehide);\n }\n if (typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this.onVisibility);\n document.removeEventListener('freeze', this.onFreeze);\n document.removeEventListener('resume', this.onResume);\n }\n\n const s = this.safeSample();\n // A session that ends before its first heartbeat — an early error, a\n // quick bounce, a short preview — would otherwise never report what it\n // was playing, even though the engine has known since the manifest\n // parsed.\n if (s) this.maybeEmitMediaInfo(s);\n const endTotals: Record<string, number | string> = {\n reason,\n };\n const bytes = s?.bytesLoaded ?? this.lastBytes;\n if (bytes > 0) endTotals.bytesDownloadedTotal = bytes;\n if (this.playingTotalMs > 0) endTotals.watchTimeMs = Math.round(this.playingTotalMs);\n if (this.rebufferCount > 0) endTotals.rebufferCount = this.rebufferCount;\n if (this.rebufferTotalMs > 0) endTotals.rebufferTimeMs = Math.round(this.rebufferTotalMs);\n if (this.startupTimeMs > 0) endTotals.startupTimeMs = Math.round(this.startupTimeMs);\n if (this.manifestLoadMs > 0) endTotals.manifestLoadMs = Math.round(this.manifestLoadMs);\n if (this.firstFragLoadMs > 0) endTotals.firstFragLoadMs = Math.round(this.firstFragLoadMs);\n const license = s?.licenseTimeMs ?? 0;\n if (license > 0) endTotals.licenseTimeMs = Math.round(license);\n const dropped = s?.droppedFrames ?? this.lastDropped;\n if (dropped > 0) endTotals.droppedFramesTotal = dropped;\n if (this.errorCount > 0) endTotals.errorCount = this.errorCount;\n if (this.hiddenTotalMs > 0) endTotals.hiddenTimeMs = Math.round(this.hiddenTotalMs);\n if (this.seekCount > 0) endTotals.seekCount = this.seekCount;\n if (this.pauseCount > 0) endTotals.pauseCount = this.pauseCount;\n\n // ---- Contract v2 totals.\n if (this.firstAudioMs > 0) endTotals.firstAudioMs = Math.round(this.firstAudioMs);\n if (this.waitingTotalMs > 0) endTotals.waitingForLiveMs = Math.round(this.waitingTotalMs);\n if (this.timeToFirstStallMs > 0) {\n endTotals.timeToFirstStallMs = Math.round(this.timeToFirstStallMs);\n }\n const exit = s?.positionMs ?? 0;\n if (exit > 0) endTotals.exitPositionMs = Math.round(exit);\n if (this.activeTotalMs > 0) endTotals.activeWatchTimeMs = Math.round(this.activeTotalMs);\n if (this.mutedTotalMs > 0) endTotals.mutedTimeMs = Math.round(this.mutedTotalMs);\n // Abandon cohorts: only when the session actually ended NEAR the event,\n // so an unrelated stall an hour earlier never reads as an abandonment.\n // Not on a clean end: a viewer who stalled, recovered and watched to\n // the finish has not abandoned anything.\n const abandoned = reason !== 'END_REASON_ENDED';\n if (abandoned && this.lastStallMs > 0 && t - this.lastStallMs <= ABANDON_WINDOW_MS) {\n endTotals.abandonAfterStallMs = Math.max(MIN_STAGE_MS, Math.round(t - this.lastStallMs));\n }\n if (abandoned && this.lastErrorMs > 0 && t - this.lastErrorMs <= ABANDON_WINDOW_MS) {\n endTotals.abandonAfterErrorMs = Math.max(MIN_STAGE_MS, Math.round(t - this.lastErrorMs));\n }\n if (this.fullscreenTotalMs > 0) {\n endTotals.fullscreenTimeMs = Math.round(this.fullscreenTotalMs);\n }\n if (this.pipTotalMs > 0) endTotals.pipTimeMs = Math.round(this.pipTotalMs);\n if (this.volumeChanges > 0) endTotals.volumeChanges = this.volumeChanges;\n if (this.muteChanges > 0) endTotals.muteChanges = this.muteChanges;\n const decodedTotal = s?.decodedFrames ?? this.lastDecoded;\n if (decodedTotal > 0) endTotals.decodedFramesTotal = decodedTotal;\n // Only for a session that never played: which stage it died at. The last\n // error's stage when there was one, otherwise the first startup stage\n // that never closed.\n if (this.startupTimeMs === 0) {\n endTotals.failedStage =\n this.lastErrorStage ??\n (this.manifestLoadMs === 0\n ? 'ERROR_STAGE_MANIFEST'\n : this.firstFragLoadMs === 0\n ? 'ERROR_STAGE_FIRST_SEGMENT'\n : 'ERROR_STAGE_OTHER');\n }\n\n this.pending.push(this.wrap({ sessionEnd: endTotals }));\n this.flush(reason === 'END_REASON_PAGEHIDE');\n }\n\n private safeSample(): AnalyticsSample | null {\n try {\n return this.opts.sample();\n } catch {\n return null; // engine already torn down — totals fall back to last-seen\n }\n }\n\n private wrap(event: Omit<WireBeacon, 'sid' | 'clientTime' | 'seq'>): WireBeacon {\n this.seq += 1;\n return {\n sid: this.opts.sid,\n clientTime: new Date(this.now()).toISOString(),\n seq: this.seq,\n ...event,\n };\n }\n\n private enqueue(event: Omit<WireBeacon, 'sid' | 'clientTime' | 'seq'>): void {\n this.pending.push(this.wrap(event));\n while (this.pending.length > MAX_PENDING) {\n // Evict the oldest routine beacon (heartbeat/state change) first;\n // lifecycle and error beacons carry the session's story and only go\n // when nothing routine is left to shed.\n const i = this.pending.findIndex((b) => b.heartbeat || b.stateChange);\n this.pending.splice(i >= 0 ? i : 0, 1);\n }\n }\n\n private flush(useSendBeacon = false): void {\n if (this.firstFlushTimer) {\n clearTimeout(this.firstFlushTimer);\n this.firstFlushTimer = null;\n }\n this.pendingStart = null;\n if (this.pending.length === 0) return;\n // The batch carries the beacons and nothing else. No api key rides a\n // beacon in any form — not as a header, not in the envelope: the\n // ingest has no origin gate to resolve it against (the origin was\n // enforced by havik-streams when it minted the sid, and the ingest's\n // reads join the mints), and a publishable key is never an identity.\n // What identifies the batch is the sid on every beacon.\n const body = JSON.stringify({ beacons: this.pending });\n this.pending = [];\n\n if (useSendBeacon) {\n // Deliberately a STRING body: sendBeacon with a string posts\n // text/plain, which is CORS-safelisted — no preflight, and a\n // preflight never completes on an unloading page. A Blob typed\n // application/json would trigger one and lose the final batch. The\n // ingest parses the body regardless of Content-Type by design (D9).\n const send =\n this.opts.sendBeacon ??\n (typeof navigator !== 'undefined' && navigator.sendBeacon\n ? navigator.sendBeacon.bind(navigator)\n : undefined);\n if (send && send(this.url, body)) return;\n // sendBeacon refused (quota) — fall through to keepalive fetch, which\n // may or may not complete on an unloading page; acceptable loss.\n }\n const doFetch = this.opts.fetchFn ?? (typeof fetch !== 'undefined' ? fetch : undefined);\n if (!doFetch) return;\n void doFetch(this.url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body,\n keepalive: true,\n }).catch(() => {\n // Fire-and-forget: a failed batch is dropped by design.\n });\n }\n}\n","// Session environment for the QoE plane (contract v2, SessionStart): the\n// IP-free facts about where the player runs, read once at session start.\n//\n// Model and OS version come from UA client hints ONLY — the high-entropy\n// values a browser is willing to hand over, on request. The raw user agent\n// string is never read or sent from here: the ingest already derives\n// browser family and major from the transport header and drops it (plan\n// D11), and parsing it a second time in the client would put a\n// fingerprint-grade string on the wire to no benefit. On a browser without\n// client hints (Safari, Firefox) both fields are simply absent.\n\nexport type DeviceClass =\n | 'DEVICE_CLASS_PHONE'\n | 'DEVICE_CLASS_TABLET'\n | 'DEVICE_CLASS_DESKTOP'\n | 'DEVICE_CLASS_TV'\n | 'DEVICE_CLASS_OTHER';\nexport type ConnectionType =\n | 'CONNECTION_TYPE_UNKNOWN'\n | 'CONNECTION_TYPE_WIFI'\n | 'CONNECTION_TYPE_CELLULAR'\n | 'CONNECTION_TYPE_ETHERNET'\n | 'CONNECTION_TYPE_OTHER';\n/**\n * The generation of the access network, separate from the bearer above.\n *\n * The web can only ever report the ESTIMATED_ half. `effectiveType` is not\n * a radio reading: it names the cellular generation whose typical\n * performance the connection RESEMBLES, derived from observed rtt and\n * downlink. A fast WiFi link reports '4g', which is exactly why these\n * values may not share a value space with the measured ones the native\n * SDKs send — merged, every Chrome-desktop WiFi session would be charted as\n * cellular traffic that does not exist.\n *\n * There is no ESTIMATED_5G: the API is specified to top out at '4g'. That\n * is a ceiling, not a mapping — a congested 5G link reports '3g', because\n * the value follows throughput rather than the radio.\n */\nexport type RadioAccess =\n | 'RADIO_ACCESS_ESTIMATED_SLOW_2G'\n | 'RADIO_ACCESS_ESTIMATED_2G'\n | 'RADIO_ACCESS_ESTIMATED_3G'\n | 'RADIO_ACCESS_ESTIMATED_4G';\nexport type IntegrationKind = 'INTEGRATION_KIND_SDK' | 'INTEGRATION_KIND_IFRAME';\n\n/**\n * Where the player is embedded, and how.\n *\n * Not simply location.origin: inside an iframe that is the PLAYER's own\n * origin, which is the same constant for every hosted-embed session and\n * answers a question nobody asked. The embedding page is what a customer\n * means by \"where\", so a framed player reports the top-level ancestor —\n * from ancestorOrigins where the browser has it, else the referrer's\n * origin — and says so with INTEGRATION_KIND_IFRAME.\n *\n * A cross-origin embedder that reveals neither leaves the host empty\n * rather than substituting the player's own origin: a wrong attribution\n * is worse than a missing one.\n */\nfunction embedding(): { host?: string; integration: IntegrationKind } {\n if (typeof window === 'undefined') return { integration: 'INTEGRATION_KIND_SDK' };\n let framed: boolean;\n try {\n framed = window.top !== window.self;\n } catch {\n // A cross-origin ancestor can make even the comparison throw, and that\n // it threw is itself proof of being framed.\n framed = true;\n }\n const own = window.location?.origin;\n if (!framed) {\n return { host: own && own !== 'null' ? own : undefined, integration: 'INTEGRATION_KIND_SDK' };\n }\n let host: string | undefined;\n try {\n const ancestors = (window.location as Location & { ancestorOrigins?: DOMStringList })\n .ancestorOrigins;\n // The LAST entry is the top-level document — the page the viewer is\n // actually on; [0] would be an intermediate wrapper frame.\n if (ancestors && ancestors.length > 0) host = ancestors[ancestors.length - 1] ?? undefined;\n if (!host && typeof document !== 'undefined' && document.referrer) {\n host = new URL(document.referrer).origin;\n }\n } catch {\n host = undefined;\n }\n if (host === 'null') host = undefined;\n return { host, integration: 'INTEGRATION_KIND_IFRAME' };\n}\n\nexport interface SessionEnvironment {\n deviceClass: DeviceClass;\n /** Marketing model name, e.g. \"Pixel 8\". Empty on desktop and wherever\n * client hints are unavailable. */\n deviceModel?: string;\n /** Platform version as the browser reports it, e.g. \"14.0.0\". */\n osVersion?: string;\n screenWidth?: number;\n screenHeight?: number;\n devicePixelRatio?: number;\n viewportWidth?: number;\n viewportHeight?: number;\n connectionType: ConnectionType;\n /** The generation, INDEPENDENT of connectionType: Chrome on Android sets\n * both, Chrome on desktop sets only this one, Safari and Firefox set\n * neither. Absent when the browser reports no effectiveType. */\n radioAccess?: RadioAccess;\n downlinkKbps?: number;\n /** Round-trip estimate the browser reports, beside downlinkKbps. Absent\n * when unreported; never sent as 0, which would read as \"measured, and\n * instant\". */\n rttMs?: number;\n saveData?: boolean;\n /** The page the player is embedded in: the document's own origin when the\n * SDK runs top-level, the TOP-LEVEL ancestor when it runs framed. Absent\n * when a cross-origin embedder reveals neither. */\n host?: string;\n integration: IntegrationKind;\n}\n\ninterface UAData {\n mobile?: boolean;\n platform?: string;\n getHighEntropyValues?: (hints: string[]) => Promise<{ model?: string; platformVersion?: string }>;\n}\ninterface NetInfo {\n type?: string;\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\n/** Coarse form factor from UA client hints where they exist, else from the viewport. */\nfunction deviceClass(): DeviceClass {\n if (typeof navigator === 'undefined') return 'DEVICE_CLASS_OTHER';\n const ua = (navigator as Navigator & { userAgentData?: UAData }).userAgentData;\n const platform = ua?.platform ?? '';\n if (/tv|tizen|webos/i.test(platform)) return 'DEVICE_CLASS_TV';\n const shortSide = typeof screen !== 'undefined' ? Math.min(screen.width, screen.height) : 0;\n const mobile =\n ua?.mobile ??\n (typeof navigator.maxTouchPoints === 'number' &&\n navigator.maxTouchPoints > 1 &&\n shortSide > 0 &&\n shortSide < 900);\n if (mobile) return shortSide >= 600 ? 'DEVICE_CLASS_TABLET' : 'DEVICE_CLASS_PHONE';\n if (ua || shortSide >= 900 || /mac|win|linux|chrome os/i.test(platform))\n return 'DEVICE_CLASS_DESKTOP';\n return 'DEVICE_CLASS_OTHER';\n}\n\n/**\n * The Network Information API, read as TWO independent facts.\n *\n * `type` is the bearer and `effectiveType` is the generation estimate, and\n * a browser may report either, both, or neither — they are not fallbacks\n * for one another. Chrome on Android sets both; Chrome on DESKTOP sets\n * `effectiveType`, `downlink` and `rtt` but leaves `type` undefined; Safari\n * and Firefox implement none of it. Reading only `type` therefore threw\n * away a populated reading on every Chrome-desktop session and reported\n * CONNECTION_TYPE_UNKNOWN beside a perfectly good downlink figure.\n *\n * The two are mapped into two fields rather than one: a WiFi session whose\n * effectiveType is '4g' is WIFI and ESTIMATED_4G, never 4G. See RadioAccess.\n */\nfunction connection(): Pick<\n SessionEnvironment,\n 'connectionType' | 'radioAccess' | 'downlinkKbps' | 'rttMs' | 'saveData'\n> {\n const c =\n typeof navigator !== 'undefined'\n ? (navigator as Navigator & { connection?: NetInfo }).connection\n : undefined;\n if (!c) return { connectionType: 'CONNECTION_TYPE_UNKNOWN' };\n const map: Record<string, ConnectionType> = {\n wifi: 'CONNECTION_TYPE_WIFI',\n cellular: 'CONNECTION_TYPE_CELLULAR',\n ethernet: 'CONNECTION_TYPE_ETHERNET',\n };\n // The four values the spec defines, and only those: an unrecognised\n // string is left ABSENT rather than mapped to a nearest neighbour, so a\n // future spec value cannot silently land in the wrong bucket.\n const radio: Record<string, RadioAccess> = {\n 'slow-2g': 'RADIO_ACCESS_ESTIMATED_SLOW_2G',\n '2g': 'RADIO_ACCESS_ESTIMATED_2G',\n '3g': 'RADIO_ACCESS_ESTIMATED_3G',\n '4g': 'RADIO_ACCESS_ESTIMATED_4G',\n };\n // Own-property lookups, not a bare index. `navigator.connection` is\n // replaceable by the embedding page, so these strings are not guaranteed\n // to be the spec's — and a bare `radio['constructor']` resolves to an\n // INHERITED property of Object.prototype, which is truthy. That would put\n // a non-enum value on the beacon and break the rule directly above: an\n // unrecognised reading stays ABSENT.\n //\n // hasOwnProperty.call, not Object.hasOwn: the global build targets\n // firefox91 and safari15 (vite.global.config.ts), and Object.hasOwn needs\n // Firefox 92 / Safari 15.4. Vite downlevels syntax but does not polyfill\n // built-in methods, so it would be undefined there — and the page that\n // defines its own navigator.connection, which is the whole reason for\n // this guard, is exactly the case that would then throw out of session\n // start.\n const own = <T>(table: Record<string, T>, key: string): T | undefined =>\n Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;\n const out: ReturnType<typeof connection> = {\n connectionType: c.type\n ? (own(map, c.type) ?? 'CONNECTION_TYPE_OTHER')\n : 'CONNECTION_TYPE_UNKNOWN',\n };\n if (c.effectiveType) {\n const r = own(radio, c.effectiveType);\n if (r) out.radioAccess = r;\n }\n if (typeof c.downlink === 'number' && c.downlink > 0)\n out.downlinkKbps = Math.round(c.downlink * 1000);\n // Same guard as downlink: an unreported rtt must stay absent, not become\n // a measured zero.\n if (typeof c.rtt === 'number' && c.rtt > 0) out.rttMs = Math.round(c.rtt);\n if (c.saveData) out.saveData = true;\n return out;\n}\n\nexport function collectEnvironment(video: HTMLVideoElement): SessionEnvironment {\n const where = embedding();\n const env: SessionEnvironment = {\n deviceClass: deviceClass(),\n integration: where.integration,\n ...connection(),\n };\n if (where.host) env.host = where.host;\n if (typeof screen !== 'undefined' && screen.width > 0) {\n env.screenWidth = screen.width;\n env.screenHeight = screen.height;\n }\n if (typeof window !== 'undefined' && window.devicePixelRatio > 0) {\n env.devicePixelRatio = Math.round(window.devicePixelRatio * 100) / 100;\n }\n const rect = video.getBoundingClientRect?.();\n if (rect && rect.width > 0) {\n env.viewportWidth = Math.round(rect.width);\n env.viewportHeight = Math.round(rect.height);\n }\n return env;\n}\n\n/**\n * The two high-entropy hints, which the API only exposes asynchronously.\n * Resolves from data the browser already holds, so it settles in well under\n * the window SessionStart waits before it goes out; a browser that declines\n * or lacks the API yields an empty object and the fields stay absent.\n */\nexport async function collectHighEntropy(): Promise<\n Pick<SessionEnvironment, 'deviceModel' | 'osVersion'>\n> {\n const ua =\n typeof navigator !== 'undefined'\n ? (navigator as Navigator & { userAgentData?: UAData }).userAgentData\n : undefined;\n if (!ua?.getHighEntropyValues) return {};\n try {\n const v = await ua.getHighEntropyValues(['model', 'platformVersion']);\n const out: Pick<SessionEnvironment, 'deviceModel' | 'osVersion'> = {};\n if (v.model) out.deviceModel = v.model;\n if (v.platformVersion) out.osVersion = v.platformVersion;\n return out;\n } catch {\n return {};\n }\n}\n","// SDK version for the analytics SessionStart beacon (build-time injected).\n//\n// `__HAVIK_SDK_VERSION__` is defined by the vite configs from\n// npm_package_version at build time; `typeof` on an undeclared identifier is\n// safe, so test runners and consumers that bypass the define get 'dev'.\n// Fidelity note: the value is only as good as package.json at build time —\n// semantic-release stamps it during the release pipeline, local builds say\n// whatever the placeholder version is.\ndeclare const __HAVIK_SDK_VERSION__: string | undefined;\n\nexport const SDK_VERSION: string =\n typeof __HAVIK_SDK_VERSION__ === 'string' ? __HAVIK_SDK_VERSION__ : 'dev';\n","// Player theming. The control bar is styled entirely from CSS custom properties\n// (--havik-*), so a host can re-skin it by passing a `theme` to createPlayer OR\n// by overriding the variables in their own stylesheet targeting `.havik-player`.\n\nexport interface HavikTheme {\n /** Primary accent (play button, progress fill, live dot, focus). */\n accent: string;\n /** Text/icon color that sits ON the accent (e.g. on the play button). */\n accentText: string;\n /** Letterbox / behind-video background. */\n background: string;\n /** Control bar + menu surface color. */\n surface: string;\n /** Hover/active surface color. */\n surfaceMuted: string;\n /** Primary text/icon color. */\n text: string;\n /** Secondary text color (timestamps, inactive). */\n textMuted: string;\n /** Hairline borders. */\n border: string;\n /** Corner radius for buttons/menus. */\n radius: string;\n /** Font stack for control-bar text. */\n fontFamily: string;\n /** Optional logo shown as a small corner watermark (URL). */\n logoUrl?: string;\n}\n\n/**\n * Default skin — Oddin.gg brand identity: gold accent on dark navy, white text.\n */\nexport const ODDIN_THEME: HavikTheme = {\n accent: '#E1B600',\n accentText: '#1B1C23',\n background: '#0e0f13',\n surface: '#1B1C23',\n surfaceMuted: '#2a2c36',\n text: '#ffffff',\n textMuted: '#9aa0ad',\n border: 'rgba(255, 255, 255, 0.1)',\n radius: '8px',\n fontFamily:\n \"ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif\",\n};\n\nconst VAR: Record<keyof Omit<HavikTheme, 'logoUrl'>, string> = {\n accent: '--havik-accent',\n accentText: '--havik-accent-text',\n background: '--havik-bg',\n surface: '--havik-surface',\n surfaceMuted: '--havik-surface-muted',\n text: '--havik-text',\n textMuted: '--havik-text-muted',\n border: '--havik-border',\n radius: '--havik-radius',\n fontFamily: '--havik-font',\n};\n\n/** Apply a (possibly partial) theme to a player root element as CSS variables. */\nexport function applyTheme(root: HTMLElement, theme?: Partial<HavikTheme>): void {\n const merged: HavikTheme = { ...ODDIN_THEME, ...theme };\n for (const key of Object.keys(VAR) as Array<keyof typeof VAR>) {\n root.style.setProperty(VAR[key], merged[key]);\n }\n if (merged.logoUrl) root.style.setProperty('--havik-logo', `url(\"${merged.logoUrl}\")`);\n}\n","// Control-bar styles, injected once. Every color/radius/font reads from a\n// --havik-* CSS variable (set by applyTheme), so the whole skin is themeable.\n\nconst STYLE_ID = 'havik-player-styles';\n\nconst CSS = `\n.havik-player{position:relative;display:block;width:100%;background:var(--havik-bg,#0e0f13);\n color:var(--havik-text,#fff);font-family:var(--havik-font,system-ui,sans-serif);\n overflow:hidden;line-height:1.4;-webkit-tap-highlight-color:transparent}\n.havik-player video{display:block;width:100%;height:auto;aspect-ratio:16/9;\n object-fit:contain;background:#000;outline:none}\n.havik-player:focus{outline:none}\n.havik-player *{box-sizing:border-box}\n\n.havik-player:fullscreen,.havik-player:-webkit-full-screen{display:flex;align-items:center;\n justify-content:center;width:100vw;height:100vh;aspect-ratio:auto}\n.havik-player:fullscreen video,.havik-player:-webkit-full-screen video{width:100%;height:100%;\n aspect-ratio:auto;object-fit:contain}\n\n.havik-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;cursor:pointer}\n.havik-gradient{position:absolute;left:0;right:0;bottom:0;height:120px;pointer-events:none;\n background:linear-gradient(to top,rgba(0,0,0,.78),transparent);\n opacity:1;transition:opacity .25s}\n.havik-player.havik-hide .havik-gradient,\n.havik-player.havik-hide .havik-bar{opacity:0;pointer-events:none}\n.havik-player.havik-hide{cursor:none}\n\n.havik-center{width:74px;height:74px;border-radius:50%;border:0;cursor:pointer;\n background:var(--havik-accent,#E1B600);color:var(--havik-accent-text,#1B1C23);\n display:flex;align-items:center;justify-content:center;\n box-shadow:0 6px 24px rgba(0,0,0,.45);transition:transform .15s,opacity .15s}\n.havik-center:hover{transform:scale(1.06)}\n.havik-center svg{width:38px;height:38px;margin-left:2px}\n.havik-center.havik-gone{opacity:0;pointer-events:none;transform:scale(.8)}\n\n.havik-spinner{width:56px;height:56px;border-radius:50%;\n border:4px solid rgba(255,255,255,.2);border-top-color:var(--havik-accent,#E1B600);\n animation:havik-spin .8s linear infinite}\n@keyframes havik-spin{to{transform:rotate(360deg)}}\n\n/* Status card — branded full-bleed overlay for the stopped/pre-play states\n (ended / fatal error / pre-play / armed-waiting). */\n.havik-status{position:absolute;inset:0;display:flex;flex-direction:column;gap:16px;\n align-items:center;justify-content:center;background:rgba(14,15,19,.82);text-align:center;padding:24px}\n.havik-status__logo{width:128px;height:46px;background:var(--havik-logo) center/contain no-repeat;opacity:.95}\n.havik-status__msg{color:var(--havik-text,#fff);font-size:15px;font-weight:600;max-width:480px}\n.havik-status__btn{display:inline-flex;align-items:center;gap:8px;border:0;cursor:pointer;\n padding:10px 18px;border-radius:var(--havik-radius,8px);font-weight:700;font-size:14px;\n background:var(--havik-accent,#E1B600);color:var(--havik-accent-text,#1B1C23);transition:transform .15s}\n.havik-status__btn:hover{transform:scale(1.04)}\n.havik-status__btn svg{width:18px;height:18px}\n/* Pre-play: lighter scrim so the poster shows through, and a round play CTA. */\n.havik-status--preplay{background:rgba(14,15,19,.4)}\n.havik-status--preplay .havik-status__btn--play{width:74px;height:74px;border-radius:50%;padding:0;\n justify-content:center;box-shadow:0 6px 24px rgba(0,0,0,.45)}\n.havik-status--preplay .havik-status__btn--play svg{width:38px;height:38px;margin-left:3px}\n\n.havik-watermark{position:absolute;top:12px;right:14px;height:22px;opacity:.85;pointer-events:none;\n content:'';background:var(--havik-logo) center/contain no-repeat;width:90px}\n/* A status card carries its own logo — the corner watermark would duplicate it. */\n.havik-player.havik-has-status .havik-watermark{display:none}\n\n.havik-bar{position:absolute;left:0;right:0;bottom:0;padding:0 12px 8px;\n opacity:1;transition:opacity .25s;z-index:2}\n.havik-seek{position:relative;height:16px;display:flex;align-items:center;cursor:pointer;margin-bottom:2px}\n.havik-seek__track{position:relative;width:100%;height:4px;border-radius:3px;background:rgba(255,255,255,.25)}\n.havik-seek__buffered{position:absolute;left:0;top:0;height:100%;border-radius:3px;background:rgba(255,255,255,.4)}\n.havik-seek__played{position:absolute;left:0;top:0;height:100%;border-radius:3px;background:var(--havik-accent,#E1B600)}\n.havik-seek__thumb{position:absolute;top:50%;width:13px;height:13px;border-radius:50%;\n background:var(--havik-accent,#E1B600);transform:translate(-50%,-50%) scale(0);transition:transform .1s}\n.havik-seek:hover .havik-seek__thumb{transform:translate(-50%,-50%) scale(1)}\n\n.havik-controls{display:flex;align-items:center;gap:6px;height:40px}\n.havik-spacer{flex:1}\n.havik-btn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;\n border:0;background:transparent;color:var(--havik-text,#fff);cursor:pointer;border-radius:var(--havik-radius,8px);\n padding:0;transition:background .12s,color .12s}\n.havik-btn:hover{background:rgba(255,255,255,.12)}\n.havik-btn:focus-visible{outline:2px solid var(--havik-accent,#E1B600);outline-offset:1px}\n.havik-btn svg{width:22px;height:22px}\n\n/* The volume level is filled in accent up to the thumb, like the seek bar's\n played span — a native range input paints no \"elapsed\" side, which left the\n level readable only from the dot's position. --havik-vol (0..1) is set by\n syncVolume(); the track width and thumb size live here as variables because\n the WebKit fill has to compute the thumb centre from both. */\n.havik-vol{display:flex;align-items:center}\n.havik-vol__slider{--havik-vol-w:70px;--havik-vol-thumb:12px;--havik-vol:1;\n --havik-vol-pos:calc(var(--havik-vol-thumb) / 2 + var(--havik-vol) * (var(--havik-vol-w) - var(--havik-vol-thumb)));\n width:0;opacity:0;transition:width .18s,opacity .18s;height:4px;cursor:pointer;\n appearance:none;-webkit-appearance:none;background:transparent;border-radius:3px;margin:0 4px}\n.havik-vol:hover .havik-vol__slider,.havik-vol:focus-within .havik-vol__slider{width:var(--havik-vol-w);opacity:1}\n/* WebKit/Blink have no progress pseudo-element: paint the fill as a hard-stop\n gradient that ends at the thumb centre. */\n.havik-vol__slider::-webkit-slider-runnable-track{height:4px;border-radius:3px;\n background:linear-gradient(to right,var(--havik-accent,#E1B600) var(--havik-vol-pos),\n rgba(255,255,255,.3) var(--havik-vol-pos))}\n/* Firefox fills natively, and tracks the thumb exactly. */\n.havik-vol__slider::-moz-range-track{height:4px;border-radius:3px;background:rgba(255,255,255,.3)}\n.havik-vol__slider::-moz-range-progress{height:4px;border-radius:3px;background:var(--havik-accent,#E1B600)}\n.havik-vol__slider::-webkit-slider-thumb{-webkit-appearance:none;width:var(--havik-vol-thumb);\n height:var(--havik-vol-thumb);border-radius:50%;background:var(--havik-accent,#E1B600);\n margin-top:calc((4px - var(--havik-vol-thumb)) / 2)}\n.havik-vol__slider::-moz-range-thumb{width:var(--havik-vol-thumb);height:var(--havik-vol-thumb);\n border:0;border-radius:50%;background:var(--havik-accent,#E1B600)}\n\n.havik-time{font-size:12px;color:var(--havik-text-muted,#9aa0ad);\n font-variant-numeric:tabular-nums;padding:0 6px;white-space:nowrap}\n.havik-live{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;\n letter-spacing:.4px;padding:0 8px;color:var(--havik-text,#fff)}\n.havik-live svg{width:9px;height:9px;color:var(--havik-text-muted,#9aa0ad)}\n.havik-live.is-live svg{color:var(--havik-accent,#E1B600)}\n.havik-live__go{margin-left:2px;border:0;cursor:pointer;background:transparent;color:var(--havik-text-muted,#9aa0ad);\n font:inherit;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 6px;border-radius:6px}\n.havik-live.is-live .havik-live__go{display:none}\n.havik-live__go:hover{color:var(--havik-accent,#E1B600)}\n\n.havik-menu{position:absolute;bottom:54px;right:12px;min-width:170px;max-height:230px;overflow:auto;\n background:var(--havik-surface,#1B1C23);border:1px solid var(--havik-border,rgba(255,255,255,.1));\n border-radius:var(--havik-radius,8px);box-shadow:0 10px 30px rgba(0,0,0,.5);padding:6px;z-index:3}\n.havik-menu[hidden]{display:none}\n.havik-menu__title{font-size:11px;text-transform:uppercase;letter-spacing:.6px;\n color:var(--havik-text-muted,#9aa0ad);padding:6px 8px 4px}\n.havik-menu__item{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;\n border:0;background:transparent;color:var(--havik-text,#fff);cursor:pointer;font:inherit;font-size:13px;\n text-align:left;padding:7px 8px;border-radius:6px}\n.havik-menu__item:hover{background:var(--havik-surface-muted,#2a2c36)}\n.havik-menu__item[aria-checked=\"true\"]{color:var(--havik-accent,#E1B600);font-weight:600}\n.havik-menu__item[aria-checked=\"true\"]::after{content:'✓'}\n`;\n\nexport function injectStyles(doc: Document = document): void {\n if (doc.getElementById(STYLE_ID)) return;\n const style = doc.createElement('style');\n style.id = STYLE_ID;\n style.textContent = CSS;\n doc.head.appendChild(style);\n}\n","// Compact inline SVG icons for the control bar (currentColor-filled, 24x24).\nexport const ICONS = {\n play: '<path d=\"M8 5v14l11-7z\"/>',\n pause: '<path d=\"M6 5h4v14H6zM14 5h4v14h-4z\"/>',\n volumeHigh:\n '<path d=\"M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zm-2.5-9v2.06A7 7 0 0 1 14 19v2a9 9 0 0 0 0-18z\"/>',\n volumeMute:\n '<path d=\"M3 10v4h4l5 5V5L7 10H3zm16.5 2 2.5-2.5-1.4-1.4L18 10.6 15.4 8 14 9.4l2.6 2.6L14 14.6l1.4 1.4 2.6-2.6 2.6 2.6 1.4-1.4z\"/>',\n cc: '<path d=\"M19 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zM10 11H8.5v-.5h-2v3h2V13H10v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H17v1a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1z\"/>',\n settings:\n '<path d=\"M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.11-.2.06-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z\"/>',\n pip: '<path d=\"M19 7h-8v6h8V7zm4 12V5a2 2 0 0 0-2-2H3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2zm-2 .02H3V4.98h18v14.04z\"/>',\n fullscreen:\n '<path d=\"M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z\"/>',\n fullscreenExit:\n '<path d=\"M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z\"/>',\n liveDot: '<circle cx=\"12\" cy=\"12\" r=\"6\"/>',\n retry:\n '<path d=\"M17.65 6.35A8 8 0 1 0 19.73 13h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z\"/>',\n} as const;\n\nexport type IconName = keyof typeof ICONS;\n\nexport function svgIcon(name: IconName): string {\n return `<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\">${ICONS[name]}</svg>`;\n}\n","// A full, skinnable control bar rendered in vanilla DOM over a managed Player.\n// Everything visual is driven by --havik-* CSS variables (see theme.ts), so a\n// customer re-skins it purely with a `theme` object or their own stylesheet.\n//\n// It speaks only the public Player surface (play/pause/setMuted/…/on), so it has\n// no privileged access to engine internals and could equally wrap a BYO player.\n\nimport { injectStyles } from './styles';\nimport { svgIcon, type IconName } from './icons';\nimport {\n enterVideoFullscreen,\n exitVideoFullscreen,\n videoFullscreenActive,\n} from '../core/fullscreen';\nimport type { Player } from '../managed';\nimport type { AudioTrackInfo, PlayerState, QualityLevel, TextTrackInfo } from '../core/stats';\nimport type { PlaybackError } from '../core/errors';\n\nexport interface ControlBarOptions {\n /** The `.havik-player` container that wraps the <video>. */\n root: HTMLElement;\n video: HTMLVideoElement;\n player: Player;\n /** Optional corner watermark + status-card logo (logo URL). */\n logoUrl?: string;\n /** Milliseconds of pointer inactivity before the bar auto-hides. Default 3000. */\n autoHideMs?: number;\n /**\n * Branded full-bleed cards for the stopped/pre-play states (ended, fatal\n * error, pre-play, armed-waiting) instead of a frozen frame + dead play\n * button. Default true. Set false if you render your own end/error UI from\n * the player events.\n */\n statusOverlays?: boolean;\n /** Message on the end-of-stream card. Default \"This stream has ended\". */\n endedMessage?: string;\n /**\n * Message shown INSTEAD of endedMessage when the server says the stream\n * stopped but the match is still running (`endedReason: 'interrupted'` —\n * an ingest outage that outlived the bridge's hold). Telling a viewer\n * mid-match that the stream \"has ended\" is what this exists to prevent.\n *\n * Default \"Reconnecting — please stand by\", which matches what the player\n * does WHEN auto-rejoin is on (`rejoinOnLive`, the default, and it needs\n * `liveStateEvents` too). A host that turns rejoin off should pass its own\n * wording here: nothing will re-attach on its own, so the default would\n * promise the viewer a reconnect that never comes.\n */\n interruptedMessage?: string;\n /** Message on the fatal-error card. Default: the error's own message. */\n errorMessage?: string;\n}\n\nexport interface ControlBarHandle {\n destroy(): void;\n}\n\ntype MenuKind = 'quality' | 'audio' | 'cc';\n\nconst HIDE_AFTER_MS = 3000;\n\n// Terminal error codes get NO Retry button — re-resolving returns the same\n// outcome (stream gone / unknown URN / bad key / malformed URN). Everything else\n// (network / media / DRM / internal / transient) keeps Retry, since retry()\n// re-resolves AND re-attaches and can recover — including refreshing an expired\n// signed DRM license URL.\nconst NON_RETRYABLE_ERROR_CODES = new Set(['GONE', 'NOT_FOUND', 'INVALID_URN', 'UNAUTHORIZED']);\n\n/** Mount the Oddin control bar onto a managed player. Returns a teardown handle. */\nexport function mountControlBar(opts: ControlBarOptions): ControlBarHandle {\n return new ControlBar(opts);\n}\n\nclass ControlBar implements ControlBarHandle {\n private readonly root: HTMLElement;\n private readonly video: HTMLVideoElement;\n private readonly player: Player;\n private readonly doc: Document;\n private readonly autoHideMs: number;\n\n // Elements\n private readonly overlay: HTMLElement;\n private readonly center: HTMLButtonElement;\n private readonly spinner: HTMLElement;\n private readonly bar: HTMLElement;\n private readonly gradient: HTMLElement;\n private readonly seek: HTMLElement;\n private readonly seekBuffered: HTMLElement;\n private readonly seekPlayed: HTMLElement;\n private readonly seekThumb: HTMLElement;\n private readonly playBtn: HTMLButtonElement;\n private readonly muteBtn: HTMLButtonElement;\n private readonly volSlider: HTMLInputElement;\n private readonly live: HTMLElement;\n private readonly liveGo: HTMLButtonElement;\n private readonly time: HTMLElement;\n private readonly ccBtn: HTMLButtonElement;\n private readonly audioBtn: HTMLButtonElement;\n private readonly qualityBtn: HTMLButtonElement;\n private readonly pipBtn: HTMLButtonElement;\n private readonly fsBtn: HTMLButtonElement;\n private readonly menu: HTMLElement;\n\n // Status card (ended / error / pre-play / waiting) — see syncStatus().\n private statusBox: HTMLElement | null = null;\n private statusKind: 'ended' | 'error' | 'preplay' | 'waiting' | null = null;\n /** Copy currently rendered on the card; part of the dedup key (see\n * renderStatus) so a same-kind message change still repaints. */\n private statusMsg: string | null = null;\n private hasPlayed = false;\n private lastError: PlaybackError | null = null;\n private readonly logoUrl?: string;\n private readonly statusOverlays: boolean;\n private readonly endedMessage: string;\n private readonly interruptedMessage: string;\n private readonly errorMessage?: string;\n\n private hideTimer?: ReturnType<typeof setTimeout>;\n private openMenu: MenuKind | null = null;\n private scrubbing = false;\n private readonly offFns: Array<() => void> = [];\n\n constructor(o: ControlBarOptions) {\n this.root = o.root;\n this.video = o.video;\n this.player = o.player;\n this.doc = o.root.ownerDocument ?? document;\n this.autoHideMs = o.autoHideMs ?? HIDE_AFTER_MS;\n this.logoUrl = o.logoUrl;\n this.statusOverlays = o.statusOverlays !== false;\n this.endedMessage = o.endedMessage ?? 'This stream has ended';\n this.interruptedMessage = o.interruptedMessage ?? 'Reconnecting — please stand by';\n this.errorMessage = o.errorMessage;\n injectStyles(this.doc);\n\n this.root.classList.add('havik-player');\n if (!this.root.hasAttribute('tabindex')) this.root.tabIndex = 0;\n this.root.setAttribute('role', 'region');\n this.root.setAttribute('aria-label', 'Video player');\n\n const el = <K extends keyof HTMLElementTagNameMap>(\n tag: K,\n cls: string,\n attrs?: Record<string, string>,\n ): HTMLElementTagNameMap[K] => {\n const node = this.doc.createElement(tag);\n node.className = cls;\n if (attrs) for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);\n return node;\n };\n const btn = (cls: string, icon: IconName, label: string): HTMLButtonElement => {\n const b = el('button', cls, { type: 'button', 'aria-label': label, title: label });\n b.innerHTML = svgIcon(icon);\n return b;\n };\n\n // Overlay (click = play/pause) + big center button + spinner.\n this.overlay = el('div', 'havik-overlay');\n this.center = el('button', 'havik-center', { type: 'button', 'aria-label': 'Play' });\n this.center.innerHTML = svgIcon('play');\n this.spinner = el('div', 'havik-spinner');\n this.spinner.style.display = 'none';\n this.overlay.append(this.center, this.spinner);\n\n this.gradient = el('div', 'havik-gradient');\n\n // Seek bar.\n this.bar = el('div', 'havik-bar');\n this.seek = el('div', 'havik-seek', {\n role: 'slider',\n 'aria-label': 'Seek',\n 'aria-valuemin': '0',\n 'aria-valuemax': '100',\n tabindex: '0',\n });\n const track = el('div', 'havik-seek__track');\n this.seekBuffered = el('div', 'havik-seek__buffered');\n this.seekPlayed = el('div', 'havik-seek__played');\n this.seekThumb = el('div', 'havik-seek__thumb');\n track.append(this.seekBuffered, this.seekPlayed, this.seekThumb);\n this.seek.append(track);\n\n // Controls row.\n const controls = el('div', 'havik-controls');\n this.playBtn = btn('havik-btn', 'play', 'Play');\n this.muteBtn = btn('havik-btn', 'volumeHigh', 'Mute');\n this.volSlider = el('input', 'havik-vol__slider', {\n type: 'range',\n min: '0',\n max: '1',\n step: '0.05',\n 'aria-label': 'Volume',\n });\n const vol = el('div', 'havik-vol');\n vol.append(this.muteBtn, this.volSlider);\n\n this.live = el('div', 'havik-live');\n this.live.innerHTML = svgIcon('liveDot');\n const liveLabel = this.doc.createElement('span');\n liveLabel.textContent = 'LIVE';\n this.liveGo = el('button', 'havik-live__go', { type: 'button' });\n this.liveGo.textContent = 'Go live';\n this.live.append(liveLabel, this.liveGo);\n this.time = el('div', 'havik-time');\n this.time.textContent = '0:00';\n this.time.style.display = 'none';\n\n const spacer = el('div', 'havik-spacer');\n this.ccBtn = btn('havik-btn', 'cc', 'Subtitles');\n this.audioBtn = btn('havik-btn', 'settings', 'Audio track');\n this.qualityBtn = btn('havik-btn', 'settings', 'Quality');\n this.pipBtn = btn('havik-btn', 'pip', 'Picture in picture');\n this.fsBtn = btn('havik-btn', 'fullscreen', 'Fullscreen');\n this.ccBtn.style.display = 'none';\n this.audioBtn.style.display = 'none';\n this.qualityBtn.style.display = 'none';\n if (!this.pipSupported()) this.pipBtn.style.display = 'none';\n\n controls.append(\n this.playBtn,\n vol,\n this.live,\n this.time,\n spacer,\n this.ccBtn,\n this.audioBtn,\n this.qualityBtn,\n this.pipBtn,\n this.fsBtn,\n );\n this.bar.append(this.seek, controls);\n\n this.menu = el('div', 'havik-menu', { role: 'menu' });\n this.menu.hidden = true;\n\n this.root.append(this.gradient, this.overlay, this.bar, this.menu);\n if (o.logoUrl) {\n const wm = el('div', 'havik-watermark');\n wm.style.backgroundImage = `url(\"${o.logoUrl}\")`;\n this.root.append(wm);\n }\n\n this.wire();\n this.syncPlayPause();\n this.syncVolume();\n this.scheduleHide();\n }\n\n // ---- wiring -------------------------------------------------------------\n\n private on<T extends EventTarget>(\n target: T,\n type: string,\n handler: EventListenerOrEventListenerObject,\n options?: AddEventListenerOptions,\n ): void {\n target.addEventListener(type, handler, options);\n this.offFns.push(() => target.removeEventListener(type, handler, options));\n }\n\n private wire(): void {\n const v = this.video;\n\n // Buttons → player.\n this.on(this.playBtn, 'click', () => this.togglePlay());\n this.on(this.center, 'click', () => this.togglePlay());\n this.on(this.overlay, 'click', (e) => {\n if (e.target === this.overlay) this.togglePlay();\n });\n this.on(this.muteBtn, 'click', () => this.player.setMuted(!v.muted));\n this.on(this.volSlider, 'input', () => {\n const value = Number(this.volSlider.value);\n // Paint the fill from the input event rather than waiting for the\n // 'volumechange' round-trip, so it tracks the thumb during a drag.\n this.setVolumeFill(value);\n this.player.setVolume(value);\n if (value > 0 && v.muted) this.player.setMuted(false);\n });\n this.on(this.liveGo, 'click', () => this.player.seekToLive());\n this.on(this.pipBtn, 'click', () => void this.togglePip());\n this.on(this.fsBtn, 'click', () => void this.toggleFullscreen());\n this.on(this.ccBtn, 'click', () => this.toggleMenu('cc'));\n this.on(this.audioBtn, 'click', () => this.toggleMenu('audio'));\n this.on(this.qualityBtn, 'click', () => this.toggleMenu('quality'));\n\n // Seek.\n this.on(this.seek, 'pointerdown', (e) => this.onSeekDown(e as PointerEvent));\n this.on(this.seek, 'keydown', (e) => this.onSeekKey(e as KeyboardEvent));\n\n // Video element → UI.\n this.on(v, 'play', () => this.syncPlayPause());\n this.on(v, 'pause', () => this.syncPlayPause());\n this.on(v, 'volumechange', () => this.syncVolume());\n this.on(v, 'timeupdate', () => this.syncProgress());\n this.on(v, 'progress', () => this.syncProgress());\n this.on(v, 'durationchange', () => this.syncProgress());\n\n // Player events → UI.\n this.offFns.push(this.player.on('statechange', () => this.syncState()));\n // `ended` can fire a second time with a refined reason (interrupted →\n // match_ended) WITHOUT a state transition, so statechange alone would\n // never repaint the card and the viewer would keep being promised a\n // reconnect for a match that is over. syncState re-reads player.state\n // (still 'ended') and the copy-aware dedup does the rest.\n this.offFns.push(this.player.on('ended', () => this.syncState()));\n this.offFns.push(this.player.on('ready', () => this.refreshTracks()));\n this.offFns.push(this.player.on('qualitychange', () => this.onTracksChanged()));\n this.offFns.push(this.player.on('audiotrackchange', () => this.onTracksChanged()));\n this.offFns.push(this.player.on('texttrackchange', () => this.onTracksChanged()));\n this.offFns.push(this.player.on('pipchange', (active) => this.syncPip(active)));\n this.offFns.push(this.player.on('stats', () => this.syncLive()));\n this.offFns.push(\n this.player.on('error', (err) => {\n this.lastError = err;\n this.statusKind = null; // force the error card to re-render with this error\n this.syncState();\n }),\n );\n\n // Auto-hide + keyboard.\n this.on(this.root, 'pointermove', () => this.poke());\n this.on(this.root, 'pointerleave', () => this.scheduleHide(0));\n this.on(this.root, 'focusin', () => this.poke());\n this.on(this.root, 'keydown', (e) => this.onKey(e as KeyboardEvent));\n this.on(this.doc, 'pointerdown', (e) => this.onDocPointer(e), { capture: true });\n this.on(this.doc, 'fullscreenchange', () => this.syncFullscreen());\n // Prefixed/native-fullscreen state changes: older Safari fires the\n // prefixed document event for element fullscreen, and iPhone Safari's\n // video-native fullscreen (webkitEnterFullscreen, see toggleFullscreen)\n // reports only through these video-scoped events.\n this.on(this.doc, 'webkitfullscreenchange', () => this.syncFullscreen());\n this.on(v, 'webkitbeginfullscreen', () => this.syncFullscreen());\n this.on(v, 'webkitendfullscreen', () => this.syncFullscreen());\n\n this.syncState();\n }\n\n // ---- play / pause -------------------------------------------------------\n\n private togglePlay(): void {\n if (this.video.paused || this.video.ended) void this.player.play();\n else this.player.pause();\n }\n\n private syncPlayPause(): void {\n const playing = !this.video.paused && !this.video.ended;\n this.setIcon(this.playBtn, playing ? 'pause' : 'play');\n this.playBtn.setAttribute('aria-label', playing ? 'Pause' : 'Play');\n this.setIcon(this.center, playing ? 'pause' : 'play');\n // A status card (pre-play / ended / error) owns the CTA, so keep the\n // mid-playback center toggle hidden while one is shown.\n this.center.classList.toggle('havik-gone', playing || !!this.statusBox);\n if (playing) this.scheduleHide();\n else this.show();\n }\n\n private syncState(): void {\n const state = this.player.state;\n if (state === 'playing' || state === 'buffering') this.hasPlayed = true;\n this.syncStatus(state);\n // Spinner only while genuinely buffering with no card up (the card owns the\n // visual otherwise); the center play/pause button is hidden whenever a card\n // is shown (the card carries its own CTA) or while busy.\n const busy = state === 'loading' || state === 'buffering';\n this.spinner.style.display = busy && !this.statusBox ? '' : 'none';\n if (busy || this.statusBox) this.center.classList.add('havik-gone');\n this.syncLive();\n }\n\n /** Map the player state to a branded status card (ended / fatal error /\n * pre-play / armed-waiting), or none. Disabled via `statusOverlays: false`. */\n private syncStatus(state: PlayerState): void {\n let kind: ControlBar['statusKind'] = null;\n if (this.statusOverlays) {\n if (state === 'ended') kind = 'ended';\n else if (state === 'error') kind = 'error';\n else if (state === 'waiting') kind = 'waiting';\n else if (!this.hasPlayed && (state === 'idle' || state === 'paused')) kind = 'preplay';\n }\n this.renderStatus(kind);\n }\n\n /** Copy for a card kind. Split out of renderStatus so the dedup below can\n * compare the TEXT, not just the kind — an \"ended\" card can change its\n * message without changing kind (interrupted → match_ended). */\n private statusMessageFor(kind: ControlBar['statusKind']): string | null {\n if (kind === 'ended') {\n // An \"interrupted\" end is a live match whose stream dropped. With\n // auto-rejoin on (the default) the player keeps its live-state\n // subscription open and re-attaches when the bridge returns, so the\n // default copy promises exactly what it will do; a host that disabled\n // rejoin overrides interruptedMessage (see the option's docs).\n return this.player.endedReason === 'interrupted'\n ? this.interruptedMessage\n : this.endedMessage;\n }\n if (kind === 'waiting') {\n // A waiting card after playback has run is a mid-session rejoin (ingest\n // outage → session recreated), not a pre-kickoff wait — \"Starting soon\"\n // would misdescribe a live match recovering.\n return this.hasPlayed ? 'Reconnecting — please stand by' : 'Starting soon…';\n }\n if (kind === 'error') {\n return this.errorMessage ?? this.lastError?.message ?? 'Playback unavailable';\n }\n return null; // preplay carries a CTA, not copy; null = no card\n }\n\n /** Build (or tear down) the status card for `kind`. No-op when neither the\n * kind NOR its copy changed, so repeated statechange events don't\n * rebuild/flicker the DOM — but a same-kind message change (the\n * interrupted → match_ended upgrade) still re-renders, otherwise a viewer\n * keeps being promised a reconnect for a match that is over. */\n private renderStatus(kind: ControlBar['statusKind']): void {\n const msgText = this.statusMessageFor(kind);\n if (kind === this.statusKind && msgText === this.statusMsg) return;\n this.statusKind = kind;\n this.statusMsg = msgText;\n this.statusBox?.remove();\n this.statusBox = null;\n // The card carries its own logo, so the corner watermark is redundant\n // (and reads as a duplicate) while one is up.\n this.root.classList.toggle('havik-has-status', !!kind);\n if (!kind) return;\n\n const box = this.doc.createElement('div');\n box.className = `havik-status havik-status--${kind}`;\n if (this.logoUrl) {\n const logo = this.doc.createElement('div');\n logo.className = 'havik-status__logo';\n box.append(logo);\n }\n\n if (kind === 'preplay') {\n // Poster shows through a light scrim; a single branded play CTA.\n const play = this.doc.createElement('button');\n play.type = 'button';\n play.className = 'havik-status__btn havik-status__btn--play';\n play.setAttribute('aria-label', 'Play');\n play.innerHTML = svgIcon('play');\n play.addEventListener('click', () => void this.player.play());\n box.append(play);\n } else {\n const msg = this.doc.createElement('div');\n msg.className = 'havik-status__msg';\n msg.textContent = msgText ?? '';\n box.append(msg);\n // Retry only when re-resolving can plausibly succeed (terminal errors\n // would just fail again).\n if (\n kind === 'error' &&\n this.lastError &&\n !NON_RETRYABLE_ERROR_CODES.has(this.lastError.code)\n ) {\n const retry = this.doc.createElement('button');\n retry.type = 'button';\n retry.className = 'havik-status__btn';\n retry.innerHTML = `${svgIcon('retry')}<span>Retry</span>`;\n retry.addEventListener('click', () => void this.player.retry());\n box.append(retry);\n }\n }\n\n this.root.append(box);\n this.statusBox = box;\n this.show();\n }\n\n // ---- volume -------------------------------------------------------------\n\n private syncVolume(): void {\n const v = this.video;\n const muted = v.muted || v.volume === 0;\n this.setIcon(this.muteBtn, muted ? 'volumeMute' : 'volumeHigh');\n this.muteBtn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');\n const level = muted ? 0 : v.volume;\n this.volSlider.value = String(level);\n this.setVolumeFill(level);\n }\n\n /** Drives the accent fill on the volume track (see --havik-vol in styles). */\n private setVolumeFill(level: number): void {\n this.volSlider.style.setProperty('--havik-vol', String(Math.min(1, Math.max(0, level))));\n }\n\n // ---- progress / live ----------------------------------------------------\n\n /**\n * The span the scrub bar maps onto, and the window a seek may land in.\n *\n * On a live stream the right end is the live-edge TARGET, not `seekable.end()`\n * — the bleeding edge, where the newest parts aren't published yet and\n * playback stutters frame-by-frame (see the over-seek clamp in the hls.js\n * engine for why hls.js can't recover from it). Mapping 100% to the target\n * means dragging to the far right is \"go live\", the same spot seekToLive()\n * uses, and the thumb reads full at the live edge instead of parking ~2s short\n * of an end the player deliberately never reaches.\n *\n * `targetLatencySeconds` is engine-reported and hls.js-only, so the native\n * (Safari) path keeps the raw range — AVPlayer runs its own live-edge policy.\n */\n private seekableRange(): { start: number; end: number } | null {\n const v = this.video;\n let start: number;\n let end: number;\n if (v.seekable && v.seekable.length > 0) {\n start = v.seekable.start(0);\n end = v.seekable.end(v.seekable.length - 1);\n } else if (Number.isFinite(v.duration) && v.duration > 0) {\n start = 0;\n end = v.duration;\n } else {\n return null;\n }\n const stats = this.player.getStats();\n // isLive !== false = live or not yet known; targetLatencySeconds is only\n // reported once level details exist, i.e. once isLive is actually known, so\n // VOD never pulls the end back.\n if (stats.isLive !== false && stats.targetLatencySeconds && stats.targetLatencySeconds > 0) {\n end = Math.max(start, end - stats.targetLatencySeconds);\n }\n if (end > start) return { start, end };\n return null;\n }\n\n private syncProgress(): void {\n if (this.scrubbing) return;\n const range = this.seekableRange();\n if (!range) {\n this.seekPlayed.style.width = '100%';\n return;\n }\n const span = range.end - range.start;\n const played = Math.min(1, Math.max(0, (this.video.currentTime - range.start) / span));\n this.seekPlayed.style.width = `${(played * 100).toFixed(2)}%`;\n this.seekThumb.style.left = `${(played * 100).toFixed(2)}%`;\n this.seek.setAttribute('aria-valuenow', String(Math.round(played * 100)));\n\n const b = this.video.buffered;\n if (b && b.length > 0) {\n const end = b.end(b.length - 1);\n const buf = Math.min(1, Math.max(0, (end - range.start) / span));\n this.seekBuffered.style.width = `${(buf * 100).toFixed(2)}%`;\n }\n\n // Time readout only matters for non-live (finite, non-DVR) content.\n const stats = this.player.getStats();\n if (!stats.isLive && Number.isFinite(this.video.duration)) {\n this.time.textContent = `${clock(this.video.currentTime)} / ${clock(this.video.duration)}`;\n }\n }\n\n private syncLive(): void {\n const stats = this.player.getStats();\n const isLive = stats.isLive !== false; // default to live (these are live streams)\n this.live.style.display = isLive ? '' : 'none';\n this.time.style.display = isLive ? 'none' : '';\n this.live.classList.toggle('is-live', stats.atLiveEdge !== false && isLive);\n }\n\n // ---- seeking ------------------------------------------------------------\n\n private onSeekDown(e: PointerEvent): void {\n const range = this.seekableRange();\n if (!range) return;\n e.preventDefault();\n this.scrubbing = true;\n const move = (ev: PointerEvent) => this.seekToPointer(ev, range);\n const up = () => {\n this.scrubbing = false;\n this.doc.removeEventListener('pointermove', move);\n this.doc.removeEventListener('pointerup', up);\n };\n this.doc.addEventListener('pointermove', move);\n this.doc.addEventListener('pointerup', up);\n this.seekToPointer(e, range);\n }\n\n private seekToPointer(e: PointerEvent, range: { start: number; end: number }): void {\n const rect = this.seek.getBoundingClientRect();\n const pct = rect.width > 0 ? Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)) : 0;\n this.seekPlayed.style.width = `${(pct * 100).toFixed(2)}%`;\n this.seekThumb.style.left = `${(pct * 100).toFixed(2)}%`;\n this.video.currentTime = range.start + pct * (range.end - range.start);\n }\n\n private onSeekKey(e: KeyboardEvent): void {\n if (e.key === 'ArrowLeft') {\n this.nudge(-5);\n e.preventDefault();\n } else if (e.key === 'ArrowRight') {\n this.nudge(5);\n e.preventDefault();\n }\n }\n\n private nudge(seconds: number): void {\n const range = this.seekableRange();\n if (!range) return;\n this.video.currentTime = Math.min(\n range.end,\n Math.max(range.start, this.video.currentTime + seconds),\n );\n }\n\n // ---- menus --------------------------------------------------------------\n\n private toggleMenu(kind: MenuKind): void {\n if (this.openMenu === kind) {\n this.closeMenu();\n return;\n }\n this.openMenu = kind;\n this.renderMenu(kind);\n this.menu.hidden = false;\n this.show();\n }\n\n private closeMenu(): void {\n this.openMenu = null;\n this.menu.hidden = true;\n this.menu.replaceChildren();\n }\n\n private renderMenu(kind: MenuKind): void {\n if (kind === 'quality') this.renderQualityMenu();\n else if (kind === 'audio') this.renderAudioMenu();\n else this.renderCcMenu();\n }\n\n private menuItem(label: string, checked: boolean, onSelect: () => void): HTMLButtonElement {\n const item = this.doc.createElement('button');\n item.type = 'button';\n item.className = 'havik-menu__item';\n item.setAttribute('role', 'menuitemradio');\n item.setAttribute('aria-checked', checked ? 'true' : 'false');\n const text = this.doc.createElement('span');\n text.textContent = label;\n item.append(text);\n item.addEventListener('click', () => {\n onSelect();\n this.closeMenu();\n });\n return item;\n }\n\n private renderQualityMenu(): void {\n const levels = this.player.getQualityLevels();\n const current = this.player.getCurrentQuality();\n const title = this.doc.createElement('div');\n title.className = 'havik-menu__title';\n title.textContent = 'Quality';\n this.menu.replaceChildren(\n title,\n this.menuItem('Auto', current === -1, () => this.player.setQuality('auto')),\n );\n for (const lvl of levels) {\n this.menu.append(\n this.menuItem(qualityLabel(lvl), current === lvl.index, () =>\n this.player.setQuality(lvl.index),\n ),\n );\n }\n }\n\n private renderAudioMenu(): void {\n const tracks = this.player.getAudioTracks();\n const title = this.doc.createElement('div');\n title.className = 'havik-menu__title';\n title.textContent = 'Audio';\n this.menu.replaceChildren(title);\n for (const t of tracks) {\n this.menu.append(\n this.menuItem(audioLabel(t), t.default, () => this.player.setAudioTrack(t.id)),\n );\n }\n }\n\n private renderCcMenu(): void {\n const tracks = this.player.getTextTracks();\n const title = this.doc.createElement('div');\n title.className = 'havik-menu__title';\n title.textContent = 'Subtitles';\n this.menu.replaceChildren(\n title,\n this.menuItem('Off', false, () => this.player.setTextTrack(-1)),\n );\n for (const t of tracks) {\n this.menu.append(this.menuItem(textLabel(t), false, () => this.player.setTextTrack(t.id)));\n }\n }\n\n private onTracksChanged(): void {\n if (this.openMenu) this.renderMenu(this.openMenu);\n }\n\n private refreshTracks(): void {\n const quality = this.player.getQualityLevels();\n const audio = this.player.getAudioTracks();\n const text = this.player.getTextTracks();\n this.qualityBtn.style.display = quality.length > 1 ? '' : 'none';\n this.audioBtn.style.display = audio.length > 1 ? '' : 'none';\n this.ccBtn.style.display = text.length > 0 ? '' : 'none';\n this.onTracksChanged();\n }\n\n // ---- pip / fullscreen ---------------------------------------------------\n\n private pipSupported(): boolean {\n return (\n 'pictureInPictureEnabled' in this.doc &&\n typeof (this.video as unknown as { requestPictureInPicture?: unknown })\n .requestPictureInPicture === 'function'\n );\n }\n\n private async togglePip(): Promise<void> {\n const doc = this.doc as Document & { pictureInPictureElement?: Element | null };\n if (doc.pictureInPictureElement === this.video) await this.player.exitPictureInPicture();\n else await this.player.enterPictureInPicture();\n }\n\n private syncPip(active: boolean): void {\n this.pipBtn.setAttribute('aria-pressed', active ? 'true' : 'false');\n }\n\n /**\n * Delegates to the shared capability ladder (core/fullscreen.ts): element\n * fullscreen on the player root where the API exists (keeps this control\n * bar on screen), video-native webkitEnterFullscreen on iPhone (NATIVE\n * controls; the user exits via the native Done button and\n * webkitendfullscreen re-syncs the icon).\n */\n private async toggleFullscreen(): Promise<void> {\n try {\n if (videoFullscreenActive(this.video, this.root)) {\n await exitVideoFullscreen(this.video, this.root);\n } else {\n await enterVideoFullscreen(this.video, this.root);\n }\n } catch {\n // fullscreen can be blocked by policy; ignore\n }\n }\n\n private syncFullscreen(): void {\n const full = videoFullscreenActive(this.video, this.root);\n this.setIcon(this.fsBtn, full ? 'fullscreenExit' : 'fullscreen');\n this.fsBtn.setAttribute('aria-label', full ? 'Exit fullscreen' : 'Fullscreen');\n }\n\n // ---- auto-hide ----------------------------------------------------------\n\n private poke(): void {\n this.show();\n this.scheduleHide();\n }\n\n private show(): void {\n this.root.classList.remove('havik-hide');\n }\n\n private scheduleHide(delay = this.autoHideMs): void {\n if (this.hideTimer) clearTimeout(this.hideTimer);\n this.hideTimer = setTimeout(() => {\n const playing = !this.video.paused && !this.video.ended;\n if (playing && !this.openMenu && !this.statusBox) this.root.classList.add('havik-hide');\n }, delay);\n }\n\n private onDocPointer(e: Event): void {\n if (this.openMenu && !this.menu.contains(e.target as Node)) {\n // a click on the toggle button is handled by its own handler\n const onToggle = [this.qualityBtn, this.audioBtn, this.ccBtn].some((b) =>\n b.contains(e.target as Node),\n );\n if (!onToggle) this.closeMenu();\n }\n }\n\n // ---- keyboard -----------------------------------------------------------\n\n private onKey(e: KeyboardEvent): void {\n const target = e.target as HTMLElement | null;\n if (target && target !== this.root && target !== this.seek && target.tagName === 'INPUT')\n return;\n switch (e.key) {\n case ' ':\n case 'k':\n this.togglePlay();\n break;\n case 'm':\n this.player.setMuted(!this.video.muted);\n break;\n case 'f':\n void this.toggleFullscreen();\n break;\n case 'c':\n if (this.ccBtn.style.display !== 'none') this.toggleMenu('cc');\n break;\n case 'l':\n this.player.seekToLive();\n break;\n case 'ArrowUp':\n this.player.setVolume(Math.min(1, this.video.volume + 0.1));\n break;\n case 'ArrowDown':\n this.player.setVolume(Math.max(0, this.video.volume - 0.1));\n break;\n case 'ArrowLeft':\n this.nudge(-5);\n break;\n case 'ArrowRight':\n this.nudge(5);\n break;\n default:\n return;\n }\n this.poke();\n e.preventDefault();\n }\n\n // ---- helpers ------------------------------------------------------------\n\n private setIcon(btnEl: HTMLElement, icon: IconName): void {\n btnEl.innerHTML = svgIcon(icon);\n }\n\n destroy(): void {\n if (this.hideTimer) clearTimeout(this.hideTimer);\n for (const off of this.offFns) {\n try {\n off();\n } catch {\n // best effort\n }\n }\n this.offFns.length = 0;\n this.closeMenu();\n this.statusBox?.remove();\n for (const node of [this.overlay, this.gradient, this.bar, this.menu]) node.remove();\n this.root.classList.remove('havik-player', 'havik-hide', 'havik-has-status');\n }\n}\n\nfunction clock(seconds: number): string {\n if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;\n const s = Math.floor(seconds % 60);\n const m = Math.floor((seconds / 60) % 60);\n const h = Math.floor(seconds / 3600);\n const mm = h > 0 ? String(m).padStart(2, '0') : String(m);\n const ss = String(s).padStart(2, '0');\n return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;\n}\n\nfunction qualityLabel(lvl: QualityLevel): string {\n if (lvl.height) return `${lvl.height}p`;\n return `${Math.round(lvl.bitrate / 1000)} kbps`;\n}\n\nfunction audioLabel(t: AudioTrackInfo): string {\n return t.name || t.lang || `Track ${t.id}`;\n}\n\nfunction textLabel(t: TextTrackInfo): string {\n return t.name || t.lang || `Track ${t.id}`;\n}\n","import { type HlsConfig } from 'hls.js';\nimport { isWaitable, PlaybackError } from '../core/errors';\nimport { baseUrlForEnv, type HavikEnv } from '../core/env';\nimport { resolvePlaybackOnce, resolveStream, type ResolveOptions } from '../core/resolve';\nimport { HlsEngine, hlsSupported } from '../core/engine/hls';\nimport { NativeHlsEngine, nativeHlsSupported, webkitMediaStack } from '../core/engine/native';\nimport { type EngineEvent, type PlaybackEngine } from '../core/engine/types';\nimport {\n attachMediaStateListeners,\n EMPTY_STATS,\n type AudioTrackInfo,\n type PlaybackStats,\n type PlayerState,\n type QualityLevel,\n type TextTrackInfo,\n} from '../core/stats';\nimport { getDeviceId } from '../core/drm/deviceId';\nimport { enterVideoFullscreen, exitVideoFullscreen } from '../core/fullscreen';\nimport {\n resolveCredential,\n type Credential,\n type CredentialSource,\n type StreamDescriptor,\n} from '../core/types';\nimport { type WaitForLive, type WaitForLiveOptions, type WaitState } from '../core/waitForLive';\nimport {\n subscribeLiveState,\n deriveEventsBaseUrl,\n type EndedReason,\n type LiveStateSubscription,\n} from '../core/events';\nimport { AnalyticsSession, deriveBeaconsBaseUrl, type Presentation } from '../core/analytics';\nimport { collectEnvironment, collectHighEntropy } from '../core/environment';\n\n// Re-exported: it names the `ended` event payload and Player.endedReason, so\n// a managed-player consumer must be able to refer to it without reaching into\n// the BYO entry point.\nexport type { EndedReason } from '../core/events';\nimport { SDK_VERSION } from '../core/version';\nimport { applyTheme, type HavikTheme } from '../ui/theme';\nimport { mountControlBar, type ControlBarHandle } from '../ui/controls';\n\n// Armed-phase SSE tuning. While SSE is healthy the player does NOT poll\n// /v1/playback — it waits for the pushed `live`. These bound the fallback:\nconst SSE_ARM_CONNECT_GRACE_MS = 6_000; // no SSE frame by now → start polling\nconst SSE_ARM_LIVENESS_MS = 45_000; // heartbeat is 20s; 2 missed → SSE stale → poll\nconst SSE_ARM_RESOLVE_JITTER_MS = 2_000; // stagger the go-live resolve so 1000s of armed viewers don't resolve in lockstep\nconst LIVE_STATE_WARN_AFTER = 3; // consecutive no-data failures before the one-time degraded-end-detection warning\n\nexport interface CreatePlayerOptions {\n /** The <video> element the SDK will own. */\n video: HTMLVideoElement;\n /**\n * Which Oddin environment to play against. Prefer this over\n * {@link baseUrl}: the host is then correct by construction, and so is\n * everything derived from it (the SSE events host, the QoE beacons host).\n *\n * Exactly one of `env` or {@link baseUrl} is required. Passing both is an\n * error rather than a precedence rule — the two disagreeing is precisely\n * the misconfiguration worth refusing, and silently preferring one would\n * point a player at an environment nobody chose.\n */\n env?: HavikEnv;\n /**\n * API base URL, when {@link env} does not name it. Use this for a\n * deployment the named environments do not cover: your own CDN or gateway\n * fronting the streams API, the CN plane, or a pinned build that must\n * outlive a domain change.\n *\n * Note that a base which is not a `feed[-dev].<domain>` host derives no QoE\n * beacons endpoint, so pair it with `analytics: { endpoint }`.\n */\n baseUrl?: string;\n matchUrn: string;\n credential: CredentialSource;\n /**\n * Start playback once ready. Default: true. With false the player resolves\n * playback, then holds in a pre-play 'paused' state (poster + play card,\n * no spinner) and defers media loading — segments and DRM license traffic —\n * until the first play(). The hls.js engine still parses the manifest\n * pre-play ('ready', track lists); the native engine defers all fetching\n * and emits 'ready' at attach. A policy-blocked programmatic play()\n * ('autoplayblocked') leaves the loader warming the buffer — media and DRM\n * traffic flows from that kick, not from a viewer gesture.\n */\n autoplay?: boolean;\n /** Mute the element (required for reliable autoplay with sound policies). Default: false. */\n muted?: boolean;\n /** Arm on an upcoming match and auto-attach at go-live. See WaitForLive. */\n waitForLive?: WaitForLive;\n /** 'auto' lets hls.js decide from the manifest (safe default). Default: 'auto'. */\n lowLatency?: 'auto' | boolean;\n /** Poster image shown before playback starts. */\n poster?: string;\n /** Initial rendition index (-1 = auto, the default). */\n startLevel?: number;\n /** Cap ABR to renditions at/below this bitrate (bps). */\n maxBitrate?: number;\n /**\n * Target live-edge latency in seconds (maps to hls.js liveSyncDuration).\n * Default: 2 (a balance the engine ships — close to live but back from the\n * bleeding PART-HOLD-BACK edge, where parts aren't reliably published yet).\n * Lower = closer to live but more rebuffer-prone; higher = steadier. The\n * engine derives a matching latency ceiling internally, so this is the only\n * live-latency knob you need. Live/LL only.\n */\n liveLatencyTarget?: number;\n /**\n * Snap straight to the live edge when a backgrounded tab is refocused and\n * playback has fallen well behind. Default: true. Live/LL only.\n */\n snapToLiveOnRefocus?: boolean;\n /** Force an engine; 'auto' prefers hls.js wherever supported (incl. modern\n * Safari/iOS via ManagedMediaSource) and falls back to native HLS. Default: 'auto'. */\n engine?: 'auto' | 'hls' | 'native';\n /** Optional viewer id (X-User-Id on the license POST). */\n userId?: string;\n /**\n * Which controls to render:\n * - 'custom' (default): the Oddin-branded, skinnable control bar.\n * - 'native': the browser's built-in `<video controls>` UI.\n * - 'none': no controls (drive the player via its API).\n */\n controls?: 'custom' | 'native' | 'none';\n /**\n * Re-skin the custom control bar. Merged over the Oddin default; any omitted\n * key keeps the brand value. Equivalent to overriding the `--havik-*` CSS\n * variables on the player element. Ignored unless controls is 'custom'.\n */\n theme?: Partial<HavikTheme>;\n /**\n * Show branded full-bleed cards for the stopped/pre-play states — ended\n * (\"This stream has ended\"), fatal error (message + Retry when retryable),\n * pre-play (poster + play CTA), and armed-waiting (\"Starting soon…\") — instead\n * of a frozen frame + a dead play button. Themed via the control-bar tokens +\n * `theme.logoUrl`. Default: true. Set false if you render your own end/error\n * UI from the player events (`ended`, `error`). Ignored unless controls is\n * 'custom'.\n */\n statusOverlays?: boolean;\n /** Message on the end-of-stream card. Default: \"This stream has ended\". */\n endedMessage?: string;\n /**\n * Message shown instead of {@link endedMessage} when the stream stopped but\n * the match is still running (the server's `interrupted` reason — an ingest\n * outage that outlived the bridge's reconnect hold). Default:\n * \"Reconnecting — please stand by\" — a promise the player keeps while\n * {@link rejoinOnLive} is on (the default; it also needs\n * {@link liveStateEvents}). Override it if you disable auto-rejoin, since\n * nothing will then re-attach on its own.\n */\n interruptedMessage?: string;\n /** Message on the fatal-error card. Default: the error's own message. */\n errorMessage?: string;\n /**\n * Turn on hls.js's internal console logging — segment/part scheduling, ABR\n * switches, and EME key-session + license traffic. Default: false.\n *\n * For diagnosing a playback problem with Oddin support; leave it off in\n * production (it logs on every fragment, and the EME lines include license\n * request/response metadata). hls.js engine only — a no-op on the native\n * engine, which has no equivalent. Equivalent to `hlsConfig: { debug: true }`,\n * which still wins if you set both.\n */\n debug?: boolean;\n /** Escape hatch for low-level hls.js tuning. */\n hlsConfig?: Partial<HlsConfig>;\n /** Stats sampling cadence. Default: 2000ms. */\n statsIntervalMs?: number;\n /**\n * How often to silently re-resolve playback so the signed DRM license URL\n * the player would use next stays inside its signature lifetime (~10 min).\n * Default: 8 min. Set 0 to disable.\n *\n * This refreshes the URL, not the license. It gives the player a newer URL\n * for the next license request it makes, and issues none of its own, so it\n * cannot extend a license the CDM already holds: how long that stays valid\n * is set when it is issued, server-side. A session that outlasts its license\n * ends with a fatal `DRM_CLIENT` error whatever this is set to.\n */\n licenseRefreshMs?: number;\n /**\n * Subscribe to push-based live-state over SSE (`events.<domain>/v1/events/{urn}`)\n * to detect end-of-stream the instant the bridge flips, instead of waiting on\n * the frozen Tencent manifest (which never emits #EXT-X-ENDLIST). On `ended`/\n * `gone` the player stops buffering and emits `ended`; a later pushed `live`\n * rejoins automatically (see {@link rejoinOnLive}). Default: true — and\n * fully graceful: if the endpoint is unreachable the subscription retries\n * quietly in the background while the HLS staleness watchdog remains the\n * layer-fallback, so playback is never affected. Set false to disable.\n */\n liveStateEvents?: boolean;\n /**\n * Rejoin automatically when a stream that ended comes back live. An ingest\n * outage longer than the bridge's reconnect grace tears the session down\n * and — when the encoder reconnects — recreates it under a NEW manifest\n * URL, while the old playlist freezes at HTTP 200 forever. A player that\n * latched `ended` can therefore only recover by re-resolving playback.\n * With this on (the default), the live-state subscription stays open after\n * `ended` (it closes on `gone`, the past-catchup terminal), and a pushed\n * `live` re-arms and re-attaches automatically through the same jittered\n * go-live path the pre-live wait uses. Set false to keep `ended` terminal\n * (the pre-1.13 behavior). No effect when {@link liveStateEvents} is\n * disabled — without the push channel there is nothing to rejoin on.\n */\n rejoinOnLive?: boolean;\n /**\n * Override the SSE endpoint base URL. Default: derived from {@link baseUrl} by\n * prefixing the host with `events.` (e.g. `feed.<domain>` →\n * `events.<domain>`, served ALB-direct). Set this if your events subdomain\n * differs from that convention.\n */\n eventsBaseUrl?: string;\n /**\n * Extra headers attached to every request the SDK makes to {@link baseUrl}\n * (playback resolution, live-status polling) — and ONLY there: never to\n * CDN manifest/segment hosts, DRM license endpoints, the SSE events host,\n * or the beacons endpoint. For integrations whose API front door wants\n * its own auth signal on top of the publishable key — an operator console\n * proving a session to its reverse proxy, or a customer gateway in front\n * of the streams API. `x-api-key` cannot be overridden through this map.\n */\n apiHeaders?: Record<string, string>;\n /**\n * QoE beacons + CMCD session attribution. Active only when the playback\n * response carries an analytics sid (the server-side flag) — without one\n * this option does nothing. Default: true, with the beacons endpoint\n * derived from {@link baseUrl} by hostname convention (`feed[-dev].…` →\n * `beacons[-dev].…`). Pass `false` to opt out entirely, or\n * `{ endpoint }` to point at a non-conventional beacons host.\n *\n * Privacy: the sid is an opaque per-session id, held in memory only and\n * never persisted. Beacons carry playback QoE numbers plus the session's\n * environment (form factor, device model and OS version where the browser\n * offers client hints, screen, connection, embedding origin) — never an\n * IP, never the raw user agent, and no identifier beyond the sid.\n */\n analytics?:\n | boolean\n | {\n endpoint?: string;\n /**\n * How long the player may sit paused before the session closes itself\n * (`END_REASON_IDLE`), and the suspension gap (OS sleep, a frozen tab)\n * after which it does the same on waking. Default 10 minutes; `0`\n * disables the idle close; the suspension gap never goes below five\n * minutes, whatever this is set to (a hidden tab's throttled timers\n * legitimately gap to a minute). After an idle close the next play()\n * reloads the stream — a fresh session, a fresh sid, the live edge.\n */\n idleMs?: number;\n };\n}\n\nexport type PlayerEvent =\n | 'ready'\n | 'waiting'\n | 'playing'\n | 'buffering'\n | 'paused'\n | 'ended'\n | 'error'\n | 'warning'\n | 'autoplayblocked'\n | 'qualitychange'\n | 'audiotrackchange'\n | 'texttrackchange'\n | 'pipchange'\n | 'stats'\n | 'statechange';\n\nexport interface PlayerEventMap {\n ready: void;\n waiting: WaitState;\n playing: void;\n buffering: void;\n paused: void;\n /**\n * End of stream. The payload says WHY when the server told us\n * (`interrupted` = the match is still running and play should resume, so\n * do NOT say \"ended\"; `match_ended` = a real end); `undefined` means the\n * end came from the HLS staleness watchdog or a server predating the\n * field — treat it as a real end, the pre-existing behavior. Fires again\n * with `match_ended` if an interruption later turns out to be the end.\n */\n ended: EndedReason | undefined;\n error: PlaybackError;\n /** Non-fatal condition (e.g. FairPlay-not-implemented passthrough, a failed license refresh). Playback continues. */\n warning: PlaybackError;\n /** The browser blocked autoplay (NotAllowedError). Prompt a tap-to-play / tap-to-unmute affordance. */\n autoplayblocked: void;\n /** Active rendition changed; payload is the level index (-1 = auto). */\n qualitychange: number;\n /** Active audio track changed; payload is the track id. */\n audiotrackchange: number;\n /** Active text track changed; payload is the track id (-1 = off). */\n texttrackchange: number;\n /** Picture-in-Picture entered (true) or exited (false). */\n pipchange: boolean;\n stats: PlaybackStats;\n statechange: PlayerState;\n}\n\nexport interface Player {\n readonly state: PlayerState;\n readonly descriptor: StreamDescriptor | null;\n /** Why the stream ended, when the server said so; null otherwise. Cleared\n * when fresh playback commits (including a rejoin). See EndedReason. */\n readonly endedReason: EndedReason | null;\n play(): Promise<void>;\n pause(): void;\n setMuted(muted: boolean): void;\n setVolume(volume: number): void;\n getStats(): PlaybackStats;\n /** Available renditions (empty on the native engine, which does ABR internally). */\n getQualityLevels(): QualityLevel[];\n /** Current rendition index, or -1 when ABR is choosing automatically. */\n getCurrentQuality(): number;\n /** Pick a rendition by index, or 'auto'/-1 to re-enable ABR. */\n setQuality(index: number | 'auto'): void;\n /** Cap ABR to renditions at/below this bitrate (bps), or null to clear. */\n setMaxBitrate(bitrate: number | null): void;\n getAudioTracks(): AudioTrackInfo[];\n setAudioTrack(id: number): void;\n getTextTracks(): TextTrackInfo[];\n /** Select a text track by id, or -1 to disable captions. */\n setTextTrack(id: number): void;\n /** Jump to the live edge (live streams). */\n seekToLive(): void;\n enterPictureInPicture(): Promise<void>;\n exitPictureInPicture(): Promise<void>;\n /**\n * Enter fullscreen: element fullscreen on the player container where the\n * API exists (custom controls stay on screen); on iPhone Safari falls back\n * to the video-native presentation with NATIVE controls (there is no\n * element Fullscreen API there at all). Call from a user gesture; on\n * iPhone it takes effect once media is loaded. Resolves false when no\n * fullscreen mechanism exists on the platform.\n */\n enterFullscreen(): Promise<boolean>;\n /** Leave fullscreen (element or video-native). Safe when not fullscreen. */\n exitFullscreen(): Promise<void>;\n /** Retry playback after an error (re-resolves and re-attaches). */\n retry(): Promise<void>;\n /** Re-resolve playback (refreshes the signed license URL) and re-attach. */\n reload(): Promise<void>;\n on<E extends PlayerEvent>(event: E, cb: (payload: PlayerEventMap[E]) => void): () => void;\n destroy(): void;\n}\n\nconst DEFAULT_STATS_INTERVAL_MS = 2000;\n/**\n * How often to re-resolve playback so the signed license URL the engine would\n * use is younger than havik-streams' DRM_SIGN_TTL (10m).\n *\n * This refreshes the URL, NOT the license: it hands the engine a new string\n * (see refreshLicense → engine.setLicenseUrl), and licenseXhrSetup reads that\n * string when hls.js next POSTs. It issues no license request of its own, so it\n * cannot extend a license the CDM has already taken — the licence's own\n * expiration governs that, and it is set server-side\n * (havik-drm LICENSE_EXPIRATION_DURATION). Do not \"fix\" that by rebuilding the\n * instance on a timer: a rebuild is a visible interruption, and the CDM it\n * would want to re-key is not replaceable anyway (see core/drm/cdm.ts).\n */\nconst DEFAULT_LICENSE_REFRESH_MS = 8 * 60 * 1000;\n\n/**\n * Options with the endpoint decision already made. The player reads\n * `opts.baseUrl` in a dozen places (resolve, SSE derivation, beacons\n * derivation); resolving once at the boundary keeps every one of them a\n * plain string instead of pushing an `env`-or-`baseUrl` branch inward.\n */\ntype ResolvedPlayerOptions = CreatePlayerOptions & { baseUrl: string };\n\n/**\n * Settle `env` / `baseUrl` into the one base URL the player will use, or\n * throw. Kept separate from the other createPlayer guards because it is the\n * only one that has to reject a combination rather than an absence.\n */\nfunction resolveEndpoint(opts: CreatePlayerOptions): string {\n if (opts.env !== undefined && opts.baseUrl !== undefined) {\n throw new PlaybackError(\n 'INTERNAL',\n 0,\n `createPlayer: pass env OR baseUrl, not both (got env=${JSON.stringify(opts.env)} ` +\n `and baseUrl=${JSON.stringify(opts.baseUrl)})`,\n );\n }\n if (opts.env !== undefined) return baseUrlForEnv(opts.env);\n if (!opts.baseUrl) {\n throw new PlaybackError(\n 'INTERNAL',\n 0,\n \"createPlayer: env or baseUrl is required (env: 'integration' | 'production')\",\n );\n }\n return opts.baseUrl;\n}\n\n/**\n * Endpoint selection, enforced at COMPILE time.\n *\n * `env` and `baseUrl` are both optional on {@link CreatePlayerOptions} so it\n * stays an interface others can `extend` and `Partial<>`. That alone would\n * make \"neither given\" type-check and fail only in the browser, which is a\n * guarantee this SDK used to have while `baseUrl` was required. These two\n * overloads put it back at the call site without changing the interface: one\n * names `env` and forbids `baseUrl`, the other the reverse, so neither and\n * both are both \"no overload matches this call\".\n *\n * resolveEndpoint still enforces the same rule at runtime — the overloads do\n * nothing for a JavaScript caller, and that is most of the embed surface.\n */\nexport function createPlayer(\n opts: CreatePlayerOptions & { env: HavikEnv; baseUrl?: never },\n): Promise<Player>;\nexport function createPlayer(\n opts: CreatePlayerOptions & { baseUrl: string; env?: never },\n): Promise<Player>;\n/**\n * Mode A — managed player. Owns the <video> and a bundled hls.js, wiring\n * LL-HLS + DRM automatically. With `waitForLive`, arms on an upcoming match and\n * auto-plays the instant it goes live. Resolves to a Player as soon as it is\n * armed/attached — terminal errors (404/410/401/...) reject; the live wait does\n * not block and is surfaced via 'waiting' events.\n */\nexport async function createPlayer(opts: CreatePlayerOptions): Promise<Player> {\n // Fail fast with typed errors on misuse, before any async/DOM work.\n if (!opts.video || typeof (opts.video as HTMLVideoElement).play !== 'function') {\n throw new PlaybackError('INTERNAL', 0, 'createPlayer: opts.video must be an HTMLVideoElement');\n }\n const baseUrl = resolveEndpoint(opts);\n if (!opts.matchUrn)\n throw new PlaybackError('INVALID_URN', 0, 'createPlayer: matchUrn is required');\n if (!opts.credential) {\n throw new PlaybackError('INTERNAL', 0, 'createPlayer: credential is required');\n }\n const player = new ManagedPlayer({ ...opts, baseUrl });\n await player.start();\n return player;\n}\n\nclass ManagedPlayer implements Player {\n private readonly opts: ResolvedPlayerOptions;\n private readonly abort = new AbortController();\n private readonly listeners: { [E in PlayerEvent]?: Set<(p: never) => void> } = {};\n private engine: PlaybackEngine | null = null;\n private controlBar: ControlBarHandle | null = null;\n private container: HTMLElement | null = null;\n private detachMedia?: () => void;\n private detachPip?: () => void;\n private statsTimer?: ReturnType<typeof setInterval>;\n private refreshTimer?: ReturnType<typeof setInterval>;\n private _state: PlayerState = 'idle';\n private _descriptor: StreamDescriptor | null = null;\n private destroyed = false;\n /** Monotonic load generation; a load whose generation is stale must not commit. */\n private loadGen = 0;\n /** Push-based live-state subscription (SSE), active during committed playback. */\n private liveStateSub: LiveStateSubscription | null = null;\n /** Guards the end-of-stream handler so SSE and the HLS watchdog can't double-fire. */\n private endedHandled = false;\n /** Why the current end happened, when the server said (see EndedReason).\n * null = ended without a reason (an older server, or the HLS staleness\n * watchdog getting there first) — callers must read that as \"unknown\",\n * which is exactly the pre-#191 behavior: assume a real end. */\n private _endedReason: EndedReason | null = null;\n /** Pending post-ended rejoin (the jitter timer; see scheduleRejoin). */\n private rejoinTimer: ReturnType<typeof setTimeout> | null = null;\n /** Live-state subscription used during the pre-live armed/waiting phase. */\n private armingSub: LiveStateSubscription | null = null;\n /** Connect-grace / heartbeat-liveness timer for the armed SSE subscription. */\n private armSseTimer: ReturnType<typeof setTimeout> | null = null;\n /** Abort handle for the SSE arm's polling fallback — closeArming() (and thus\n * reload()/retry()/destroy()) must cancel it, or an orphaned poll keeps\n * hitting /v1/playback and emitting stale 'waiting' for a dead generation. */\n private armFallbackCtrl: AbortController | null = null;\n /** Last fatal error routed through fail() — start() turns a fatal engine\n * load into a createPlayer rejection with it (the emit happens before the\n * caller can attach listeners, so the event alone is unobservable there). */\n private _lastError: PlaybackError | null = null;\n /** QoE beacon session (plan A2); one per sid, created at engine commit. */\n private analyticsSession: AnalyticsSession | null = null;\n private detachMediaListeners?: () => void;\n /** True once play() was ever asked for (user gesture or API). Ends the\n * autoplay-off deferral, and lets a post-play reload/rejoin resume without\n * a second gesture — playback the viewer started should come back on its\n * own after a recovery re-attach. */\n private playRequested = false;\n /**\n * The beacon session closed itself for inactivity (see the `idleMs`\n * option): the sid is finished and the engine was stopped, so the next\n * play() is a rejoin — reload() resolves a new sid and re-plays from the\n * live edge — not a resume of a session that no longer exists.\n */\n private idleClosed = false;\n /** A deferLoad engine is attached and waits for the first play() to start\n * media loading (autoplay-off pre-play). */\n private pendingStartLoad = false;\n\n constructor(opts: ResolvedPlayerOptions) {\n this.opts = opts;\n }\n\n get state(): PlayerState {\n return this._state;\n }\n get descriptor(): StreamDescriptor | null {\n return this._descriptor;\n }\n get endedReason(): EndedReason | null {\n return this._endedReason;\n }\n\n async start(): Promise<void> {\n if (this.opts.muted) this.opts.video.muted = true;\n if (this.opts.poster) this.opts.video.poster = this.opts.poster;\n this.mountControls();\n this.attachPipListeners();\n this.setState('loading');\n const gen = ++this.loadGen;\n const resolveOpts = this.resolveOptions();\n try {\n const descriptor = await resolvePlaybackOnce(resolveOpts);\n await this.onResolved(descriptor, gen);\n if (gen === this.loadGen && this._state === 'error' && this._lastError) {\n // Fatal engine load (already routed through fail()) — reject so the\n // first-load caller observes the typed error; the 'error' event fired\n // before any listener could exist. Unwind FULLY first: the caller\n // never receives a Player to destroy(), so leaving the mounted\n // control bar / PiP listeners / video wrapper behind would leak DOM\n // for the life of the page.\n const err = this._lastError;\n this.destroy();\n throw err;\n }\n } catch (err) {\n if (this.opts.waitForLive && err instanceof PlaybackError && isWaitable(err.code)) {\n // Don't block: return the (armed) player; keep polling in the background.\n this.setState('waiting');\n this.beginArming(gen, err.liveStartsAtMs);\n return;\n }\n throw err;\n }\n }\n\n private resolveOptions(): ResolveOptions {\n return {\n baseUrl: this.opts.baseUrl,\n matchUrn: this.opts.matchUrn,\n credential: this.opts.credential,\n signal: this.abort.signal,\n apiHeaders: this.opts.apiHeaders,\n };\n }\n\n private beginArming(gen: number, liveStartsAtMs?: number, reconnect = false): void {\n const base =\n this.opts.liveStateEvents === false\n ? undefined\n : (this.opts.eventsBaseUrl ?? deriveEventsBaseUrl(this.opts.baseUrl));\n if (base) this.armWithSse(gen, base, liveStartsAtMs, reconnect);\n else this.beginArmingByPolling(gen);\n }\n\n /** SSE-disabled path: the original kickoff-aware /v1/playback poll. */\n private beginArmingByPolling(gen: number): void {\n resolveStream({\n ...this.resolveOptions(),\n signal: this.abort.signal,\n waitForLive: this.buildWaitConfig(gen),\n })\n .then((descriptor) => {\n // Same staleness guard as the catch: a resolve that SETTLED before a\n // supersede (reload/destroy bumps loadGen) must not build an engine.\n if (this.destroyed || this.abort.signal.aborted || gen !== this.loadGen) return;\n return this.onResolved(descriptor, gen);\n })\n .catch((err) => {\n if (this.destroyed || this.abort.signal.aborted || gen !== this.loadGen) return;\n // Wrap non-PlaybackErrors (e.g. a rejecting credential function) so\n // the 'error' event payload honors its documented type.\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n });\n }\n\n /**\n * Armed wait, SSE-first. While the live-state stream is healthy the player\n * does NOT poll /v1/playback — it waits for the pushed `live`, then resolves\n * exactly once (jittered, to stagger the go-live herd). Polling only starts\n * if SSE fails to connect within a grace, goes stale (missed heartbeats), or\n * errors — so an unreachable events endpoint is no worse than today.\n */\n private armWithSse(\n gen: number,\n eventsBaseUrl: string,\n liveStartsAtMs?: number,\n reconnect = false,\n ): void {\n let committed = false;\n let fallbackStarted = false;\n const fallbackCtrl = new AbortController();\n // No abort-listener chain to this.abort here: every abandonment path\n // (reload()/retry()/destroy()/commit) runs closeArming(), which aborts\n // this controller — a per-arm listener on the player-lifetime signal\n // would just accumulate one captured closure per re-arm cycle.\n this.armFallbackCtrl = fallbackCtrl;\n this.setState('waiting');\n // Kickoff hint precedence mirrors the polling path: the server's 425\n // liveStartsAt wins, the caller's waitForLive.kickoffAt is the fallback.\n const wf = this.opts.waitForLive;\n const kickoffOpt = wf && wf !== true ? wf.kickoffAt : undefined;\n const kickoffOptMs =\n typeof kickoffOpt === 'number'\n ? kickoffOpt\n : typeof kickoffOpt === 'string'\n ? Date.parse(kickoffOpt)\n : NaN;\n const startsAtMs = liveStartsAtMs ?? (Number.isFinite(kickoffOptMs) ? kickoffOptMs : undefined);\n // Emit an observable 'waiting' for the SSE arm. The polling path emits\n // one per poll via buildWaitConfig, but a healthy SSE wait makes no\n // /v1/playback requests at all — without this, an armed player is\n // invisible to hosts (the pre-resolve statechange is deduped and\n // createPlayer resolves already armed; the ops Demo Player had to probe\n // player.state synchronously to find out). Deferred a MACROTASK: this\n // runs synchronously inside start(), before createPlayer resolves — a\n // synchronous (or microtask) emit would fire before the caller can\n // attach listeners and be missed, recreating the exact gap this closes.\n // Synthetic-but-honest shape: the next self-check is the SSE liveness\n // window.\n // No clearTimeout-on-abort bookkeeping: the callback's committed/\n // destroyed/gen guard makes a post-teardown firing a no-op, and the\n // timer is 0 ms — long gone by any later re-arm. (A per-arm abort\n // listener on the player-lifetime signal would leak one closure per\n // re-arm cycle.)\n setTimeout(() => {\n if (committed || this.destroyed || gen !== this.loadGen) return;\n this.emit('waiting', {\n // A rejoin arm is a mid-match reconnect, not a pre-kickoff wait —\n // 'unavailable' is the phase hosts map to \"temporarily unavailable\"\n // copy; 'tooEarly' would read as \"starting soon\" while a live match\n // recovers from an ingest outage.\n phase: reconnect ? 'unavailable' : 'tooEarly',\n retryInMs: SSE_ARM_LIVENESS_MS,\n attempt: 1,\n elapsedMs: 0,\n ...(!reconnect && startsAtMs !== undefined && startsAtMs > Date.now()\n ? { ttkMs: startsAtMs - Date.now() }\n : {}),\n });\n }, 0);\n\n const stale = (): boolean =>\n this.destroyed || this.abort.signal.aborted || gen !== this.loadGen;\n\n const commit = async (descriptor: StreamDescriptor): Promise<void> => {\n if (committed || stale()) return;\n committed = true;\n this.closeArming();\n fallbackCtrl.abort(); // supersede any running poll fallback\n try {\n await this.onResolved(descriptor, gen);\n } catch (err) {\n // onResolved can reject (credential mint at go-live, engine\n // construction). By now committed=true, so the callers' .catch\n // handlers early-return — an unhandled rejection here would strand\n // the player in 'waiting' forever with arming already closed.\n // Surface it: listeners exist by now (this is post-createPlayer).\n if (stale()) return;\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n }\n };\n\n const startFallback = (): void => {\n if (committed || fallbackStarted || stale()) return;\n fallbackStarted = true;\n // Route the fallback's success through commit(), NOT straight to\n // onResolved: SSE can recover and push 'live' while the poll's resolve\n // has already settled (abort can't retract a settled promise), and two\n // unguarded onResolved calls for the same generation would build two\n // engines on one video — the first leaks its watchdog + media\n // listeners. commit() is idempotent, so whichever side wins, the other\n // becomes a no-op.\n resolveStream({\n ...this.resolveOptions(),\n signal: fallbackCtrl.signal,\n waitForLive: this.buildWaitConfig(gen),\n })\n .then((descriptor) => commit(descriptor))\n .catch((err) => {\n if (committed || stale() || fallbackCtrl.signal.aborted) return;\n committed = true;\n this.closeArming();\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n });\n };\n\n const resolveOnce = (): void => {\n if (committed || stale()) return;\n resolvePlaybackOnce(this.resolveOptions())\n .then((d) => commit(d))\n .catch((err) => {\n if (committed || stale()) return;\n // Not live yet (425/503/transient). We only get here because SSE\n // PUSHED live/gone, so playback lagging the push is a consistency\n // race, not \"keep waiting for the next push\": a healthy SSE never\n // re-pushes an unchanged state, and its heartbeats keep deferring\n // the staleness fallback — returning here wedged the post-ended\n // rejoin until the SSE lifetime-cap reconnect replayed the snapshot\n // (observed live 2026-08-25 on a 10s-delayed stream, where the\n // recreated session's /v1/playback view lagged the bridge's `live`\n // flip by ~5s). Hand the wait to the polling ladder instead: it\n // retries with backoff and commits through the same idempotent\n // commit(), and a terminal answer still surfaces through its catch.\n if (err instanceof PlaybackError && isWaitable(err.code)) {\n startFallback();\n return;\n }\n // Terminal (e.g. 410 GONE — cancelled before go-live): surface it.\n committed = true;\n this.closeArming();\n fallbackCtrl.abort();\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n });\n };\n\n const rearmStale = (ms: number): void => {\n if (this.armSseTimer) clearTimeout(this.armSseTimer);\n this.armSseTimer = setTimeout(() => {\n if (!committed && !stale()) startFallback(); // SSE didn't connect / went silent\n }, ms);\n };\n rearmStale(SSE_ARM_CONNECT_GRACE_MS);\n\n this.armingSub = subscribeLiveState({\n eventsBaseUrl,\n matchUrn: this.opts.matchUrn,\n credential: this.opts.credential,\n onAlive: () => rearmStale(SSE_ARM_LIVENESS_MS),\n onState: (ev) => {\n if (committed || stale()) return;\n // live → go play; gone → resolve (will 410) so the terminal surfaces.\n if (ev.state === 'live' || ev.state === 'gone') {\n setTimeout(resolveOnce, Math.random() * SSE_ARM_RESOLVE_JITTER_MS);\n }\n },\n onError: () => startFallback(),\n });\n }\n\n private closeArming(): void {\n if (this.armSseTimer) {\n clearTimeout(this.armSseTimer);\n this.armSseTimer = null;\n }\n // Cancel the SSE arm's polling fallback too — reload()/retry()/destroy()\n // all route through here, and an orphaned poll would keep hitting\n // /v1/playback and emitting stale 'waiting' for a dead generation.\n this.armFallbackCtrl?.abort();\n this.armFallbackCtrl = null;\n this.armingSub?.close();\n this.armingSub = null;\n }\n\n private buildWaitConfig(gen: number): WaitForLive {\n const base: WaitForLiveOptions =\n this.opts.waitForLive && this.opts.waitForLive !== true ? this.opts.waitForLive : {};\n return {\n ...base,\n onState: (s) => {\n // Generation guard: an orphaned poll (superseded by reload/destroy)\n // must not flip a newer generation's state back to 'waiting'.\n if (this.destroyed || gen !== this.loadGen) return;\n this.setState('waiting');\n this.emit('waiting', s);\n base.onState?.(s);\n },\n };\n }\n\n /**\n * Build and attach the engine for a resolved descriptor. `gen` is the load\n * generation this attempt belongs to: it is re-checked after every await so a\n * destroy()/reload() that lands mid-flight cannot leave an orphaned engine or\n * timers behind (the continuation cleans up its locals and returns).\n */\n private async onResolved(descriptor: StreamDescriptor, gen: number): Promise<void> {\n if (this.destroyed || gen !== this.loadGen) return;\n this._descriptor = descriptor;\n this.endedHandled = false; // fresh playback can end again\n this._endedReason = null;\n\n const credential = await resolveCredential(this.opts.credential);\n if (this.destroyed || gen !== this.loadGen) return; // superseded during credential mint\n this.setState('loading');\n\n // Autoplay off and no play() yet: attach a deferLoad engine — it parses\n // the manifest ('ready', track lists) but fetches no media and mints no\n // DRM license until the first play(). Without this the pre-play player\n // buffered the live stream forever: state never left 'loading' (spinner,\n // no poster card) and hls.js force-seeked the paused video to the live\n // edge every ~12s (the drift ceiling), repainting the frame (AV-202).\n const deferLoad = this.opts.autoplay === false && !this.playRequested;\n const engine = this.createEngine(descriptor, credential, deferLoad);\n const detachMedia = attachMediaStateListeners(this.opts.video, (s) => {\n // Terminal latch: after 'ended'/'error' the media element stays attached\n // and its queued events — the 'pause' from engine.stop(), buffer-drain\n // 'waiting'/'stalled' after a fatal error — would flip the state right\n // back off the terminal value (a host rendering an overlay from\n // statechange sees it dismiss itself). Explicit transitions\n // (retry/reload/load → 'loading', destroy → 'idle') call setState\n // directly and are unaffected.\n if (this._state === 'ended' || this._state === 'error') return;\n this.setState(s);\n });\n await engine.load();\n if (this.destroyed || gen !== this.loadGen) {\n // Torn down or superseded while loading — release what we just built.\n detachMedia();\n engine.destroy();\n return;\n }\n if (this._state === 'error') {\n // A FATAL engine error was emitted during load() (e.g. the platform\n // fail-fast: DRM required but no key system usable here). Do not wire\n // the dead engine — no stats ticker, no 8-minute license-refresh\n // resolves, no SSE subscription. start() additionally turns this into\n // a createPlayer rejection: the emit fired before the caller could\n // attach listeners, so the event alone is unobservable on first load.\n detachMedia();\n engine.destroy();\n return;\n }\n\n this.engine = engine;\n this.detachMedia = detachMedia;\n if (this.opts.maxBitrate != null) engine.setMaxBitrate(this.opts.maxBitrate);\n this.startStats();\n this.scheduleLicenseRefresh();\n this.startLiveState(gen);\n this.startAnalytics(descriptor, engine);\n if (deferLoad) {\n // Pre-play: 'paused' is what renders the poster + play card (and hides\n // the spinner — 'loading' means work is happening, and none is). No\n // media event will fire to move the state until play() starts loading.\n this.pendingStartLoad = true;\n this.setState('paused');\n // Native engine: its 'ready' (loadedmetadata) cannot fire pre-play\n // under preload=\"none\", and the engine-side emit is suppressed for a\n // deferred instance — emit it here instead, so host event timing\n // matches the hls.js engine (ready from MANIFEST_PARSED, which still\n // loads). Deferred a MACROTASK: this runs synchronously inside\n // start()/reload(), before createPlayer resolves — a synchronous emit\n // would fire before the host can attach listeners and before the\n // analytics session exists to stamp noteReady(). Same pattern as the\n // SSE arm's 'waiting' emit.\n if (engine.name === 'native') {\n setTimeout(() => {\n if (this.destroyed || gen !== this.loadGen || this.engine !== engine) return;\n this.analyticsSession?.noteReady();\n this.emit('ready');\n }, 0);\n }\n }\n }\n\n /**\n * Start the QoE beacon session for a committed engine (plan A2). This is\n * deliberately AFTER engine selection: player identity is only truthful\n * once the engine is chosen (the SessionStart contract). A reload/retry\n * resolves a NEW sid, so the superseded session ends as STOPPED first.\n */\n /**\n * The beacon session ended itself (END_REASON_IDLE). Stop the engine with\n * it: a paused player has nothing to keep loading, and a player that\n * slept through the idle limit while playing would otherwise carry on\n * unbeaconed, miles behind live. stop() pauses the element, so the viewer\n * sees a paused player and the next play() rejoins (see idleClosed). Only\n * for the engine the session belonged to — a reload that raced the timer\n * has already replaced both.\n */\n private onAnalyticsIdle(engine: PlaybackEngine): void {\n if (this.destroyed || this.engine !== engine) return;\n this.idleClosed = true;\n this.pendingStartLoad = false; // a deferred engine is stopped too; play() reloads instead\n engine.stop();\n }\n\n private startAnalytics(descriptor: StreamDescriptor, engine: PlaybackEngine): void {\n this.analyticsSession?.noteDestroyed();\n this.analyticsSession = null;\n this.detachMediaListeners?.();\n\n if (this.opts.analytics === false) return;\n const sid = descriptor.analytics?.sid;\n if (!sid) return; // server-side analytics flag is off — nothing to do\n const explicit =\n typeof this.opts.analytics === 'object' ? this.opts.analytics.endpoint : undefined;\n const endpoint = explicit ?? deriveBeaconsBaseUrl(this.opts.baseUrl);\n if (!endpoint) return;\n\n const video = this.opts.video;\n const session = new AnalyticsSession({\n endpoint,\n sid,\n player: engine.name === 'native' ? 'PLAYER_SAFARI_NATIVE' : 'PLAYER_HLSJS',\n sdkVersion: SDK_VERSION,\n environment: collectEnvironment(video),\n idleMs: typeof this.opts.analytics === 'object' ? this.opts.analytics.idleMs : undefined,\n onIdle: () => this.onAnalyticsIdle(engine),\n sample: () => {\n const stats = this.engine?.getStats() ?? EMPTY_STATS;\n const buffered = video.buffered.length\n ? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)\n : 0;\n return {\n ...stats,\n bufferedMs: Math.round(buffered * 1000),\n state: this._state,\n positionMs: Math.round(video.currentTime * 1000),\n playbackRate: video.playbackRate,\n };\n },\n });\n this.analyticsSession = session;\n const onSeeking = () => session.noteSeek();\n const onVolume = () => session.noteVolume(video.muted, video.volume);\n video.addEventListener('seeking', onSeeking);\n video.addEventListener('volumechange', onVolume);\n this.detachMediaListeners = () => {\n video.removeEventListener('seeking', onSeeking);\n video.removeEventListener('volumechange', onVolume);\n };\n session.start();\n // Seeds the muted/active-watch accounting for a player that starts muted\n // (the autoplay-friendly default), which fires no volumechange of its\n // own. seedVolume, not noteVolume: the starting state is not something\n // the viewer did, and counting it would put a phantom change on every\n // session.\n session.seedVolume(video.muted, video.volume);\n session.notePresentation(this.currentPresentation());\n // Model and OS version resolve asynchronously; SessionStart is held\n // briefly for exactly this, and a browser without client hints simply\n // never patches anything.\n void collectHighEntropy().then((extra) => session.noteEnvironment(extra));\n // Autoplay disabled is a verdict this session already has: nothing will\n // be attempted, so say so rather than leaving the field unset.\n if (this.opts.autoplay === false) session.noteAutoplay('AUTOPLAY_RESULT_NOT_ATTEMPTED');\n }\n\n /** Which surface the video is showing on right now. */\n private currentPresentation(): Presentation {\n const video = this.opts.video as HTMLVideoElement & {\n webkitDisplayingFullscreen?: boolean;\n };\n if (typeof document !== 'undefined' && document.pictureInPictureElement === video) return 'pip';\n const fs = typeof document !== 'undefined' ? document.fullscreenElement : null;\n if (video.webkitDisplayingFullscreen === true) return 'fullscreen';\n if (fs && (fs === video || fs.contains(video))) return 'fullscreen';\n return 'inline';\n }\n\n private createEngine(\n descriptor: StreamDescriptor,\n credential: Credential,\n deferLoad = false,\n ): PlaybackEngine {\n const hlsConfig: Partial<HlsConfig> = { ...this.opts.hlsConfig };\n if (this.opts.startLevel != null) hlsConfig.startLevel = this.opts.startLevel;\n // liveLatencyTarget -> hls.js liveSyncDuration. The engine's normalizeLiveSync\n // derives the matching drift ceiling (liveMaxLatencyDuration, kept strictly\n // above the target) on the final merged config, so we set only the target\n // here and never risk an illegal pair. When unset, the engine default target\n // (2s) applies. liveCatchUpRate is no longer a public knob — the engine's\n // 1.5x catch-up + that ceiling handle drift; advanced callers can still set\n // maxLiveSyncPlaybackRate (or the raw live-sync keys) via hlsConfig.\n if (this.opts.liveLatencyTarget != null)\n hlsConfig.liveSyncDuration = this.opts.liveLatencyTarget;\n\n const deps = {\n video: this.opts.video,\n descriptor,\n credential,\n deviceId: getDeviceId(),\n userId: this.opts.userId,\n lowLatency:\n this.opts.lowLatency === undefined || this.opts.lowLatency === 'auto'\n ? true\n : this.opts.lowLatency,\n snapToLiveOnRefocus: this.opts.snapToLiveOnRefocus,\n debug: this.opts.debug,\n deferLoad,\n hlsConfig,\n emit: (ev: EngineEvent) => this.onEngineEvent(ev),\n };\n const choice = this.opts.engine ?? 'auto';\n if (choice === 'native') return new NativeHlsEngine(deps);\n if (choice === 'hls') return new HlsEngine(deps);\n // auto: FairPlay content on an Apple browser goes to the NATIVE engine.\n // hls.js/MSE cannot decrypt FairPlay here — WebKit binds the key through\n // the fragment `sinf` box, so a session built from a synthesized skd asset\n // ID reports 'usable' and still renders nothing. Measured on macOS Safari\n // 26.5 against live DRM (2026-08-06): hls.js+MSE 1 pass / 6, native 4 / 4.\n // Everything else keeps hls.js, which gives Widevine + LL-HLS control.\n //\n // webkitMediaStack() is what makes \"on an Apple browser\" true: Chromium\n // answers canPlayType('application/vnd.apple.mpegurl') with \"maybe\" while\n // being unable to play HLS natively, so nativeHlsSupported() alone sent\n // every DRM-enabled Chrome session into the native engine and failed it.\n const fairplayOnApple =\n descriptor.drmEnabled &&\n Boolean(descriptor.drm?.fairplay?.licenseUrl && descriptor.drm.fairplay.certificateUrl) &&\n nativeHlsSupported(this.opts.video) &&\n webkitMediaStack();\n if (fairplayOnApple) return new NativeHlsEngine(deps);\n if (hlsSupported()) return new HlsEngine(deps);\n if (nativeHlsSupported(this.opts.video)) return new NativeHlsEngine(deps);\n throw new PlaybackError('INTERNAL', 0, 'no supported HLS playback engine in this browser');\n }\n\n private onEngineEvent(ev: EngineEvent): void {\n if (this.destroyed) return;\n switch (ev.type) {\n case 'ready':\n this.analyticsSession?.noteReady();\n this.emit('ready');\n // playRequested keeps autoplay-off resume semantics: once the viewer\n // pressed play, a re-attach (rejoin after an interruption, a play()\n // during the armed wait, reload) resumes without a second gesture —\n // otherwise it would sit in 'loading' forever, exactly AV-202 again.\n if (this.opts.autoplay !== false || this.playRequested) void this.play();\n break;\n case 'lowLatencyDetected':\n if (this._descriptor) this._descriptor.isLowLatency = ev.value;\n break;\n case 'qualitychange':\n this.emit('qualitychange', ev.index);\n break;\n case 'audiotrackchange':\n this.emit('audiotrackchange', ev.id);\n break;\n case 'texttrackchange':\n this.emit('texttrackchange', ev.id);\n break;\n case 'ended':\n // HLS-layer end signal (ENDLIST, or the staleness watchdog catching a\n // frozen Tencent manifest). The SSE live-state path can reach the same\n // handler first; endedHandled dedupes.\n this.onStreamEnded();\n break;\n case 'error':\n this.analyticsSession?.noteError(\n ev.error.code,\n ev.error.message,\n ev.fatal,\n ev.error.httpStatus ?? 0,\n Math.round(this.opts.video.currentTime * 1000),\n ev.detail,\n );\n if (ev.fatal) this.fail(ev.error);\n else this.emit('warning', ev.error);\n break;\n }\n }\n\n /**\n * Start the push-based live-state subscription for the committed playback.\n * On `ended`/`gone` it routes to onStreamEnded — the fast, clean end signal\n * (fires when the bridge flips, before the manifest would even 503). Failures\n * are non-fatal: the subscription retries in the background and the HLS\n * staleness watchdog (engine 'ended') is the layer-fallback, so a missing or\n * unreachable events endpoint never affects playback. But a PERSISTENTLY\n * failing subscription (blocked origin, wrong events host, CSP) used to be\n * fully invisible — end detection silently degraded to the watchdog with no\n * way to tell from the outside. After LIVE_STATE_WARN_AFTER consecutive\n * failures with no data ever flowing, a one-time non-fatal 'warning' is\n * emitted so integrators can see it; a later successful connect resets the\n * counter (and re-arms the warning) since the outage evidently healed.\n */\n private startLiveState(gen: number): void {\n if (this.opts.liveStateEvents === false) return;\n const base = this.opts.eventsBaseUrl ?? deriveEventsBaseUrl(this.opts.baseUrl);\n if (!base) return;\n this.closeLiveState();\n let failures = 0;\n let warned = false;\n this.liveStateSub = subscribeLiveState({\n eventsBaseUrl: base,\n matchUrn: this.opts.matchUrn,\n credential: this.opts.credential,\n onState: (ev) => {\n if (this.destroyed || gen !== this.loadGen) return;\n if (ev.state === 'gone') {\n // Past the catch-up window — truly terminal, nothing left to rejoin.\n this.onStreamEnded(true, ev.reason ?? 'match_ended');\n } else if (ev.state === 'ended') {\n this.onStreamEnded(false, ev.reason);\n } else if (ev.state === 'live' && this.endedHandled) {\n // A live push AFTER end-of-stream means the bridge recreated the\n // session (encoder reconnected past the grace window) — and it now\n // serves under a NEW manifest URL, so only a re-resolve gets back.\n this.scheduleRejoin(gen);\n }\n },\n onAlive: () => {\n // Data flowed — the subscription is healthy. Reset so only a NEW\n // sustained outage (not routine reconnects) can trigger the warning.\n failures = 0;\n warned = false;\n },\n onError: () => {\n if (this.destroyed || gen !== this.loadGen || warned) return;\n // After end-of-stream the subscription's only remaining job is the\n // rejoin trigger — end detection already happened, so the degraded-\n // detection warning below would be misinformation there.\n if (this.endedHandled) return;\n failures += 1;\n if (failures >= LIVE_STATE_WARN_AFTER) {\n warned = true;\n this.emit(\n 'warning',\n new PlaybackError(\n 'NETWORK',\n 0,\n `live-state events unreachable at ${base} after ${failures} attempts — ` +\n `end-of-stream detection degraded to the HLS staleness watchdog. ` +\n `Check the events host, the api-key's allowed origins, and CSP connect-src.`,\n ),\n );\n }\n },\n });\n }\n\n private closeLiveState(): void {\n this.liveStateSub?.close();\n this.liveStateSub = null;\n }\n\n /**\n * End-of-stream: stop the engine's buffer/poll loop (keeping the last\n * frame) and surface `ended` (via setState). Idempotent across the SSE and\n * HLS-watchdog signals. The live-state subscription closes only when the\n * end is `terminal` (`gone` — past the catch-up window) or rejoin is off;\n * otherwise it stays open as the rejoin trigger — a later pushed `live`\n * means the session was recreated and playback can re-arm (scheduleRejoin).\n */\n private onStreamEnded(terminal = false, reason?: EndedReason): void {\n if (this.destroyed) return;\n if (terminal || !this.rejoinEnabled()) {\n this.cancelRejoin();\n this.closeLiveState();\n }\n if (this.endedHandled) {\n // Already ended, but the SERVER may have refined WHY: an outage that\n // outlived the bridge's hold reports `interrupted` first and flips to\n // `match_ended` when the match actually finishes (havik-streams #191\n // publishes that as a second event — the state never changes, only the\n // reason). Upgrade the reason and re-render so a viewer stops being\n // told \"reconnecting\" for a match that is over. Never downgrade: once\n // we know the match ended, a late `interrupted` cannot un-end it.\n if (reason === 'match_ended' && this._endedReason !== 'match_ended') {\n this._endedReason = reason;\n // Only `ended` — NOT `statechange`. The state does not transition\n // here (it was, and stays, 'ended'), and `statechange` is documented\n // as a transition signal: re-emitting it would make an\n // edge-triggered host run its end-of-stream work twice. `ended`\n // re-firing with a refined reason IS part of its contract (see\n // PlayerEventMap), and the built-in control bar re-renders off it.\n this.emit('ended', reason);\n }\n return;\n }\n this.endedHandled = true;\n this._endedReason = reason ?? null;\n this.stopRefresh();\n this.pendingStartLoad = false; // the engine is stopped; a later play() must not re-kick it\n this.engine?.stop();\n this.setState('ended');\n }\n\n private rejoinEnabled(): boolean {\n return this.opts.rejoinOnLive !== false && this.opts.liveStateEvents !== false;\n }\n\n /**\n * A `live` push arrived after end-of-stream. Jittered like the armed\n * go-live resolve, so a popular stream's stranded viewers don't all\n * re-resolve in lockstep when the encoder comes back.\n */\n private scheduleRejoin(gen: number): void {\n if (!this.rejoinEnabled() || this.rejoinTimer) return;\n this.rejoinTimer = setTimeout(() => {\n this.rejoinTimer = null;\n if (this.destroyed || gen !== this.loadGen || this._state !== 'ended') return;\n this.rejoin();\n }, Math.random() * SSE_ARM_RESOLVE_JITTER_MS);\n }\n\n private cancelRejoin(): void {\n if (this.rejoinTimer) {\n clearTimeout(this.rejoinTimer);\n this.rejoinTimer = null;\n }\n }\n\n /**\n * Tear down the ended playback and re-enter the armed wait under a fresh\n * load generation. beginArming (SSE-first) re-resolves off the events\n * snapshot / `live` push, which keeps this robust to the recreate race: if\n * the pushed `live` slightly precedes /v1/playback consistency, the arming\n * ladder keeps waiting through waitable errors instead of failing the\n * player, and a terminal answer (`gone` → 410) still surfaces as an error.\n */\n private rejoin(): void {\n const gen = ++this.loadGen; // supersedes anything stale for the old playback\n this.teardownPlayback();\n this.setState('waiting');\n this.beginArming(gen, undefined, true);\n }\n\n /**\n * Stop and release the committed-playback plumbing: stats, license refresh,\n * any pending rejoin, arming, the live-state subscription, media listeners,\n * and the engine. Callers bump loadGen first so in-flight continuations for\n * the old playback go stale. destroy() keeps its own sequence — it\n * interleaves these with controls/PiP/analytics teardown in a load-bearing\n * order.\n */\n private teardownPlayback(): void {\n this.stopStats();\n this.stopRefresh();\n this.cancelRejoin();\n this.closeArming();\n this.closeLiveState();\n this.detachMedia?.();\n this.engine?.destroy();\n this.engine = null;\n this.pendingStartLoad = false; // the deferred engine is gone with it\n this.idleClosed = false; // and so is the idle-closed session; the next engine starts clean\n }\n\n /**\n * Set up the chosen controls. 'custom' (default) wraps the <video> in a\n * `.havik-player` container, applies the (themeable) Oddin skin, and mounts\n * the control bar. 'native' shows the browser UI; 'none' shows nothing.\n */\n private mountControls(): void {\n const mode = this.opts.controls ?? 'custom';\n const video = this.opts.video;\n if (mode === 'native') {\n video.controls = true;\n return;\n }\n if (mode === 'none') {\n video.controls = false;\n return;\n }\n video.controls = false;\n const doc = video.ownerDocument ?? document;\n const container = doc.createElement('div');\n const parent = video.parentNode;\n if (parent) parent.insertBefore(container, video);\n container.appendChild(video);\n applyTheme(container, this.opts.theme);\n this.container = container;\n this.controlBar = mountControlBar({\n root: container,\n video,\n player: this,\n logoUrl: this.opts.theme?.logoUrl,\n statusOverlays: this.opts.statusOverlays,\n endedMessage: this.opts.endedMessage,\n interruptedMessage: this.opts.interruptedMessage,\n errorMessage: this.opts.errorMessage,\n });\n }\n\n private unmountControls(): void {\n this.controlBar?.destroy();\n this.controlBar = null;\n const container = this.container;\n if (container) {\n if (container.parentNode) {\n container.parentNode.insertBefore(this.opts.video, container);\n container.remove();\n } else if (this.opts.video.parentNode === container) {\n // Mounted while the video was still detached (host creates the\n // element before inserting it): there is no parent to restore into,\n // but the video must still be released from the wrapper — otherwise\n // a destroyed player (or a rejected createPlayer, where the caller\n // never gets a handle to destroy) leaves an orphan container holding\n // the element.\n container.removeChild(this.opts.video);\n }\n }\n this.container = null;\n }\n\n private attachPipListeners(): void {\n const v = this.opts.video;\n const sync = () => this.analyticsSession?.notePresentation(this.currentPresentation());\n const onEnter = () => {\n sync();\n this.emit('pipchange', true);\n };\n const onLeave = () => {\n sync();\n this.emit('pipchange', false);\n };\n v.addEventListener('enterpictureinpicture', onEnter);\n v.addEventListener('leavepictureinpicture', onLeave);\n // Fullscreen is a document-level event and the QoE plane wants the same\n // answer for both surfaces, so one sync serves them: how long a viewer\n // watched fullscreen, in PiP, or inline.\n const doc = typeof document !== 'undefined' ? document : undefined;\n doc?.addEventListener('fullscreenchange', sync);\n doc?.addEventListener('webkitfullscreenchange', sync);\n this.detachPip = () => {\n v.removeEventListener('enterpictureinpicture', onEnter);\n v.removeEventListener('leavepictureinpicture', onLeave);\n doc?.removeEventListener('fullscreenchange', sync);\n doc?.removeEventListener('webkitfullscreenchange', sync);\n };\n }\n\n private startStats(): void {\n this.stopStats();\n const interval = Math.max(500, this.opts.statsIntervalMs ?? DEFAULT_STATS_INTERVAL_MS);\n this.statsTimer = setInterval(() => {\n if (!this.engine) return;\n const stats = this.engine.getStats();\n // First-segment startup stage at stats granularity (~2s), not the 15s\n // beacon cadence — first_frag_load_ms would otherwise overreport badly.\n if ((stats.bytesLoaded ?? 0) > 0) this.analyticsSession?.noteFirstBytes();\n this.emit('stats', stats);\n }, interval);\n }\n\n private stopStats(): void {\n if (this.statsTimer) clearInterval(this.statsTimer);\n this.statsTimer = undefined;\n }\n\n private scheduleLicenseRefresh(): void {\n this.stopRefresh();\n const interval = this.opts.licenseRefreshMs ?? DEFAULT_LICENSE_REFRESH_MS;\n if (!interval || !this._descriptor?.drmEnabled) return;\n this.refreshTimer = setInterval(() => void this.refreshLicense(), interval);\n }\n\n private stopRefresh(): void {\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.refreshTimer = undefined;\n }\n\n private async refreshLicense(): Promise<void> {\n const engine = this.engine;\n if (this.destroyed || !engine) return;\n try {\n const fresh = await resolvePlaybackOnce(this.resolveOptions());\n // A reload()/destroy() may have swapped or dropped the engine while the\n // refresh GET was in flight — don't apply a stale URL to a newer engine.\n if (this.destroyed || this.engine !== engine) return;\n const widevine = fresh.drm?.widevine?.licenseUrl;\n const fairplay = fresh.drm?.fairplay?.licenseUrl;\n if (widevine || fairplay) {\n engine.setLicenseUrls({ widevine, fairplay });\n this._descriptor = fresh;\n }\n } catch (err) {\n // A terminal error (e.g. 410 ended) means there's nothing left to refresh;\n // surface it as a non-fatal warning so a stalling refresh is observable.\n if (err instanceof PlaybackError && !isWaitable(err.code)) {\n this.stopRefresh();\n if (!this.destroyed) this.emit('warning', err);\n }\n }\n }\n\n async play(): Promise<void> {\n if (this.destroyed) return;\n const firstRequest = !this.playRequested;\n this.playRequested = true;\n // After an idle close the session and the engine are gone: rejoin. With\n // playRequested set, reload() → 'ready' re-enters play() on the fresh\n // engine, where the ordinary path below runs.\n // Not from a terminal state: an `ended` push that landed after the idle\n // close owns the UI exactly as it does below, and a reload would resolve\n // a finished match into an error card.\n if (this.idleClosed && this._state !== 'ended' && this._state !== 'error') {\n this.idleClosed = false;\n await this.reload();\n return;\n }\n // Terminal states own the UI; a play() arriving there (the control-bar\n // button stays clickable on the ended card) must not kick the loader and\n // resurrect 'loading' over an ended/error stream — the pre-PR player\n // resumed buffered frames there, and a dead session has nothing to load.\n const kickedDeferred =\n this.pendingStartLoad && this._state !== 'ended' && this._state !== 'error';\n // Startup is measured from HERE for the autoplay-off path, not from\n // load(): the player has been sitting behind a poster with the manifest\n // already parsed, and the time the viewer spent deciding is theirs.\n //\n // Gated on the PLAYER being autoplay-off, not on kickedDeferred, and\n // that difference is load-bearing. A gestureless play() that the policy\n // blocks still clears pendingStartLoad, and the blocked path\n // deliberately leaves the loader warm rather than re-latching — so the\n // eventual real gesture does NOT take the kicked branch. Anchoring there\n // would have pinned startup to the failed probe and measured the whole\n // wait for the gesture as latency, which is the bug this is here to fix.\n // notePlayIntent's own guard stops a post-playback resume re-anchoring a\n // startup that already finished; before the first frame, the LAST intent\n // is the right one. The blocked branch below says the same thing in\n // words: \"a policy-blocked play() is not viewer intent\".\n //\n // autoplay:true never reaches this: its intent is at load(), where the\n // manifest fetch genuinely is part of startup, so its ladder keeps\n // measuring from bornMs.\n if (this.opts.autoplay === false) this.analyticsSession?.notePlayIntent();\n if (kickedDeferred) {\n // First play() on an autoplay-off player: start media loading now.\n // 'loading' is honest again from here — the spinner shows while the\n // first segments and (when enabled) the DRM license are fetched, until\n // the media element's own events take over.\n this.pendingStartLoad = false;\n this.setState('loading');\n this.engine?.startLoad();\n }\n try {\n await this.opts.video.play();\n // The autoplay verdict, which only exists once play() has resolved.\n // Reported on the FIRST attempt of a session; SessionStart has gone by\n // the time a later user gesture plays, so a hand-started session can\n // never overwrite it.\n if (firstRequest) {\n this.analyticsSession?.noteAutoplay(\n this.opts.video.muted ? 'AUTOPLAY_RESULT_ALLOWED_MUTED' : 'AUTOPLAY_RESULT_ALLOWED',\n );\n }\n } catch (err) {\n // Autoplay blocked by browser policy → emit 'autoplayblocked' so the host\n // can show a tap-to-play / tap-to-unmute affordance. Other rejections\n // (e.g. a competing-load AbortError) are benign; genuine playback failures\n // arrive via the 'error' event from the engine.\n if (err instanceof DOMException && err.name === 'NotAllowedError') {\n this.analyticsSession?.noteAutoplay('AUTOPLAY_RESULT_BLOCKED');\n // A policy-blocked play() is not viewer intent: nothing ever played.\n // Un-latch, so a later reload/rejoin does not keep attempting\n // gestureless auto-plays — but only when THIS call set the latch; a\n // block after real playback must keep resume semantics.\n if (firstRequest) this.playRequested = false;\n // And put the pre-play card back instead of the spinner the blocked\n // kick left up. The loader is not stopped HERE: the warmed buffer\n // makes a prompt gesture play() start instantly; the hls.js engine's\n // own pause suspension stops it after its grace (PAUSE_SUSPEND_MS)\n // so a play card nobody clicks does not pull the live stream forever.\n // The state guard keeps a mid-await teardown/reload/ended transition\n // untouched.\n if (kickedDeferred && !this.destroyed && this._state === 'loading') {\n this.setState('paused');\n }\n this.emit('autoplayblocked');\n }\n }\n }\n\n pause(): void {\n if (this.destroyed) return;\n this.opts.video.pause();\n }\n setMuted(muted: boolean): void {\n if (this.destroyed) return;\n this.opts.video.muted = muted;\n }\n setVolume(volume: number): void {\n if (this.destroyed) return;\n this.opts.video.volume = Math.min(1, Math.max(0, volume));\n }\n\n getStats(): PlaybackStats {\n return this.engine?.getStats() ?? EMPTY_STATS;\n }\n\n getQualityLevels(): QualityLevel[] {\n return this.engine?.getQualityLevels() ?? [];\n }\n getCurrentQuality(): number {\n return this.engine?.getCurrentQuality() ?? -1;\n }\n setQuality(index: number | 'auto'): void {\n if (this.destroyed) return;\n this.engine?.setQuality(index === 'auto' ? -1 : index);\n }\n setMaxBitrate(bitrate: number | null): void {\n if (this.destroyed) return;\n this.engine?.setMaxBitrate(bitrate);\n }\n getAudioTracks(): AudioTrackInfo[] {\n return this.engine?.getAudioTracks() ?? [];\n }\n setAudioTrack(id: number): void {\n if (this.destroyed) return;\n this.engine?.setAudioTrack(id);\n }\n getTextTracks(): TextTrackInfo[] {\n return this.engine?.getTextTracks() ?? [];\n }\n setTextTrack(id: number): void {\n if (this.destroyed) return;\n this.engine?.setTextTrack(id);\n }\n seekToLive(): void {\n if (this.destroyed) return;\n this.engine?.seekToLive();\n }\n async enterPictureInPicture(): Promise<void> {\n if (this.destroyed) return;\n const v = this.opts.video as HTMLVideoElement & {\n requestPictureInPicture?: () => Promise<unknown>;\n };\n if (typeof v.requestPictureInPicture === 'function') await v.requestPictureInPicture();\n }\n async exitPictureInPicture(): Promise<void> {\n const doc = document as Document & {\n exitPictureInPicture?: () => Promise<void>;\n pictureInPictureElement?: Element | null;\n };\n if (\n doc.pictureInPictureElement === this.opts.video &&\n typeof doc.exitPictureInPicture === 'function'\n ) {\n await doc.exitPictureInPicture();\n }\n }\n async enterFullscreen(): Promise<boolean> {\n if (this.destroyed) return false;\n // The custom-controls wrapper (when mounted) is the element-fullscreen\n // target so the branded UI stays visible; native/none modes present the\n // bare video, where the browser's own fullscreen controls appear.\n return enterVideoFullscreen(this.opts.video, this.container ?? undefined);\n }\n async exitFullscreen(): Promise<void> {\n await exitVideoFullscreen(this.opts.video, this.container ?? undefined);\n }\n retry(): Promise<void> {\n return this.reload();\n }\n\n async reload(): Promise<void> {\n if (this.destroyed) return;\n const gen = ++this.loadGen; // supersedes any in-flight load/arming\n this.teardownPlayback();\n try {\n const descriptor = await resolveStream({\n ...this.resolveOptions(),\n waitForLive: this.opts.waitForLive ? this.buildWaitConfig(gen) : undefined,\n });\n await this.onResolved(descriptor, gen);\n if (gen === this.loadGen && this._state === 'error' && this._lastError) {\n // Fatal engine load — already routed through fail() by the engine\n // event channel; reject so the awaiting caller observes it too.\n throw this._lastError;\n }\n } catch (err) {\n if (this.destroyed || this.abort.signal.aborted || gen !== this.loadGen) return;\n // Route a failed reload through the error channel (consistent with start/arming)\n // AND reject so an awaiting caller observes it — unless it already went\n // through fail() above (double-emitting 'error' would fire host handlers twice).\n if (err !== this._lastError) {\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n }\n throw err;\n }\n }\n\n on<E extends PlayerEvent>(event: E, cb: (payload: PlayerEventMap[E]) => void): () => void {\n const set = (this.listeners[event] ??= new Set()) as Set<(p: PlayerEventMap[E]) => void>;\n set.add(cb);\n return () => set.delete(cb);\n }\n\n destroy(): void {\n if (this.destroyed) return;\n this.destroyed = true;\n this.loadGen += 1; // invalidate any in-flight load/arming continuation\n this.abort.abort();\n this.stopStats();\n this.stopRefresh();\n // Tear the control bar down (and unsubscribe its listeners) before the final\n // 'idle' emit so it never touches a half-dismantled DOM.\n this.unmountControls();\n this.cancelRejoin();\n this.closeArming();\n this.closeLiveState();\n this.detachMedia?.();\n this.detachPip?.();\n // End the beacon session BEFORE the engine dies so the final sample can\n // still read totals; no-op if 'ended'/fatal already closed it.\n this.analyticsSession?.noteDestroyed();\n this.analyticsSession = null;\n this.detachMediaListeners?.();\n this.engine?.destroy();\n this.engine = null;\n this.setState('idle'); // final terminal signal to any still-registered listeners\n this.clearListeners();\n }\n\n private clearListeners(): void {\n for (const key of Object.keys(this.listeners)) {\n (this.listeners as Record<string, Set<unknown> | undefined>)[key]?.clear();\n }\n }\n\n private fail(err: PlaybackError): void {\n this._lastError = err;\n this.setState('error');\n // After the terminal StateChange: SessionEnd(FATAL_ERROR) closes the\n // beacon session — further events for this sid are meaningless.\n this.analyticsSession?.noteFatal();\n this.emit('error', err);\n }\n\n private setState(state: PlayerState): void {\n if (state === this._state) return;\n const from = this._state;\n this._state = state;\n if (this.analyticsSession) {\n const video = this.opts.video;\n this.analyticsSession.noteStateChange(\n from,\n state,\n Math.round(video.currentTime * 1000),\n this.engine?.getStats().levelHeight,\n );\n if (state === 'ended') this.analyticsSession.noteEnded();\n }\n this.emit('statechange', state);\n switch (state) {\n case 'playing':\n this.emit('playing');\n break;\n case 'buffering':\n this.emit('buffering');\n break;\n case 'paused':\n this.emit('paused');\n break;\n case 'ended':\n this.emit('ended', this._endedReason ?? undefined);\n break;\n default:\n break;\n }\n }\n\n private emit<E extends PlayerEvent>(event: E, payload?: PlayerEventMap[E]): void {\n const set = this.listeners[event] as\n Set<(p: PlayerEventMap[E] | undefined) => void> | undefined;\n set?.forEach((cb) => {\n try {\n cb(payload);\n } catch {\n // never let a listener break the player\n }\n });\n }\n}\n","import { type PlaybackErrorCode } from '../core/errors';\nimport {\n type AudioTrackInfo,\n type PlayerState,\n type QualityLevel,\n type TextTrackInfo,\n} from '../core/stats';\n\n/** postMessage protocol version. Bump on a breaking change; sent in `oddin:ready`. */\nexport const PROTOCOL_VERSION = 1;\n\n/** Commands the host page sends down to the iframe. */\nexport type HostToFrame =\n | { type: 'oddin:play' }\n | { type: 'oddin:pause' }\n | { type: 'oddin:setMuted'; muted: boolean }\n | { type: 'oddin:setVolume'; volume: number }\n | { type: 'oddin:setQuality'; index: number } // -1 = auto\n | { type: 'oddin:setMaxBitrate'; bitrate: number | null }\n | { type: 'oddin:setAudioTrack'; id: number }\n | { type: 'oddin:setTextTrack'; id: number } // -1 = off\n | { type: 'oddin:seekToLive' }\n | { type: 'oddin:enterPip' }\n | { type: 'oddin:exitPip' }\n | { type: 'oddin:retry' }\n | { type: 'oddin:load'; matchUrn: string }\n | { type: 'oddin:destroy' };\n\n/** Events the iframe sends up to the host page. */\nexport type FrameToHost =\n | { type: 'oddin:ready'; protocol: number }\n | { type: 'oddin:autoplayblocked' }\n | { type: 'oddin:state'; state: PlayerState }\n | { type: 'oddin:waiting'; retryInMs: number; attempt: number; phase: string }\n | {\n type: 'oddin:tracks';\n quality: QualityLevel[];\n audio: AudioTrackInfo[];\n text: TextTrackInfo[];\n }\n | { type: 'oddin:qualitychange'; index: number }\n | { type: 'oddin:audiotrackchange'; id: number }\n | { type: 'oddin:texttrackchange'; id: number }\n | {\n type: 'oddin:stats';\n droppedFrames: number;\n latencySeconds?: number;\n bandwidthKbps?: number;\n levelHeight?: number;\n lowLatency?: boolean;\n isLive?: boolean;\n atLiveEdge?: boolean;\n }\n | { type: 'oddin:error'; code: PlaybackErrorCode; message: string };\n\nexport function isOddinMessage(data: unknown): data is { type: string } {\n return (\n !!data &&\n typeof data === 'object' &&\n typeof (data as { type?: unknown }).type === 'string' &&\n (data as { type: string }).type.startsWith('oddin:')\n );\n}\n","import { type FrameToHost, type HostToFrame, isOddinMessage, PROTOCOL_VERSION } from './protocol';\n\nexport interface MountEmbedOptions {\n /** Element to mount the iframe into. */\n container: HTMLElement;\n /** URL of the hosted embed page, e.g. `https://player-dev.oddin-video.gg/embed/`. */\n src: string;\n /**\n * API base URL, forwarded to the embed page as a query parameter.\n *\n * ⚠️ QoE beacons: the beacons endpoint is derived from this host by\n * convention (`feed[-dev].<domain>` → `beacons[-dev].<domain>`). If your\n * base is NOT a feed host, nothing derives and analytics is off — and\n * `mountEmbed` CANNOT fix it, because the only way to name an endpoint is\n * {@link EmbedConfig.analytics}, which the embed page reads from injected\n * config and deliberately never from the query string (a link must not be\n * able to redirect a session's telemetry). Host the embed page yourself\n * and inject `window.HAVIK_EMBED_CONFIG` with `analytics: { endpoint }`.\n */\n baseUrl: string;\n matchUrn: string;\n /**\n * DEV/DEMO ONLY: appended as a query param so the embed page can read it.\n * In production the embed page is hosted by you and injects the key\n * server-side — do not pass it here.\n */\n apiKey?: string;\n /** Origin of the embed page used to verify its messages. Defaults to new URL(src).origin. */\n allowedFrameOrigin?: string;\n muted?: boolean;\n /**\n * iframe sandbox attribute. Default keeps playback + EME + fullscreen working\n * while blocking top-frame navigation and popups. Override only if you know\n * what you're doing.\n */\n sandbox?: string;\n /** Convenience callback for every event from the iframe. */\n onEvent?: (msg: FrameToHost) => void;\n}\n\nexport interface EmbedHandle {\n readonly iframe: HTMLIFrameElement;\n play(): void;\n pause(): void;\n setMuted(muted: boolean): void;\n setVolume(volume: number): void;\n setQuality(index: number): void; // -1 = auto\n setMaxBitrate(bitrate: number | null): void;\n setAudioTrack(id: number): void;\n setTextTrack(id: number): void; // -1 = off\n seekToLive(): void;\n enterPip(): void;\n exitPip(): void;\n retry(): void;\n load(matchUrn: string): void;\n on(cb: (msg: FrameToHost) => void): () => void;\n destroy(): void;\n}\n\n/**\n * Mode C — mount the hosted iframe player and bridge it over postMessage. Both\n * ends verify event.origin on every message; commands are posted only to the\n * embed page's origin.\n */\nexport function mountEmbed(opts: MountEmbedOptions): EmbedHandle {\n if (!opts.container || typeof opts.container.appendChild !== 'function') {\n throw new Error('mountEmbed: opts.container must be a DOM element');\n }\n if (!opts.src) throw new Error('mountEmbed: opts.src is required');\n if (!opts.baseUrl || !opts.matchUrn) {\n throw new Error('mountEmbed: opts.baseUrl and opts.matchUrn are required');\n }\n const url = new URL(opts.src, location.href);\n const frameOrigin = opts.allowedFrameOrigin ?? url.origin;\n\n url.searchParams.set('baseUrl', opts.baseUrl);\n url.searchParams.set('matchUrn', opts.matchUrn);\n url.searchParams.set('parentOrigin', location.origin);\n if (opts.muted != null) url.searchParams.set('muted', String(opts.muted));\n if (opts.apiKey) {\n url.searchParams.set('apiKey', opts.apiKey);\n console.warn(\n 'havik-player: passing apiKey to mountEmbed places it in the iframe URL — DEV/DEMO only. ' +\n 'In production inject it server-side into window.HAVIK_EMBED_CONFIG on the embed page.',\n );\n }\n\n const iframe = document.createElement('iframe');\n iframe.src = url.toString();\n iframe.allow = 'autoplay; encrypted-media; fullscreen';\n iframe.setAttribute('allowfullscreen', '');\n // Keep any api-key-bearing URL out of Referer, and sandbox the embed so a\n // compromised embed page can't navigate the top frame or open popups.\n // allow-same-origin is required for EME/MediaKeys and the embed's own storage.\n iframe.referrerPolicy = 'no-referrer';\n iframe.setAttribute(\n 'sandbox',\n opts.sandbox ?? 'allow-scripts allow-same-origin allow-presentation',\n );\n iframe.style.cssText = 'border:0;width:100%;height:100%;display:block';\n opts.container.appendChild(iframe);\n\n const listeners = new Set<(msg: FrameToHost) => void>();\n\n const onMessage = (ev: MessageEvent) => {\n if (ev.source !== iframe.contentWindow) return;\n if (ev.origin !== frameOrigin) return;\n if (!isOddinMessage(ev.data)) return;\n const msg = ev.data as FrameToHost;\n if (msg.type === 'oddin:ready' && msg.protocol !== PROTOCOL_VERSION) {\n console.warn(\n `havik-player: embed protocol v${msg.protocol} != host v${PROTOCOL_VERSION}. ` +\n 'Pin the embed page version to the SDK version to avoid skew.',\n );\n }\n opts.onEvent?.(msg);\n listeners.forEach((cb) => cb(msg));\n };\n window.addEventListener('message', onMessage);\n\n const send = (msg: HostToFrame) => iframe.contentWindow?.postMessage(msg, frameOrigin);\n\n return {\n iframe,\n play: () => send({ type: 'oddin:play' }),\n pause: () => send({ type: 'oddin:pause' }),\n setMuted: (muted) => send({ type: 'oddin:setMuted', muted }),\n setVolume: (volume) => send({ type: 'oddin:setVolume', volume }),\n setQuality: (index) => send({ type: 'oddin:setQuality', index }),\n setMaxBitrate: (bitrate) => send({ type: 'oddin:setMaxBitrate', bitrate }),\n setAudioTrack: (id) => send({ type: 'oddin:setAudioTrack', id }),\n setTextTrack: (id) => send({ type: 'oddin:setTextTrack', id }),\n seekToLive: () => send({ type: 'oddin:seekToLive' }),\n enterPip: () => send({ type: 'oddin:enterPip' }),\n exitPip: () => send({ type: 'oddin:exitPip' }),\n retry: () => send({ type: 'oddin:retry' }),\n load: (matchUrn) => send({ type: 'oddin:load', matchUrn }),\n on: (cb) => {\n listeners.add(cb);\n return () => listeners.delete(cb);\n },\n destroy: () => {\n send({ type: 'oddin:destroy' });\n window.removeEventListener('message', onMessage);\n iframe.remove();\n },\n };\n}\n","import { createPlayer, type Player } from '../managed';\nimport { PlaybackError } from '../core/errors';\nimport { type WaitForLive } from '../core/waitForLive';\nimport { type FrameToHost, type HostToFrame, isOddinMessage, PROTOCOL_VERSION } from './protocol';\n\nexport interface EmbedConfig {\n baseUrl: string;\n matchUrn: string;\n /** DEV/DEMO ONLY when read from the query string — in production inject server-side. */\n apiKey: string;\n /** Origin to post events to and accept commands from. Required for the bridge. */\n parentOrigin?: string;\n autoplay?: boolean;\n muted?: boolean;\n waitForLive?: WaitForLive;\n /**\n * QoE beacons, forwarded verbatim to {@link createPlayer}. Same meaning as\n * there: `false` opts out, `{ endpoint }` names a beacons host.\n *\n * Mode C needs this for the same reason Mode A does, and until now had no\n * way to say it. An embed whose `baseUrl` is not a `feed[-dev].<domain>`\n * host — a CDN or gateway fronting the streams API — derives no beacons\n * endpoint, so analytics is off and stays off. Without this option the\n * only remedy was to move `baseUrl` back onto a feed host, which is not a\n * choice an integrator who fronts the API deliberately can make.\n *\n * INJECTED CONFIG ONLY: unlike every other field here, this is not read\n * from the query string. See {@link readConfig}.\n */\n analytics?: boolean | { endpoint?: string };\n}\n\ndeclare global {\n interface Window {\n HAVIK_EMBED_CONFIG?: Partial<EmbedConfig>;\n }\n}\n\nconst URN_RE = /^[A-Za-z0-9:_.-]{1,128}$/;\n\n/**\n * Normalize an injected `analytics` value.\n *\n * The host page writes this object, so it is trusted — but it is plain JS\n * with no type checking at the boundary, and a wrong shape is worse than a\n * missing one here: `analytics: 'yes'` reaches createPlayer as a truthy\n * non-object, reads as `{ endpoint: undefined }`, and silently falls back to\n * derivation. Anything unrecognized becomes undefined instead, so the\n * documented default applies and the SDK's own diagnostics see an absent\n * option rather than a malformed one.\n */\nfunction readAnalytics(v: unknown): EmbedConfig['analytics'] {\n if (typeof v === 'boolean') return v;\n if (typeof v === 'object' && v !== null) {\n const endpoint = (v as { endpoint?: unknown }).endpoint;\n return typeof endpoint === 'string' ? { endpoint } : {};\n }\n return undefined;\n}\n\n/**\n * Config is read in ONE of two mutually-exclusive modes:\n * - Production: when window.HAVIK_EMBED_CONFIG is injected server-side, ONLY it is\n * trusted. The query string is ignored entirely, so a tampered `?baseUrl=` can\n * never redirect the injected api-key to an attacker origin.\n * - Dev/demo: when no injected config is present, the query string is used.\n *\n * `analytics` is deliberately absent from the query-string branch. It names a\n * telemetry sink, and a link is the wrong place to set one: `?analytics.\n * endpoint=` would let any URL that opens this page redirect a session's QoE\n * beacons somewhere else while `baseUrl` still looks legitimate. There is no\n * demo case that needs it — a dev embed points `baseUrl` at a feed host and\n * gets the derived endpoint for free. Injected config only.\n */\nfunction readConfig(): EmbedConfig {\n const injected = window.HAVIK_EMBED_CONFIG;\n if (injected) {\n return {\n baseUrl: injected.baseUrl ?? '',\n matchUrn: injected.matchUrn ?? '',\n apiKey: injected.apiKey ?? '',\n parentOrigin: injected.parentOrigin,\n autoplay: injected.autoplay ?? true,\n muted: injected.muted ?? true,\n waitForLive: injected.waitForLive ?? true,\n analytics: readAnalytics(injected.analytics),\n };\n }\n const q = new URLSearchParams(location.search);\n return {\n baseUrl: q.get('baseUrl') ?? '',\n matchUrn: q.get('matchUrn') ?? '',\n apiKey: q.get('apiKey') ?? '',\n parentOrigin: q.get('parentOrigin') ?? undefined,\n autoplay: true,\n muted: q.get('muted') !== 'false',\n waitForLive: true,\n };\n}\n\n/**\n * Boots the iframe-side player and speaks the postMessage protocol with the host.\n * The api-key is NEVER accepted over postMessage. The bridge fails CLOSED: with no\n * trusted parentOrigin it neither accepts commands nor broadcasts events.\n */\nexport async function bootEmbed(): Promise<void> {\n const cfg = readConfig();\n applyFullBleedStyles();\n\n const video = document.createElement('video');\n video.setAttribute('playsinline', '');\n // createPlayer mounts the branded custom control bar (controls: 'custom' default).\n video.muted = cfg.muted ?? true;\n video.style.cssText =\n 'width:100%;height:100%;object-fit:contain;background:#000;aspect-ratio:auto';\n document.body.appendChild(video);\n\n const trustedOrigin = cfg.parentOrigin && cfg.parentOrigin !== 'null' ? cfg.parentOrigin : null;\n if (!trustedOrigin) {\n console.warn(\n 'havik-player: embed has no parentOrigin — the postMessage bridge is disabled ' +\n '(no events posted, no commands accepted). mountEmbed sets parentOrigin automatically.',\n );\n }\n\n const post = (msg: FrameToHost) => {\n if (!trustedOrigin) return; // never broadcast to '*'\n try {\n window.parent?.postMessage(msg, trustedOrigin);\n } catch {\n // parent may be gone\n }\n };\n\n if (!cfg.baseUrl || !cfg.matchUrn || !cfg.apiKey) {\n post({\n type: 'oddin:error',\n code: 'INTERNAL',\n message: 'embed missing baseUrl/matchUrn/apiKey',\n });\n return;\n }\n\n let player: Player | null = null;\n\n const wire = (p: Player) => {\n p.on('statechange', (state) => post({ type: 'oddin:state', state }));\n p.on('ready', () =>\n post({\n type: 'oddin:tracks',\n quality: p.getQualityLevels(),\n audio: p.getAudioTracks(),\n text: p.getTextTracks(),\n }),\n );\n p.on('waiting', (w) =>\n post({ type: 'oddin:waiting', retryInMs: w.retryInMs, attempt: w.attempt, phase: w.phase }),\n );\n p.on('qualitychange', (index) => post({ type: 'oddin:qualitychange', index }));\n p.on('audiotrackchange', (id) => post({ type: 'oddin:audiotrackchange', id }));\n p.on('texttrackchange', (id) => post({ type: 'oddin:texttrackchange', id }));\n p.on('stats', (s) =>\n post({\n type: 'oddin:stats',\n droppedFrames: s.droppedFrames,\n latencySeconds: s.latencySeconds,\n bandwidthKbps: s.bandwidthKbps,\n levelHeight: s.levelHeight,\n lowLatency: s.lowLatency,\n isLive: s.isLive,\n atLiveEdge: s.atLiveEdge,\n }),\n );\n p.on('error', (e) => post({ type: 'oddin:error', code: e.code, message: e.message }));\n p.on('autoplayblocked', () => {\n post({ type: 'oddin:autoplayblocked' });\n showTapToPlay(video, p);\n });\n };\n\n // Switch matches in place (destroy + re-create) rather than re-navigating — this\n // preserves injected config and keeps the api-key out of the URL/history.\n const startPlayer = async (matchUrn: string): Promise<void> => {\n player?.destroy();\n player = null;\n try {\n const p = await createPlayer({\n video,\n baseUrl: cfg.baseUrl,\n matchUrn,\n credential: { apiKey: cfg.apiKey },\n autoplay: cfg.autoplay,\n muted: cfg.muted,\n waitForLive: cfg.waitForLive,\n analytics: cfg.analytics,\n });\n player = p;\n // The custom bar wraps <video> in a `.havik-player` container; make it\n // fill the iframe (the default skin is content-sized 16:9 otherwise).\n const container = video.parentElement;\n if (container?.classList.contains('havik-player')) {\n container.style.cssText =\n 'position:absolute;inset:0;width:100%;height:100%;aspect-ratio:auto';\n }\n wire(p);\n } catch (e) {\n const code = e instanceof PlaybackError ? e.code : 'INTERNAL';\n const message = e instanceof Error ? e.message : String(e);\n post({ type: 'oddin:error', code, message });\n }\n };\n\n const dispose = () => {\n window.removeEventListener('message', onMessage);\n window.removeEventListener('pagehide', dispose);\n player?.destroy();\n player = null;\n };\n\n const handleCommand = (msg: HostToFrame): void => {\n switch (msg.type) {\n case 'oddin:play':\n void player?.play();\n break;\n case 'oddin:pause':\n player?.pause();\n break;\n case 'oddin:setMuted':\n if (typeof msg.muted === 'boolean') player?.setMuted(msg.muted);\n break;\n case 'oddin:setVolume':\n if (typeof msg.volume === 'number' && Number.isFinite(msg.volume)) {\n player?.setVolume(msg.volume);\n }\n break;\n case 'oddin:setQuality':\n if (typeof msg.index === 'number') player?.setQuality(msg.index);\n break;\n case 'oddin:setMaxBitrate':\n if (\n msg.bitrate === null ||\n (typeof msg.bitrate === 'number' && Number.isFinite(msg.bitrate))\n ) {\n player?.setMaxBitrate(msg.bitrate);\n }\n break;\n case 'oddin:setAudioTrack':\n if (typeof msg.id === 'number') player?.setAudioTrack(msg.id);\n break;\n case 'oddin:setTextTrack':\n if (typeof msg.id === 'number') player?.setTextTrack(msg.id);\n break;\n case 'oddin:seekToLive':\n player?.seekToLive();\n break;\n case 'oddin:enterPip':\n void player?.enterPictureInPicture();\n break;\n case 'oddin:exitPip':\n void player?.exitPictureInPicture();\n break;\n case 'oddin:retry':\n void player?.retry();\n break;\n case 'oddin:load':\n if (typeof msg.matchUrn === 'string' && URN_RE.test(msg.matchUrn)) {\n void startPlayer(msg.matchUrn);\n }\n break;\n case 'oddin:destroy':\n dispose();\n break;\n }\n };\n\n const onMessage = (ev: MessageEvent) => {\n if (!trustedOrigin || ev.origin !== trustedOrigin) return;\n if (!isOddinMessage(ev.data)) return;\n handleCommand(ev.data as HostToFrame);\n };\n\n if (trustedOrigin) window.addEventListener('message', onMessage);\n window.addEventListener('pagehide', dispose, { once: true });\n\n await startPlayer(cfg.matchUrn);\n post({ type: 'oddin:ready', protocol: PROTOCOL_VERSION });\n}\n\nfunction showTapToPlay(video: HTMLVideoElement, player: Player): void {\n if (document.getElementById('havik-tap')) return;\n const overlay = document.createElement('button');\n overlay.id = 'havik-tap';\n overlay.textContent = '▶ Tap to play';\n overlay.style.cssText =\n 'position:fixed;inset:0;width:100%;height:100%;border:0;background:rgba(0,0,0,.55);' +\n 'color:#fff;font:600 18px system-ui,sans-serif;cursor:pointer;z-index:9';\n overlay.addEventListener('click', () => {\n overlay.remove();\n void player.play(); // a user gesture — play() now succeeds\n });\n document.body.appendChild(overlay);\n void video; // overlay covers the element; kept for signature clarity\n}\n\nfunction applyFullBleedStyles(): void {\n const style = document.createElement('style');\n style.textContent =\n 'html,body{margin:0;height:100%;background:#000;overflow:hidden}body{display:flex}';\n document.head.appendChild(style);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../src/core/errors.ts","../src/core/types.ts","../src/core/http.ts","../src/core/waitForLive.ts","../src/core/resolve.ts","../src/core/fullscreen.ts","../src/core/catalog.ts","../src/core/poll.ts","../src/core/events.ts","../src/core/drm/deviceId.ts","../src/core/drm/headers.ts","../src/core/drm/cdm.ts","../src/core/drm/support.ts","../src/core/manifest.ts","../src/core/env.ts","../src/core/stats.ts","../src/core/engine/native.ts","../src/core/engine/hls.ts","../src/core/analytics.ts","../src/core/environment.ts","../src/core/version.ts","../src/ui/theme.ts","../src/ui/styles.ts","../src/ui/icons.ts","../src/ui/controls.ts","../src/managed/index.ts","../src/embed/protocol.ts","../src/embed/host.ts","../src/embed/page.ts"],"sourcesContent":["// Typed error taxonomy mapped from the havik-streams playback outcome table.\n\nexport type PlaybackErrorCode =\n | 'INVALID_URN' // 400 — malformed match URN (terminal)\n | 'NOT_FOUND' // 404 — unknown URN OR not entitled, indistinguishable (terminal)\n | 'GONE' // 410 — ended past the catchup window (terminal)\n | 'TOO_EARLY' // 425 — upcoming, session not upserted yet (retry honoring Retry-After)\n | 'UNAVAILABLE' // 503 — should-be-live but origin failing (retry with backoff)\n | 'UNAUTHORIZED' // 401 — bad/absent key (terminal)\n | 'FORBIDDEN' // 403 — origin not allowed / DRM signature rejected (terminal)\n | 'DRM_CLIENT' // client-side DRM failure: key system unusable on this platform, CDM/EME error, or license transport exhausted — NOT a server entitlement denial (httpStatus carries the license status when there was one, else 0)\n | 'RATE_LIMITED' // 429 — per-IP limiter (back off Retry-After)\n | 'INTERNAL' // 5xx other / unexpected (terminal)\n | 'NETWORK' // fetch threw (transient — retryable in the live-poll loop)\n | 'TIMEOUT' // SDK gave up waiting for live (terminal)\n | 'ABORTED'; // caller aborted via AbortSignal (terminal)\n\nexport interface PlaybackErrorInit {\n retryAfterMs?: number;\n requestId?: string;\n /** The raw server-provided error code string, if any — forward-compatible with new server codes. */\n serverCode?: string;\n /**\n * Scheduled kickoff time (epoch ms), if the server included it on a TOO_EARLY\n * response body. The SDK uses this to widen poll cadence for far-future\n * matches without a separate catalog call.\n */\n liveStartsAtMs?: number;\n cause?: unknown;\n}\n\nexport class PlaybackError extends Error {\n readonly code: PlaybackErrorCode;\n readonly httpStatus: number;\n /** Present for TOO_EARLY / RATE_LIMITED / UNAVAILABLE when the server sent Retry-After. */\n readonly retryAfterMs?: number;\n /** Server request id, when surfaced — useful for correlating opaque 403/503 in server logs. */\n readonly requestId?: string;\n /** Raw server error code (e.g. \"TOO_EARLY\"), preserved even if `code` maps it to a known enum. */\n readonly serverCode?: string;\n /**\n * Scheduled kickoff time (epoch ms), if the server carried `liveStartsAt` on\n * a TOO_EARLY body. `pollToLive` reads this to widen poll cadence for\n * far-future matches.\n */\n readonly liveStartsAtMs?: number;\n\n constructor(\n code: PlaybackErrorCode,\n httpStatus: number,\n message: string,\n init?: PlaybackErrorInit,\n ) {\n super(message);\n this.name = 'PlaybackError';\n this.code = code;\n this.httpStatus = httpStatus;\n this.retryAfterMs = init?.retryAfterMs;\n this.requestId = init?.requestId;\n this.serverCode = init?.serverCode;\n this.liveStartsAtMs = init?.liveStartsAtMs;\n if (init?.cause !== undefined) {\n (this as { cause?: unknown }).cause = init.cause;\n }\n }\n}\n\n/** Map an HTTP status from /v1/playback to a typed code. */\nexport function classifyStatus(status: number): PlaybackErrorCode {\n switch (status) {\n case 400:\n return 'INVALID_URN';\n case 401:\n return 'UNAUTHORIZED';\n case 403:\n return 'FORBIDDEN';\n case 404:\n return 'NOT_FOUND';\n case 410:\n return 'GONE';\n case 425:\n return 'TOO_EARLY';\n case 429:\n return 'RATE_LIMITED';\n case 503:\n return 'UNAVAILABLE';\n default:\n return 'INTERNAL';\n }\n}\n\n/**\n * Map the HTTP status of a DRM license/certificate exchange to a typed code.\n * Only a genuine server auth/entitlement denial keeps the server taxonomy;\n * everything else (no response, transport exhausted, CDM/EME failure) is a\n * client-side DRM failure. Shared by both engines so they cannot drift.\n */\nexport function classifyDrmStatus(status: number): PlaybackErrorCode {\n return status === 401 ? 'UNAUTHORIZED' : status === 403 ? 'FORBIDDEN' : 'DRM_CLIENT';\n}\n\nconst WAITABLE: ReadonlySet<PlaybackErrorCode> = new Set<PlaybackErrorCode>([\n 'TOO_EARLY',\n 'UNAVAILABLE',\n 'RATE_LIMITED',\n 'NETWORK',\n]);\n\n/** True for codes the wait-for-live loop should keep polling on. */\nexport function isWaitable(code: PlaybackErrorCode): boolean {\n return WAITABLE.has(code);\n}\n","// Shared domain types for the Havik player SDK.\n\nexport type DrmSystem = 'widevine' | 'fairplay';\n\nexport interface WidevineInfo {\n licenseUrl: string;\n}\nexport interface FairPlayInfo {\n licenseUrl: string;\n certificateUrl: string;\n}\nexport interface DrmInfo {\n widevine?: WidevineInfo;\n fairplay?: FairPlayInfo;\n}\n\n/**\n * The per-match playback credential returned by `GET /v1/playback/{urn}`,\n * normalized for the SDK. `manifestUrl` is an unsigned HLS `.m3u8`; the DRM\n * license URLs are pre-signed by havik-streams (the signed query string IS the\n * token — never rewrite them).\n */\nexport interface StreamDescriptor {\n matchUrn: string;\n protocol: 'hls';\n /** Producer-set DRM decision. When false, init no EME/CDM at all. */\n drmEnabled: boolean;\n manifestUrl: string;\n /** Present only when drmEnabled. */\n drm?: DrmInfo;\n serverTime: string;\n /** Present only while the match is upcoming. */\n liveStartsAt?: string;\n /** Filled by the player once a media playlist with EXT-X-PART-INF is seen. */\n isLowLatency?: boolean;\n /**\n * Analytics session binding minted by havik-streams (present when the\n * server has analytics enabled). The sid is an opaque per-playback-session\n * id — non-secret by design (DRM remains the access gate) — echoed in CMCD\n * query params on CDN requests and carried by QoE beacons. It lives only in\n * memory for the duration of the session and is never persisted.\n */\n analytics?: { sid?: string };\n /** Any unrecognized top-level fields from the playback response (forward-compat). */\n extensions?: Record<string, unknown>;\n}\n\nexport interface Credential {\n /** A publishable api-key, e.g. `pk_live_…` / `pk_test_…`. */\n apiKey: string;\n}\n\n/**\n * A credential, or a function that produces one. The function form lets a host\n * plug in a future short-lived-token mint service without an API change; it is\n * invoked per request so refresh works transparently.\n */\nexport type CredentialSource = Credential | (() => Credential | Promise<Credential>);\n\n/** @internal */\nexport async function resolveCredential(source: CredentialSource): Promise<Credential> {\n const cred = typeof source === 'function' ? await source() : source;\n if (!cred || !cred.apiKey) {\n throw new Error('havik-player: credential is missing an apiKey');\n }\n return cred;\n}\n\nexport type MatchLiveStatus = 'upcoming' | 'live' | 'ended';\n\n/**\n * Coerce a server-sent match status into the union, never throwing and never\n * inventing playability.\n *\n * The server's status vocabulary can grow — a value this SDK has not been\n * taught about (say a state between \"kickoff passed\" and \"media ready\") must\n * not silently break a caller who switches on the union. Before this, the\n * parse sites cast the raw string with `as MatchLiveStatus`, which lies to the\n * type system: an unrecognised value arrived typed as one of the three, so\n * `if (status === 'upcoming')` quietly matched nothing and the match landed in\n * no bucket at all — no error, no log, just a match rendered nowhere.\n *\n * Unknown collapses to `'upcoming'` deliberately. It is the only one of the\n * three that claims nothing: not playable now, may be later. Collapsing to\n * `'live'` would have a caller try to play something the server never said was\n * playable, and `'ended'` would hide a match that has not happened.\n *\n * The verbatim value is preserved separately (`statusRaw`), the same\n * forward-compatibility shape `PlaybackError.serverCode` uses for error codes,\n * so a caller who DOES know the new value can read it without waiting for an\n * SDK release.\n */\nexport function normaliseMatchStatus(raw: unknown): MatchLiveStatus {\n return raw === 'live' || raw === 'ended' || raw === 'upcoming' ? raw : 'upcoming';\n}\n\nexport interface MatchStatus {\n matchUrn: string;\n matchName?: string;\n status: MatchLiveStatus;\n /**\n * The server's status string verbatim, when it sent one. Equals `status`\n * for values this SDK knows; carries the new value when the server's\n * vocabulary has grown past it. Read this to act on a status the union does\n * not name yet.\n */\n statusRaw?: string;\n datePlannedStart?: string;\n}\n","import { PlaybackError } from './errors';\nimport { resolveCredential, type CredentialSource } from './types';\n\n/**\n * Join a base URL and an absolute path into a validated absolute http(s) URL.\n * Rejects empty, protocol-relative (`//evil`), and non-http(s) bases up front so\n * an unvalidated baseUrl can never become the (credentialed) request origin.\n */\nexport function joinUrl(base: string, path: string): string {\n const trimmed = base.replace(/\\/+$/, '');\n let url: URL;\n try {\n url = new URL(trimmed + path);\n } catch {\n throw new PlaybackError('INTERNAL', 0, `invalid baseUrl: ${JSON.stringify(base)}`);\n }\n if (url.protocol !== 'https:' && url.protocol !== 'http:') {\n throw new PlaybackError('INTERNAL', 0, `baseUrl must be http(s), got: ${JSON.stringify(base)}`);\n }\n return url.toString();\n}\n\n/**\n * Parse an HTTP `Retry-After` header (delta-seconds or HTTP-date) into ms.\n * Returns undefined when absent/unparseable.\n */\nexport function parseRetryAfterMs(value: string | null): number | undefined {\n if (!value) return undefined;\n const secs = Number(value);\n if (Number.isFinite(secs)) return Math.max(0, Math.round(secs * 1000));\n const when = Date.parse(value);\n if (!Number.isNaN(when)) return Math.max(0, when - Date.now());\n return undefined;\n}\n\nexport interface FetchOptions {\n credential: CredentialSource;\n signal?: AbortSignal;\n method?: string;\n headers?: Headers;\n body?: BodyInit;\n /**\n * Extra headers for this request — and strictly extra: an entry whose\n * name the SDK already set (e.g. `If-None-Match` on conditional catalog\n * and status polls) is ignored rather than allowed to clobber it, and\n * `x-api-key` is applied after this map so the credential can never be\n * replaced either.\n */\n apiHeaders?: Record<string, string>;\n}\n\n/**\n * fetch() with the `x-api-key` header attached from the (possibly async)\n * credential source. Network failures and aborts are normalized to\n * PlaybackError so callers handle one error type. The credential is resolved\n * per call so a getCredential() hook can refresh transparently.\n */\nexport async function fetchWithCredential(url: string, opts: FetchOptions): Promise<Response> {\n const cred = await resolveCredential(opts.credential);\n const headers = opts.headers ?? new Headers();\n if (opts.apiHeaders) {\n for (const [name, value] of Object.entries(opts.apiHeaders)) {\n if (!headers.has(name)) headers.set(name, value);\n }\n }\n headers.set('x-api-key', cred.apiKey);\n try {\n return await fetch(url, {\n method: opts.method ?? 'GET',\n headers,\n body: opts.body,\n signal: opts.signal,\n cache: 'no-store',\n credentials: 'omit',\n mode: 'cors',\n });\n } catch (err) {\n if (opts.signal?.aborted) {\n throw new PlaybackError('ABORTED', 0, 'request aborted', { cause: err });\n }\n throw new PlaybackError('NETWORK', 0, `network request failed: ${describe(err)}`, {\n cause: err,\n });\n }\n}\n\nfunction describe(err: unknown): string {\n if (err instanceof Error) return err.message;\n return String(err);\n}\n","import { isWaitable, PlaybackError } from './errors';\n\nexport interface WaitForLiveOptions {\n /** Overall budget before giving up with a TIMEOUT error. Default: unbounded. */\n timeoutMs?: number;\n /** Minimum delay between polls. Floored at 1000ms (the server's min / limiter). Default: 1000. */\n floorMs?: number;\n /**\n * Maximum delay between polls in the IMMINENT window (≤ {@link imminentMs}\n * before kickoff). Far-from-kickoff polls are widened automatically using\n * `kickoffAt` (see below) up to {@link sanityFloorMs}, regardless of this\n * value — so this knob really controls only the near-kickoff cadence. Default: 30000.\n */\n ceilMs?: number;\n /**\n * Caller-supplied scheduled kickoff time (epoch ms, or an ISO string), used to\n * widen the long-tail poll cadence so the SDK does not hammer the per-viewer\n * `/v1/playback` endpoint when a viewer is armed hours early.\n *\n * Far-from-kickoff (time-to-kickoff > {@link imminentMs}) the poll interval is\n * stretched to `min(ttk / 10, sanityFloorMs)` (never tighter than the server's\n * `Retry-After`, never longer than {@link sanityFloorMs}, never longer than\n * the time remaining to kickoff). Inside the imminent window the cadence\n * collapses to the tight `[floorMs, ceilMs]` range so a 200 is caught fast.\n *\n * When omitted, the SDK also reads `liveStartsAt` off the 425 body if the\n * server returns it (`PlaybackError.liveStartsAtMs`), so newer servers tune\n * automatically without any client change.\n */\n kickoffAt?: number | string;\n /**\n * Threshold (ms) for \"imminent kickoff\" — inside this window the cadence\n * collapses to the tight `[floorMs, ceilMs]` range. Default: 10 minutes.\n */\n imminentMs?: number;\n /**\n * Absolute hard ceiling on any single wait, even when far from kickoff or\n * when the server returns a very large `Retry-After`. Sanity floor so an\n * early go-live or a schedule change is caught within bounded latency.\n * Default: 5 minutes (300_000ms).\n */\n sanityFloorMs?: number;\n /** Add up to +15% jitter to each delay (never reduces below the server's ask). Default: true. */\n jitter?: boolean;\n /** Called before each wait with the upcoming retry. */\n onState?: (state: WaitState) => void;\n}\n\nexport type WaitForLive = boolean | WaitForLiveOptions;\n\nexport interface WaitState {\n phase: 'tooEarly' | 'unavailable' | 'rateLimited' | 'network';\n /** How long until the next poll, in ms. */\n retryInMs: number;\n /** 1-based retry count. */\n attempt: number;\n /** Wall-clock ms since the wait started. */\n elapsedMs: number;\n /**\n * Time-to-kickoff (ms) used to compute this delay, if known. `undefined` when\n * no kickoff hint is available (caller didn't pass `kickoffAt` and the server\n * didn't carry `liveStartsAt` on the error).\n */\n ttkMs?: number;\n}\n\ninterface NormalizedWait {\n timeoutMs?: number;\n floorMs: number;\n ceilMs: number;\n imminentMs: number;\n sanityFloorMs: number;\n /** Caller-supplied kickoff (epoch ms), or undefined. */\n kickoffMs?: number;\n jitter: boolean;\n onState?: (state: WaitState) => void;\n}\n\nconst DEFAULT_FLOOR_MS = 1000;\nconst DEFAULT_CEIL_MS = 30_000;\nconst DEFAULT_IMMINENT_MS = 10 * 60 * 1000; // 10 min — collapse to tight cadence\nconst DEFAULT_SANITY_FLOOR_MS = 5 * 60 * 1000; // 5 min — hard cap on any single sleep\nconst SERVER_MIN_MS = 1000; // never poll /v1/playback faster than this (per-IP limiter)\n\nconst PHASE: Record<string, WaitState['phase']> = {\n TOO_EARLY: 'tooEarly',\n UNAVAILABLE: 'unavailable',\n RATE_LIMITED: 'rateLimited',\n NETWORK: 'network',\n};\n\nfunction normalize(cfg: WaitForLive): NormalizedWait {\n const o: WaitForLiveOptions = cfg === true || cfg === false ? {} : cfg;\n const floorMs = Math.max(SERVER_MIN_MS, o.floorMs ?? DEFAULT_FLOOR_MS);\n const ceilMs = Math.max(floorMs, o.ceilMs ?? DEFAULT_CEIL_MS);\n const imminentMs = Math.max(0, o.imminentMs ?? DEFAULT_IMMINENT_MS);\n const sanityFloorMs = Math.max(ceilMs, o.sanityFloorMs ?? DEFAULT_SANITY_FLOOR_MS);\n return {\n timeoutMs: o.timeoutMs,\n floorMs,\n ceilMs,\n imminentMs,\n sanityFloorMs,\n kickoffMs: parseKickoff(o.kickoffAt),\n jitter: o.jitter ?? true,\n onState: o.onState,\n };\n}\n\nfunction parseKickoff(v: number | string | undefined): number | undefined {\n if (v == null) return undefined;\n if (typeof v === 'number') return Number.isFinite(v) ? v : undefined;\n const t = Date.parse(v);\n return Number.isFinite(t) ? t : undefined;\n}\n\nfunction clamp(value: number, lo: number, hi: number): number {\n return Math.min(hi, Math.max(lo, value));\n}\n\nfunction addJitter(delay: number): number {\n // Only ADDS time, so we never poll faster than the server asked.\n return Math.round(delay * (1 + 0.15 * Math.random()));\n}\n\n/**\n * Compute the next poll delay, given the server's hint, an optional kickoff\n * time, and the caller's bounds.\n *\n * The rule:\n * 1. **Server `Retry-After` is the floor.** If the server says \"wait 2s,\" we\n * never poll sooner, regardless of how far away kickoff is. This preserves\n * the server's authority to tighten cadence near kickoff.\n * 2. **Inside the imminent window** (`ttk ≤ imminentMs`, or no ttk known) the\n * delay is the classic `clamp(retryAfter, floor, ceil)`.\n * 3. **Far from kickoff** (`ttk > imminentMs`) the delay is widened to\n * `min(ttk / 10, sanityFloorMs)`, but **never below** the server's\n * `Retry-After` and **never longer than** the time remaining to kickoff\n * (so we don't sleep past it).\n * 4. **Sanity floor caps everything** so an early go-live or a schedule slip\n * is caught within `sanityFloorMs` (default 5 min).\n *\n * Returns `{ delay, ttkMs }` so the caller can surface `ttk` to UI.\n */\nexport function nextDelayMs(\n retryAfterMs: number | undefined,\n now: number,\n o: Pick<NormalizedWait, 'floorMs' | 'ceilMs' | 'imminentMs' | 'sanityFloorMs' | 'kickoffMs'>,\n kickoffOverrideMs?: number,\n): { delay: number; ttkMs: number | undefined } {\n const kickoffMs = kickoffOverrideMs ?? o.kickoffMs;\n const ttkMs = kickoffMs != null ? Math.max(0, kickoffMs - now) : undefined;\n const serverFloor = Math.max(o.floorMs, retryAfterMs ?? 0);\n\n // Imminent (or no ttk known): use the classic tight band.\n if (ttkMs == null || ttkMs <= o.imminentMs) {\n return { delay: clamp(retryAfterMs ?? o.floorMs, serverFloor, o.ceilMs), ttkMs };\n }\n\n // Far from kickoff: widen to ~10% of remaining time, capped by sanity floor\n // and by ttk itself (don't sleep past kickoff).\n const widened = Math.min(Math.floor(ttkMs / 10), o.sanityFloorMs, ttkMs);\n // Server can always tighten with a smaller Retry-After (treated as floor).\n return { delay: Math.max(serverFloor, widened), ttkMs };\n}\n\nfunction sleep(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new PlaybackError('ABORTED', 0, 'wait-for-live aborted'));\n return;\n }\n const onAbort = () => {\n clearTimeout(timer);\n reject(new PlaybackError('ABORTED', 0, 'wait-for-live aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\n/**\n * Poll `attempt` until it resolves, retrying on waitable PlaybackErrors\n * (TOO_EARLY / UNAVAILABLE / RATE_LIMITED / NETWORK). Each retry's delay is\n * computed by {@link nextDelayMs} from (a) the server's `Retry-After` (always a\n * floor) and (b) the time-to-kickoff from `kickoffAt` and/or\n * `PlaybackError.liveStartsAtMs`. Far from kickoff the cadence widens (to\n * `~ttk/10`, capped at `sanityFloorMs`) so the SDK does not hammer the\n * per-viewer `/v1/playback` endpoint when a viewer is armed hours early; inside\n * the imminent window it collapses to the tight `[floorMs, ceilMs]` band so a\n * 200 — i.e. the instant the match goes live — is caught fast. Terminal errors\n * (404 / 410 / 400 / 401 / 403 / INTERNAL) reject straight away.\n */\nexport async function pollToLive<T>(\n attempt: () => Promise<T>,\n cfg: WaitForLive,\n signal?: AbortSignal,\n): Promise<T> {\n const o = normalize(cfg);\n const start = Date.now();\n const deadline = o.timeoutMs != null ? start + o.timeoutMs : Infinity;\n let attemptNo = 0;\n // Cache the latest kickoff hint we've learned from a 425 body. Re-read every\n // loop (don't snapshot once) so a schedule change visible to the server is\n // honored — see the four correctness guards in the design note.\n let lastKickoffHintMs: number | undefined;\n\n for (;;) {\n if (signal?.aborted) throw new PlaybackError('ABORTED', 0, 'wait-for-live aborted');\n try {\n return await attempt();\n } catch (err) {\n if (!(err instanceof PlaybackError) || !isWaitable(err.code)) throw err;\n attemptNo += 1;\n\n // If the server now carries a kickoff hint (newer havik-streams), refresh\n // it. Caller-supplied `kickoffAt` still wins (set in normalize()).\n if (err.liveStartsAtMs != null && Number.isFinite(err.liveStartsAtMs)) {\n lastKickoffHintMs = err.liveStartsAtMs;\n }\n\n const now = Date.now();\n const computed = nextDelayMs(err.retryAfterMs, now, o, lastKickoffHintMs);\n const ttkMs = computed.ttkMs;\n let delay = computed.delay;\n if (o.jitter) delay = addJitter(delay);\n\n if (now + delay > deadline) {\n throw new PlaybackError(\n 'TIMEOUT',\n err.httpStatus,\n `timed out after ${o.timeoutMs}ms waiting for the stream to go live`,\n { retryAfterMs: err.retryAfterMs, cause: err },\n );\n }\n\n o.onState?.({\n phase: PHASE[err.code] ?? 'unavailable',\n retryInMs: delay,\n attempt: attemptNo,\n elapsedMs: now - start,\n ttkMs,\n });\n\n await sleep(delay, signal);\n }\n }\n}\n","import { classifyStatus, PlaybackError } from './errors';\nimport { fetchWithCredential, joinUrl, parseRetryAfterMs } from './http';\nimport { type CredentialSource, type StreamDescriptor } from './types';\nimport { pollToLive, type WaitForLive } from './waitForLive';\n\nexport interface ResolveOptions {\n /**\n * Base URL of the havik-streams API — `https://feed.oddin-video.gg`\n * (production) or `https://feed-dev.oddin-video.gg` (integration). Prefer\n * resolving it from the endpoint directory at runtime over hard-coding it;\n * see Service URL discovery in the API reference.\n */\n baseUrl: string;\n matchUrn: string;\n credential: CredentialSource;\n /**\n * When set, resolve retries internally on 425 TOO_EARLY honoring Retry-After\n * and resolves the instant the stream is live. See WaitForLive.\n */\n waitForLive?: WaitForLive;\n signal?: AbortSignal;\n /**\n * Extra headers attached to every request this call makes to {@link baseUrl}\n * — and ONLY there: never to CDN manifest/segment hosts or DRM license\n * endpoints. For integrations whose API front door wants its own auth\n * signal on top of the publishable key (gateways, operator consoles).\n * `x-api-key` cannot be overridden.\n */\n apiHeaders?: Record<string, string>;\n}\n\ninterface RawPlayback {\n matchUrn?: string;\n protocol?: string;\n drmEnabled?: boolean;\n manifestUrl?: string;\n drm?: {\n widevine?: { licenseUrl?: string };\n fairplay?: { licenseUrl?: string; certificateUrl?: string };\n };\n serverTime?: string;\n liveStartsAt?: string;\n analytics?: { sid?: string };\n}\n\n/**\n * Single GET /v1/playback/{urn}. Throws a typed PlaybackError on non-2xx.\n * @internal Use resolveStream (which adds the live-pickup loop); this is the SDK's internal one-shot.\n */\nexport async function resolvePlaybackOnce(opts: ResolveOptions): Promise<StreamDescriptor> {\n if (!opts.matchUrn || typeof opts.matchUrn !== 'string') {\n throw new PlaybackError('INVALID_URN', 0, 'resolveStream: a non-empty matchUrn is required');\n }\n const url = joinUrl(opts.baseUrl, `/v1/playback/${encodeURIComponent(opts.matchUrn)}`);\n const res = await fetchWithCredential(url, {\n credential: opts.credential,\n signal: opts.signal,\n apiHeaders: opts.apiHeaders,\n });\n if (!res.ok) throw await toPlaybackError(res);\n const raw = (await res.json()) as RawPlayback;\n return toDescriptor(raw, opts.matchUrn);\n}\n\n/**\n * Resolve a match into a playback descriptor (manifest URL + DRM config). With\n * `waitForLive`, blocks (politely, server-paced) until the stream is live.\n */\nexport function resolveStream(opts: ResolveOptions): Promise<StreamDescriptor> {\n const attempt = () => resolvePlaybackOnce(opts);\n return opts.waitForLive ? pollToLive(attempt, opts.waitForLive, opts.signal) : attempt();\n}\n\nasync function toPlaybackError(res: Response): Promise<PlaybackError> {\n const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));\n const requestId =\n res.headers.get('x-request-id') ?? res.headers.get('x-amzn-requestid') ?? undefined;\n let message = res.statusText || `HTTP ${res.status}`;\n let serverCode: string | undefined;\n let liveStartsAtMs: number | undefined;\n try {\n const body = (await res.json()) as {\n error?: { code?: string; message?: string };\n liveStartsAt?: string;\n };\n if (body?.error?.message) message = body.error.message;\n if (body?.error?.code) serverCode = body.error.code;\n // On a 425 TOO_EARLY body, newer havik-streams carries `liveStartsAt` so the\n // SDK can widen far-future poll cadence without a separate catalog call.\n if (typeof body?.liveStartsAt === 'string') {\n const t = Date.parse(body.liveStartsAt);\n if (Number.isFinite(t)) liveStartsAtMs = t;\n }\n } catch {\n // non-JSON / empty body — keep the status-text message\n }\n return new PlaybackError(classifyStatus(res.status), res.status, message, {\n retryAfterMs,\n requestId,\n serverCode,\n liveStartsAtMs,\n });\n}\n\nconst KNOWN_PLAYBACK_KEYS = new Set([\n 'matchUrn',\n 'protocol',\n 'drmEnabled',\n 'manifestUrl',\n 'drm',\n 'serverTime',\n 'liveStartsAt',\n 'expiresAt',\n 'analytics',\n]);\n\n// Reject obviously-wrong URL schemes before any of these strings reach a live\n// sink (video.src, hls.loadSource, the EME license XHR). The streams API is the\n// trust anchor, so this is defense-in-depth against a malformed/compromised\n// response — not a substitute for server-side trust. httpStatus 0 marks an\n// SDK-side (non-transport) error, matching NETWORK/ABORTED.\nfunction assertHttpUrl(label: string, value: string): string {\n let parsed: URL;\n try {\n parsed = new URL(value);\n } catch {\n throw new PlaybackError('INTERNAL', 0, `playback ${label} is not a valid URL`);\n }\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {\n throw new PlaybackError(\n 'INTERNAL',\n 0,\n `playback ${label} has a disallowed scheme: ${parsed.protocol}`,\n );\n }\n return value;\n}\n\nfunction toDescriptor(raw: RawPlayback, fallbackUrn: string): StreamDescriptor {\n if (!raw.manifestUrl) {\n throw new PlaybackError('INTERNAL', 0, 'playback response is missing manifestUrl');\n }\n const drmEnabled = raw.drmEnabled ?? Boolean(raw.drm);\n const descriptor: StreamDescriptor = {\n matchUrn: raw.matchUrn ?? fallbackUrn,\n protocol: 'hls',\n drmEnabled,\n manifestUrl: assertHttpUrl('manifestUrl', raw.manifestUrl),\n serverTime: raw.serverTime ?? '',\n };\n if (raw.liveStartsAt) descriptor.liveStartsAt = raw.liveStartsAt;\n if (typeof raw.analytics?.sid === 'string' && raw.analytics.sid) {\n descriptor.analytics = { sid: raw.analytics.sid };\n }\n\n if (drmEnabled && raw.drm) {\n const drm: NonNullable<StreamDescriptor['drm']> = {};\n if (raw.drm.widevine?.licenseUrl) {\n drm.widevine = {\n licenseUrl: assertHttpUrl('widevine licenseUrl', raw.drm.widevine.licenseUrl),\n };\n }\n if (raw.drm.fairplay?.licenseUrl && raw.drm.fairplay.certificateUrl) {\n drm.fairplay = {\n licenseUrl: assertHttpUrl('fairplay licenseUrl', raw.drm.fairplay.licenseUrl),\n certificateUrl: assertHttpUrl('fairplay certificateUrl', raw.drm.fairplay.certificateUrl),\n };\n }\n if (drm.widevine || drm.fairplay) descriptor.drm = drm;\n }\n\n // Forward-compat: carry any unrecognized top-level fields the server adds (e.g.\n // a future token TTL, CDN hint, or variant) so Mode-B consumers aren't blind to them.\n const extensions: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {\n if (!KNOWN_PLAYBACK_KEYS.has(k)) extensions[k] = v;\n }\n if (Object.keys(extensions).length > 0) descriptor.extensions = extensions;\n\n return descriptor;\n}\n","// Cross-platform fullscreen for a playing <video>. Exported for Mode B\n// (bring-your-own-player) integrators and shared by the managed player API\n// and the custom control bar, so the platform quirks live in exactly one\n// place.\n//\n// Capability ladder, because the element Fullscreen API is not universal:\n// 1. Standard element fullscreen on `container` (or the video itself) — a\n// container keeps custom DOM controls/overlays on screen.\n// 2. Prefixed element fullscreen — older iPad/macOS Safari (< 16.4) ships\n// only webkitRequestFullscreen / webkitExitFullscreen.\n// 3. Video-native fullscreen — iPhone Safari has NEVER shipped the element\n// API in any form (`requestFullscreen` is undefined on every element),\n// so the only fullscreen there is the AVPlayer-style\n// `video.webkitEnterFullscreen()` with NATIVE controls (the YouTube\n// behavior). Custom DOM is not visible inside it, it requires loaded\n// media (a pre-play call is a no-op), and it must run from a user\n// gesture.\n\ntype FullscreenTarget = HTMLElement & {\n requestFullscreen?: () => Promise<void>;\n webkitRequestFullscreen?: () => void;\n};\n\ntype FullscreenDocument = Document & {\n fullscreenElement?: Element | null;\n exitFullscreen?: () => Promise<void>;\n webkitFullscreenElement?: Element | null;\n webkitExitFullscreen?: () => void;\n};\n\ntype WebkitVideo = HTMLVideoElement & {\n webkitSupportsFullscreen?: boolean;\n webkitDisplayingFullscreen?: boolean;\n webkitEnterFullscreen?: () => void;\n webkitExitFullscreen?: () => void;\n};\n\nfunction ownerDoc(video: HTMLVideoElement): FullscreenDocument {\n return (video.ownerDocument ?? document) as FullscreenDocument;\n}\n\n/** The element currently in element fullscreen (unprefixed or prefixed), if any. */\nfunction fullscreenElement(doc: FullscreenDocument): Element | null {\n return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;\n}\n\n/**\n * True while this video's fullscreen presentation is active: element\n * fullscreen on `container` or the video itself, or iPhone's video-native\n * fullscreen. Pass the same `container` you pass to enterVideoFullscreen.\n */\nexport function videoFullscreenActive(video: HTMLVideoElement, container?: HTMLElement): boolean {\n const el = fullscreenElement(ownerDoc(video));\n if (el != null && (el === video || (container != null && el === container))) return true;\n return (video as WebkitVideo).webkitDisplayingFullscreen === true;\n}\n\n/**\n * Enter fullscreen for `video`, walking the capability ladder above.\n * `container` is the element to present in element fullscreen (defaults to\n * the video) — pass your player wrapper to keep your own controls visible on\n * platforms that support it. Returns false when NO mechanism exists (some\n * webviews) so the caller can hide or disable its button; rejections from a\n * denied request (permissions policy) propagate.\n *\n * Call from a user gesture: every branch of the ladder requires one.\n */\nexport async function enterVideoFullscreen(\n video: HTMLVideoElement,\n container?: HTMLElement,\n): Promise<boolean> {\n const target = (container ?? video) as FullscreenTarget;\n if (typeof target.requestFullscreen === 'function') {\n await target.requestFullscreen();\n return true;\n }\n if (typeof target.webkitRequestFullscreen === 'function') {\n target.webkitRequestFullscreen();\n return true;\n }\n const v = video as WebkitVideo;\n if (v.webkitSupportsFullscreen && typeof v.webkitEnterFullscreen === 'function') {\n v.webkitEnterFullscreen();\n return true;\n }\n return false;\n}\n\n/**\n * Leave whichever fullscreen presentation `video` is in: iPhone's video-native\n * fullscreen first (it reports only through the video, never the document),\n * then element fullscreen via the document (unprefixed or prefixed). Safe to\n * call when not fullscreen — and scoped to THIS video's presentation: an\n * unrelated element's fullscreen (a lightbox, another player) is left alone,\n * mirroring the ownership check exitPictureInPicture makes on\n * pictureInPictureElement. Pass the same `container` you entered with.\n */\nexport async function exitVideoFullscreen(\n video: HTMLVideoElement,\n container?: HTMLElement,\n): Promise<void> {\n const v = video as WebkitVideo;\n if (v.webkitDisplayingFullscreen) {\n v.webkitExitFullscreen?.();\n return;\n }\n const doc = ownerDoc(video);\n const el = fullscreenElement(doc);\n if (el != null && (el === video || (container != null && el === container))) {\n if (typeof doc.exitFullscreen === 'function') await doc.exitFullscreen();\n else doc.webkitExitFullscreen?.();\n }\n}\n","import { classifyStatus, PlaybackError } from './errors';\nimport { fetchWithCredential, joinUrl } from './http';\nimport { normaliseMatchStatus, type CredentialSource, type MatchLiveStatus } from './types';\n\nexport interface CatalogMatch {\n matchUrn: string;\n matchName: string;\n status: MatchLiveStatus;\n /**\n * The server's status string verbatim (see {@link normaliseMatchStatus}).\n * Equals `status` for known values; carries the new one when the server's\n * vocabulary has grown past this SDK.\n */\n statusRaw?: string;\n datePlannedStart?: string;\n tournamentUrn: string;\n tournamentName: string;\n sport: string;\n}\n\nexport interface CatalogTournament {\n urn: string;\n name: string;\n sport: string;\n isOffline: boolean;\n matches: CatalogMatch[];\n}\n\nexport interface Catalog {\n tournaments: CatalogTournament[];\n /** ETag for a cheap conditional refresh next time. */\n etag?: string;\n /** True when the server returned 304 (the prior etag is still fresh); tournaments is empty. */\n notModified: boolean;\n}\n\nexport interface FetchCatalogOptions {\n baseUrl: string;\n credential: CredentialSource;\n signal?: AbortSignal;\n /** Prior ETag for a conditional GET (304 when unchanged). */\n etag?: string;\n status?: MatchLiveStatus | MatchLiveStatus[];\n sport?: string;\n /**\n * Extra headers attached to every request to {@link baseUrl} — and only\n * there: never to CDN or license hosts. `x-api-key` cannot be overridden.\n */\n apiHeaders?: Record<string, string>;\n}\n\ninterface RawCatalog {\n tournaments?: Array<{\n urn?: string;\n name?: string;\n sport?: string;\n isOffline?: boolean;\n matches?: Array<{\n matchUrn?: string;\n matchName?: string;\n status?: string;\n datePlannedStart?: string;\n }>;\n }>;\n}\n\n/** GET /v1/catalog — metadata only (no manifest/DRM). Supports conditional refresh. */\nexport async function fetchCatalog(opts: FetchCatalogOptions): Promise<Catalog> {\n const params = new URLSearchParams();\n if (opts.status) {\n params.set('status', Array.isArray(opts.status) ? opts.status.join(',') : opts.status);\n }\n if (opts.sport) params.set('sport', opts.sport);\n const qs = params.toString();\n const url = joinUrl(opts.baseUrl, '/v1/catalog' + (qs ? `?${qs}` : ''));\n\n const headers = new Headers();\n if (opts.etag) headers.set('If-None-Match', opts.etag);\n\n const res = await fetchWithCredential(url, {\n credential: opts.credential,\n signal: opts.signal,\n headers,\n apiHeaders: opts.apiHeaders,\n });\n\n if (res.status === 304) {\n return { tournaments: [], etag: opts.etag, notModified: true };\n }\n if (!res.ok) {\n throw new PlaybackError(\n classifyStatus(res.status),\n res.status,\n `catalog request failed (HTTP ${res.status})`,\n );\n }\n\n const raw = (await res.json()) as RawCatalog;\n return {\n tournaments: parseCatalog(raw),\n etag: res.headers.get('etag') ?? undefined,\n notModified: false,\n };\n}\n\nfunction parseCatalog(raw: RawCatalog): CatalogTournament[] {\n return (raw.tournaments ?? []).map((t) => {\n const urn = t.urn ?? '';\n const name = t.name ?? '';\n const sport = t.sport ?? '';\n return {\n urn,\n name,\n sport,\n isOffline: Boolean(t.isOffline),\n matches: (t.matches ?? []).map((m) => ({\n matchUrn: m.matchUrn ?? '',\n matchName: m.matchName ?? '',\n status: normaliseMatchStatus(m.status),\n statusRaw: m.status,\n datePlannedStart: m.datePlannedStart,\n tournamentUrn: urn,\n tournamentName: name,\n sport,\n })),\n };\n });\n}\n\nexport interface FetchTournamentOptions {\n baseUrl: string;\n credential: CredentialSource;\n /** Tournament URN, e.g. `od:tournament:42`. */\n urn: string;\n signal?: AbortSignal;\n /** Prior ETag for a conditional GET (304 when unchanged) — only effective once havik-streams ETags `/v1/tournaments/{urn}` (parity with `/v1/matches/{urn}`). */\n etag?: string;\n /**\n * Extra headers attached to every request to {@link baseUrl} — and only\n * there: never to CDN or license hosts. `x-api-key` cannot be overridden.\n */\n apiHeaders?: Record<string, string>;\n}\n\nexport interface TournamentResult {\n /** Present unless the tournament is unknown (404) or the server returned 304. */\n tournament?: CatalogTournament;\n /** ETag for a cheap conditional refresh next time, when the server emits one. */\n etag?: string;\n /** True when the server returned 304 (the prior etag is still fresh); `tournament` is undefined. */\n notModified: boolean;\n /** True when the URN is not in the catalog (or unknown to the caller's brand). */\n notFound: boolean;\n}\n\ninterface RawTournament {\n urn?: string;\n name?: string;\n sport?: string;\n isOffline?: boolean;\n matches?: Array<{\n matchUrn?: string;\n matchName?: string;\n status?: string;\n datePlannedStart?: string;\n }>;\n}\n\n/**\n * `GET /v1/tournaments/{urn}` — single-tournament metadata (matches included,\n * never any signed/per-viewer URLs; those live on `/v1/playback/{urn}`).\n *\n * Returns `notFound: true` on 404 (unknown URN OR caller-not-entitled — the\n * server intentionally collapses both so existence isn't leaked). Other waitable\n * statuses (5xx) reject as a typed `PlaybackError`. Supports ETag/304.\n */\nexport async function fetchTournament(opts: FetchTournamentOptions): Promise<TournamentResult> {\n const url = joinUrl(opts.baseUrl, `/v1/tournaments/${encodeURIComponent(opts.urn)}`);\n const headers = new Headers();\n if (opts.etag) headers.set('If-None-Match', opts.etag);\n\n const res = await fetchWithCredential(url, {\n credential: opts.credential,\n signal: opts.signal,\n headers,\n apiHeaders: opts.apiHeaders,\n });\n\n if (res.status === 304) {\n return { etag: opts.etag, notModified: true, notFound: false };\n }\n if (res.status === 404) {\n return { notModified: false, notFound: true };\n }\n if (!res.ok) {\n throw new PlaybackError(\n classifyStatus(res.status),\n res.status,\n `tournament request failed (HTTP ${res.status})`,\n );\n }\n\n const raw = (await res.json()) as RawTournament;\n return {\n tournament: parseTournament(raw),\n etag: res.headers.get('etag') ?? undefined,\n notModified: false,\n notFound: false,\n };\n}\n\nfunction parseTournament(raw: RawTournament): CatalogTournament {\n const urn = raw.urn ?? '';\n const name = raw.name ?? '';\n const sport = raw.sport ?? '';\n return {\n urn,\n name,\n sport,\n isOffline: Boolean(raw.isOffline),\n matches: (raw.matches ?? []).map((m) => ({\n matchUrn: m.matchUrn ?? '',\n matchName: m.matchName ?? '',\n status: normaliseMatchStatus(m.status),\n statusRaw: m.status,\n datePlannedStart: m.datePlannedStart,\n tournamentUrn: urn,\n tournamentName: name,\n sport,\n })),\n };\n}\n\n/** Flatten a Catalog to a single match list (handy for grids). */\nexport function flattenMatches(catalog: Catalog): CatalogMatch[] {\n return catalog.tournaments.flatMap((t) => t.matches);\n}\n","import { classifyStatus, PlaybackError } from './errors';\nimport { fetchWithCredential, joinUrl } from './http';\nimport { normaliseMatchStatus, type CredentialSource, type MatchStatus } from './types';\n\nexport interface WatchStatusOptions {\n baseUrl: string;\n matchUrn: string;\n credential: CredentialSource;\n /** Fired whenever the status changes (and once on first read). */\n onChange: (status: MatchStatus) => void;\n /** Fired on a poll error; polling continues. */\n onError?: (err: PlaybackError) => void;\n /** Poll interval in ms. Floored at 2000. Default: 10000 (catalog max-age). */\n intervalMs?: number;\n /**\n * Extra headers attached to every request to {@link baseUrl} — and only\n * there: never to CDN or license hosts. `x-api-key` cannot be overridden.\n */\n apiHeaders?: Record<string, string>;\n}\n\nexport interface StatusWatcher {\n stop(): void;\n}\n\n/**\n * Poll `GET /v1/matches/{urn}` for the live status (there is no push channel),\n * firing onChange on transitions. Uses ETag/If-None-Match so unchanged polls\n * are cheap 304s. This drives discovery; actual readiness is the /v1/playback\n * 200 (see resolveStream + waitForLive).\n */\nexport function watchStatus(opts: WatchStatusOptions): StatusWatcher {\n const interval = Math.max(2000, opts.intervalMs ?? 10_000);\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n let etag: string | undefined;\n // The SERVER's status string as last seen, not the normalised member —\n // see the change-detection comment below.\n let lastStatus: string | undefined;\n let stopped = false;\n\n const schedule = () => {\n if (!stopped) timer = setTimeout(tick, interval);\n };\n\n const tick = async () => {\n try {\n const url = joinUrl(opts.baseUrl, `/v1/matches/${encodeURIComponent(opts.matchUrn)}`);\n const headers = new Headers();\n if (etag) headers.set('If-None-Match', etag);\n const res = await fetchWithCredential(url, {\n credential: opts.credential,\n signal: controller.signal,\n headers,\n apiHeaders: opts.apiHeaders,\n });\n if (res.status !== 304) {\n if (!res.ok) {\n throw new PlaybackError(\n classifyStatus(res.status),\n res.status,\n `match status request failed (HTTP ${res.status})`,\n );\n }\n etag = res.headers.get('etag') ?? etag;\n const m = (await res.json()) as {\n matchUrn?: string;\n matchName?: string;\n status?: string;\n datePlannedStart?: string;\n };\n const status = normaliseMatchStatus(m.status);\n // Change detection keys on the SERVER's string, not the normalised\n // one. Two different server statuses can normalise to the same union\n // member (an unrecognised value collapses to 'upcoming'), and keying\n // on the union would swallow that transition entirely — the caller\n // would never learn `statusRaw` had moved, which is the whole point\n // of carrying it. A caller switching on `status` sees the same member\n // twice, which is a re-render, not a lie.\n const rawStatus = typeof m.status === 'string' ? m.status : status;\n if (rawStatus !== lastStatus) {\n lastStatus = rawStatus;\n opts.onChange({\n matchUrn: m.matchUrn ?? opts.matchUrn,\n matchName: m.matchName,\n status,\n statusRaw: m.status,\n datePlannedStart: m.datePlannedStart,\n });\n }\n }\n } catch (err) {\n if (!controller.signal.aborted) {\n opts.onError?.(\n err instanceof PlaybackError ? err : new PlaybackError('NETWORK', 0, String(err)),\n );\n }\n } finally {\n schedule();\n }\n };\n\n void tick();\n\n return {\n stop() {\n stopped = true;\n controller.abort();\n if (timer) clearTimeout(timer);\n },\n };\n}\n","import { joinUrl } from './http';\nimport { resolveCredential, type CredentialSource } from './types';\n\n/**\n * Coarse, player-facing live state pushed by havik-streams over SSE. Mirrors\n * the `/v1/playback` outcome ladder so a pushed event and a follow-up\n * `/v1/playback` call always agree:\n * live ↔ 200 OK (a serveable session exists — play now)\n * upcoming ↔ 425 Too Early (scheduled, not serving yet)\n * ended ↔ 503 (was live; media stopped, still in catchup)\n * gone ↔ 410 Gone (ended past the catchup window)\n */\nexport type LiveState = 'live' | 'upcoming' | 'ended' | 'gone';\n\n/**\n * Why a stream ended (havik-streams #191). `ended` alone cannot tell a viewer\n * whether the MATCH finished or only its stream dropped — two situations that\n * deserve opposite messaging:\n *\n * - `interrupted` — the match is still running; no session is serving (an\n * ingest outage that outlived the bridge's reconnect grace). Play is\n * expected to resume, so surface \"reconnecting\", never \"this has ended\".\n * - `match_ended` — the match itself finished. A real end.\n *\n * Absent on every other state, and absent entirely from servers predating the\n * field — so treat `undefined` as \"unknown, assume a real end\" (what clients\n * did before it existed).\n */\nexport type EndedReason = 'interrupted' | 'match_ended';\n\nexport interface LiveStateEvent {\n matchUrn: string;\n state: LiveState;\n /** Set only on `ended`; see {@link EndedReason}. */\n reason?: EndedReason;\n /** RFC3339 server timestamp the transition was stamped at. */\n serverTime: string;\n}\n\nexport interface SubscribeLiveStateOptions {\n /** Base URL of the SSE endpoint, e.g. `https://events.feed.oddin-video.gg`. */\n eventsBaseUrl: string;\n matchUrn: string;\n credential: CredentialSource;\n /** Fired for the initial snapshot on connect and every subsequent transition. */\n onState: (ev: LiveStateEvent) => void;\n /**\n * Fired on a non-fatal subscription problem (network drop, HTTP error,\n * server lifetime-cap close). The client keeps retrying with backoff; this\n * is observational only — it never tears playback down.\n */\n onError?: (err: unknown) => void;\n /**\n * Fired on EVERY received frame, including heartbeat comments — a liveness\n * tick. Lets a caller treat the subscription as healthy (e.g. suspend\n * polling) while frames keep arriving, and fall back when they stop.\n */\n onAlive?: () => void;\n /** Reconnect backoff floor / ceiling (ms). Defaults: 1000 / 15000. */\n minBackoffMs?: number;\n maxBackoffMs?: number;\n /**\n * How long a request may stay silent before it is treated as dead and\n * reconnected. Applies to both phases: waiting for response headers, and\n * waiting for the next byte on an open connection. Default 45000 — two\n * missed 20s server heartbeats. Set `0` to disable the watchdog (a silent\n * connection then stays open forever, which is what this subscription did\n * before the watchdog existed).\n */\n idleTimeoutMs?: number;\n}\n\n/** Two missed 20s server heartbeats. See {@link SubscribeLiveStateOptions.idleTimeoutMs}. */\nconst DEFAULT_IDLE_TIMEOUT_MS = 45_000;\n\nexport interface LiveStateSubscription {\n /** Stop the subscription and abort the in-flight request. Idempotent. */\n close(): void;\n}\n\n/**\n * Subscribe to a match's live-state stream (`GET /v1/events/{urn}`).\n *\n * Uses `fetch` rather than the native `EventSource` because the endpoint\n * authenticates with the `x-api-key` HEADER, which `EventSource` cannot set.\n * The body is a `text/event-stream` we parse incrementally. The connection is\n * long-lived (the server caps it at ~30min and sends heartbeats); on any\n * close/error the client reconnects with exponential backoff and re-syncs from\n * the initial snapshot the server replays on connect. A connection that stops\n * delivering without closing is caught by the idle watchdog\n * (`idleTimeoutMs`) — see the note on it in `readStream`. A subscription\n * failure is always non-fatal to playback — callers keep the HLS staleness\n * watchdog as the layer-fallback.\n */\nexport function subscribeLiveState(opts: SubscribeLiveStateOptions): LiveStateSubscription {\n const minBackoff = Math.max(250, opts.minBackoffMs ?? 1000);\n const maxBackoff = Math.max(minBackoff, opts.maxBackoffMs ?? 15_000);\n // Not floored: a caller that asks for a very short window (a test) gets it —\n // the reconnect rate is bounded by minBackoff either way. 0/negative/NaN\n // disable the watchdog.\n const idleRaw = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;\n const idleMs = Number.isFinite(idleRaw) && idleRaw > 0 ? idleRaw : 0;\n const ctrl = new AbortController();\n let closed = false;\n\n const sleep = (ms: number): Promise<void> =>\n new Promise((resolve) => {\n if (ctrl.signal.aborted) return resolve();\n const t = setTimeout(resolve, ms);\n ctrl.signal.addEventListener(\n 'abort',\n () => {\n clearTimeout(t);\n resolve();\n },\n { once: true },\n );\n });\n\n const dispatch = (frame: string): void => {\n opts.onAlive?.(); // any frame (incl. a heartbeat comment) is a liveness tick\n let event = 'message';\n const data: string[] = [];\n for (const raw of frame.split('\\n')) {\n const line = raw.endsWith('\\r') ? raw.slice(0, -1) : raw;\n if (line === '' || line.startsWith(':')) continue; // blank / comment (heartbeat)\n const i = line.indexOf(':');\n const field = i < 0 ? line : line.slice(0, i);\n let value = i < 0 ? '' : line.slice(i + 1);\n if (value.startsWith(' ')) value = value.slice(1);\n if (field === 'event') event = value;\n else if (field === 'data') data.push(value);\n }\n if (event !== 'state' || data.length === 0) return;\n try {\n const parsed = JSON.parse(data.join('\\n')) as LiveStateEvent;\n if (parsed && typeof parsed.state === 'string') opts.onState(parsed);\n } catch {\n /* ignore a malformed frame; the next one re-syncs state */\n }\n };\n\n // SSE frame separator = a blank line. Accept LF, CRLF, and lone-CR servers\n // (per the spec, lines may end in \\n, \\r\\n, or \\r). Non-global so .exec always\n // scans from 0; it yields both the boundary index AND its length. A trailing\n // partial boundary (e.g. a chunk ending mid-\"\\r\\n\\r\\n\") simply stays buffered\n // until the next chunk completes it.\n const FRAME_BOUNDARY = /\\r\\n\\r\\n|\\n\\n|\\r\\r/;\n\n const readStream = async (\n body: ReadableStream<Uint8Array>,\n onData: () => void,\n onIdle: () => void,\n ): Promise<void> => {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n let gotData = false;\n let idleTimer: ReturnType<typeof setTimeout> | undefined;\n // Idle watchdog. `reader.read()` below parks until the body yields, ends,\n // or errors — and a black-holed socket (laptop sleep, NAT rebind, proxy\n // half-close) does none of those: no FIN ever reaches us, so the read\n // never wakes and the subscription is permanently dead AND silent (no\n // onError, no reconnect). The server's ~30min lifetime cap only helps when\n // its close actually arrives. So: arm on open (this also covers\n // headers-then-no-body-ever), rearm on every chunk, and on expiry tear the\n // attempt down so run()'s loop reconnects.\n const rearmIdle = (): void => {\n if (!idleMs) return;\n clearTimeout(idleTimer);\n idleTimer = setTimeout(() => {\n onIdle(); // flags the attempt and aborts the request (socket teardown)\n // The abort alone is not enough: the body stream is already handed to\n // this reader, and cancel() is what resolves the parked read() as\n // `done` and unblocks the loop.\n void reader.cancel().catch(() => {});\n }, idleMs);\n };\n try {\n rearmIdle();\n for (;;) {\n const { value, done } = await reader.read();\n if (done) return;\n if (value && value.length > 0) {\n rearmIdle();\n if (!gotData) {\n gotData = true;\n onData(); // first bytes flowed — the connection is genuinely healthy\n }\n }\n buf += decoder.decode(value, { stream: true });\n let m: RegExpExecArray | null;\n while ((m = FRAME_BOUNDARY.exec(buf)) !== null) {\n dispatch(buf.slice(0, m.index));\n buf = buf.slice(m.index + m[0].length);\n }\n if (closed) return;\n }\n } finally {\n clearTimeout(idleTimer);\n await reader.cancel().catch(() => {});\n }\n };\n\n const run = async (): Promise<void> => {\n let backoff = minBackoff;\n while (!closed) {\n // Per-attempt abort handle. The subscription-wide `ctrl` must stay\n // unaborted across reconnects (the catch below returns on it, and a\n // later fetch would be handed an already-aborted signal), so the idle\n // watchdog needs its own handle to tear down just THIS request. Chained\n // so close() still kills the in-flight one; the relay is removed in the\n // finally, or one closure would leak per reconnect on a long session.\n const attempt = new AbortController();\n const relayAbort = (): void => attempt.abort();\n ctrl.signal.addEventListener('abort', relayAbort, { once: true });\n // Set by whichever watchdog tore this attempt down, and reported after\n // the try/finally. Also suppresses the AbortError the teardown raises —\n // that error IS the teardown, and these messages name the cause.\n let idleReason: string | null = null;\n let connectTimer: ReturnType<typeof setTimeout> | undefined;\n const onIdle = (): void => {\n idleReason = `no data for ${idleMs}ms`;\n attempt.abort();\n };\n try {\n const cred = await resolveCredential(opts.credential);\n if (closed) return;\n const url = joinUrl(opts.eventsBaseUrl, `/v1/events/${encodeURIComponent(opts.matchUrn)}`);\n // Connect watchdog. `fetch` has no timeout of its own, so a socket that\n // black-holes during the HANDSHAKE (dropped SYN, captive portal, a NAT\n // that lost the mapping) parks this await forever — the same silent\n // death readStream's watchdog closes, one step earlier and not reachable\n // from there, since that one is only armed once a body exists. The two\n // belong together: a reconnect triggered BY the stream watchdog goes out\n // over a network that just changed under us, so it is exactly the\n // request most likely to hit a black hole. (A `credential` provider that\n // never settles is the caller's own promise and out of scope — aborting\n // cannot unpark the await above it.)\n if (idleMs) {\n connectTimer = setTimeout(() => {\n idleReason = `no response headers within ${idleMs}ms`;\n attempt.abort();\n }, idleMs);\n }\n const res = await fetch(url, {\n method: 'GET',\n headers: { 'x-api-key': cred.apiKey, accept: 'text/event-stream' },\n signal: attempt.signal,\n cache: 'no-store',\n credentials: 'omit',\n mode: 'cors',\n });\n clearTimeout(connectTimer); // headers are in; readStream arms its own\n if (closed) return;\n if (!res.ok || !res.body) {\n opts.onError?.(new Error(`live-state subscribe failed: HTTP ${res.status}`));\n } else {\n // Reset the backoff only once data actually flows (readStream's onData),\n // NOT on bare accept — an accept-then-immediate-close server would\n // otherwise reconnect in a tight loop with no backoff growth.\n await readStream(\n res.body,\n () => {\n backoff = minBackoff;\n },\n onIdle,\n );\n }\n } catch (err) {\n if (closed || ctrl.signal.aborted) return;\n // A watchdog teardown reports itself below. The AbortError it raises\n // here is the teardown, not a second failure — and this IS the usual\n // path in a real browser, where aborting errors the body stream and\n // rejects the parked read() before cancel() can resolve it as `done`.\n if (!idleReason) opts.onError?.(err);\n } finally {\n clearTimeout(connectTimer);\n ctrl.signal.removeEventListener('abort', relayAbort);\n attempt.abort(); // leave no request dangling behind a finished attempt\n }\n if (closed) return;\n if (idleReason) {\n opts.onError?.(new Error(`live-state subscribe idle: ${idleReason} — reconnecting`));\n }\n await sleep(backoff);\n backoff = Math.min(maxBackoff, backoff * 2);\n }\n };\n\n void run();\n\n return {\n close() {\n if (closed) return;\n closed = true;\n ctrl.abort();\n },\n };\n}\n\n/**\n * Derive the SSE endpoint base from the API base by prefixing the host with\n * `events.` — matching the deployed convention (`feed.<domain>` →\n * `events.<domain>`, served ALB-direct because CloudFront can't stream SSE).\n * Returns `undefined` if `baseUrl` can't be parsed.\n */\nexport function deriveEventsBaseUrl(baseUrl: string): string | undefined {\n try {\n const u = new URL(baseUrl);\n u.hostname = `events.${u.hostname}`;\n u.pathname = '';\n u.search = '';\n return u.origin;\n } catch {\n return undefined;\n }\n}\n","// Persistent per-device id required by the DRM license POST (X-Device-Id).\n// havik-drm rejects a missing id (400 missing_device_id) and validates the\n// format against ^[a-zA-Z0-9\\-_.@:]+$ — a UUID passes. We persist it in\n// localStorage so it is stable across reloads, falling back to an in-memory id\n// when storage is unavailable (private mode), which is still valid per request.\n\nconst STORAGE_KEY = 'havik.player.deviceId';\nlet cached: string | undefined;\n\nexport function getDeviceId(): string {\n if (cached) return cached;\n try {\n const stored = globalThis.localStorage?.getItem(STORAGE_KEY);\n if (stored) {\n cached = stored;\n return stored;\n }\n } catch {\n // storage blocked — fall through to generate\n }\n const id = generateUuid();\n cached = id;\n try {\n globalThis.localStorage?.setItem(STORAGE_KEY, id);\n } catch {\n // best-effort persistence\n }\n return id;\n}\n\n/**\n * Clear the persisted device id (localStorage + in-memory cache). The next\n * getDeviceId() generates a fresh one. Provide this to viewers as a privacy /\n * \"reset my device identifier\" control — the device id is sent as X-Device-Id\n * on every DRM license request and persists across sessions.\n */\nexport function clearDeviceId(): void {\n cached = undefined;\n try {\n globalThis.localStorage?.removeItem(STORAGE_KEY);\n } catch {\n // storage unavailable — the in-memory cache is already cleared\n }\n}\n\nfunction generateUuid(): string {\n const c = globalThis.crypto as Crypto | undefined;\n if (c && typeof c.randomUUID === 'function') return c.randomUUID();\n\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') {\n c.getRandomValues(bytes);\n } else {\n for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);\n }\n bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));\n return (\n hex.slice(0, 4).join('') +\n '-' +\n hex.slice(4, 6).join('') +\n '-' +\n hex.slice(6, 8).join('') +\n '-' +\n hex.slice(8, 10).join('') +\n '-' +\n hex.slice(10, 16).join('')\n );\n}\n","import { type Credential, type StreamDescriptor } from '../types';\nimport { getDeviceId } from './deviceId';\n\nexport interface LicenseHeaderOptions {\n /** Override the persistent device id (defaults to getDeviceId()). */\n deviceId?: string;\n /** Optional viewer id, forwarded as X-User-Id for billing/tracking. */\n userId?: string;\n}\n\n/**\n * Headers a host player must attach to the DRM license POST. The license URL\n * itself must be sent VERBATIM (its signed query string is the token) — these\n * headers are the rest of the contract:\n * - x-api-key : the SAME key used to resolve playback (cid binding)\n * - X-Device-Id : required, persistent\n * - X-Match-Urn : advisory (the signed `m` param is authoritative)\n * - X-User-Id : optional\n * Body must be the raw EME challenge bytes (application/octet-stream).\n */\nexport function licenseRequestHeaders(\n descriptor: StreamDescriptor,\n credential: Credential,\n opts: LicenseHeaderOptions = {},\n): Record<string, string> {\n const headers: Record<string, string> = {\n 'x-api-key': credential.apiKey,\n 'X-Match-Urn': descriptor.matchUrn,\n 'X-Device-Id': opts.deviceId ?? getDeviceId(),\n 'Content-Type': 'application/octet-stream',\n };\n if (opts.userId) headers['X-User-Id'] = opts.userId;\n return headers;\n}\n","/**\n * Per-element CDM reuse, so rebuilding the player never has to give a\n * `<video>` a second ContentDecryptionModule — which Chrome forbids outright.\n *\n * ## The browser rule\n *\n * Once a media element has both a MediaKeys and a media player, Chrome\n * refuses to change or remove that CDM. BOTH of these reject:\n *\n * video.setMediaKeys(null) // remove\n * video.setMediaKeys(otherKeys) // replace\n *\n * with `InvalidStateError: Failed to execute 'setMediaKeys' on\n * 'HTMLMediaElement': The existing ContentDecryptionModule object cannot be\n * removed at this time.`\n *\n * Measured on Chromium 152 (`org.w3.clearkey`, secure context): removal is\n * allowed while the element has no `src`, and starts failing the moment a\n * MediaSource is attached — with `readyState` still 0, nothing buffered,\n * nothing played, and no encrypted media involved. So the bar is not \"a\n * decoder holds the key\"; it is \"the element has a player\". Every DRM rebuild\n * crosses it.\n *\n * ## Why hls.js's own mitigation cannot help\n *\n * hls.js serialises CDM teardown through a static `EMEController`\n * `CDMCleanupPromise`: the destroyed instance's `_clear()` calls\n * `setMediaKeys(null)` and the next instance waits on it before setting its\n * own keys. On Chrome that clear is exactly as forbidden as the set it is\n * meant to make room for. `_clear()` swallows the rejection as a NON-FATAL\n * `KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR`, the cleanup promise resolves anyway,\n * and the new instance then fails its own `setMediaKeys` — that one fatally,\n * as `KEY_SYSTEM_NO_KEYS`. Ordering was never the problem, so no amount of\n * queueing fixes it. (Verified against the bundled 1.7.2: `_clear` at\n * `dist/hls.js:27580`, the swallowed catch at `:27604`.)\n *\n * ## What we do instead\n *\n * Hand every hls.js instance on a given element the SAME MediaKeys, via the\n * `requestMediaKeySystemAccessFunc` config hook. Nothing then tries to\n * replace the CDM:\n *\n * - `EMEController.onMediaAttached` seeds `this.mediaKeys` from\n * `media.mediaKeys` (`dist/hls.js:27568`), and `attemptSetMediaKeys`\n * early-returns when the keys it is asked to set are already the element's\n * (`:27026`) — so the forbidden call is never made; and\n * - even if that early-return is missed, `setMediaKeys(sameObject)` is\n * permitted by the browser (measured), so the fallback is benign.\n *\n * Two independent layers, which is what makes this safe to rest on across\n * hls.js upgrades: the first is an hls.js implementation detail, the second is\n * the platform's own behaviour.\n *\n * This is not a compromise on freshness. Once an element has a CDM, that CDM\n * is the only one it can ever have — the alternative is not \"a fresh CDM\", it\n * is \"a fresh `<video>`\", and the element belongs to the host application\n * (`createPlayer({ video })`), which may hold its own reference, listeners and\n * styling on it. Reuse is what lets a rebuild stay invisible to the host.\n *\n * What a rebuild actually needs is fresh *hls.js* state — the wedged\n * fragment pipeline, the orphaned key-load promise — not a fresh CDM, and it\n * still gets that. Fresh key SESSIONS also still work: closing a session and\n * opening another on a reused MediaKeys is permitted (measured), which is what\n * the license retry depends on.\n *\n * Scope: reuse covers keys this module created for the element. An element\n * that already carries a MediaKeys set by the host is not adopted — it is not\n * a case the SDK creates, and adopting a CDM whose key system we cannot\n * confirm would trade a clear error for a confusing one.\n */\n\n/** Shape of the hls.js `requestMediaKeySystemAccessFunc` hook. */\ntype MediaKeyRequest = (\n keySystem: string,\n supportedConfigurations: MediaKeySystemConfiguration[],\n) => Promise<MediaKeySystemAccess>;\n\n/**\n * element → key system → its one MediaKeys, as a promise so concurrent\n * requests (hls.js can attempt several key systems) share a single creation.\n * Weak on the element so a discarded `<video>` takes its CDM entry with it.\n */\nconst keysByElement = new WeakMap<HTMLMediaElement, Map<string, Promise<MediaKeys>>>();\n\nfunction mediaKeysFor(media: HTMLMediaElement, access: MediaKeySystemAccess): Promise<MediaKeys> {\n let bySystem = keysByElement.get(media);\n if (!bySystem) {\n bySystem = new Map();\n keysByElement.set(media, bySystem);\n }\n const cached = bySystem.get(access.keySystem);\n if (cached) return cached;\n // Evict on failure: a transient createMediaKeys() rejection must not pin a\n // permanently-rejected promise to the element for the rest of the page.\n const created = access.createMediaKeys().catch((err: unknown) => {\n if (bySystem.get(access.keySystem) === created) bySystem.delete(access.keySystem);\n throw err;\n });\n bySystem.set(access.keySystem, created);\n return created;\n}\n\n/**\n * Whether this page can request key-system access at all. False in a\n * non-secure context (EME is secure-context-only, so plain `http://` embeds\n * see no API), in a non-browser runtime, and in an embedder that strips it.\n *\n * The engine installs the hook below only when this holds, so that when it does\n * NOT hold hls.js falls back to its own null `requestMediaKeySystemAccessFunc`\n * and reports the cause — including the specific \"not available over insecure\n * protocol\" message (`dist/hls.js:26637`), which is the likeliest way an\n * integrator meets this. Installing our hook unconditionally would shadow that\n * diagnostic with an opaque TypeError.\n */\nexport function emeAvailable(): boolean {\n return (\n typeof navigator !== 'undefined' && typeof navigator.requestMediaKeySystemAccess === 'function'\n );\n}\n\n/**\n * Video robustness rungs appended below whatever the caller asked for, weakest\n * last. Widevine's ladder continues to '' below this, and that rung is\n * deliberately absent: '' means \"no requirement\", which would turn a precise\n * `keySystemNoAccess` at access time into an opaque decrypt failure much later,\n * and no measured CDM has needed it. Audio is not laddered — the engine already\n * asks for the weakest named audio level (SW_SECURE_CRYPTO).\n */\nconst VIDEO_ROBUSTNESS_FALLBACKS = ['SW_SECURE_DECODE', 'SW_SECURE_CRYPTO'] as const;\n\ntype VideoRobustness = (typeof VIDEO_ROBUSTNESS_FALLBACKS)[number];\n\n/** True for the Widevine key system, the only one whose robustness we ladder. */\nfunction isWidevine(keySystem: string): boolean {\n return keySystem.startsWith('com.widevine');\n}\n\n/**\n * Expand each configuration into an ordered preference list: the caller's own\n * request first, then the same request at successively weaker VIDEO robustness.\n *\n * `requestMediaKeySystemAccess` takes candidates in descending preference and\n * returns the FIRST supported one, so this can only widen — a CDM that accepts\n * today's request still matches on entry 0 and is granted exactly what it is\n * granted now. It is the CDMs that accept NONE of our pins that change: they\n * get a weaker but working configuration instead of a hard rejection.\n *\n * Measured (Android 15 emulator, Chrome 124, Widevine L3, 2026-09-16): that\n * CDM supports video robustness SW_SECURE_CRYPTO and '' only. The engine's\n * SW_SECURE_DECODE — and every HW_* level — rejected with `NotSupportedError:\n * Unsupported keySystem or supportedConfigurations.`, which hls.js reports as a\n * fatal keySystemNoAccess and the SDK surfaces as an unplayable stream. With\n * the ladder the same call resolves at SW_SECURE_CRYPTO, keeping cbcs, and\n * createMediaKeys() succeeds.\n *\n * Exported for tests; the hook below is the only production caller.\n */\nexport function withVideoRobustnessFallbacks(\n keySystem: string,\n configurations: MediaKeySystemConfiguration[],\n): MediaKeySystemConfiguration[] {\n if (!isWidevine(keySystem)) return configurations;\n\n /** True when `rung` is strictly weaker than every level this config asks for. */\n const weakens = (config: MediaKeySystemConfiguration, rung: VideoRobustness): boolean => {\n const video = config.videoCapabilities;\n // Nothing to weaken: a config that pins no video robustness already\n // imposes no video requirement, so a rung below it would be identical.\n if (!video?.length || !video.some((cap) => cap.robustness)) return false;\n return video.every((cap) => {\n const asked = VIDEO_ROBUSTNESS_FALLBACKS.indexOf(cap.robustness as VideoRobustness);\n // An unrecognised level (HW_*, or a future name) sorts above the whole\n // ladder: every rung here is weaker, so all of them are offered.\n return asked === -1 || VIDEO_ROBUSTNESS_FALLBACKS.indexOf(rung) > asked;\n });\n };\n\n // EVERY caller configuration comes before ANY weakening we synthesized.\n // Interleaving per-config (config, its rungs, next config, its rungs) would\n // let a weakened first configuration outrank a later one the caller asked\n // for outright: given [strictA, strictB] on a CDM that rejects strictA but\n // accepts both weakA and strictB, the browser returns the FIRST match — it\n // would pick weakA and silently drop to a lower protection level while the\n // caller's own strictB was available. Fallbacks are ours, not the caller's,\n // so they rank last; within them, rung-major order so the strongest\n // remaining protection is offered first across all configurations.\n const fallbacks = VIDEO_ROBUSTNESS_FALLBACKS.flatMap((robustness) =>\n configurations\n .filter((config) => weakens(config, robustness))\n .map((config) => ({\n ...config,\n videoCapabilities: config.videoCapabilities?.map((cap) => ({ ...cap, robustness })),\n })),\n );\n return [...configurations, ...fallbacks];\n}\n\n/**\n * Build the `requestMediaKeySystemAccessFunc` for an element: a real access\n * request (so the caller's `supportedConfigurations` are honoured and a\n * genuinely unsupported system still rejects), wrapped so `createMediaKeys()`\n * resolves to the element's one MediaKeys.\n */\nexport function reuseMediaKeysFor(media: HTMLMediaElement): MediaKeyRequest {\n return (keySystem, supportedConfigurations) => {\n // hls.js calls this hook and returns its result straight into a promise\n // chain, so a synchronous throw would escape its error handling. Keep the\n // contract the default hook has: always a promise, rejected on failure.\n if (!emeAvailable()) {\n return Promise.reject(\n new Error(\n 'navigator.requestMediaKeySystemAccess is unavailable — EME needs a secure context (https, or localhost)',\n ),\n );\n }\n return navigator\n .requestMediaKeySystemAccess(\n keySystem,\n withVideoRobustnessFallbacks(keySystem, supportedConfigurations),\n )\n .then((access) => ({\n keySystem: access.keySystem,\n getConfiguration: () => access.getConfiguration(),\n createMediaKeys: () => mediaKeysFor(media, access),\n }));\n };\n}\n\n// Deliberately no way to drop an element's cached CDM. There is no correct\n// caller: engine teardown runs on every rebuild, which is exactly when the\n// entry must survive, and player teardown does not help either — if the host\n// reuses the element for another player, that player still cannot be given a\n// different CDM. The WeakMap covers the only case that matters, an element\n// going out of scope.\n","/**\n * DRM capability detection — answers \"can protected playback work in this\n * browser at all?\" so a host can show an accurate message instead of a black\n * player (e.g. iOS Chrome/Firefox/Edge: WKWebView exposes the EME API surface\n * but grants no CDM — Widevine does not exist on iOS and WebKit withholds\n * FairPlay from third-party browsers).\n *\n * Design rules (each encodes a real failure mode):\n * - PROBE, don't UA-sniff: the probe is ground truth for playability. UA\n * strings are spoofable, iPadOS masquerades as macOS, and in-app WebViews\n * carry no browser token. Hosts may still use the UA to WORD a message —\n * never to gate playback.\n * - `requestMediaKeySystemAccess` resolving is NOT proof: some platforms\n * resolve access and then fail `createMediaKeys()` — both must succeed.\n * - Bounded: some WebView builds never settle the access promise; without a\n * timeout the caller's UI hangs on \"checking…\".\n * - Lazy by contract: the EME spec allows the call to have user-visible\n * effects (consent prompts, CDM provisioning downloads). Only call this\n * once you know the content is DRM-protected — never on page load.\n * - Per-system configs must mirror what the engine will actually request:\n * Widevine uses the SW_SECURE_* pins; FairPlay must NOT (WebKit's\n * supportedRobustnesses() for FPS is { '' } — a Widevine-shaped probe\n * false-negatives on Safari).\n * - `SecurityError` is reported separately: a cross-origin iframe missing\n * allow=\"encrypted-media\" (or an https embed in an http parent) is an\n * embedding misconfiguration, not an unsupported browser.\n */\nimport type { DrmSystem } from '../types';\nimport { withVideoRobustnessFallbacks } from './cdm';\n\nexport type DrmSupportVerdict =\n /** At least one probed system produced working MediaKeys. */\n | 'supported'\n /** EME exists but no probed system yields a CDM (e.g. iOS third-party browsers). */\n | 'no-cdm'\n /** Not a secure context — EME is unavailable by spec (http://<LAN-IP> dev, etc.). */\n | 'insecure-context'\n /** SecurityError: permissions-policy / iframe allow=\"encrypted-media\" missing. */\n | 'blocked-by-policy'\n /** The access promise never settled within timeoutMs (some WebViews). */\n | 'probe-timeout';\n\nexport interface DrmSupportResult {\n verdict: DrmSupportVerdict;\n /** The first system that probed as working (when verdict is 'supported'). */\n system?: DrmSystem;\n /** Per-system outcome, for diagnostics/support bundles. */\n detail: Partial<Record<DrmSystem, string>>;\n}\n\nexport interface DetectDrmSupportOptions {\n /** Systems to probe, in order. Default: both. */\n systems?: DrmSystem[];\n /** OVERALL budget for the whole call before concluding 'probe-timeout' —\n * shared across every probed system/stage, not per probe. Default 5000ms. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_PROBE_TIMEOUT_MS = 5_000;\n\nconst VIDEO_MP4 = 'video/mp4; codecs=\"avc1.42E01E\"';\nconst AUDIO_MP4 = 'audio/mp4; codecs=\"mp4a.40.2\"';\n\n/** Key-system strings per DRM system. EXACTLY what the hls.js engine\n * configures — the probe predicts the ENGINE, not abstract capability, so a\n * key system the engine never requests (e.g. legacy `com.apple.fps.1_0`,\n * which hls.js has no fallback to) must not produce a 'supported' verdict. */\nconst KEY_SYSTEMS: Record<DrmSystem, string[]> = {\n widevine: ['com.widevine.alpha'],\n fairplay: ['com.apple.fps'],\n};\n\n/** One config per system, mirroring the engine's EFFECTIVE access request\n * (hls.ts drmSystemOptions → hls.js builds a single configuration):\n * Widevine carries the SW_SECURE_* pins, FairPlay the empty robustness\n * WebKit requires, and BOTH pin encryptionScheme cbcs exactly as the engine\n * does. (Not literally byte-for-byte: initDataTypes ordering and the\n * canonical codec strings differ from hls.js's manifest-derived ones, and\n * spec-default fields are omitted — none of which can flip a verdict.) A\n * WEAKER probe config (pin-less, scheme-less) would report 'supported' on\n * CDMs the engine's stricter request then fails on — the exact\n * optimistic-message-then-black-player this API exists to prevent. */\nfunction probeConfigs(system: DrmSystem): MediaKeySystemConfiguration[] {\n if (system === 'fairplay') {\n return [\n {\n initDataTypes: ['sinf', 'skd', 'cenc'],\n videoCapabilities: [{ contentType: VIDEO_MP4, robustness: '', encryptionScheme: 'cbcs' }],\n audioCapabilities: [{ contentType: AUDIO_MP4, robustness: '', encryptionScheme: 'cbcs' }],\n },\n ];\n }\n // Laddered through the same helper the engine's access hook uses, so the\n // probe keeps mirroring the EFFECTIVE request. Without this the probe would\n // be STRICTER than the engine and report 'no-cdm' for a device the engine\n // then plays on — the same false verdict this file exists to prevent, only\n // inverted.\n return withVideoRobustnessFallbacks('com.widevine.alpha', [\n {\n initDataTypes: ['cenc'],\n videoCapabilities: [\n { contentType: VIDEO_MP4, robustness: 'SW_SECURE_DECODE', encryptionScheme: 'cbcs' },\n ],\n audioCapabilities: [\n { contentType: AUDIO_MP4, robustness: 'SW_SECURE_CRYPTO', encryptionScheme: 'cbcs' },\n ],\n },\n ]);\n}\n\nfunction withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n const t = setTimeout(() => reject(new Error('probe-timeout')), ms);\n p.then(\n (v) => {\n clearTimeout(t);\n resolve(v);\n },\n (e) => {\n clearTimeout(t);\n reject(e);\n },\n );\n });\n}\n\n/**\n * Probe whether DRM playback can work here. Resolves — never rejects.\n *\n * Call it lazily (only for content whose descriptor says `drmEnabled`), CACHE\n * the result yourself (probes are not memoized, and the EME spec allows each\n * call to show consent prompts / trigger CDM downloads), and treat\n * 'supported' as necessary-not-sufficient: the licence server, CORS\n * onboarding and content packaging still have to cooperate.\n */\nexport async function detectDrmSupport(\n opts: DetectDrmSupportOptions = {},\n): Promise<DrmSupportResult> {\n const systems = opts.systems ?? (['widevine', 'fairplay'] as DrmSystem[]);\n const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;\n const detail: Partial<Record<DrmSystem, string>> = {};\n\n if (typeof window !== 'undefined' && window.isSecureContext === false) {\n return { verdict: 'insecure-context', detail };\n }\n const rmksa =\n typeof navigator !== 'undefined'\n ? navigator.requestMediaKeySystemAccess?.bind(navigator)\n : undefined;\n if (!rmksa) {\n // Secure context but no EME entry point at all (very old browser, or an\n // embedder stripped it) — indistinguishable from no CDM for the caller.\n return { verdict: 'no-cdm', detail };\n }\n\n let sawTimeout = false;\n let sawSecurityError = false;\n // One WALL-CLOCK deadline for the whole call: stages consume the remaining\n // budget, so N systems × 2 stages can never stack into N×2 × timeoutMs of\n // \"checking…\" UI hang.\n const deadline = Date.now() + timeoutMs;\n const remaining = (): number => Math.max(1, deadline - Date.now());\n\n for (const system of systems) {\n const keySystems = KEY_SYSTEMS[system];\n if (!keySystems) {\n // Untyped callers can pass arbitrary strings — record, don't reject\n // (the documented contract is \"resolves, never rejects\").\n detail[system] = 'unknown system';\n continue;\n }\n for (const keySystem of keySystems) {\n if (Date.now() >= deadline) {\n sawTimeout = true;\n detail[system] ??= `${keySystem}: probe-timeout`;\n break;\n }\n try {\n const access = await withTimeout(rmksa(keySystem, probeConfigs(system)), remaining());\n // Access alone is not proof — the CDM must actually instantiate.\n await withTimeout(access.createMediaKeys(), remaining());\n detail[system] = `ok (${keySystem})`;\n return { verdict: 'supported', system, detail };\n } catch (err) {\n // Read name/message structurally: DOMException is not `instanceof\n // Error` in every runtime (jsdom, older WebKit) and the distinction\n // matters — SecurityError must not degrade into 'no-cdm'. Prefer the\n // message when the name is the generic 'Error' so a timeout is\n // legible in support bundles.\n const e = err as { name?: string; message?: string } | undefined;\n const name = (e?.name && e.name !== 'Error' ? e.name : e?.message) || String(err);\n detail[system] = `${keySystem}: ${name}`;\n if (e?.message === 'probe-timeout') sawTimeout = true;\n if (e?.name === 'SecurityError') sawSecurityError = true;\n // NotSupportedError (no CDM for this key system) → try the next one.\n }\n }\n }\n\n if (sawSecurityError) return { verdict: 'blocked-by-policy', detail };\n if (sawTimeout) return { verdict: 'probe-timeout', detail };\n return { verdict: 'no-cdm', detail };\n}\n","// LL-HLS detection from a playlist body. LL-HLS is per-distributor and OFF by\n// default, and the tags live on the *media* playlist — so we detect rather than\n// assume. The managed player uses this on level updates to report isLowLatency.\n\nconst LOW_LATENCY_TAG = /^#EXT-X-(PART-INF|PART|PRELOAD-HINT)[:\\s]/m;\n\nexport function isLowLatencyManifest(playlistText: string): boolean {\n return LOW_LATENCY_TAG.test(playlistText);\n}\n","// Named Oddin environments and the API base URL each one means.\n//\n// WHY THESE ARE PINNED HERE, since pinning an endpoint into a published\n// package is normally the wrong instinct: a hand-written `baseUrl` is\n// invisible when it is wrong. A typo, a stale env var, or the wrong\n// environment produces a player that either fails in a way that looks like an\n// outage, or — worse — works while quietly losing everything derived from the\n// host. The beacons endpoint is derived from `baseUrl` by hostname convention\n// (`feed[-dev].<domain>` → `beacons[-dev].<domain>`), so an off-convention\n// baseUrl means QoE analytics is off; an integrator who selects `env` instead\n// cannot land in that state at all, because the base is a feed host by\n// construction.\n//\n// AND WHY `baseUrl` SURVIVES ANYWAY. Pinning couples the endpoint to a\n// release, and this SDK is consumed by integrators who self-host a pinned\n// build and upgrade rarely. If these constants were the only way to reach the\n// platform, a domain change would strand every one of them. `baseUrl` is\n// therefore not deprecated: it is the escape hatch for that, for a customer\n// whose own CDN or gateway fronts the streams API, and for the CN plane,\n// whose ICP-registered host is not under this domain and rotates.\n//\n// Exactly one of the two is the right answer for any given integration, which\n// is why the callers reject being given both rather than picking a winner.\n\nimport { PlaybackError } from './errors';\n\n/**\n * A named Oddin environment.\n *\n * - `integration` — the shared pre-production stack. Where customers build\n * and test against real matches.\n * - `production` — the production stack. A separate distribution, not an\n * alias of integration.\n */\nexport type HavikEnv = 'integration' | 'production';\n\nconst BASE_URLS: Readonly<Record<HavikEnv, string>> = {\n integration: 'https://feed-dev.oddin-video.gg',\n production: 'https://feed.oddin-video.gg',\n};\n\n/**\n * The API base URL for a named environment.\n *\n * Mode A takes {@link HavikEnv} directly (`createPlayer({ env: 'production' })`).\n * Mode B and Mode C still take a `baseUrl`, so call this to get one rather\n * than copying a hostname into your own config:\n *\n * ```ts\n * resolveStream({ baseUrl: baseUrlForEnv('production'), matchUrn, credential });\n * ```\n *\n * Throws on an unrecognized value rather than returning undefined — callers\n * reach this from plain JS where the type gives no protection, and a silent\n * undefined becomes a relative URL against the embedding page.\n */\nexport function baseUrlForEnv(env: HavikEnv): string {\n // Own-property lookup, not a bare index. A bare BASE_URLS['constructor']\n // resolves an INHERITED Object.prototype property and returns a FUNCTION,\n // which is truthy — so the guard below would pass it through, createPlayer\n // would carry a function as its base URL, and joinUrl would die on\n // `base.replace is not a function`: a TypeError from deep inside the SDK\n // instead of the typed error naming the two valid values. `toString` and\n // `__proto__` do the same. Verified before fixing: all three returned\n // without throwing.\n //\n // hasOwnProperty.call, not Object.hasOwn — the global build targets\n // firefox91/safari15 (vite.global.config.ts) and Object.hasOwn needs\n // Firefox 92 / Safari 15.4, which Vite downlevels syntax for but does not\n // polyfill. Same guard and same reasoning as core/environment.ts.\n const url = Object.prototype.hasOwnProperty.call(BASE_URLS, env) ? BASE_URLS[env] : undefined;\n if (!url) {\n throw new PlaybackError(\n 'INTERNAL',\n 0,\n `unknown env ${JSON.stringify(env)} — expected 'integration' or 'production'`,\n );\n }\n return url;\n}\n","export type PlayerState =\n | 'idle'\n | 'waiting' // armed, polling for go-live (waitForLive)\n | 'loading'\n | 'playing'\n | 'buffering'\n | 'paused'\n | 'ended'\n | 'error';\n\nexport interface PlaybackStats {\n droppedFrames: number;\n /**\n * TOTAL frames this session — the denominator dropped frames need, and it\n * ALREADY INCLUDES the dropped ones (it is\n * `VideoPlaybackQuality.totalVideoFrames`). So the ratio is\n * `droppedFrames / decodedFrames`, with nothing added to the bottom.\n *\n * The name says \"decoded\" because it is the wire field's name and a\n * contract rename is not worth a migration (plan D10). Read it as \"total\".\n */\n decodedFrames?: number;\n /** Live-edge latency in seconds (hls.js engine only). */\n latencySeconds?: number;\n /** The live-sync target latency hls.js is converging to, in seconds. Compare\n * against latencySeconds to see drift. */\n targetLatencySeconds?: number;\n /** Estimated bandwidth in kbps (hls.js engine only). */\n bandwidthKbps?: number;\n /** Height of the current rendition, when known. */\n levelHeight?: number;\n /** Whether the stream is being played in low-latency mode. */\n lowLatency?: boolean;\n /** Whether the current media playlist is live. */\n isLive?: boolean;\n /** Whether playback is at/near the live edge (within the live sync window). */\n atLiveEdge?: boolean;\n /** Cumulative media segment bytes fetched this session (hls.js engine only). */\n bytesLoaded?: number;\n /** Cumulative manifest/playlist bytes fetched this session (hls.js engine only). */\n playlistBytesLoaded?: number;\n /** Cumulative ABR rendition switches this session (hls.js engine only). */\n renditionSwitches?: number;\n /** Bitrate of the current rendition in kbps, when known. */\n renditionBitrateKbps?: number;\n /** Duration of the most recent DRM license acquisition in ms (0 = none yet). */\n licenseTimeMs?: number;\n\n // ---- Contract v2 (QoE plane). Cumulative where the beacon layer takes\n // deltas; a snapshot otherwise. Engines fill what they can observe.\n /** RFC 6381 codec strings of the current rendition. */\n videoCodec?: string;\n audioCodec?: string;\n /** The offered rendition ladder, ascending heights, and its top bitrate. */\n ladderHeights?: number[];\n ladderTopBitrateKbps?: number;\n /** DRM the engine negotiated for the session. */\n keySystem?: 'widevine' | 'fairplay' | 'none';\n /** Robustness requested/granted, e.g. Widevine L3 (software) on the web. */\n drmSecurityLevel?: string;\n /** The viewer pinned a quality; ABR is not deciding. */\n qualityPinned?: boolean;\n /** Cumulative ABR switch direction split (renditionSwitches = both). */\n upshifts?: number;\n downshifts?: number;\n /** Cumulative media requests completed / failed, and the durations of the\n * most recent ones (ms, newest last, bounded) for interval percentiles. */\n requestCount?: number;\n requestErrors?: number;\n requestTimesMs?: number[];\n /** Host of the most recent media request — host only, never the path. */\n cdnHost?: string;\n}\n\nexport const EMPTY_STATS: PlaybackStats = { droppedFrames: 0 };\n\n/**\n * Host of a media URL, without the path. The beacon plane records WHICH CDN\n * served the session — so region and vendor come from the client with no IP\n * anywhere in the chain — never the path, which on a signed slot carries the\n * token.\n *\n * Shared by both engines on purpose: two copies would be two answers to\n * \"which CDN was this\", and the column is a LowCardinality string where\n * `CDN.example` and `cdn.example` would become two entries for one CDN. The\n * URL parser lowercases for us. `host`, not `hostname`: the port is signal.\n */\nexport function hostOf(url: string): string {\n try {\n return new URL(url).host;\n } catch {\n return '';\n }\n}\n\n/** Media URLs, playlists included. The playlist matters more than the\n * segments here — see mediaHostSince. */\nconst MEDIA_URL = /\\.(m3u8|ts|m4s|mp4|cmfv|cmfa)(\\?|$)/i;\n\n/**\n * The CDN host for an engine that does its own fetching, read back out of\n * Resource Timing.\n *\n * WebKit's native HLS player exposes NO per-segment network detail to script,\n * which is why the native engine reports no request timings. It does however\n * leave the media load in the resource timeline, so the host is recoverable\n * even though the transfer is not.\n *\n * Measured on iOS 26 (real WebKit, cross-origin stream), 2026-09-17:\n *\n * master.m3u8 1 entry, initiatorType 'video'\n * segments 0 entries, over 22 s of confirmed playback\n *\n * So this finds the PLAYLIST, not a segment, and two things follow:\n *\n * - It works cross-origin with no `Timing-Allow-Origin` anywhere. TAO gates\n * the TIMING fields, never `name` — the entry arrives with responseStart\n * and transferSize zeroed and its URL intact, and the URL is all we want.\n * - The host is fixed at session start. hls.js re-reads it on every\n * fragment precisely to catch a mid-session CDN failover; this engine\n * cannot see one. Where a deployment serves playlists and segments from\n * different hostnames, this would name the playlist's.\n *\n * `since` is a performance.now() mark taken when the engine was built, so a\n * stream played EARLIER on the same page cannot donate its host to this\n * session.\n */\nexport function mediaHostSince(since: number): string {\n if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') {\n return '';\n }\n let best = '';\n let bestStart = -1;\n // getEntriesByType is typed as PerformanceEntry[]; the 'resource' list is\n // PerformanceResourceTiming, which is where initiatorType lives.\n const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];\n for (const e of entries) {\n if (e.startTime < since) continue;\n // initiatorType 'video' is what WebKit attributes the native media load\n // to; the extension test carries any engine that attributes differently.\n if (e.initiatorType !== 'video' && e.initiatorType !== 'audio' && !MEDIA_URL.test(e.name)) {\n continue;\n }\n if (e.startTime >= bestStart) {\n const host = hostOf(e.name);\n if (host) {\n best = host;\n bestStart = e.startTime;\n }\n }\n }\n return best;\n}\n\n/** A selectable video rendition. */\nexport interface QualityLevel {\n /** Index into getQualityLevels() — pass to setQuality(). */\n index: number;\n width?: number;\n height?: number;\n bitrate: number;\n codecs?: string;\n}\n\n/** A selectable audio rendition. */\nexport interface AudioTrackInfo {\n id: number;\n name: string;\n lang?: string;\n default: boolean;\n}\n\n/** A selectable text (caption/subtitle) track. */\nexport interface TextTrackInfo {\n id: number;\n name: string;\n lang?: string;\n}\n\n/**\n * Frame counts via the standard media quality API (engine-agnostic). Both\n * numbers come from ONE call: a drop count without its denominator says\n * nothing — 300 dropped frames is a broken session at 30fps and a rounding\n * error over an hour.\n *\n * 🔑 `totalFrames`, NOT `decoded`. It is `VideoPlaybackQuality.totalVideoFrames`,\n * which the spec defines as every frame that WOULD have been presented —\n * dropped frames included. So `dropped / totalFrames` is already dropped over\n * total, and the denominator needs no correction.\n *\n * The old name was `decoded`, which reads as \"successfully decoded\" and\n * therefore as \"excluding dropped\". That invites a reader to fix a ratio that\n * was never broken, by switching the denominator to `decoded + dropped` and\n * double-counting every dropped frame. The maths was right; only the name was\n * wrong. Pinned by test/frames.test.ts.\n *\n * The WIRE field keeps its name (`decoded_frames_*`): renaming a contract\n * field to fix a comment is not worth a migration.\n */\nexport function readFrameStats(video: HTMLVideoElement): { dropped: number; totalFrames: number } {\n const q = (\n video as HTMLVideoElement & { getVideoPlaybackQuality?: () => VideoPlaybackQuality }\n ).getVideoPlaybackQuality?.();\n return { dropped: q?.droppedVideoFrames ?? 0, totalFrames: q?.totalVideoFrames ?? 0 };\n}\n\n/**\n * Map raw <video> media events to player states. Engine-agnostic: both the\n * hls.js and native engines drive the same state machine through this.\n */\nexport function attachMediaStateListeners(\n video: HTMLVideoElement,\n onState: (state: PlayerState) => void,\n): () => void {\n const handlers: Array<[keyof HTMLMediaElementEventMap, () => void]> = [\n ['playing', () => onState('playing')],\n ['waiting', () => onState('buffering')],\n ['stalled', () => onState('buffering')],\n [\n 'pause',\n () => {\n if (!video.ended) onState('paused');\n },\n ],\n ['ended', () => onState('ended')],\n ];\n for (const [ev, fn] of handlers) video.addEventListener(ev, fn);\n return () => {\n for (const [ev, fn] of handlers) video.removeEventListener(ev, fn);\n };\n}\n","import { classifyDrmStatus, PlaybackError } from '../errors';\nimport {\n mediaHostSince,\n readFrameStats,\n type AudioTrackInfo,\n type PlaybackStats,\n type QualityLevel,\n type TextTrackInfo,\n} from '../stats';\nimport { type EngineDeps, type PlaybackEngine } from './types';\n\n// Non-standard `video.audioTracks` (Safari) — minimal structural shape.\ninterface NativeAudioTrack {\n enabled: boolean;\n label: string;\n language: string;\n}\ninterface NativeAudioTrackList {\n readonly length: number;\n [index: number]: NativeAudioTrack;\n}\n\n/**\n * Pull the 32-char hex content IV out of Safari's FairPlay initData.\n *\n * WebKit hands `encrypted` the playlist key URI verbatim — `skd://<kid>:<iv>`\n * as UTF-8 — which is also Axinom's documented asset ID. Returns undefined\n * for any other shape so a malformed URI sends no header at all rather than a\n * stale or wrong one.\n */\nfunction extractSkdIV(initData: ArrayBuffer): string | undefined {\n try {\n const uri = new TextDecoder('utf-8').decode(new Uint8Array(initData));\n return /^skd:\\/\\/[0-9a-fA-F-]{36}:([0-9a-fA-F]{32})$/.exec(uri)?.[1];\n } catch {\n return undefined;\n }\n}\n\n/** True when the browser plays HLS natively (Safari / iOS). */\nexport function nativeHlsSupported(video?: HTMLVideoElement): boolean {\n const el =\n video ?? (typeof document !== 'undefined' ? document.createElement('video') : undefined);\n return Boolean(el?.canPlayType('application/vnd.apple.mpegurl'));\n}\n\n/**\n * True when the media stack is WebKit's — the only one that decrypts FairPlay\n * through native HLS.\n *\n * `nativeHlsSupported` cannot answer this. Chromium returns `\"maybe\"` for\n * `canPlayType('application/vnd.apple.mpegurl')` (measured 2026-08-07) while\n * being unable to play HLS natively at all, so gating the FairPlay engine on\n * it alone routes Chrome into the native engine and fails every playback with\n * an opaque 'native HLS playback error'.\n *\n * Both signals are present on Safari and on iOS third-party browsers (all\n * WebKit), and absent on Chromium — which reports `vendor: \"Google Inc.\"` and\n * exposes neither legacy FairPlay entry point. OR rather than AND so a future\n * WebKit that drops one of them still reaches the engine that works.\n */\nexport function webkitMediaStack(): boolean {\n if (typeof window === 'undefined') return false;\n const legacyFairPlay =\n 'WebKitMediaKeys' in window ||\n (typeof HTMLMediaElement !== 'undefined' && 'webkitSetMediaKeys' in HTMLMediaElement.prototype);\n const appleVendor = typeof navigator !== 'undefined' && /Apple/i.test(navigator.vendor ?? '');\n return legacyFairPlay || appleVendor;\n}\n\n/**\n * Native-HLS engine — plays via `video.src`, and drives FairPlay EME itself.\n *\n * This is the FairPlay engine on Apple platforms. The previous note here said\n * FairPlay was \"implemented on the hls.js engine, which 'auto' prefers\n * everywhere hls.js is supported (including modern Safari via\n * ManagedMediaSource)\". That was wrong, and it cost a lot of debugging: the\n * hls.js/MSE route reaches a 'usable' key and still renders nothing, because\n * WebKit binds FairPlay keys through the fragment `sinf` box rather than a\n * synthesized skd asset ID. Measured on macOS Safari 26.5 against live DRM\n * matches (2026-08-06): hls.js+MSE 1 pass / 6 attempts, this engine 4 / 4 at\n * 1280px (Widevine on Chrome 3/3 as the control).\n *\n * Widevine is not available here — that stays on the hls.js engine.\n */\nexport class NativeHlsEngine implements PlaybackEngine {\n readonly name = 'native' as const;\n private readonly deps: EngineDeps;\n private onLoaded?: () => void;\n private onError?: () => void;\n private onEncrypted?: (ev: Event) => void;\n private fairplayLicenseUrl: string | undefined;\n /** Kept so teardown can close the CDM session. */\n private keySession: MediaKeySession | null = null;\n /** Set synchronously on the first `encrypted` event; see armFairPlay. */\n private fairplayArmed = false;\n /** Content IV (32-char hex) parsed from Safari's skd:// initData. */\n private fairplayKeyIV: string | undefined;\n /** False while a deferLoad engine waits for startLoad(); see load(). */\n private loadStarted: boolean;\n /** True once load() rewrote the element's preload; destroy() restores it. */\n private preloadTouched = false;\n /** The preload attribute before load() rewrote it (null = not set). */\n private savedPreload: string | null = null;\n /** CDN host, recovered from Resource Timing once and kept; see getStats. */\n private cdnHost = '';\n /** Resource-timeline mark taken at construction, so a stream played EARLIER\n * on this page cannot donate its host to this session. */\n private readonly builtAt: number;\n\n constructor(deps: EngineDeps) {\n this.deps = deps;\n this.loadStarted = deps.deferLoad !== true;\n this.builtAt = typeof performance !== 'undefined' ? performance.now() : 0;\n }\n\n async load(): Promise<void> {\n const { video, descriptor, emit } = this.deps;\n\n // deferLoad (autoplay-off pre-play): preload 'none' keeps the native\n // pipeline from fetching the manifest, media, or FairPlay keys until the\n // viewer presses play. 'ready' (loadedmetadata) then arrives after the\n // first play() starts fetching — the managed player does not wait on it.\n if (!this.loadStarted) {\n this.preloadTouched = true;\n this.savedPreload = video.getAttribute('preload');\n video.preload = 'none';\n }\n\n if (descriptor.drmEnabled) {\n if (!descriptor.drm?.fairplay) {\n // DRM required but no FairPlay material (e.g. a Widevine-only stream): the\n // native engine cannot decrypt it. Fail fast with a clear, typed error\n // instead of an opaque 'native HLS playback error' downstream.\n emit({\n type: 'error',\n error: new PlaybackError(\n 'DRM_CLIENT',\n 0,\n 'native HLS engine cannot satisfy this DRM (Widevine requires the hls.js engine); use engine \"auto\"/\"hls\" on a Widevine-capable browser',\n ),\n fatal: true,\n });\n return;\n }\n this.armFairPlay();\n }\n\n // deferLoad: the managed layer owns the pre-play 'ready' for this engine\n // (emitted after analytics/session wiring, a macrotask after commit — an\n // emit here would fire inside load(), before the analytics session exists\n // and before the host can hold listeners). loadedmetadata can't fire\n // until play() starts fetching under preload=\"none\", so the engine-side\n // emit is suppressed entirely for a deferred instance — it would only\n // duplicate the managed one after startLoad() + play().\n const deferred = !this.loadStarted;\n this.onLoaded = deferred ? undefined : () => emit({ type: 'ready' });\n this.onError = () =>\n emit({\n type: 'error',\n error: new PlaybackError('INTERNAL', 0, 'native HLS playback error'),\n fatal: true,\n });\n if (this.onLoaded) video.addEventListener('loadedmetadata', this.onLoaded, { once: true });\n video.addEventListener('error', this.onError);\n video.src = withCmcdSid(descriptor.manifestUrl, descriptor.analytics?.sid);\n video.load();\n }\n\n startLoad(): void {\n if (this.loadStarted) return;\n this.loadStarted = true;\n // Putting the host's own preload back is enough: the play() that follows\n // drives the fetch regardless of the attribute's value, and forcing\n // 'auto' would override a host-set \"metadata\" for the rest of the\n // session. An explicit video.load() here would abort that play()'s\n // pending promise (the media load algorithm rejects it with AbortError).\n this.restorePreload();\n }\n\n /** Undo the deferLoad preload rewrite so the element reads as the host set\n * it. Idempotent; a no-op when load() never rewrote it. */\n private restorePreload(): void {\n if (!this.preloadTouched) return;\n this.preloadTouched = false;\n const { video } = this.deps;\n if (this.savedPreload !== null) video.setAttribute('preload', this.savedPreload);\n else video.removeAttribute('preload');\n }\n\n /**\n * Drive FairPlay EME on the native HLS pipeline.\n *\n * This is how FairPlay is meant to be deployed on Apple platforms, and it\n * is the path that actually decrypts. Safari raises `encrypted` with\n * initDataType 'skd' and the playlist key URI as initData — the full\n * `skd://<kid>:<iv>` string, UTF-8, 75 bytes — which is exactly Axinom's\n * documented asset ID. We never synthesize or reshape it.\n *\n * The hls.js/MSE route cannot do this: WebKit binds a FairPlay key to\n * media through the fragment `sinf` box, so a session built from a\n * synthesized skd asset ID is never associated with the stream (key\n * reports 'usable', element sits at waitingforkey, black), and driving it\n * from the sinf instead loses the IV that only the URI carries. Measured\n * on macOS Safari 26.5 against live DRM matches (2026-08-06): hls.js+MSE\n * 1 pass / 6, this path 4 passes / 4 at 1280px, with Widevine on Chrome\n * 3/3 as the control.\n *\n * Requires drm-proxy to put the content IV on every inline key of the\n * entitlement message (`content_keys_source.inline[].iv`) — Axinom rejects\n * a FairPlay request without one unless the asset ID supplies it.\n */\n private armFairPlay(): void {\n const { video, descriptor, credential, deviceId, userId, emit } = this.deps;\n const fp = descriptor.drm?.fairplay;\n if (!fp) return;\n this.fairplayLicenseUrl = fp.licenseUrl;\n\n // Only a server auth/entitlement refusal is UNAUTHORIZED/FORBIDDEN;\n // certificate/CDM/transport failures are DRM_CLIENT so callers can tell\n // \"not allowed\" from \"broke on this device\".\n const fail = (message: string, status: number, fatal: boolean) =>\n emit({\n type: 'error',\n error: new PlaybackError(classifyDrmStatus(status), status, message),\n fatal,\n });\n\n this.onEncrypted = (event: Event) => {\n const ev = event as MediaEncryptedEvent;\n // Safari raises `encrypted` several times in quick succession (once per\n // track / init segment). The guard must flip BEFORE the first await or\n // every event enters the async block and races to build its own session.\n if (!ev.initData || this.fairplayArmed) return;\n this.fairplayArmed = true;\n // Safari's initData IS the playlist key URI: skd://<kid>:<iv>, UTF-8.\n // Axinom needs that IV on every inline key of the entitlement message,\n // and drm-proxy has no other source for it, so pass it along.\n this.fairplayKeyIV = extractSkdIV(ev.initData);\n void (async () => {\n try {\n const access = await navigator.requestMediaKeySystemAccess('com.apple.fps', [\n {\n initDataTypes: [ev.initDataType],\n videoCapabilities: [{ contentType: 'application/vnd.apple.mpegurl', robustness: '' }],\n distinctiveIdentifier: 'not-allowed',\n persistentState: 'not-allowed',\n sessionTypes: ['temporary'],\n },\n ]);\n const mediaKeys = await access.createMediaKeys();\n // drm-proxy requires the api key on the certificate GET.\n const certRes = await fetch(fp.certificateUrl, {\n headers: { 'x-api-key': credential.apiKey },\n });\n if (!certRes.ok) {\n // Carry the real status so a certificate 401/403 keeps the server\n // taxonomy instead of collapsing to DRM_CLIENT/0 in the catch below.\n this.fairplayArmed = false; // allow a retry on the next `encrypted`\n fail(`FairPlay certificate request failed: ${certRes.status}`, certRes.status, true);\n return;\n }\n await mediaKeys.setServerCertificate(await certRes.arrayBuffer());\n await video.setMediaKeys(mediaKeys);\n\n const session = mediaKeys.createSession();\n this.keySession = session;\n session.addEventListener('message', (msgEvent) => {\n void (async () => {\n try {\n // Read the URL fresh: setLicenseUrls may have refreshed the\n // signed URL since the session was created (10-min TTL).\n const res = await fetch(this.fairplayLicenseUrl ?? fp.licenseUrl, {\n method: 'POST',\n body: msgEvent.message,\n headers: {\n 'x-api-key': credential.apiKey,\n 'X-Match-Urn': descriptor.matchUrn,\n 'X-Device-Id': deviceId,\n ...(userId ? { 'X-User-Id': userId } : {}),\n ...(this.fairplayKeyIV ? { 'X-Key-Iv': this.fairplayKeyIV } : {}),\n },\n });\n if (!res.ok) {\n fail(`FairPlay license request failed: ${res.status}`, res.status, true);\n return;\n }\n await session.update(new Uint8Array(await res.arrayBuffer()));\n } catch (e) {\n fail(`FairPlay license request failed: ${String(e)}`, 0, true);\n }\n })();\n });\n await session.generateRequest(ev.initDataType, ev.initData as BufferSource);\n } catch (e) {\n this.fairplayArmed = false; // allow a retry on the next `encrypted`\n fail(`FairPlay key session failed: ${String(e)}`, 0, true);\n }\n })();\n };\n video.addEventListener('encrypted', this.onEncrypted);\n }\n\n setLicenseUrls(urls: { widevine?: string; fairplay?: string }): void {\n // Keep the refreshed signed FairPlay URL for the next license request;\n // Widevine is not reachable on this engine.\n if (urls.fairplay) this.fairplayLicenseUrl = urls.fairplay;\n }\n\n // Native HLS handles ABR internally and does not expose a rendition ladder.\n getQualityLevels(): QualityLevel[] {\n return [];\n }\n getCurrentQuality(): number {\n return -1;\n }\n setQuality(_index: number): void {}\n setMaxBitrate(_bitrate: number | null): void {}\n\n getAudioTracks(): AudioTrackInfo[] {\n const list = (this.deps.video as HTMLVideoElement & { audioTracks?: NativeAudioTrackList })\n .audioTracks;\n if (!list) return [];\n return Array.from({ length: list.length }, (_v, id) => {\n const t = list[id];\n return {\n id,\n name: t.label || t.language || `Audio ${id + 1}`,\n lang: t.language || undefined,\n default: t.enabled,\n };\n });\n }\n setAudioTrack(id: number): void {\n const list = (this.deps.video as HTMLVideoElement & { audioTracks?: NativeAudioTrackList })\n .audioTracks;\n if (!list) return;\n for (let i = 0; i < list.length; i += 1) list[i].enabled = i === id;\n }\n\n getTextTracks(): TextTrackInfo[] {\n const list = this.deps.video.textTracks;\n return Array.from({ length: list.length }, (_v, id) => {\n const t = list[id];\n return { id, name: t.label || t.language || `Text ${id + 1}`, lang: t.language || undefined };\n });\n }\n setTextTrack(id: number): void {\n const list = this.deps.video.textTracks;\n for (let i = 0; i < list.length; i += 1) list[i].mode = i === id ? 'showing' : 'disabled';\n }\n\n seekToLive(): void {\n const v = this.deps.video;\n if (v.seekable.length > 0) v.currentTime = v.seekable.end(v.seekable.length - 1);\n }\n\n getStats(): PlaybackStats {\n const v = this.deps.video;\n const frames = readFrameStats(v);\n const stats: PlaybackStats = {\n droppedFrames: frames.dropped,\n decodedFrames: frames.totalFrames,\n };\n // Request timing, ABR direction and codecs stay invisible to this engine:\n // WebKit fetches the media itself and reports no per-segment network\n // detail to script. That is plan gate G8, not a gap to fill here.\n //\n // The CDN host is the exception, and it is worth the exception: without\n // it every Safari and iOS session lands in the empty cohort of a per-CDN\n // breakdown, which on a Safari-heavy audience is most of the traffic.\n // Resource Timing still carries the media load even though the transfer\n // detail is gone — see mediaHostSince, which documents what WebKit does\n // and does not expose, and why this names the PLAYLIST's host.\n //\n // Cached on first sight: the host cannot change for this engine (there is\n // no failover to observe), the entry can be evicted once the resource\n // buffer fills, and re-scanning the timeline on every heartbeat for a\n // value that never moves is waste.\n if (!this.cdnHost) this.cdnHost = mediaHostSince(this.builtAt);\n if (this.cdnHost) stats.cdnHost = this.cdnHost;\n if (this.deps.descriptor.drmEnabled) stats.keySystem = 'fairplay';\n if (v.duration === Infinity) {\n stats.isLive = true;\n if (v.seekable.length > 0) {\n stats.atLiveEdge = v.currentTime >= v.seekable.end(v.seekable.length - 1) - 2;\n }\n }\n return stats;\n }\n\n stop(): void {\n // Native HLS: pause to halt fetching; keep src so the last frame stays up.\n try {\n this.deps.video.pause();\n } catch {\n // ignore\n }\n }\n\n destroy(): void {\n const { video } = this.deps;\n if (this.onLoaded) video.removeEventListener('loadedmetadata', this.onLoaded);\n if (this.onError) video.removeEventListener('error', this.onError);\n if (this.onEncrypted) video.removeEventListener('encrypted', this.onEncrypted);\n // Release the CDM session; a stale one would keep the key alive past\n // teardown and block a fresh arm on the next load().\n void this.keySession?.close().catch(() => {});\n this.keySession = null;\n this.fairplayArmed = false;\n this.fairplayKeyIV = undefined;\n try {\n video.pause();\n } catch {\n // ignore\n }\n video.removeAttribute('src');\n this.restorePreload(); // a still-deferred engine dies with 'none' applied\n try {\n video.load();\n } catch {\n // ignore\n }\n }\n}\n\n/**\n * Append the CMCD session id to the manifest URL (CTA-5004 query mode).\n * The native pipeline fetches segments itself with no request hook, so the\n * sid can only ride the manifest request — the known partial-attribution\n * cohort of plan gate G8. No-op without a sid.\n */\nfunction withCmcdSid(manifestUrl: string, sid: string | undefined): string {\n if (!sid) return manifestUrl;\n const sep = manifestUrl.includes('?') ? '&' : '?';\n return `${manifestUrl}${sep}CMCD=${encodeURIComponent(`sid=\"${sid}\"`)}`;\n}\n","import Hls, {\n CapLevelController,\n type ErrorData,\n type HlsConfig,\n type LevelLoadedData,\n} from 'hls.js';\nimport { emeAvailable, reuseMediaKeysFor } from '../drm/cdm';\nimport { classifyDrmStatus, PlaybackError } from '../errors';\nimport { nativeHlsSupported, webkitMediaStack } from './native';\nimport {\n hostOf,\n readFrameStats,\n type AudioTrackInfo,\n type PlaybackStats,\n type QualityLevel,\n type TextTrackInfo,\n} from '../stats';\nimport { type EngineDeps, type PlaybackEngine } from './types';\n\nexport function hlsSupported(): boolean {\n return Hls.isSupported();\n}\n\nconst MAX_MEDIA_RECOVERIES = 3;\nconst MAX_NETWORK_RECOVERIES = 5;\n// hls.js has no license-request retry policy (there's certLoadPolicy/keyLoadPolicy\n// but no licenseLoadPolicy), and KEY_SYSTEM_LICENSE_REQUEST_FAILED is fatal by\n// default. A transient transport failure of the license POST is recoverable by\n// rebuilding the instance (fresh key session → fresh license request).\nconst MAX_LICENSE_RECOVERIES = 3;\nconst RECOVERY_RESET_MS = 30_000;\n// End-of-stream watchdog (live only). The live playlist not advancing for this\n// long — with no #EXT-X-ENDLIST — means the stream ended: Tencent's origin\n// freezes the manifest at end (HTTP 200, media sequence static) instead of\n// publishing ENDLIST, so hls.js would otherwise buffer forever. 12s is safely\n// above the worst observed edge refresh hiccup (~8s).\nconst END_STALE_MS = 12_000;\nconst END_WATCH_INTERVAL_MS = 3_000;\n// Stale-playlist chase suspension. A frozen live manifest keeps hls.js's\n// LatencyController extrapolating the live edge with wall clock, so measured\n// latency grows 1s/s; once it crosses liveMaxLatencyDuration — which happens\n// ceiling−target ≈ 9-10s after the freeze with our defaults, BEFORE the 12s\n// watchdog concludes — the controller force-seeks into data that doesn't\n// exist and the viewer sees a repeat-frame/reset loop until 'ended' lands.\n// The server can't cover this window: the SSE ended push may trail the\n// freeze by up to the bridge's 120s ingest-teardown debounce (feed-dies-\n// first path), so the client suspends chasing itself once the playlist has\n// been stale this long, and resumes the moment it advances again (a genuine\n// origin hiccup keeps full drift protection afterwards). Must stay below\n// ceiling−target (10s at the default target, 9s at liveLatencyTarget 3) and\n// above the 3s watch tick + normal playlist cadence.\nconst CHASE_SUSPEND_STALE_MS = 6_000;\n// Pause suspension. hls.js keeps chasing the live edge while the element is\n// PAUSED: synchronizeToLiveEdge runs on every playlist reload with no\n// `media.paused` check, so once the frozen playhead falls liveMaxLatencyDuration\n// (12s here) behind the edge it is reset to the live-sync position and fresh\n// segments load from there — forever. A paused 1080p viewer pulled the stream\n// at live rate (80–105 MB/min, latency sawing 4.5→12s, portal session\n// 2026-09-19). The pre-play case was already closed by deferLoad (AV-202); this\n// closes the post-play one: after this grace the loader is stopped, and the\n// next play() restarts it at the paused position (hls.js startLoad(-1) resumes\n// at lastCurrentTime; its next playlist reload re-syncs to live if far behind —\n// the same seek as before, now only when the viewer asked to play). A pause\n// shorter than the grace resumes from the untouched buffer. Must stay below\n// ceiling−target (10s) so the loader is quiet before the first forced re-sync.\nconst PAUSE_SUSPEND_MS = 5_000;\n// Video-starvation watchdog (live only). Kept as defence in depth: the wedge\n// below is fixed in the pinned 1.7.2 (upstream #7874 moved key loading behind\n// the part/hint selection, and we backported it to the 1.6.x line as #7976),\n// but the detection is generic — it catches any silently-dropped video load,\n// not only this mechanism, and it is the only thing that noticed this one.\n// The original wedge, on hls.js 1.6.x and encrypted LL-HLS: when a part of the\n// forward-looking `fragmentHint` fragment is selected while that\n// fragment's key-loading promise is in flight, `_doFragLoad` reassigns\n// `fragCurrent` to the hint fragment, the key promise's context check then\n// sees a \"changed\" fragment and resolves null, and the load is silently\n// dropped with the state machine parked in FRAG_LOADING (a sibling path parks\n// it in KEY_LOADING). No loader is in flight, so no load-policy timeout ever\n// fires: the level playlist keeps polling, audio keeps playing, and no video\n// fragment is ever requested again. Reproduced 2026-08-12 against feed-dev\n// with the fullscreen/PiP resize storm from Josef's report — the resize makes\n// `capLevelToPlayerSize` flap levels, which corrupts targetBufferTime past the\n// playlist's fragment end and steers loading onto the hint fragment.\n// Detection: the video element starved (readyState < HAVE_FUTURE_DATA) while\n// the live playlist still advances and hls.js has issued no video fragment\n// load for a while — that combination is never a plain rebuffer (rebuffering\n// keeps FRAG_LOADING events coming) and never stream end (the playlist would\n// be stale, which the end watchdog owns). Recovery: stopLoad() + startLoad(-1)\n// rebuilds the stream-controller state machine at the live edge; media stays\n// attached so EME key sessions survive and playback resumes in ~1-2s.\nconst STARVATION_FRAG_QUIET_MS = 4_000;\n/** Consecutive starved watchdog ticks (3s apart) before kicking. */\nconst STARVATION_TICKS = 2;\nconst MAX_STARVATION_KICKS = 5;\n// Escalation, for the wedges the kick provably cannot clear. A kick keeps the\n// media element attached — deliberately, so EME key sessions survive — but that\n// is exactly why it fails against a wedge held in hls.js's KEY-loader cache: a\n// `keyInfo.keyLoadPromise` that never settles is only discarded by detach() or\n// destroy(), so stopLoad()/startLoad() re-await the same dead promise and park\n// again within a second. Observed 2026-09-03 at join, when a client clock\n// running behind the licence server produced a first key status of `expired`:\n// hls.js renewed the key session and orphaned the promise the fragment loads\n// were waiting on, and all five kicks re-parked (the viewer's own recovery was\n// a page reload, which destroys the instance). Fixed at the source in\n// havik-drm (licence start_datetime is back-dated) and structurally in hls.js\n// 1.7.x (the EME path no longer caches that promise) — this is the client-side\n// net for the next key-status hiccup, and for whatever else the kick cannot\n// reach: if the pipeline is still starved this long after a kick, the instance\n// is unrecoverable in place and gets rebuilt, which is what a reload did.\n// Grace after a kick for main-track media to buffer before rebuilding, counted\n// in watchdog ticks rather than wall-clock milliseconds. Ticks only accrue\n// while the tab is visible and the wedge signature still holds, so the grace\n// measures ACTIVE starvation: a tab backgrounded right after a kick cannot burn\n// it through throttled or suspended timers and come back eligible for an\n// immediate rebuild. Same reason STARVATION_TICKS is a tick count.\nconst STARVATION_KICK_GRACE_TICKS = 2;\n/** Rebuilds per healthy-playback window. Two, because a rebuild that doesn't\n * take won't start taking: a fresh instance re-requests the licence, so the\n * second attempt covers a transient failure and further ones just churn. */\nconst MAX_STARVATION_REBUILDS = 2;\n// SDK-managed player-size capping (replaces hls.js's `capLevelToPlayerSize`\n// poller). hls.js re-reads the player's pixel box every 1s and applies a new\n// cap IMMEDIATELY, so a fullscreen enter/exit — which animates through\n// intermediate sizes for a few hundred ms — produces 1-2 ABR switches per\n// transition. At the LL live-sync target the forward buffer IS ~2s, and each\n// switch costs a cold rendition-playlist fetch at the CDN edge plus a decoder\n// re-init (slowest through Windows hardware-DRM pipelines), so every switch is\n// a visible ~1s stall right after the transition (the fullscreen-exit stutter\n// follow-up to Josef's freeze report: 1-2 stalls per exit, worst on Edge).\n// The SDK owns the cap instead: identical level selection — hls.js's own\n// CapLevelController.getMaxLevelByMediaSize on the DPR-scaled element box —\n// but a change only applies once the box has been STABLE for a settle window,\n// so mid-animation intermediate sizes never reach ABR. Raises settle briefly:\n// entering fullscreen should still upgrade quickly. (Known trade vs upstream,\n// VOD only: upstream flushed the forward buffer on a cap raise, so deep-buffer\n// VOD upgraded within ~a fragment; here the raise shows once buffered media\n// plays out. On live-LL the flush was a no-op anyway — hls.js skips it below\n// 2x target duration of buffer, which our latency target guarantees.)\nconst CAP_RAISE_SETTLE_MS = 2_000;\n// A shrink needs no switch to look right — the higher rendition simply\n// downscales into the smaller box; a down-cap only saves bandwidth. Applied\n// lazily, so a fullscreen exit never pays a switch stall at the transition and\n// a re-enter inside the window cancels the pending down-cap outright.\nconst CAP_SHRINK_LAZY_MS = 30_000;\n\n/**\n * Default live-edge target (seconds behind the edge) when the caller sets none.\n *\n * 2s, not the bleeding `PART-HOLD-BACK` edge (~1.5s = 3x part duration), so the\n * playhead parks where Tencent's edge has already settled the parts — chasing\n * hold-back on a low-traffic stream hits the cold per-object origin-pull flap\n * (parts/.m3u8 404<->200) and rebuffers continuously.\n *\n * Was 3s. Lowered after the operator Demo Player (havik-ops\n * streams-player.component.ts) ran 2s from 2026-07-22 to 2026-08-03 with no\n * regression, on srt-bridge rc.34+ (encoder-latency pins) where the cold-edge\n * part flap no longer reproduces. 2s keeps ~0.35s of settle margin over\n * hold-back instead of ~1.35s. If low-traffic streams start rebuffering again,\n * that view is the canary — restore 3 there first.\n */\nconst LIVE_SYNC_DURATION_S = 2;\n/** Recovery band above the target; the drift ceiling is target + this (min 12s). */\nconst LIVE_LATENCY_MARGIN_S = 9;\n/**\n * How far past the live-sync position a seek may land before it's clamped back.\n * Slack so hls.js's own forward nudges — the ≤1.5x catch-up overshooting by a\n * frame, a maxBufferHole gap-skip — never trip the clamp; a scrub to the end of\n * the DVR bar overshoots by seconds, not tenths.\n */\nconst OVER_SEEK_TOLERANCE_S = 0.5;\n/** Recent segment download durations kept for the beacon layer's per-interval\n * percentiles. One heartbeat spans ~7 segments at a 2s target; 64 leaves room\n * for LL-HLS part-level traffic without unbounded growth. */\nconst MAX_REQUEST_SAMPLES = 64;\n\n/**\n * Resolve the live-edge target (`liveSyncDuration`) + drift ceiling\n * (`liveMaxLatencyDuration`) on the final, caller-merged config, keeping it legal\n * for hls.js — mutates `config` in place.\n *\n * Why a target + ceiling at all: `lowLatencyMode` otherwise pins playback to\n * `PART-HOLD-BACK` (the bleeding edge), where Tencent's newest parts aren't\n * reliably published yet → constant rebuffering; and after a stall, latency\n * drifts unbounded toward the DVR window (~90s seen in the field). The target\n * parks ~2s back where parts are settled; the ceiling force-seeks back once\n * latency exceeds it, capping the drift. (`maxLiveSyncPlaybackRate` recovers\n * smoothly inside the band first.) Live/LL only — ignored on VOD.\n *\n * Why normalize rather than hard-code: hls.js THROWS at construction if the\n * Count and Duration variants are mixed, or if `liveMaxLatencyDuration <=\n * liveSyncDuration`. The `hlsConfig` escape hatch lets a caller pass either, so:\n * - caller opted into the Count variants → defer entirely (never inject seconds);\n * - otherwise default the target to 3 and keep the ceiling strictly above it,\n * repairing a one-sided or illegal Duration override instead of throwing.\n */\nfunction normalizeLiveSync(config: Partial<HlsConfig>): void {\n if (config.liveSyncDurationCount != null || config.liveMaxLatencyDurationCount != null) {\n // Caller chose count-based live sync — leave it; never mix in seconds keys.\n delete config.liveSyncDuration;\n delete config.liveMaxLatencyDuration;\n return;\n }\n if (config.liveSyncDuration == null) config.liveSyncDuration = LIVE_SYNC_DURATION_S;\n const ceiling = Math.max(12, config.liveSyncDuration + LIVE_LATENCY_MARGIN_S);\n if (\n config.liveMaxLatencyDuration == null ||\n config.liveMaxLatencyDuration <= config.liveSyncDuration\n ) {\n config.liveMaxLatencyDuration = ceiling;\n }\n deriveAbrReaction(config, config.liveSyncDuration);\n}\n\n/**\n * Scale ABR's DOWN-switch reaction to the live buffer we actually have.\n *\n * hls.js ships these sized for a multi-second forward buffer:\n * `maxStarvationDelay` 4 and `maxLoadingDelay` 4 (the starvation budget alone\n * is larger than our whole buffer, so ABR's own accounting still reads \"fine\"\n * while the media element is already stalling) and `abrEwmaFastLive` 3 (a\n * bandwidth drop takes 3s+ to move the estimate at all). On a live stream the\n * forward buffer IS `liveSyncDuration`, so at our target there is no runway:\n * a viewer whose link degrades rebuffers at the top rung for seconds instead\n * of dropping a rung. (Reported on the ops demo player, same config family:\n * throttled link → 1080p buffering → manual 720p fixed it.)\n *\n * LL-HLS also blunts hls.js's other rescue — `_abandonRulesCheck` aborts an\n * in-flight load and drops a rung, but it projects the overage over the\n * current PART (sub-second of media), so the overage looks small and the abort\n * rarely fires. These budgets are therefore the primary defence.\n *\n * Derived from the resolved target rather than hard-coded so responsiveness\n * stays consistent if the target moves (the 3s → 2s rollout). Caller values\n * always win — this only fills gaps. Skipped for count-based live sync, where\n * the target isn't expressible in seconds (documented escape hatch).\n */\nfunction deriveAbrReaction(config: Partial<HlsConfig>, syncS: number): void {\n const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n // Half the buffer: ABR must decide while there is still media to play.\n if (config.maxStarvationDelay == null) {\n config.maxStarvationDelay = clamp(syncS / 2, 0.5, 4);\n }\n // A fragment load that eats the whole buffer is already too slow.\n if (config.maxLoadingDelay == null) {\n config.maxLoadingDelay = clamp(syncS, 1, 4);\n }\n // The estimate has to move inside the runway, not after it.\n if (config.abrEwmaFastLive == null) {\n config.abrEwmaFastLive = clamp(syncS / 2, 1, 3);\n }\n}\n\n/** hls.js adapter — LL-HLS + Widevine/FairPlay EME, ported from the proven Demo Player config. */\nexport class HlsEngine implements PlaybackEngine {\n readonly name = 'hls' as const;\n private hls: Hls | null = null;\n private readonly deps: EngineDeps;\n /** Current signed Widevine license URL; mutable so it can be refreshed mid-session. */\n private licenseUrl: string | undefined;\n /** FairPlay application-certificate URL (public but auth'd — unsigned, stable). */\n /** Whether the live media playlist actually carries LL-HLS tags (detected, not requested). */\n private detectedLowLatency = false;\n // Consumption counters for the analytics heartbeat (plan A2/§3.1): the\n // beacon layer samples these cumulative totals via getStats() and computes\n // per-interval deltas itself, so the engine never needs a beacon cadence.\n private bytesLoaded = 0;\n private playlistBytesLoaded = 0;\n private renditionSwitches = 0;\n private licenseTimeMs = 0;\n /** A licence request has actually been issued. The descriptor flag only\n * says DRM was CONFIGURED; a session that dies before EME does anything\n * should not report a key system it never negotiated. */\n private licenseRequested = false;\n // Contract v2 counters. Switch DIRECTION splits renditionSwitches (which\n // stays the total): a session that only ever shifts down is a starved\n // viewer, one that oscillates is an unstable estimate, and the two are\n // indistinguishable from the total alone.\n private upshifts = 0;\n private downshifts = 0;\n private lastLevelIndex = -1;\n // Request timing and outcome. requestTimesMs is a ring of the most recent\n // durations; the beacon layer takes the tail its interval added.\n private requestCount = 0;\n private requestErrors = 0;\n private readonly requestTimesMs: number[] = [];\n private cdnHost = '';\n // The offered ladder, cached at MANIFEST_PARSED — recomputing it on every\n // 2s stats poll would walk every level for a value that cannot change.\n private ladderHeights: number[] | undefined;\n private ladderTopBitrateKbps = 0;\n // Recovery budgets are windowed: they reset after a stretch of healthy playback\n // (FRAG_BUFFERED), so a long live session isn't permanently disabled by a few\n // unrelated transient glitches spread across hours.\n private mediaRecoveries = 0;\n private networkRecoveries = 0;\n private licenseRecoveries = 0;\n private recoveryResetTimer: ReturnType<typeof setTimeout> | null = null;\n private networkRetryTimer: ReturnType<typeof setTimeout> | null = null;\n private licenseRetryTimer: ReturnType<typeof setTimeout> | null = null;\n /** visibilitychange handler — snaps back to the live edge after a backgrounded tab. */\n private onVisibility: (() => void) | null = null;\n /** seeking handler — clamps a seek past the live-sync position back to it. */\n private onSeeking: (() => void) | null = null;\n // Pause suspension state (see PAUSE_SUSPEND_MS).\n private onPause: (() => void) | null = null;\n private onPlay: (() => void) | null = null;\n private pauseSuspendTimer: ReturnType<typeof setTimeout> | null = null;\n /** Loading is stopped for a lingering pause; the next play() restarts it. */\n private loadSuspended = false;\n /** stop() was called (end of stream): a later `play` event must not restart loading. */\n private stopped = false;\n // End-of-stream watchdog state (armed once the stream is seen LIVE).\n private sawLive = false;\n private lastEndSN = -1;\n private lastAdvanceMs = 0;\n private endWatchdog: ReturnType<typeof setInterval> | null = null;\n private endedEmitted = false;\n // Video-starvation watchdog state (see STARVATION_* constants).\n private lastFragLoadingMs = 0;\n private starvedTicks = 0;\n private starvationKicks = 0;\n /** A kick is awaiting proof it worked; cleared by main-track FRAG_BUFFERED. */\n private kickPending = false;\n /** Active starved ticks observed since that kick (see STARVATION_KICK_GRACE_TICKS). */\n private kickTicks = 0;\n private starvationRebuilds = 0;\n // Stale-playlist chase suspension (see CHASE_SUSPEND_STALE_MS).\n private chaseSuspended = false;\n private savedLiveMaxLatency: number | undefined;\n private savedMaxSyncRate: number | undefined;\n // SDK-managed player-size cap state (see the CAP_* constants).\n private capObserver: ResizeObserver | null = null;\n private capTimer: ReturnType<typeof setTimeout> | null = null;\n /** Size-derived cap currently applied (level index); null = none applied yet. */\n private sizeCapLevel: number | null = null;\n /** Cap derived from setMaxBitrate (-1 = none); combined with the size cap via min. */\n private bitrateCapLevel = -1;\n /** False when the caller opted back into hls.js's own capLevelToPlayerSize poller. */\n private sizeCapManaged = true;\n // DPR watcher: ResizeObserver only tracks the CSS box, so a devicePixelRatio\n // change with an unchanged layout (window dragged to a different-DPI\n // monitor, OS scale change) would never re-evaluate the cap. The media query\n // pins the CURRENT ratio, so any change fires once; the handler re-pins.\n private dprMedia: MediaQueryList | null = null;\n private onDprChange: (() => void) | null = null;\n\n /** False while a deferLoad engine waits for startLoad(); guards the config\n * key on a license-retry rebuild and makes startLoad() idempotent. */\n private loadStarted: boolean;\n\n constructor(deps: EngineDeps) {\n this.deps = deps;\n this.licenseUrl = deps.descriptor.drm?.widevine?.licenseUrl;\n this.loadStarted = deps.deferLoad !== true;\n }\n\n async load(): Promise<void> {\n const { descriptor, snapToLiveOnRefocus, video, emit } = this.deps;\n\n // Fail fast on a DRM-required stream with no system USABLE ON THIS\n // PLATFORM, instead of loading with EME off (or a definitely-rejecting\n // drmSystems) and surfacing an opaque KEY_SYSTEM error after the manifest\n // parses. The platform signal is WebKit-ness: WebKit has FairPlay and\n // never Widevine; everything else hls.js runs on has Widevine and never\n // FairPlay. So a FairPlay-only descriptor on Chrome AND a Widevine-only\n // descriptor on Safari both fail here, with a message that names the\n // actual mismatch.\n //\n // WebKit-ness is NOT native-HLS capability, though the two look alike:\n // Chromium answers canPlayType('application/vnd.apple.mpegurl') with\n // \"maybe\" (measured 2026-08-07) while playing no HLS natively. Reading\n // that as WebKit made `widevineUsable` false on every Chrome session\n // carrying FairPlay material, and this guard then refused a stream\n // hls.js plays fine over Widevine.\n //\n // Known heuristic edge: some OEM/smart-TV browsers report native-HLS\n // capability AND ship Widevine (Tizen/WebOS builds). There a\n // Widevine-ONLY descriptor would fail fast although it might have\n // played. Accepted: havik-streams always emits both systems (a\n // both-systems descriptor loads fine there — FairPlay rejects at access\n // and hls.js falls back to Widevine), and those UAs are outside the\n // supported matrix. Revisit if a widevine-only + smart-TV case appears.\n const webkit = nativeHlsSupported(video) && webkitMediaStack();\n const widevineUsable = Boolean(descriptor.drm?.widevine?.licenseUrl) && !webkit;\n // FairPlay is NOT usable on this engine. WebKit binds FairPlay keys through\n // the fragment `sinf` box, so an MSE session built from a synthesized skd\n // asset ID reaches a 'usable' key and still renders nothing (macOS Safari\n // 26.5, live DRM: 1 pass / 6 attempts vs 4 / 4 on the native engine).\n // 'auto' routes FairPlay-on-Apple to NativeHlsEngine; only an explicit\n // engine:'hls' lands here, and it must fail loudly rather than play black.\n if (descriptor.drmEnabled && !widevineUsable) {\n const fairplayOnly = Boolean(descriptor.drm?.fairplay?.licenseUrl) && webkit;\n emit({\n type: 'error',\n error: new PlaybackError(\n 'DRM_CLIENT',\n 0,\n fairplayOnly\n ? 'FairPlay cannot be played on the hls.js engine (WebKit binds keys via the media sinf box); use engine \"auto\" or \"native\"'\n : 'DRM is required but none of the stream’s key systems is usable on this platform (WebKit takes FairPlay only; other browsers take Widevine only)',\n ),\n fatal: true,\n });\n return;\n }\n\n this.startHls();\n\n // A backgrounded tab throttles timers, so the live-sync controller can't\n // chase the edge and latency balloons while hidden. On refocus, if we're\n // well past the controller's max-latency ceiling, snap straight to live\n // rather than catch-up-playing through minutes of backlog. Opt out via\n // snapToLiveOnRefocus: false. Registered once here (not in startHls) so a\n // license-retry rebuild doesn't stack duplicate visibilitychange listeners.\n if (snapToLiveOnRefocus !== false && typeof document !== 'undefined') {\n this.onVisibility = () => {\n const h = this.hls;\n if (document.visibilityState !== 'visible' || !h) return;\n const pos = h.liveSyncPosition;\n if (typeof pos !== 'number') return;\n // maxLatency is Infinity unless a count-based max is configured (we\n // deliberately don't — it's the illegal-config trap), so key the snap\n // off the sync target instead of h.maxLatency.\n const target =\n typeof h.targetLatency === 'number' && h.targetLatency > 0 ? h.targetLatency : 3;\n if (pos - video.currentTime > Math.max(target * 3, 8)) video.currentTime = pos;\n };\n document.addEventListener('visibilitychange', this.onVisibility);\n }\n\n // Over-seek clamp. Registered here (not in startHls) for the same reason as\n // the visibility listener: a license-retry rebuild must not stack duplicates.\n this.onSeeking = () => {\n this.clampOverSeek();\n // A seek while suspended — the scrubber, the LIVE button, the refocus\n // snap above — asks for a frame the stopped loader cannot fetch, so the\n // picture would stay frozen until play(). Load it, then re-arm: five\n // seconds of loading per seek, not an open tap.\n if (this.loadSuspended) {\n this.resumeLoading();\n this.schedulePauseSuspend();\n }\n };\n video.addEventListener('seeking', this.onSeeking);\n\n // Pause suspension (see PAUSE_SUSPEND_MS). Same registration rule: once,\n // here, so a rebuild cannot stack the handlers.\n this.onPause = () => this.schedulePauseSuspend();\n this.onPlay = () => this.resumeLoading();\n video.addEventListener('pause', this.onPause);\n video.addEventListener('play', this.onPlay);\n\n // SDK-managed player-size cap (see the CAP_* constants). Registered once\n // here so a license-retry rebuild doesn't stack observers; each rebuilt\n // instance gets the cap re-applied by its MANIFEST_PARSED handler. Guarded:\n // SSR/older engines without ResizeObserver just play uncapped, exactly as\n // a caller-disabled cap would.\n if (this.sizeCapManaged && typeof ResizeObserver !== 'undefined') {\n this.capObserver = new ResizeObserver(() => this.onMediaResize());\n this.capObserver.observe(video);\n this.watchDprChanges();\n }\n }\n\n /**\n * A `pause` event: arm the suspension grace. Only once media loading has\n * been asked for (a deferLoad engine before its first play() fetches nothing\n * to suspend), never after stop() (the stream is over; the loader is already\n * down and must stay down), and never twice.\n */\n private schedulePauseSuspend(): void {\n if (this.pauseSuspendTimer || this.loadSuspended || this.stopped || !this.loadStarted) return;\n this.pauseSuspendTimer = setTimeout(() => {\n this.pauseSuspendTimer = null;\n const { video } = this.deps;\n // Re-checked at fire time: a play() inside the grace clears the timer,\n // but a stop() or an `ended` element between arm and fire does not.\n if (!video.paused || video.ended || this.stopped || !this.hls) return;\n try {\n this.hls.stopLoad();\n } catch {\n return; // not suspended: the loader is still running, so the watchdogs must keep watching it\n }\n this.loadSuspended = true;\n }, PAUSE_SUSPEND_MS);\n }\n\n /**\n * A `play` event (or a seek while suspended): cancel a pending suspension,\n * and if the loader was stopped, restart it. startLoad(-1) resumes at\n * hls.js's lastCurrentTime (the paused or seeked position); the next\n * playlist reload then re-syncs to live if the position is past the drift\n * ceiling — the ordinary go-live seek.\n */\n private resumeLoading(): void {\n if (this.pauseSuspendTimer) {\n clearTimeout(this.pauseSuspendTimer);\n this.pauseSuspendTimer = null;\n }\n if (!this.loadSuspended) return;\n this.loadSuspended = false;\n if (this.stopped || !this.hls) return;\n // The playlist was deliberately not reloaded while suspended: restart the\n // staleness and starvation clocks so the quiet window never reads as a\n // dead stream or a wedged pipeline.\n this.lastAdvanceMs = Date.now();\n this.lastFragLoadingMs = 0;\n this.starvedTicks = 0;\n // A pause/resume restarts the pipeline the same way a kick does, so a\n // kick still awaiting proof must not escalate to a rebuild on pre-pause\n // evidence (same reset stop() makes).\n this.kickPending = false;\n this.kickTicks = 0;\n try {\n this.hls.startLoad(-1);\n } catch {\n /* ignore */\n }\n }\n\n startLoad(): void {\n if (this.loadStarted) return;\n this.loadStarted = true;\n // The first play() of an autoplay-off player kicks the loader and then\n // calls video.play(). If that play is blocked (a gestureless probe) the\n // element stays paused with the loader running, MANIFEST_PARSED is long\n // gone and no pause event will ever come — so this is the third entry\n // point that must arm the grace. A play that goes through clears it.\n if (this.deps.video.paused) this.schedulePauseSuspend();\n // -1 = hls.js default start position: the live-sync point on live\n // playlists (the same target the armed go-live path lands on), 0 on VOD.\n // The poster card renders before MANIFEST_PARSED, so this often lands\n // pre-manifest: hls.js then sets its forceStartLoad flag and re-starts at\n // config.startPosition (hls.js default -1) once the manifest arrives —\n // identical here, but a caller hlsConfig.startPosition override would be\n // honored on the pre-manifest path only.\n this.hls?.startLoad(-1);\n }\n\n /**\n * Re-evaluate the cap when devicePixelRatio changes without a layout change\n * (see the dprMedia field). Re-armed after every fire because the query pins\n * the ratio that was current when it was built. Guarded feature-detection:\n * without matchMedia (SSR) or MQL addEventListener (legacy WebKit) the cap\n * simply doesn't track monitor moves — the pre-existing ResizeObserver\n * behavior, never an error.\n */\n private watchDprChanges(): void {\n if (typeof matchMedia !== 'function') return;\n this.unwatchDprChanges();\n const dpr = (typeof self !== 'undefined' && self.devicePixelRatio) || 1;\n const mql = matchMedia(`(resolution: ${dpr}dppx)`);\n if (typeof mql.addEventListener !== 'function') return;\n const handler = () => {\n this.onMediaResize();\n this.watchDprChanges();\n };\n mql.addEventListener('change', handler);\n this.dprMedia = mql;\n this.onDprChange = handler;\n }\n\n private unwatchDprChanges(): void {\n if (this.dprMedia && this.onDprChange) {\n this.dprMedia.removeEventListener('change', this.onDprChange);\n }\n this.dprMedia = null;\n this.onDprChange = null;\n }\n\n /**\n * Pull a seek that landed ahead of the live-sync position back onto it.\n *\n * Dragging the scrub bar (or a native control) to the far right lands the\n * playhead on `seekable.end()` — the bleeding edge of the live playlist, where\n * latency reads <1s and Tencent's newest parts aren't reliably published yet.\n * The forward buffer is then permanently empty: each ~0.5s part plays out\n * before the next exists, so playback advances a few frames, starves,\n * advances again. hls.js will NOT recover from this on its own — both of its\n * live-sync mechanisms only correct being too far BEHIND:\n * `maxLiveSyncPlaybackRate` rebases to 1.0x once `latency - targetLatency`\n * goes negative and never slows below it, and `liveMaxLatencyDuration`'s\n * force-seek (stream-controller's synchronizeToLiveEdge) skips out entirely\n * while `currentTime >= liveSyncPosition`. Nothing pulls the playhead back, so\n * the stutter persists for the rest of the session. This clamp is that missing\n * near-side counterpart to the drift ceiling.\n *\n * Clamping on `seeking` (not `seeked`) means the bleeding edge is never even\n * fetched. `liveSyncPosition` is the same spot seekToLive() targets, so an\n * over-seek reads as \"go live\" rather than as a broken seek.\n *\n * Skipped while chase suspension is active: a stale playlist is exactly the\n * state #39 stopped seeking in (the extrapolated edge points at data that\n * isn't there), and the policy there is to play out the buffer and hold the\n * last frame until 'ended' lands. An over-seek costs nothing then — there's no\n * live edge left to stutter against.\n *\n * Live only: hls.js still reports a non-null `liveSyncPosition` on VOD (it's\n * derived from liveSyncDuration, which has no VOD meaning), so gating on\n * `details.live` is what keeps this from capping VOD seeks at duration − 3s.\n *\n * Terminates without a re-entrancy flag: the clamped position is at or below\n * the limit, and the limit only moves forward, so the follow-up `seeking`\n * event this assignment queues returns at the tolerance check.\n */\n private clampOverSeek(): void {\n const hls = this.hls;\n if (!hls || this.chaseSuspended) return;\n const level = hls.levels[hls.currentLevel] ?? hls.levels[hls.loadLevel];\n if (level?.details?.live !== true) return;\n const limit = hls.liveSyncPosition;\n if (typeof limit !== 'number' || !Number.isFinite(limit)) return;\n const { video } = this.deps;\n if (video.currentTime <= limit + OVER_SEEK_TOLERANCE_S) return;\n video.currentTime = limit;\n }\n\n /**\n * Build (or rebuild) the hls.js instance, wire its events, and start loading.\n * Split out of load() so a transient license-request failure can re-drive EME\n * with a fresh instance (new key session → new license request) without\n * re-adding the one-time visibility listener.\n */\n private startHls(): void {\n const { video, descriptor, credential, deviceId, userId, lowLatency, debug, hlsConfig, emit } =\n this.deps;\n\n // Per-instance watchdog state. A rebuilt instance has issued no kick and\n // has loaded no fragment, so carrying either across would let the first\n // tick after a rebuild escalate again on the previous instance's evidence.\n this.kickPending = false;\n this.kickTicks = 0;\n this.starvedTicks = 0;\n this.lastFragLoadingMs = 0;\n // The end-of-stream stamps are per-instance for a sharper reason: trackEnd\n // only restamps lastAdvanceMs when endSN CHANGES, so a rebuilt instance\n // whose first playlist load repeats the endSN the old one last saw would\n // keep ageing an advance stamp from before the rebuild, and can cross\n // CHASE_SUSPEND_STALE_MS — or END_STALE_MS, reporting a healthy stream as\n // ended. Zeroing them gives the new instance a fresh liveness window:\n // checkEndStale returns early while lastAdvanceMs is 0, and checkStarvation\n // treats the playlist as not-yet-alive, so both watchdogs stay disarmed\n // until this instance sees its own first load. The ENDLIST path\n // (live→false) and the server's SSE push still catch a real end meanwhile.\n this.lastEndSN = -1;\n this.lastAdvanceMs = 0;\n\n const drmEnabled =\n descriptor.drmEnabled &&\n Boolean(descriptor.drm?.widevine?.licenseUrl || descriptor.drm?.fairplay?.licenseUrl);\n // FairPlay is only offered where WebKit can actually take it (Safari /\n // iOS — the same signal as native-HLS capability). This also scopes\n // drmSystemOptions below: the option object is GLOBAL in hls.js (applied\n // to every key system's requestMediaKeySystemAccess), and WebKit's\n // supportedRobustnesses() for FairPlay is { '' } — handing it the\n // Widevine SW_SECURE_* pins makes the FairPlay access request reject.\n\n const config: Partial<HlsConfig> = {\n // hls.js internal logging (fragment/part scheduling, ABR switches, EME\n // key sessions + license traffic) — opt-in, for diagnosing a playback\n // problem with support. The operator Demo Player (havik-ops\n // streams-player.component.ts) hardcodes this on; it is the one key the\n // two configs are meant to differ on, which is why it is an option here\n // rather than a constant.\n debug: debug === true,\n // Workers are disabled in low-latency mode: on hls.js 1.6.x the\n // LL-HLS partial-segment append path hits a bufferAppendError race\n // (\"SourceBuffer is still processing an appendBuffer/remove\") that\n // reproduces ~100% with workers ON (video-dev/hls.js#7321). Disabling\n // the worker is the documented mitigation; standard (non-LL) playback\n // keeps workers for off-main-thread parsing. Re-enable once a hls.js\n // release fixes the worker append race.\n enableWorker: !lowLatency,\n lowLatencyMode: lowLatency,\n backBufferLength: 90,\n // Audio-hole bridge. Tencent's LL-HLS audio packager cuts a ~0.45s\n // \"remainder\" part at each segment boundary; the hls.js default\n // maxBufferHole (0.1s) is smaller than the hole, so the player stalls\n // and gap-skips ~0.5s every few seconds. 0.5 bridges it as a\n // sub-perceptible hitch instead of a rebuffer.\n maxBufferHole: 0.5,\n // Live-edge target (liveSyncDuration) + drift ceiling (liveMaxLatencyDuration)\n // are applied by normalizeLiveSync() AFTER the caller's hlsConfig is merged\n // below — so a raw override (incl. the Count variants) can never produce an\n // illegal pair that throws at construction. See that helper for the why.\n // Catch up at up to 1.5x (ramped by how far behind) to recover drift inside\n // the target..ceiling band before the hard seek. Internal — not a public\n // knob (1.0 silently re-introduces drift); overridable via raw hlsConfig.\n maxLiveSyncPlaybackRate: 1.5,\n // ABR start quality — start conservative, then ramp. Seeding high (~4 Mbps\n // → a ~720p first rendition) and relying on \"drop fast\" to recover was still\n // rebuffering in the field on constrained links: the high first grab stalls\n // to fill the buffer before ABR can drop. Per the root-cause investigation\n // (docs/llhls-buffering-investigation.md §3), seed ~1 Mbps so the first\n // rendition is one a modest link sustains (~360p) and ABR ramps up within a\n // few seconds when real bandwidth allows — trading a slightly softer first\n // ~2s for not stalling on startup (the first-20s buffering we were chasing).\n // The SDK-managed size cap still bounds the session to the player's\n // pixel box (below). Caller startLevel / maxBitrate still override.\n abrEwmaDefaultEstimate: 1_000_000,\n // Player-size capping is SDK-managed (see the CAP_* constants): hls.js's\n // own poller applies a new cap the instant the box changes, which turns\n // every fullscreen enter/exit into immediate ABR switches — each a\n // visible ~1s stall at LL buffer depth. Explicit false (the hls.js\n // default) so the two managers never fight over autoLevelCapping; a\n // caller hlsConfig `capLevelToPlayerSize: true` opts back into the\n // hls.js poller and stands the SDK manager down.\n capLevelToPlayerSize: false,\n // deferLoad (autoplay-off pre-play): parse the manifest (MANIFEST_PARSED\n // → 'ready', track lists) but fetch no level playlists or fragments —\n // and so no EME/license traffic — until startLoad(). Keyed off\n // loadStarted, not deps.deferLoad, so a license-retry rebuild after\n // playback began loads eagerly again. The hlsConfig escape hatch (spread\n // below) wins here like everywhere else — an explicit autoStartLoad:\n // true is the \"warm the buffer, wait for the gesture\" pattern;\n // loadStarted is re-synced to the final merged config after the spread.\n ...(this.loadStarted ? {} : { autoStartLoad: false }),\n ...(drmEnabled\n ? {\n emeEnabled: true,\n drmSystems: {\n ...(this.licenseUrl ? { 'com.widevine.alpha': { licenseUrl: this.licenseUrl } } : {}),\n },\n // FairPlay is NOT configured here — it lives on NativeHlsEngine.\n // WebKit binds FairPlay keys through the fragment `sinf` box, so an\n // MSE session built from a synthesized skd asset ID reaches a\n // 'usable' key and still renders nothing. load() fails fast above\n // rather than reaching this config.\n drmSystemOptions: {\n videoRobustness: 'SW_SECURE_DECODE',\n audioRobustness: 'SW_SECURE_CRYPTO',\n videoEncryptionScheme: 'cbcs',\n audioEncryptionScheme: 'cbcs',\n },\n // Reuse this element's CDM across rebuilds. Chrome refuses to\n // replace OR remove a media element's ContentDecryptionModule once\n // the element has a player, so every rebuildInstance() below —\n // starvation, license retry, or a managed reload() building a fresh\n // engine on the same <video> — would otherwise die on\n // `setMediaKeys`: \"The existing ContentDecryptionModule object\n // cannot be removed at this time\", surfaced as a fatal\n // KEY_SYSTEM_NO_KEYS. hls.js's CDMCleanupPromise cannot help; its\n // cleanup call is forbidden too. See core/drm/cdm.ts.\n //\n // Only when EME exists: with no key-system API at all (a plain-http\n // embed — EME is secure-context-only), leaving the hook unset lets\n // hls.js report that cause itself rather than us shadowing it.\n ...(emeAvailable()\n ? { requestMediaKeySystemAccessFunc: reuseMediaKeysFor(video) }\n : {}),\n // EME license requests route through licenseXhrSetup — NOT the\n // segment-side xhrSetup (a hard-won Demo Player lesson; putting the\n // key in xhrSetup makes every license 401). Open the XHR ourselves\n // before setting any headers (setRequestHeader throws if the XHR\n // isn't OPENED) so we never depend on hls.js's internal open-ordering;\n // opening against the CURRENT license URL also picks up a refreshed\n // signed URL (hls.js keeps passing the construction-time one).\n // Widevine only — FairPlay lives on the native engine.\n licenseXhrSetup: (xhr: XMLHttpRequest, url: string) => {\n xhr.open('POST', this.licenseUrl ?? url, true);\n xhr.setRequestHeader('x-api-key', credential.apiKey);\n xhr.setRequestHeader('X-Match-Urn', descriptor.matchUrn);\n xhr.setRequestHeader('X-Device-Id', deviceId);\n if (userId) xhr.setRequestHeader('X-User-Id', userId);\n // License-acquisition timing for the QoE plane — fleet-wide DRM\n // regressions (WAF license 403s, the canPlayType trap) were\n // invisible without it.\n this.licenseRequested = true;\n const t0 = performance.now();\n xhr.addEventListener('loadend', () => {\n this.licenseTimeMs = Math.max(1, Math.round(performance.now() - t0));\n });\n },\n }\n : {}),\n // CMCD in query mode (plan D1/D2): the per-session sid rides every CDN\n // request as the attribution key the edge-log plane joins on. Query\n // mode, never headers — header mode would force a CORS preflight per\n // segment. Only when streams minted a sid (server-side analytics flag).\n ...(descriptor.analytics?.sid\n ? {\n cmcd: {\n sessionId: descriptor.analytics.sid,\n contentId: descriptor.matchUrn,\n useHeaders: false,\n },\n }\n : {}),\n ...hlsConfig,\n };\n // The FairPlay certificate GET goes through hls.js's GENERIC loader (not\n // licenseXhrSetup), and drm-proxy requires the api key on it (its router\n // test asserts 401 without). Strictly URL-scoped: manifest/segment\n // requests are public CDN fetches and must not carry auth. hls.js hands\n // xhrSetup an UNOPENED xhr — open it first or setRequestHeader throws.\n // COMPOSED with any caller hlsConfig.xhrSetup (applied post-spread): the\n // escape hatch must not silently disable certificate auth on WebKit.\n // ORDER MATTERS: the caller runs FIRST — it gets the unopened xhr hls.js\n // hands out (the documented contract, and many hooks call open()\n // themselves; the XHR spec EMPTIES author headers on open(), so auth set\n // before a caller's open() would be silently wiped). Our cert branch then\n // opens only if still UNSENT and sets the api key on the final, opened xhr.\n // Resolve the live-edge target/ceiling on the FINAL merged config so a raw\n // hlsConfig override can't produce an illegal hls.js pair (see helper).\n normalizeLiveSync(config);\n // The escape hatch wins: a caller that re-enabled hls.js's own player-size\n // poller gets exactly that, and the SDK manager stands down (two writers on\n // autoLevelCapping would fight, the 1s poller always winning).\n this.sizeCapManaged = config.capLevelToPlayerSize !== true;\n // Sync loadStarted with the FINAL config: a caller hlsConfig.autoStartLoad\n // may have overridden the deferLoad key above, and a stale flag would make\n // startLoad() re-kick a loader that is already running (or believe a\n // caller-stopped one had started).\n this.loadStarted = config.autoStartLoad !== false;\n\n const hls = new Hls(config);\n this.hls = hls;\n\n hls.on(Hls.Events.MANIFEST_PARSED, () => {\n this.mediaRecoveries = 0;\n this.networkRecoveries = 0;\n const heights = hls.levels.map((l) => l.height).filter((h): h is number => !!h);\n this.ladderHeights = heights.length ? [...new Set(heights)].sort((a, b) => a - b) : undefined;\n this.ladderTopBitrateKbps = hls.levels.length\n ? Math.round(Math.max(...hls.levels.map((l) => l.bitrate)) / 1000)\n : 0;\n // Initial size cap, applied immediately (no settle window): the first\n // fetch of a small/embedded player must not grab 1080p.\n this.applySizeCap();\n // An instance that starts paused — autoplay still pending, autoplay\n // BLOCKED (the play card, with hls.js chasing live behind it forever:\n // no `pause` event ever fires for an element that never played), or a\n // rebuild while suspended (the new instance loads regardless) — gets\n // the same grace as a pause. Pending autoplay resolves long before it\n // fires, and the fire-time re-check sees the element playing.\n this.loadSuspended = false;\n if (video.paused) this.schedulePauseSuspend();\n emit({ type: 'ready' });\n });\n // After a stretch of uninterrupted buffering, refund the recovery budget so a\n // healthy long session isn't permanently capped. A fresh error cancels the\n // pending refund (see clearRecoveryReset in onError), preserving tight-loop\n // protection. Main-track fragments only: the starvation wedge keeps audio\n // buffering while video is dead, and audio frags must not refund the kicks.\n hls.on(Hls.Events.FRAG_BUFFERED, (_e, data) => {\n if (data?.frag?.type !== 'main') return;\n // Main-track media buffered, so the last kick did its job: cancel the\n // pending escalation. This is the only place the grace is cleared, which\n // is what makes the escalation fire on exactly the case it is for — a\n // kick that produced no video, rather than one that merely took a while.\n this.kickPending = false;\n this.kickTicks = 0;\n if (\n (this.mediaRecoveries > 0 ||\n this.networkRecoveries > 0 ||\n this.licenseRecoveries > 0 ||\n this.starvationKicks > 0 ||\n this.starvationRebuilds > 0) &&\n !this.recoveryResetTimer\n ) {\n this.recoveryResetTimer = setTimeout(() => {\n this.mediaRecoveries = 0;\n this.networkRecoveries = 0;\n this.licenseRecoveries = 0;\n this.starvationKicks = 0;\n this.starvationRebuilds = 0;\n this.recoveryResetTimer = null;\n }, RECOVERY_RESET_MS);\n }\n });\n // Starvation-watchdog liveness input: a healthy or merely rebuffering\n // pipeline keeps issuing main-track fragment loads; a wedged one goes quiet.\n hls.on(Hls.Events.FRAG_LOADING, (_e, data) => {\n if (data?.frag?.type === 'main') this.lastFragLoadingMs = Date.now();\n });\n // Consumption accounting: media segment payload bytes (all tracks — the\n // dashboard-grade half of D3; edge logs stay the billing truth).\n hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {\n const stats = data?.frag?.stats;\n const loaded = stats?.loaded;\n if (typeof loaded === 'number') this.bytesLoaded += loaded;\n // Per-request delivery timing: the viewer-side view of how the CDN is\n // serving this session, which the edge plane's own latency cannot show\n // (it never sees the last mile).\n const loading = stats?.loading;\n if (loading && loading.end > 0 && loading.start > 0) {\n this.requestCount += 1;\n this.requestTimesMs.push(Math.max(0, Math.round(loading.end - loading.start)));\n if (this.requestTimesMs.length > MAX_REQUEST_SAMPLES) this.requestTimesMs.shift();\n }\n const url = data?.frag?.url;\n if (url) {\n const host = hostOf(url);\n // Re-read every fragment on purpose: a mid-session CDN failover is\n // exactly the event this column exists to make visible.\n if (host) this.cdnHost = host;\n }\n });\n hls.on(Hls.Events.LEVEL_LOADED, (_e, data: LevelLoadedData) => {\n // Playlist chatter is nontrivial at 2s LL refresh windows — counted\n // separately from media bytes (plan §3.1).\n const loaded = data?.stats?.loaded;\n if (typeof loaded === 'number') this.playlistBytesLoaded += loaded;\n if (this.detectedLowLatency) return;\n const details = data.details;\n const isLL = Boolean(details?.partList?.length) || (details?.partTarget ?? 0) > 0;\n if (isLL) {\n this.detectedLowLatency = true;\n emit({ type: 'lowLatencyDetected', value: true });\n }\n });\n // End-of-stream detection (separate handler so the LL-detection early-return\n // above never short-circuits it). ENDLIST flips details.live→false; a frozen\n // manifest is caught by the staleness watchdog interval below.\n hls.on(Hls.Events.LEVEL_LOADED, (_e, data: LevelLoadedData) => this.trackEnd(data));\n // startHls() also runs on a rebuild, and assigning over a live handle\n // leaks the previous interval: both keep firing, so every checkEndStale /\n // checkStarvation tick doubles up. That already applied to the\n // license-retry rebuild (up to MAX_LICENSE_RECOVERIES extra intervals);\n // it would also break the starvation escalation, whose grace is measured\n // between ticks — a doubled tick rate re-stamps the kick before the grace\n // can ever elapse.\n if (this.endWatchdog) clearInterval(this.endWatchdog);\n this.endWatchdog = setInterval(() => {\n this.checkEndStale();\n this.checkStarvation();\n }, END_WATCH_INTERVAL_MS);\n hls.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => {\n // Rendition is only sampled at heartbeats; without this counter, ABR\n // churn between samples is invisible (plan §3.1).\n this.renditionSwitches += 1;\n // Direction by BITRATE, not by index: level order is a property of\n // the manifest, not a contract, and a reordered reload would invert\n // every switch this session reports.\n const from = this.lastLevelIndex >= 0 ? hls.levels[this.lastLevelIndex] : undefined;\n const to = hls.levels[data.level];\n if (from && to && to.bitrate !== from.bitrate) {\n if (to.bitrate > from.bitrate) this.upshifts += 1;\n else this.downshifts += 1;\n }\n this.lastLevelIndex = data.level;\n emit({ type: 'qualitychange', index: data.level });\n });\n hls.on(Hls.Events.AUDIO_TRACK_SWITCHED, (_e, data) =>\n emit({ type: 'audiotrackchange', id: data.id }),\n );\n hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (_e, data) =>\n emit({ type: 'texttrackchange', id: data.id }),\n );\n hls.on(Hls.Events.ERROR, (_e, data: ErrorData) => this.onError(data));\n\n hls.loadSource(descriptor.manifestUrl);\n hls.attachMedia(video);\n }\n\n getQualityLevels(): QualityLevel[] {\n if (!this.hls) return [];\n return this.hls.levels.map((l, index) => ({\n index,\n width: l.width || undefined,\n height: l.height || undefined,\n bitrate: l.bitrate,\n codecs: l.videoCodec || l.codecSet || undefined,\n }));\n }\n\n getCurrentQuality(): number {\n if (!this.hls) return -1;\n return this.hls.autoLevelEnabled ? -1 : this.hls.currentLevel;\n }\n\n setQuality(index: number): void {\n if (this.hls) this.hls.currentLevel = index; // -1 re-enables ABR\n }\n\n setMaxBitrate(bitrate: number | null): void {\n const hls = this.hls;\n if (!hls) return;\n if (bitrate == null) {\n this.bitrateCapLevel = -1;\n } else {\n // Cap ABR at the highest level whose bitrate fits the budget.\n let cap = -1;\n hls.levels.forEach((l, i) => {\n if (l.bitrate <= bitrate) cap = i;\n });\n this.bitrateCapLevel = cap;\n }\n // Routed through pushCap so clearing the bitrate budget no longer wipes the\n // player-size cap (both used to write autoLevelCapping directly, last one\n // winning until hls.js's 1s poller re-imposed the size cap).\n this.pushCap();\n }\n\n /**\n * Level index the player's current pixel box caps ABR to, or null when it\n * can't be computed (no levels yet, or a zero-size/hidden box — upstream\n * skips those the same way). Selection is hls.js's own\n * CapLevelController.getMaxLevelByMediaSize, on the element's bounding box\n * scaled by the same DPR rules the upstream poller uses\n * (ignoreDevicePixelRatio / maxDevicePixelRatio from the merged config), so\n * flipping between the two managers can never change WHICH level is chosen —\n * only WHEN it is applied.\n */\n private computeSizeCapTarget(): number | null {\n const hls = this.hls;\n if (!hls || hls.levels.length === 0) return null;\n const { video } = this.deps;\n const rect = video.getBoundingClientRect();\n let width = rect.width;\n let height = rect.height;\n if (!width && !height) {\n // Mirror upstream getDimensions(): a media element that isn't laid out\n // (equivalent to not being in the DOM) falls back to its width/height\n // attributes — without this, off-DOM playback would never receive an\n // initial cap, since ResizeObserver doesn't fire for detached elements.\n width = video.width || 0;\n height = video.height || 0;\n }\n if (!(width > 0) || !(height > 0)) return null;\n const cfg: Partial<HlsConfig> = hls.config ?? {};\n let scale = 1;\n if (!cfg.ignoreDevicePixelRatio && typeof self !== 'undefined' && self.devicePixelRatio > 0) {\n scale = self.devicePixelRatio;\n }\n scale = Math.min(scale, cfg.maxDevicePixelRatio ?? Number.POSITIVE_INFINITY);\n return CapLevelController.getMaxLevelByMediaSize(hls.levels, width * scale, height * scale);\n }\n\n /**\n * ResizeObserver callback: debounce cap changes so only a SETTLED box ever\n * reaches ABR. Every size event restarts the pending window, so a fullscreen\n * transition animating through intermediate sizes keeps deferring until the\n * box stops moving — and a box that settles back onto the applied cap (exit\n * + prompt re-enter) cancels the pending change without ever touching ABR.\n */\n private onMediaResize(): void {\n if (!this.sizeCapManaged) return;\n const target = this.computeSizeCapTarget();\n if (target == null) return;\n if (this.sizeCapLevel == null) {\n // Nothing applied yet (manifest parsed while the box was hidden/zero):\n // this is the initial cap, not a change — apply immediately.\n this.applySizeCap();\n return;\n }\n if (target === this.sizeCapLevel) {\n if (this.capTimer) {\n clearTimeout(this.capTimer);\n this.capTimer = null;\n }\n return;\n }\n if (this.capTimer) clearTimeout(this.capTimer);\n const delay = target > this.sizeCapLevel ? CAP_RAISE_SETTLE_MS : CAP_SHRINK_LAZY_MS;\n this.capTimer = setTimeout(() => {\n this.capTimer = null;\n // The box can move between the last observed event and this firing\n // (ResizeObserver delivery is frame-aligned, timers aren't): apply only\n // if the box still selects the level this window was scheduled FOR;\n // otherwise re-debounce in the fresh direction — a raise window must\n // never be the door a shrink walks through.\n if (this.computeSizeCapTarget() === target) this.applySizeCap();\n else this.onMediaResize();\n }, delay);\n }\n\n /** Recompute the size cap from the CURRENT box and apply it (recomputing at\n * timer fire, not schedule time, so a stale pending value is never applied). */\n private applySizeCap(): void {\n if (!this.sizeCapManaged) return;\n if (this.capTimer) {\n clearTimeout(this.capTimer);\n this.capTimer = null;\n }\n const target = this.computeSizeCapTarget();\n if (target == null) return;\n this.sizeCapLevel = target;\n this.pushCap();\n }\n\n /** Combine the size cap and the setMaxBitrate cap (min wins) into hls.js's\n * single autoLevelCapping knob. */\n private pushCap(): void {\n const hls = this.hls;\n if (!hls) return;\n const size = this.sizeCapLevel ?? -1;\n const rate = this.bitrateCapLevel;\n hls.autoLevelCapping = size < 0 ? rate : rate < 0 ? size : Math.min(size, rate);\n }\n\n getAudioTracks(): AudioTrackInfo[] {\n if (!this.hls) return [];\n return this.hls.audioTracks.map((t) => ({\n id: t.id,\n name: t.name,\n lang: t.lang || undefined,\n default: Boolean(t.default),\n }));\n }\n\n setAudioTrack(id: number): void {\n if (this.hls) this.hls.audioTrack = id;\n }\n\n getTextTracks(): TextTrackInfo[] {\n if (!this.hls) return [];\n return this.hls.subtitleTracks.map((t) => ({\n id: t.id,\n name: t.name,\n lang: t.lang || undefined,\n }));\n }\n\n setTextTrack(id: number): void {\n const hls = this.hls;\n if (!hls) return;\n if (id < 0) {\n hls.subtitleTrack = -1;\n hls.subtitleDisplay = false;\n } else {\n hls.subtitleTrack = id;\n hls.subtitleDisplay = true;\n }\n }\n\n seekToLive(): void {\n const hls = this.hls;\n if (hls && typeof hls.liveSyncPosition === 'number') {\n this.deps.video.currentTime = hls.liveSyncPosition;\n }\n }\n\n setLicenseUrls(urls: { widevine?: string; fairplay?: string }): void {\n // Refresh only what the caller has — licenseXhrSetup reads this field per\n // request, so an in-flight session picks the new signed URL up on its next\n // license POST without an instance rebuild. FairPlay is ignored here: it\n // runs on NativeHlsEngine, which keeps its own refreshed URL.\n if (urls.widevine) this.licenseUrl = urls.widevine;\n }\n\n getStats(): PlaybackStats {\n const hls = this.hls;\n const frames = readFrameStats(this.deps.video);\n const stats: PlaybackStats = {\n droppedFrames: frames.dropped,\n decodedFrames: frames.totalFrames,\n lowLatency: this.detectedLowLatency,\n bytesLoaded: this.bytesLoaded,\n playlistBytesLoaded: this.playlistBytesLoaded,\n renditionSwitches: this.renditionSwitches,\n licenseTimeMs: this.licenseTimeMs,\n upshifts: this.upshifts,\n downshifts: this.downshifts,\n requestCount: this.requestCount,\n requestErrors: this.requestErrors,\n // A copy: PlaybackStats is public surface, and a consumer that\n // mutated this array would corrupt the engine's own accounting.\n requestTimesMs: [...this.requestTimesMs],\n ladderHeights: this.ladderHeights,\n ladderTopBitrateKbps: this.ladderTopBitrateKbps || undefined,\n };\n if (this.cdnHost) stats.cdnHost = this.cdnHost;\n // Widevine with the robustness this engine requests (SW_SECURE_*) is\n // software-path by definition: L3. A hardware-backed L1 is not reachable\n // through hls.js/MSE here, so reporting the requested level is honest.\n if (this.licenseRequested) {\n stats.keySystem = 'widevine';\n stats.drmSecurityLevel = 'L3';\n }\n if (hls) {\n stats.qualityPinned = !hls.autoLevelEnabled;\n if (typeof hls.latency === 'number') stats.latencySeconds = hls.latency;\n if (typeof hls.targetLatency === 'number' && hls.targetLatency > 0) {\n stats.targetLatencySeconds = hls.targetLatency;\n }\n if (typeof hls.bandwidthEstimate === 'number') {\n stats.bandwidthKbps = Math.round(hls.bandwidthEstimate / 1000);\n }\n const level = hls.currentLevel >= 0 ? hls.levels[hls.currentLevel] : undefined;\n if (level) stats.levelHeight = level.height;\n if (level?.videoCodec) stats.videoCodec = level.videoCodec;\n if (level?.audioCodec) stats.audioCodec = level.audioCodec;\n if (level && typeof level.bitrate === 'number') {\n stats.renditionBitrateKbps = Math.round(level.bitrate / 1000);\n }\n if (typeof level?.details?.live === 'boolean') {\n stats.isLive = level.details.live;\n if (typeof hls.liveSyncPosition === 'number') {\n stats.atLiveEdge = this.deps.video.currentTime >= hls.liveSyncPosition - 1.5;\n }\n }\n }\n return stats;\n }\n\n /**\n * Track live-playlist progress for end detection. Once the stream has been\n * seen LIVE, a transition to `details.live === false` means the origin\n * published #EXT-X-ENDLIST (clean end); otherwise the staleness watchdog\n * (checkEndStale) catches a frozen-but-200 manifest.\n */\n private trackEnd(data: LevelLoadedData): void {\n const d = data.details;\n if (!d) return;\n if (d.live === true) {\n this.sawLive = true;\n const sn = d.endSN ?? -1;\n if (sn !== this.lastEndSN) {\n this.lastEndSN = sn;\n this.lastAdvanceMs = Date.now();\n // The playlist is moving again — restore normal live-edge chasing\n // before the stale window that suspended it is forgotten.\n if (this.chaseSuspended) this.resumeChasing();\n }\n return;\n }\n // VOD that was never live is not an \"end\" — only treat live→false as ENDLIST.\n if (this.sawLive) this.emitEnded('endlist');\n }\n\n private checkEndStale(): void {\n if (this.endedEmitted || !this.sawLive || this.lastAdvanceMs === 0) return;\n // Chrome's intensive throttling (muted tab hidden >5 min) batches ALL timers\n // to ~1/min — including hls.js's playlist-reload timer — so advance stamps\n // gap to ~60s on a healthy stream (this check wakes in the same batch as the\n // reload, before its XHR completes). Re-stamp instead of evaluating while\n // hidden: staleness detection resumes with a fresh window on refocus, and\n // the SSE live-state push still catches a real end immediately. Mirrors the\n // Demo Player guard (havik-ops#68).\n if (typeof document !== 'undefined' && document.hidden) {\n this.lastAdvanceMs = Date.now();\n return;\n }\n // Same re-stamp while loading is suspended for a pause: no playlist is\n // being reloaded, so \"not advancing\" is by design, not a frozen origin.\n if (this.loadSuspended) {\n this.lastAdvanceMs = Date.now();\n return;\n }\n const staleMs = Date.now() - this.lastAdvanceMs;\n if (!this.chaseSuspended && staleMs > CHASE_SUSPEND_STALE_MS) this.suspendChasing();\n if (staleMs > END_STALE_MS) this.emitEnded('stalled');\n }\n\n /**\n * Detect and heal a wedged hls.js video pipeline (see the STARVATION_*\n * constants for the mechanism). All three conditions must hold across\n * consecutive ticks:\n * - the element is starved (readyState < HAVE_FUTURE_DATA) while playing;\n * - the live playlist is still advancing (a stale playlist is stream end —\n * the end watchdog and chase suspension own that path);\n * - hls.js has issued no main-track fragment load recently (a plain\n * rebuffer keeps FRAG_LOADING events flowing; the wedge goes silent).\n * Hidden tabs are skipped for the same timer-throttling reason as\n * checkEndStale. The kick budget is windowed like the other recoveries\n * (refunded after RECOVERY_RESET_MS of healthy buffering).\n */\n private checkStarvation(): void {\n const hls = this.hls;\n const { video } = this.deps;\n if (!hls || this.endedEmitted || !this.sawLive || this.chaseSuspended) return;\n if ((typeof document !== 'undefined' && document.hidden) || this.loadSuspended) {\n this.starvedTicks = 0;\n return;\n }\n const playlistAlive =\n this.lastAdvanceMs !== 0 && Date.now() - this.lastAdvanceMs < CHASE_SUSPEND_STALE_MS;\n const fragQuiet =\n this.lastFragLoadingMs !== 0 &&\n Date.now() - this.lastFragLoadingMs > STARVATION_FRAG_QUIET_MS;\n const starved = video.readyState < HTMLMediaElement.HAVE_FUTURE_DATA && !video.paused;\n if (!(starved && playlistAlive && fragQuiet)) {\n this.starvedTicks = 0;\n return;\n }\n // Reached only on a visible tick that still shows the wedge signature, so\n // this is the grace's clock: active starvation, not elapsed wall time.\n if (this.kickPending) this.kickTicks += 1;\n if (++this.starvedTicks < STARVATION_TICKS) return;\n this.starvedTicks = 0;\n\n // A kick that has had its grace and still produced no main-track media did\n // not reach the wedge. Escalate to a rebuild before spending the rest of\n // the kick budget re-parking the same instance.\n if (this.kickPending && this.kickTicks >= STARVATION_KICK_GRACE_TICKS) {\n this.kickPending = false;\n this.kickTicks = 0;\n if (this.starvationRebuilds < MAX_STARVATION_REBUILDS) {\n this.starvationRebuilds += 1;\n console.warn(\n 'havik-player: restarting the load did not resume video (the wedge is in hls.js state that only a rebuild clears) — rebuilding the player',\n );\n this.rebuildInstance('starvation rebuild');\n return;\n }\n }\n\n if (this.starvationKicks >= MAX_STARVATION_KICKS) return;\n this.starvationKicks += 1;\n this.kickPending = true;\n this.kickTicks = 0;\n console.warn(\n 'havik-player: video starved while the live playlist advances (wedged hls.js fragment pipeline) — restarting the load',\n );\n try {\n hls.stopLoad();\n hls.startLoad(-1);\n } catch (err) {\n // The kick is best-effort: a throw from a wedged hls.js must not escape\n // the watchdog interval and take the page down with it.\n console.warn('havik-player: starvation kick failed', err);\n }\n }\n\n /**\n * Freeze live-edge chasing on a stale playlist. Both knobs are read\n * dynamically by hls.js's LatencyController on every tick, so mutating the\n * live config takes effect immediately: an effectively-infinite latency\n * ceiling disables the force-seek (which would land in data that doesn't\n * exist), and a 1.0 sync rate stops the catch-up speedup — the video simply\n * plays out its buffer and holds the last frame until 'ended' (or fresh\n * bytes) arrive.\n */\n private suspendChasing(): void {\n const hls = this.hls;\n if (!hls) return;\n this.chaseSuspended = true;\n this.savedLiveMaxLatency = hls.config.liveMaxLatencyDuration;\n this.savedMaxSyncRate = hls.config.maxLiveSyncPlaybackRate;\n hls.config.liveMaxLatencyDuration = Number.MAX_SAFE_INTEGER;\n hls.config.maxLiveSyncPlaybackRate = 1;\n }\n\n private resumeChasing(): void {\n this.chaseSuspended = false;\n const hls = this.hls;\n if (!hls) return;\n // Restore UNCONDITIONALLY — including `undefined`. Count-based caller\n // configs (liveSyncDurationCount) have their seconds keys deleted by\n // normalizeLiveSync, so the saved value is legitimately undefined; a\n // guarded restore would leave the suspension's MAX_SAFE_INTEGER ceiling\n // in place forever (hls.js prefers the seconds key over the count key),\n // silently disabling the drift ceiling for the rest of the session.\n hls.config.liveMaxLatencyDuration = this.savedLiveMaxLatency as number;\n hls.config.maxLiveSyncPlaybackRate = this.savedMaxSyncRate as number;\n }\n\n private emitEnded(reason: 'endlist' | 'stalled'): void {\n if (this.endedEmitted) return;\n this.endedEmitted = true;\n if (this.endWatchdog) {\n clearInterval(this.endWatchdog);\n this.endWatchdog = null;\n }\n this.deps.emit({ type: 'ended', reason });\n }\n\n stop(): void {\n if (this.endWatchdog) {\n clearInterval(this.endWatchdog);\n this.endWatchdog = null;\n }\n // A deliberate stop is not a wedge: drop any pending escalation so a\n // later resume can't rebuild on evidence from before the stop.\n this.kickPending = false;\n this.kickTicks = 0;\n // And it outranks the pause suspension: the `pause` this stop fires must\n // not arm a grace, and a later `play` on the ended element (the control\n // bar stays clickable) must not restart the loader.\n this.stopped = true;\n this.loadSuspended = false;\n if (this.pauseSuspendTimer) {\n clearTimeout(this.pauseSuspendTimer);\n this.pauseSuspendTimer = null;\n }\n try {\n this.hls?.stopLoad();\n } catch {\n /* ignore */\n }\n try {\n this.deps.video.pause();\n } catch {\n /* ignore */\n }\n }\n\n destroy(): void {\n this.clearRecoveryReset();\n if (this.capTimer) {\n clearTimeout(this.capTimer);\n this.capTimer = null;\n }\n if (this.capObserver) {\n this.capObserver.disconnect();\n this.capObserver = null;\n }\n this.unwatchDprChanges();\n if (this.endWatchdog) {\n clearInterval(this.endWatchdog);\n this.endWatchdog = null;\n }\n if (this.onVisibility && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this.onVisibility);\n this.onVisibility = null;\n }\n if (this.onSeeking) {\n this.deps.video.removeEventListener('seeking', this.onSeeking);\n this.onSeeking = null;\n }\n if (this.onPause) {\n this.deps.video.removeEventListener('pause', this.onPause);\n this.onPause = null;\n }\n if (this.onPlay) {\n this.deps.video.removeEventListener('play', this.onPlay);\n this.onPlay = null;\n }\n if (this.pauseSuspendTimer) {\n clearTimeout(this.pauseSuspendTimer);\n this.pauseSuspendTimer = null;\n }\n if (this.networkRetryTimer) {\n clearTimeout(this.networkRetryTimer);\n this.networkRetryTimer = null;\n }\n if (this.licenseRetryTimer) {\n clearTimeout(this.licenseRetryTimer);\n this.licenseRetryTimer = null;\n }\n if (this.hls) {\n try {\n this.hls.destroy();\n } catch (e) {\n // Surface a failed teardown rather than silently leaking the worker/EME session.\n console.warn('havik-player: hls.js destroy() threw during teardown', e);\n }\n this.hls = null;\n }\n }\n\n private clearRecoveryReset(): void {\n if (this.recoveryResetTimer) {\n clearTimeout(this.recoveryResetTimer);\n this.recoveryResetTimer = null;\n }\n }\n\n /**\n * A transient transport failure of the EME license (or service-certificate)\n * request — no response, timeout, 429, or 5xx — is recoverable: hls.js has no\n * license-retry policy and reports it as a fatal KEY_SYSTEM error, so we\n * rebuild the instance (a fresh key session re-issues the request), bounded\n * and backed off. A real auth/entitlement denial (401/403 or any other 4xx)\n * is NOT retried. Returns true iff a retry was scheduled.\n */\n private scheduleLicenseRetry(data: ErrorData): boolean {\n const requestFailure =\n data.details === Hls.ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED ||\n data.details === Hls.ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED;\n if (!requestFailure) return false;\n // 0 = network/CORS/timeout (the POST never got a response); 408/429/5xx are\n // transient server-side. Any other 4xx (esp. 401/403) is a real denial.\n const status = data.response?.code ?? 0;\n const transient = status === 0 || status === 408 || status === 429 || status >= 500;\n if (!transient || this.licenseRecoveries >= MAX_LICENSE_RECOVERIES) return false;\n this.licenseRecoveries += 1;\n const delay = Math.min(8000, 500 * 2 ** (this.licenseRecoveries - 1));\n this.licenseRetryTimer = setTimeout(() => {\n this.licenseRetryTimer = null;\n this.rebuildInstance('license retry'); // fresh key session re-issues the request\n }, delay);\n return true;\n }\n\n /**\n * Tear the hls.js instance down and build a fresh one, which is the only\n * recovery that reaches state the instance owns privately: destroy() runs\n * KeyLoader.detach(), and that is what discards a cached `keyInfo` whose\n * key-load promise will never settle (`isCommonEncryption` entries go even\n * when their session context is null — the wedged shape). Equivalent to the\n * page reload viewers resort to, minus the page.\n *\n * Deliberately not a stopLoad()/startLoad(): those keep the media element\n * attached and re-await the same cached promise.\n *\n * Callers own their own budget — this is the mechanism, not the policy.\n */\n private rebuildInstance(reason: string): void {\n if (!this.hls) return;\n try {\n this.hls.destroy();\n } catch (e) {\n console.warn(`havik-player: hls.js destroy() threw during ${reason}`, e);\n }\n this.hls = null;\n this.startHls();\n }\n\n private onError(data: ErrorData): void {\n // Counted BEFORE the non-fatal early return: hls.js retries a failed\n // segment internally, so every request error a viewer actually suffers\n // is invisible to the QoE plane unless it is tallied here.\n //\n // Fragment failures only. requestCount counts fragment loads, so\n // folding manifest and playlist failures in here would let a\n // manifest-flapping session report an error ratio above 100% against a\n // denominator those errors were never part of.\n if (\n data.type === Hls.ErrorTypes.NETWORK_ERROR &&\n String(data.details ?? '').startsWith('frag')\n ) {\n this.requestErrors += 1;\n }\n if (!data.fatal) return; // hls.js recovers non-fatal gaps internally\n const hls = this.hls;\n if (!hls) return;\n\n // A fatal error cancels any pending recovery-budget refund so a tight\n // recover-loop still trips the caps.\n this.clearRecoveryReset();\n\n // A transient transport failure of the license/cert request is recoverable\n // by rebuilding hls.js; a real auth/entitlement denial and other key-system\n // errors are not.\n if (data.type === Hls.ErrorTypes.KEY_SYSTEM_ERROR) {\n if (this.scheduleLicenseRetry(data)) return;\n this.deps.emit({\n type: 'error',\n error: this.toError(data, 'DRM'),\n fatal: true,\n detail: data.details,\n });\n return;\n }\n if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {\n // Transient origin/segment/license network failure: restart the loader with\n // exponential backoff, but cap retries so a persistently-failing origin\n // eventually surfaces a fatal error instead of restarting forever.\n if (this.networkRecoveries < MAX_NETWORK_RECOVERIES) {\n this.networkRecoveries += 1;\n const delay = Math.min(8000, 1000 * 2 ** (this.networkRecoveries - 1));\n this.networkRetryTimer = setTimeout(() => {\n this.networkRetryTimer = null;\n if (this.hls) this.hls.startLoad();\n }, delay);\n return;\n }\n this.deps.emit({\n type: 'error',\n error: this.toError(data, 'NETWORK'),\n fatal: true,\n detail: data.details,\n });\n return;\n }\n if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {\n if (this.mediaRecoveries < MAX_MEDIA_RECOVERIES) {\n this.mediaRecoveries += 1;\n hls.recoverMediaError();\n return;\n }\n this.deps.emit({\n type: 'error',\n error: this.toError(data),\n fatal: true,\n detail: data.details,\n });\n return;\n }\n this.deps.emit({ type: 'error', error: this.toError(data), fatal: true, detail: data.details });\n }\n\n private toError(data: ErrorData, kind = 'PLAYBACK'): PlaybackError {\n const status = data.response?.code ?? 0;\n const detail = data.details ?? data.type;\n const reason = data.reason ?? data.error?.message ?? '';\n const code =\n kind === 'DRM'\n ? classifyDrmStatus(status)\n : status === 401\n ? 'UNAUTHORIZED'\n : status === 403\n ? 'FORBIDDEN'\n : 'INTERNAL';\n return new PlaybackError(\n code,\n status,\n `${kind} error: ${detail}${reason ? ` (${reason})` : ''}`,\n );\n }\n}\n","// QoE beacon emitter — plan A2 (havik-streams docs/plans/havik-analytics-plan.md).\n//\n// One AnalyticsSession per playback session (per sid). Wire format is the\n// proto3 JSON mapping of `havik_analytics.BeaconBatch` (protocol-buffers\n// repo): camelCase keys, enums by NAME, and — the one contract landmine —\n// StateChange uses `fromState`/`toState` (`from` is a Python keyword; the\n// contract renamed the pair for every generator).\n//\n// Privacy posture (plan D11, asserted by test): the sid arrives in the\n// playback response, lives in this object, and dies with it. This module\n// never touches localStorage, sessionStorage, cookies, or IndexedDB.\n//\n// Delivery is fire-and-forget (plan D3/D9): beacons are dashboard-grade, the\n// CDN edge-log plane is the billing truth, so a lost batch is dropped —\n// never retried into a stampede, never allowed to affect playback.\n\nimport { type SessionEnvironment } from './environment';\nimport { type PlaybackStats, type PlayerState } from './stats';\n\n/** Beacon cadence — plan §9 settled at review time to 15s. */\nconst DEFAULT_HEARTBEAT_MS = 15_000;\n/**\n * How long a session may sit PAUSED before it closes itself, and the\n * suspension gap (no timer fired for this long: OS sleep, a frozen tab) after\n * which it does the same on waking.\n *\n * Without this a session had exactly four ends — pagehide, stream end,\n * destroy(), fatal error — so a viewer who paused and forgot the tab, closed\n * the lid, or had the tab discarded left a session \"still open\" for hours\n * (portal, 2026-09-19: paused heartbeats from 16:13 on, no end), and a slept\n * laptop booked its whole sleep as playing time on waking. Ten minutes is\n * longer than any pause that is still \"the same viewing\" (an ad break, a\n * phone call) and shorter than half-time; a viewer who comes back after it\n * starts a NEW session (the player reloads on the next play()), which is\n * what it is. Hidden-but-playing is NOT idle: audio keeps playing in a\n * background tab.\n */\nconst DEFAULT_IDLE_MS = 10 * 60_000;\n/**\n * The suspension detector's floor, whatever idleMs is. A gap between two of\n * our ticks is only a suspension when no legitimate cadence explains it: a\n * hidden tab's throttled timers gap to a minute (Chrome, after 5 min\n * hidden), and a host-configured idleMs below the heartbeat interval is a\n * PAUSE limit, not a licence to close a playing session on its first\n * ordinary heartbeat. Five minutes is above every throttled cadence seen and\n * below any sleep worth closing over.\n */\nconst MIN_SUSPENSION_GAP_MS = 5 * 60_000;\n/** Queue bound: past this the oldest non-lifecycle beacons drop first. */\nconst MAX_PENDING = 100;\n/** Startup stages report >=1ms when reached; 0 means never reached. */\nconst MIN_STAGE_MS = 1;\n/**\n * How long SessionStart waits for the autoplay verdict before going out.\n *\n * The contract puts the autoplay result on SessionStart, but the engine is\n * committed (and this session constructed) BEFORE play() is ever called, so\n * at enqueue time the verdict does not exist yet. Holding the first flush\n * for a beat lets the real value ride the beacon it belongs on; nothing is\n * lost if the verdict misses the window, and any earlier flush (an error,\n * end, pagehide) sends immediately regardless.\n */\nconst FIRST_FLUSH_DELAY_MS = 1_000;\n/** A stall or error this close to the end of a session is what the viewer\n * left over — beyond it, the two are unrelated events in one session. */\nconst ABANDON_WINDOW_MS = 30_000;\n/**\n * How long after a seek a stall is still the seek's.\n *\n * Was an inline 1_000 in causeOf; named because the rebuffer counters now\n * share it. A seek re-buffers from a new position, so the gap is bounded by\n * a segment fetch rather than by anything the viewer did — one second covers\n * it at any realistic segment duration without swallowing a genuine stall\n * that merely happens to follow a seek.\n */\nconst SEEK_STALL_WINDOW_MS = 1_000;\n\n/** Contract enum names (proto3 JSON encodes enums by name). */\ntype WirePlayer = 'PLAYER_HLSJS' | 'PLAYER_SAFARI_NATIVE';\ntype WireState =\n | 'PLAYER_STATE_UNSPECIFIED'\n | 'PLAYER_STATE_IDLE'\n | 'PLAYER_STATE_WAITING'\n | 'PLAYER_STATE_LOADING'\n | 'PLAYER_STATE_PLAYING'\n | 'PLAYER_STATE_BUFFERING'\n | 'PLAYER_STATE_PAUSED'\n | 'PLAYER_STATE_ENDED'\n | 'PLAYER_STATE_ERROR';\ntype WireKeySystem = 'KEY_SYSTEM_NONE' | 'KEY_SYSTEM_WIDEVINE' | 'KEY_SYSTEM_FAIRPLAY';\ntype WirePresentation = 'PRESENTATION_INLINE' | 'PRESENTATION_FULLSCREEN' | 'PRESENTATION_PIP';\ntype WireCause =\n | 'TRANSITION_CAUSE_USER'\n | 'TRANSITION_CAUSE_SEEK'\n | 'TRANSITION_CAUSE_NETWORK_STALL'\n | 'TRANSITION_CAUSE_ERROR'\n | 'TRANSITION_CAUSE_AUTO';\ntype WireErrorStage =\n | 'ERROR_STAGE_RESOLVE'\n | 'ERROR_STAGE_MANIFEST'\n | 'ERROR_STAGE_DRM'\n | 'ERROR_STAGE_FIRST_SEGMENT'\n | 'ERROR_STAGE_SEGMENT'\n | 'ERROR_STAGE_DECODE'\n | 'ERROR_STAGE_OTHER';\nexport type WireAutoplay =\n | 'AUTOPLAY_RESULT_ALLOWED'\n | 'AUTOPLAY_RESULT_ALLOWED_MUTED'\n | 'AUTOPLAY_RESULT_BLOCKED'\n | 'AUTOPLAY_RESULT_NOT_ATTEMPTED';\n/** Presentation the viewer is watching in, as the player reports it. */\nexport type Presentation = 'inline' | 'fullscreen' | 'pip';\nconst PRESENTATION_TO_WIRE: Record<Presentation, WirePresentation> = {\n inline: 'PRESENTATION_INLINE',\n fullscreen: 'PRESENTATION_FULLSCREEN',\n pip: 'PRESENTATION_PIP',\n};\n\ntype WireEndReason =\n | 'END_REASON_ENDED'\n | 'END_REASON_PAGEHIDE'\n | 'END_REASON_STOPPED'\n | 'END_REASON_FATAL_ERROR'\n | 'END_REASON_IDLE';\n\nconst KEY_SYSTEM_TO_WIRE: Record<'widevine' | 'fairplay' | 'none', WireKeySystem> = {\n widevine: 'KEY_SYSTEM_WIDEVINE',\n fairplay: 'KEY_SYSTEM_FAIRPLAY',\n none: 'KEY_SYSTEM_NONE',\n};\n\n/**\n * Nearest-rank percentile over an ascending slice; 0 for an empty one.\n *\n * Nearest rank, not interpolation: the value returned is one the session\n * actually observed. The rank is ceil(p/100 * N), so p50 of four samples\n * is the second, not the third — worth stating because the two conventions\n * differ only at exact boundaries and a future reader is otherwise likely\n * to \"fix\" one into the other.\n */\nfunction percentile(ascending: number[], p: number): number {\n if (ascending.length === 0) return 0;\n const rank = Math.min(ascending.length, Math.max(1, Math.ceil((p / 100) * ascending.length)));\n return ascending[rank - 1] ?? 0;\n}\n\n/** Codes raised by /v1/playback before any media is fetched. */\nconst RESOLVE_CODES = new Set([\n 'INVALID_URN',\n 'NOT_FOUND',\n 'GONE',\n 'TOO_EARLY',\n 'UNAVAILABLE',\n 'UNAUTHORIZED',\n 'FORBIDDEN',\n 'RATE_LIMITED',\n 'TIMEOUT',\n]);\n\nconst STATE_TO_WIRE: Record<PlayerState, WireState> = {\n idle: 'PLAYER_STATE_IDLE',\n waiting: 'PLAYER_STATE_WAITING',\n loading: 'PLAYER_STATE_LOADING',\n playing: 'PLAYER_STATE_PLAYING',\n buffering: 'PLAYER_STATE_BUFFERING',\n paused: 'PLAYER_STATE_PAUSED',\n ended: 'PLAYER_STATE_ENDED',\n error: 'PLAYER_STATE_ERROR',\n};\n\ninterface WireSessionStart extends Partial<SessionEnvironment> {\n player: WirePlayer;\n sdkVersion: string;\n autoplay?: WireAutoplay;\n}\n\ninterface WireBeacon {\n sid: string;\n clientTime: string;\n seq: number;\n sessionStart?: WireSessionStart;\n mediaInfo?: {\n videoCodec?: string;\n audioCodec?: string;\n keySystem?: WireKeySystem;\n drmSecurityLevel?: string;\n ladderHeights?: number[];\n ladderTopBitrateKbps?: number;\n lowLatency?: boolean;\n };\n heartbeat?: Record<string, number | string | boolean>;\n stateChange?: {\n fromState: WireState;\n toState: WireState;\n positionMs?: number;\n renditionHeight?: number;\n cause?: WireCause;\n presentation?: WirePresentation;\n };\n error?: {\n code: string;\n detail?: string;\n fatal?: boolean;\n httpStatus?: number;\n recoveryAttempts?: number;\n recovered?: boolean;\n positionMs?: number;\n stage?: WireErrorStage;\n cdnHost?: string;\n };\n sessionEnd?: Record<string, number | string>;\n}\n\n/** What the player hands the session at each heartbeat. */\nexport type AnalyticsSample = PlaybackStats & {\n state: PlayerState;\n positionMs: number;\n /** Forward buffer depth, which only the player can measure. */\n bufferedMs?: number;\n /** >1 means the engine is catching up toward the live edge. */\n playbackRate?: number;\n};\n\nexport interface AnalyticsSessionOptions {\n /** Beacons ingest base URL — POSTs go to `<endpoint>/v1/beacons`. */\n endpoint: string;\n /** The per-session id minted by havik-streams at resolve time. */\n sid: string;\n player: WirePlayer;\n sdkVersion: string;\n /**\n * Called at each heartbeat for the current cumulative stats; the session\n * computes per-interval deltas itself.\n */\n sample: () => AnalyticsSample;\n /**\n * Where the session runs (contract v2): form factor, device model, OS\n * version, screen, connection, embedding host. Collected once by the\n * player. Model and OS version come from UA client hints and are absent\n * on browsers that do not implement them; no IP and no raw user agent\n * ever leave the client (see core/environment).\n */\n environment?: SessionEnvironment;\n heartbeatMs?: number;\n /**\n * Idle limit; see DEFAULT_IDLE_MS. `0` disables the idle close entirely.\n * The suspension detector never fires below MIN_SUSPENSION_GAP_MS,\n * whatever this is set to.\n */\n idleMs?: number;\n /**\n * Called once, after the session ended itself for inactivity\n * (END_REASON_IDLE) — the player latches \"reload on the next play()\" on it,\n * because this sid is finished and whatever plays next is a new session.\n */\n onIdle?: () => void;\n /** Test seams. */\n now?: () => number;\n sendBeacon?: (url: string, body: string) => boolean;\n fetchFn?: typeof fetch;\n}\n\n/**\n * Derive the beacons ingest base from the streams API base by the platform\n * hostname convention (`feed[-dev].<domain>` → `beacons[-dev].<domain>`),\n * mirroring how the SSE endpoint derives `events.<domain>`. Returns\n * undefined when the convention doesn't apply — the caller must then pass\n * an explicit endpoint for beacons to flow.\n */\nexport function deriveBeaconsBaseUrl(baseUrl: string): string | undefined {\n try {\n const url = new URL(baseUrl);\n const [first, ...rest] = url.hostname.split('.');\n if (!first || rest.length === 0) return undefined;\n if (first !== 'feed' && !first.startsWith('feed-')) return undefined;\n url.hostname = [first.replace(/^feed/, 'beacons'), ...rest].join('.');\n url.pathname = '';\n url.search = '';\n return url.origin;\n } catch {\n return undefined;\n }\n}\n\n/** Causes already warned about, so each console line is printed once. */\nconst warnedBeaconCauses = new Set<string>();\n\n/** What {@link resolveBeaconsEndpoint} decided, and why. */\nexport interface BeaconsEndpointResolution {\n /** Where beacons go. Undefined means delivery is OFF for this session. */\n endpoint?: string;\n /**\n * Set whenever delivery is off for a reason the integrator can fix — a\n * ready-to-surface sentence naming the cause. Always populated in that case\n * (the caller emits it on every session, so a host that attaches a listener\n * late still hears about it); the console line it also triggers is deduped\n * per cause.\n */\n disabledReason?: string;\n}\n\n/** Disable delivery with a reason: said once on the console, returned always. */\nfunction beaconsDisabled(cause: string, reason: string): BeaconsEndpointResolution {\n if (!warnedBeaconCauses.has(cause)) {\n warnedBeaconCauses.add(cause);\n console.warn(`havik-player: ${reason}`);\n }\n return { disabledReason: reason };\n}\n\n/**\n * Decide where this session's beacons go, and make a silent disable loud.\n *\n * The derivation convention is deliberately narrow (see\n * {@link deriveBeaconsBaseUrl}), so an integrator fronting the streams API\n * with their own hostname — a CDN in front of `feed.<domain>`, say — gets no\n * endpoint and, before this, no beacons and no word of it. That failure is\n * invisible from the client: playback is perfect, only the analytics plane is\n * empty, and it stays empty until someone asks the warehouse why a client has\n * descriptor mints and zero sessions. It cost ~a week of QoE data for one\n * client in Sept 2026.\n *\n * Widening the convention is NOT the fix (a guessed beacons host is a silent\n * 404 instead of a silent nothing) — saying so is.\n */\nexport function resolveBeaconsEndpoint(\n baseUrl: string,\n explicitEndpoint?: string,\n): BeaconsEndpointResolution {\n if (explicitEndpoint) return { endpoint: explicitEndpoint };\n\n // Supplied but EMPTY — an unset env var, or a template that resolved to\n // nothing. Test truthiness, not `!== undefined`, but do not fall through to\n // derivation: the caller asked for a specific endpoint, and quietly\n // substituting a derived host would start delivery to somewhere they never\n // named. So this stays disabled, exactly as it was before this function\n // existed (`explicit ?? derive(...)` kept the '' and failed the `!endpoint`\n // check). What changes is that it no longer happens in silence.\n if (explicitEndpoint !== undefined) {\n return beaconsDisabled(\n 'empty-endpoint',\n `analytics is disabled: no QoE beacons will be sent. analytics.endpoint ` +\n `was supplied but empty. Pass a real beacons origin, ` +\n `analytics: { endpoint: 'https://beacons[-dev].<domain>' }, or omit the ` +\n `option to derive it from baseUrl (or analytics: false to opt out).`,\n );\n }\n\n const derived = deriveBeaconsBaseUrl(baseUrl);\n if (derived) return { endpoint: derived };\n\n // Name the host, not the whole baseUrl: it is the part the convention\n // rejected, and it cannot carry a query string into the console.\n let host: string;\n try {\n host = new URL(baseUrl).hostname || baseUrl;\n } catch {\n host = baseUrl; // not a URL at all — echo it back, that IS the problem\n }\n return beaconsDisabled(\n host,\n `analytics is disabled: no QoE beacons will be sent. The beacons endpoint ` +\n `cannot be derived from the baseUrl host \"${host}\" — the convention is ` +\n `feed[-dev].<domain> → beacons[-dev].<domain>. Pass an explicit endpoint, ` +\n `analytics: { endpoint: 'https://beacons[-dev].<domain>' }, to createPlayer ` +\n `(or analytics: false to opt out deliberately).`,\n );\n}\n\n/**\n * One playback session's QoE beacon emitter. Construct after engine\n * selection (player identity is only truthful then), call lifecycle hooks\n * from the player, and end() exactly once — later calls are no-ops.\n */\nexport class AnalyticsSession {\n private readonly opts: Required<Pick<AnalyticsSessionOptions, 'heartbeatMs' | 'idleMs'>> &\n AnalyticsSessionOptions;\n private readonly url: string;\n private readonly now: () => number;\n\n private seq = 0;\n private pending: WireBeacon[] = [];\n private timer: ReturnType<typeof setInterval> | null = null;\n private endedFlag = false;\n\n // Delta bases for the cumulative counters sampled at each heartbeat.\n private lastBytes = 0;\n private lastPlaylistBytes = 0;\n private lastDropped = 0;\n private lastSwitches = 0;\n\n // Wall-clock accounting: playing/hidden time accumulate between their\n // state flips; totals close out at end().\n private playingSinceMs: number | null = null;\n private hiddenSinceMs: number | null = null;\n private playingTotalMs = 0;\n private hiddenTotalMs = 0;\n private lastHbPlayingMs = 0;\n private lastHbHiddenMs = 0;\n private lastHbRebufferMs = 0;\n private lastHbRebufferCount = 0;\n private lastHbActiveMs = 0;\n\n // Session totals for SessionEnd.\n private rebufferCount = 0;\n private rebufferTotalMs = 0;\n private bufferingSinceMs: number | null = null;\n // Rebuffer, like active watch, is a visible-only window: a backgrounded tab\n // is throttled into 'buffering' and would otherwise book minutes of stall the\n // viewer never saw. isBuffering is the state; bufferingSinceMs is the open\n // window, held only while isBuffering && !isHidden (see syncBuffering).\n private isBuffering = false;\n private errorCount = 0;\n private seekCount = 0;\n private pauseCount = 0;\n\n // ---- Contract v2 accounting.\n // Active watch = playing AND visible AND unmuted. Kept as its own window\n // rather than derived at read time, because the three flip independently\n // and a server can only ever reconstruct a lower bound from the deltas.\n private isPlaying = false;\n private isHidden = false;\n private isMuted = false;\n private activeSinceMs: number | null = null;\n private activeTotalMs = 0;\n // Muted time is counted only WHILE PLAYING, so it is subtractable from\n // playing time; a muted paused tab is not watch time to discount.\n private mutedSinceMs: number | null = null;\n private mutedTotalMs = 0;\n private lastHbMutedMs = 0;\n private volumeChanges = 0;\n private muteChanges = 0;\n // Presentation: which surface the viewer watched in, and for how long.\n private presentation: Presentation = 'inline';\n private presentationSinceMs: number;\n private fullscreenTotalMs = 0;\n private pipTotalMs = 0;\n // Armed-before-live time: a viewer who waited five minutes for kickoff had\n // a different session from one who joined a running match.\n private waitingSinceMs: number | null = null;\n private waitingTotalMs = 0;\n // Startup and abandon signals.\n private firstAudioMs = 0;\n private timeToFirstStallMs = 0;\n private lastStallMs = 0;\n private lastErrorMs = 0;\n // −1 means \"no seek yet\": 0 is a real seek time when `now` starts at 0.\n private lastSeekMs = -1;\n /** True while the CURRENT stall is the seek's rather than the network's. */\n private stallIsSeekInduced = false;\n private lastErrorStage: WireErrorStage | null = null;\n private sawFirstBytes = false;\n private mediaInfoSignature = '';\n // Delta bases for the v2 cumulative counters.\n private lastUpshifts = 0;\n private lastDownshifts = 0;\n private lastDecoded = 0;\n private lastRequestCount = 0;\n private lastRequestErrors = 0;\n // The un-flushed SessionStart, held briefly so the autoplay verdict can\n // land on it (see FIRST_FLUSH_DELAY_MS).\n private pendingStart: WireSessionStart | null = null;\n private firstFlushTimer: ReturnType<typeof setTimeout> | null = null;\n // Idle close (see DEFAULT_IDLE_MS): the pause timer, and the last instant a\n // timer of ours actually ran — the suspension detector's reference.\n private idleTimer: ReturnType<typeof setTimeout> | null = null;\n private lastTickMs = 0;\n\n // Startup breakdown (0 = stage never reached, plan contract).\n private readonly bornMs: number;\n /**\n * What the startup ladder measures FROM: the moment the viewer asked for\n * playback, not the moment the player was constructed.\n *\n * Defaults to bornMs, which is correct for autoplay — intent and\n * construction coincide there. It moves once, on the first play(), for the\n * autoplay-off path, where the player is built with the manifest parsed and\n * then sits behind a poster for as long as the viewer takes to click. That\n * dwell was being reported as startup LATENCY: a viewer who read the page\n * for thirty seconds produced a thirty-second \"time to first frame\", and\n * ops' own help text has always described the figure this now computes\n * (\"from the player being asked to play to the first frame\").\n *\n * Every stage shares it, or they stop summing: the ladder is\n * manifest -> first segment -> first frame measured from one origin.\n */\n private startupAnchorMs: number;\n private startupTimeMs = 0;\n private manifestLoadMs = 0;\n private firstFragLoadMs = 0;\n\n private readonly onPagehide = () => this.end('END_REASON_PAGEHIDE');\n /**\n * Page Lifecycle `freeze`: the browser is about to stop running this page\n * (a backgrounded tab after ~5 min in Chrome, ahead of a discard). Nothing\n * runs after it — a discard fires no pagehide — so what is queued goes out\n * now, by sendBeacon, the only delivery a stopping page guarantees. Not an\n * end: the page may `resume`, and the suspension check there decides.\n */\n private readonly onFreeze = () => {\n if (this.endedFlag) return;\n this.flush(true);\n };\n /** Page Lifecycle `resume`: the page runs again; was it away past the idle limit? */\n private readonly onResume = () => this.checkSuspension(this.now());\n private readonly onVisibility = () => {\n if (typeof document === 'undefined') return;\n if (document.visibilityState === 'hidden') {\n if (this.hiddenSinceMs === null) this.hiddenSinceMs = this.now();\n this.isHidden = true;\n } else {\n if (this.hiddenSinceMs !== null) {\n this.hiddenTotalMs += this.now() - this.hiddenSinceMs;\n this.hiddenSinceMs = null;\n }\n this.isHidden = false;\n }\n this.syncActive();\n this.syncBuffering();\n };\n\n constructor(opts: AnalyticsSessionOptions) {\n // ?? at the use point, not object spread: a caller passing an explicit\n // `heartbeatMs: undefined` must not clobber the default into a 0ms\n // busy-loop interval.\n this.opts = {\n ...opts,\n heartbeatMs: opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,\n idleMs: opts.idleMs ?? DEFAULT_IDLE_MS,\n };\n this.url = `${opts.endpoint.replace(/\\/$/, '')}/v1/beacons`;\n this.now = opts.now ?? (() => Date.now());\n this.bornMs = this.now();\n // Not a startup stage: presentation time is fullscreen/PiP accounting and\n // is measured over the session's whole life, so it keeps bornMs.\n this.presentationSinceMs = this.bornMs;\n this.startupAnchorMs = this.bornMs;\n }\n\n /** Emit SessionStart (always the session's first beacon) and arm timers. */\n start(): void {\n if (this.endedFlag) return;\n const sessionStart: WireSessionStart = {\n player: this.opts.player,\n sdkVersion: this.opts.sdkVersion,\n ...this.opts.environment,\n };\n this.pendingStart = sessionStart;\n this.enqueue({ sessionStart });\n // Not flushed here: the autoplay verdict does not exist yet. See\n // FIRST_FLUSH_DELAY_MS — noteAutoplay, an error, a heartbeat or end all\n // send earlier if they happen first.\n this.firstFlushTimer = setTimeout(() => this.flush(), FIRST_FLUSH_DELAY_MS);\n this.lastTickMs = this.now();\n this.timer = setInterval(() => this.heartbeat(), this.opts.heartbeatMs);\n if (typeof window !== 'undefined') {\n window.addEventListener('pagehide', this.onPagehide);\n }\n if (typeof document !== 'undefined') {\n document.addEventListener('visibilitychange', this.onVisibility);\n document.addEventListener('freeze', this.onFreeze);\n document.addEventListener('resume', this.onResume);\n this.onVisibility(); // seed hidden accounting if born in a hidden tab\n }\n }\n\n /**\n * Did the page stop running for at least the idle limit? Timers do not\n * fire through OS sleep or a frozen tab, so the gap between two of our own\n * ticks is the measure: a heartbeat that finds the clock jumped past the\n * limit is the first tick after a suspension, and the session it wakes\n * into is over — nothing was watched, and every open accounting window\n * (playing, active, muted…) would otherwise book the whole gap. Ends as\n * IDLE; the next play() starts a new session.\n */\n private checkSuspension(t: number): boolean {\n if (this.endedFlag || !this.opts.idleMs) return false;\n const gap = Math.max(this.opts.idleMs, MIN_SUSPENSION_GAP_MS);\n if (this.lastTickMs > 0 && t - this.lastTickMs >= gap) {\n this.endIdle(this.lastTickMs);\n return true;\n }\n this.lastTickMs = t;\n return false;\n }\n\n /** Arm (paused) or disarm (anything else) the idle close. */\n private syncIdle(state: PlayerState): void {\n if (state === 'paused' && this.opts.idleMs) {\n if (this.idleTimer === null) {\n this.idleTimer = setTimeout(() => {\n this.idleTimer = null;\n this.endIdle();\n }, this.opts.idleMs);\n }\n } else if (this.idleTimer !== null) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n }\n\n private endIdle(at?: number): void {\n if (this.endedFlag) return;\n this.end('END_REASON_IDLE', at);\n this.opts.onIdle?.();\n }\n\n /** Player state transition — also drives rebuffer/watch-time accounting. */\n noteStateChange(\n from: PlayerState,\n to: PlayerState,\n positionMs: number,\n renditionHeight?: number,\n ): void {\n if (this.endedFlag || from === to) return;\n const t = this.now();\n\n if (to === 'playing') {\n if (this.playingSinceMs === null) this.playingSinceMs = t;\n if (this.startupTimeMs === 0) {\n this.startupTimeMs = Math.max(MIN_STAGE_MS, t - this.startupAnchorMs);\n }\n if (this.firstAudioMs === 0 && !this.isMuted) {\n // The closest a browser gets to \"first audible frame\": playback\n // running with the element neither muted nor at zero volume.\n this.firstAudioMs = Math.max(MIN_STAGE_MS, t - this.startupAnchorMs);\n }\n if (this.isMuted && this.mutedSinceMs === null) this.mutedSinceMs = t;\n } else if (this.playingSinceMs !== null) {\n this.playingTotalMs += t - this.playingSinceMs;\n this.playingSinceMs = null;\n if (this.mutedSinceMs !== null) {\n this.mutedTotalMs += t - this.mutedSinceMs;\n this.mutedSinceMs = null;\n }\n }\n this.isPlaying = to === 'playing';\n this.syncActive();\n\n // Time spent armed before the match went live, closed on the way out of\n // 'waiting' so it never includes the playback that followed.\n if (to === 'waiting') this.waitingSinceMs = t;\n else if (this.waitingSinceMs !== null) {\n this.waitingTotalMs += t - this.waitingSinceMs;\n this.waitingSinceMs = null;\n }\n\n if (to === 'buffering' && from === 'playing') {\n // Two kinds of stall are not the delivery path's fault and are excluded\n // from every stall figure:\n //\n // hidden — a backgrounded tab is throttled into 'buffering'; the\n // viewer saw nothing.\n // seek — the buffering that follows a seek is the seek's. The\n // state machine is driven by media events that carry no\n // reason, so this is inferred, and causeOf has always\n // inferred it for the LABEL; now the counters agree.\n //\n // (A stall that begins visible and later goes hidden is still one\n // stall: the count runs once here; syncBuffering only pauses its time.)\n this.stallIsSeekInduced = this.seekInduced(t);\n const counts = !this.isHidden && !this.stallIsSeekInduced;\n if (counts) this.rebufferCount += 1;\n this.isBuffering = true;\n this.lastStallMs = t;\n // Gated identically: \"time to first stall\" is a delivery figure, and a\n // seek is not a delivery event. Leaving it ungated would let a session\n // report a first stall it never counted.\n if (counts && this.timeToFirstStallMs === 0) {\n // Playback delivered before the first interruption — a session that\n // stalls at 3s and one that stalls at 30 minutes read identically\n // through the stall count alone.\n this.timeToFirstStallMs = Math.max(MIN_STAGE_MS, Math.round(this.playingTotalMs));\n }\n } else if (from === 'buffering') {\n this.isBuffering = false;\n this.stallIsSeekInduced = false;\n }\n // Open/close the rebuffer window against the new buffering state; visibility\n // changes come through onVisibility, which also calls syncBuffering.\n this.syncBuffering();\n if (to === 'paused') this.pauseCount += 1;\n this.syncIdle(to);\n\n const change: NonNullable<WireBeacon['stateChange']> = {\n fromState: STATE_TO_WIRE[from] ?? 'PLAYER_STATE_UNSPECIFIED',\n toState: STATE_TO_WIRE[to] ?? 'PLAYER_STATE_UNSPECIFIED',\n presentation: PRESENTATION_TO_WIRE[this.presentation],\n };\n const cause = this.causeOf(from, to, t);\n if (cause) change.cause = cause;\n if (positionMs > 0) change.positionMs = Math.round(positionMs);\n if (renditionHeight) change.renditionHeight = renditionHeight;\n this.enqueue({ stateChange: change });\n }\n\n /**\n * Was the stall starting at `t` the seek's rather than the network's?\n *\n * ONE definition, shared by the transition cause and by the rebuffer\n * accounting, because they are the same question: if the label said SEEK\n * while the counter said network stall, the beacon would contradict itself\n * inside a single session.\n *\n * The `lastSeekMs >= 0` guard is load-bearing, not defensive. Without it,\n * any stall within the first second of a clock that starts near zero reads\n * as seek-induced — true of an injected `now`, and the first stall of a\n * session is exactly the one a join-time analysis cares about most. The\n * \"no seek yet\" sentinel is −1 rather than 0 because 0 is a real seek\n * time on such a clock: a seek at the epoch must still own the\n * buffering that follows it.\n */\n private seekInduced(t: number): boolean {\n return this.lastSeekMs >= 0 && t - this.lastSeekMs < SEEK_STALL_WINDOW_MS;\n }\n\n /**\n * Why a transition happened, as far as the player can tell. Inferred, not\n * reported: the state machine is driven by media events that carry no\n * reason, so buffering right after a seek is the seek's, and buffering\n * otherwise is the network running dry — which is what the state means.\n */\n private causeOf(from: PlayerState, to: PlayerState, t: number): WireCause | undefined {\n if (to === 'error') return 'TRANSITION_CAUSE_ERROR';\n if (to === 'buffering') {\n return this.seekInduced(t) ? 'TRANSITION_CAUSE_SEEK' : 'TRANSITION_CAUSE_NETWORK_STALL';\n }\n if (to === 'paused' || (to === 'playing' && from === 'paused')) {\n return 'TRANSITION_CAUSE_USER';\n }\n return undefined;\n }\n\n /** Open or close the active-watch window after any of its three inputs moved. */\n private syncActive(at = this.now()): void {\n const on = this.isPlaying && !this.isHidden && !this.isMuted;\n if (on && this.activeSinceMs === null) this.activeSinceMs = at;\n else if (!on && this.activeSinceMs !== null) {\n this.activeTotalMs += at - this.activeSinceMs;\n this.activeSinceMs = null;\n }\n }\n\n /**\n * Open or close the rebuffer window after buffering or visibility moved.\n * Mirrors syncActive: a hidden tab is throttled into 'buffering', and that\n * stall time is not something the viewer experienced, so rebuffer time\n * accrues only while isBuffering && !isHidden.\n */\n private syncBuffering(): void {\n const on = this.isBuffering && !this.isHidden && !this.stallIsSeekInduced;\n if (on && this.bufferingSinceMs === null) this.bufferingSinceMs = this.now();\n else if (!on && this.bufferingSinceMs !== null) {\n this.rebufferTotalMs += this.now() - this.bufferingSinceMs;\n this.bufferingSinceMs = null;\n }\n }\n\n /** Bank the time spent in the current presentation and restart the clock. */\n private closePresentation(t = this.now()): void {\n const spent = t - this.presentationSinceMs;\n if (this.presentation === 'fullscreen') this.fullscreenTotalMs += spent;\n else if (this.presentation === 'pip') this.pipTotalMs += spent;\n this.presentationSinceMs = t;\n }\n\n /**\n * The autoplay verdict, once play() has resolved (or was never attempted).\n * Lands on SessionStart when it arrives inside the first-flush window,\n * which is the normal case; a slow manifest can miss it, and the field is\n * then simply absent rather than wrong.\n */\n noteAutoplay(result: WireAutoplay): void {\n if (this.pendingStart) this.pendingStart.autoplay = result;\n }\n\n /**\n * Environment facts that only resolve asynchronously (the high-entropy UA\n * client hints). Same one-shot window as the autoplay verdict: patched\n * onto SessionStart while it is still held, ignored once it has gone.\n */\n noteEnvironment(extra: Partial<SessionEnvironment>): void {\n if (this.pendingStart) Object.assign(this.pendingStart, extra);\n }\n\n /**\n * The audio state a session STARTS in. Counts no change: a player that\n * begins muted (the autoplay-friendly default) has had nothing done to\n * it, and reporting that as a mute change would put a phantom 1 on every\n * session and destroy the \"did the viewer touch the volume\" signal.\n */\n seedVolume(muted: boolean, volume: number): void {\n if (this.endedFlag) return;\n this.isMuted = muted || volume === 0;\n if (this.isMuted && this.isPlaying && this.mutedSinceMs === null) {\n this.mutedSinceMs = this.now();\n }\n this.syncActive();\n }\n\n /** Volume or mute changed on the media element. */\n noteVolume(muted: boolean, volume: number): void {\n if (this.endedFlag) return;\n const silent = muted || volume === 0;\n if (silent === this.isMuted) {\n // Same audibility, different level: a volume change, not a mute.\n this.volumeChanges += 1;\n return;\n }\n this.muteChanges += 1;\n const t = this.now();\n if (silent) {\n // Only while playing: a muted pause is not discountable watch time.\n if (this.isPlaying && this.mutedSinceMs === null) this.mutedSinceMs = t;\n } else {\n if (this.mutedSinceMs !== null) {\n this.mutedTotalMs += t - this.mutedSinceMs;\n this.mutedSinceMs = null;\n }\n // Unmuting mid-playback is when a muted-autoplay session first\n // becomes audible — the only moment that stage can be observed for\n // the dominant startup path, which no state change reports.\n if (this.firstAudioMs === 0 && this.isPlaying) {\n this.firstAudioMs = Math.max(MIN_STAGE_MS, t - this.startupAnchorMs);\n }\n }\n this.isMuted = silent;\n this.syncActive();\n }\n\n /** Fullscreen / picture-in-picture / inline transition. */\n notePresentation(next: Presentation): void {\n if (this.endedFlag || next === this.presentation) return;\n this.closePresentation();\n this.presentation = next;\n }\n\n /**\n * The viewer asked for playback. Re-anchors the startup ladder here, once.\n *\n * Only the autoplay-off path calls this, and only on the first play(): an\n * autoplay session already has intent at construction, and a SECOND call\n * after playback began would rewrite a finished measurement.\n *\n * A stage that already closed is re-stamped PRE-PAID rather than left\n * alone or allowed to go negative. In the deferred path the engine parses\n * the manifest while fetching no media, so manifestLoadMs is genuinely\n * already done when the viewer clicks — it cost them nothing, and\n * MIN_STAGE_MS is how this contract says \"reached, immeasurably fast\".\n * Leaving the old value would keep the poster dwell inside the ladder by\n * another route.\n */\n notePlayIntent(): void {\n if (this.endedFlag || this.startupTimeMs > 0) return;\n this.startupAnchorMs = this.now();\n if (this.manifestLoadMs > 0) this.manifestLoadMs = MIN_STAGE_MS;\n if (this.firstFragLoadMs > 0) this.firstFragLoadMs = MIN_STAGE_MS;\n }\n\n /** Engine 'ready' — closes the manifest-load startup stage. */\n noteReady(): void {\n if (this.manifestLoadMs === 0) {\n this.manifestLoadMs = Math.max(MIN_STAGE_MS, this.now() - this.startupAnchorMs);\n }\n }\n\n /** First media bytes observed — closes the first-segment startup stage. */\n noteFirstBytes(): void {\n this.sawFirstBytes = true;\n if (this.firstFragLoadMs === 0) {\n this.firstFragLoadMs = Math.max(MIN_STAGE_MS, this.now() - this.startupAnchorMs);\n }\n }\n\n noteSeek(): void {\n if (this.endedFlag) return;\n this.seekCount += 1;\n // Remembered so the buffering that usually follows is attributed to the\n // seek rather than reported as a network stall.\n this.lastSeekMs = this.now();\n }\n\n /** Playback error (code from src/core/errors.ts — strings, no lockstep). */\n noteError(\n code: string,\n detail: string | undefined,\n fatal: boolean,\n httpStatus: number,\n positionMs: number,\n /** The ENGINE's own name for the failure, when it supplied one. */\n engineDetail?: string,\n ): void {\n if (this.endedFlag) return;\n this.errorCount += 1;\n this.lastErrorMs = this.now();\n const err: NonNullable<WireBeacon['error']> = { code };\n if (detail) err.detail = detail;\n if (fatal) err.fatal = true;\n if (httpStatus > 0) err.httpStatus = httpStatus;\n if (positionMs > 0) err.positionMs = Math.round(positionMs);\n const stage = this.stageOf(code, detail, engineDetail);\n err.stage = stage;\n this.lastErrorStage = stage;\n const host = this.safeSample()?.cdnHost;\n if (host) err.cdnHost = host;\n this.enqueue({ error: err });\n // Errors shouldn't wait up to 15s to surface fleet-wide.\n this.flush();\n }\n\n /**\n * Which step of the pipeline an error interrupted. Resolve-time codes come\n * from the playback API before any media is touched; the rest are read off\n * the engine's own detail string, which is why the contract carries the\n * stage as an enum instead of leaving every reader to parse that string.\n */\n private stageOf(\n code: string,\n detail: string | undefined,\n engineDetail: string | undefined,\n ): WireErrorStage {\n // The ENGINE's own name for the failure when it gave one. The player's\n // message is composed prose — \"NETWORK error: fragLoadError (...)\" —\n // and staging by pattern-matching that prose is how the single most\n // common fatal error, a fragment load giving up, was staged as OTHER:\n // no anchored pattern can match a string that opens with the error\n // KIND. It also settles DRM: a licence 401 arrives with the code\n // UNAUTHORIZED, which is indistinguishable from a resolve failure by\n // code alone, but the engine calls it keyLoadError.\n const engine = engineDetail ?? '';\n if (engine !== '') {\n // fragLOAD only: a fragment that arrived and would not parse\n // (fragParsingError, fragDecryptError) is a decode failure, not a\n // delivery one, and staging it as a segment problem would send\n // someone to look at the CDN for a codec bug.\n if (/^fragLoad/i.test(engine)) {\n return this.sawFirstBytes ? 'ERROR_STAGE_SEGMENT' : 'ERROR_STAGE_FIRST_SEGMENT';\n }\n if (/^(manifest|level|audioTrack|subtitleTrack)/i.test(engine)) {\n return 'ERROR_STAGE_MANIFEST';\n }\n if (/key|licen[cs]e/i.test(engine)) return 'ERROR_STAGE_DRM';\n if (/parsing|append|buffer|remux|decrypt|media/i.test(engine)) return 'ERROR_STAGE_DECODE';\n return 'ERROR_STAGE_OTHER';\n }\n\n // No engine detail: the failure did not come from a running engine, so\n // the resolve stage is reachable here and nowhere else.\n if (code === 'DRM_CLIENT') return 'ERROR_STAGE_DRM';\n if (RESOLVE_CODES.has(code)) return 'ERROR_STAGE_RESOLVE';\n const d = detail ?? '';\n if (/frag|segment/i.test(d)) {\n return this.sawFirstBytes ? 'ERROR_STAGE_SEGMENT' : 'ERROR_STAGE_FIRST_SEGMENT';\n }\n if (/manifest|level|playlist/i.test(d)) return 'ERROR_STAGE_MANIFEST';\n if (/key|licen[cs]e/i.test(d)) return 'ERROR_STAGE_DRM';\n if (/buffer|decode|media|append/i.test(d)) return 'ERROR_STAGE_DECODE';\n return 'ERROR_STAGE_OTHER';\n }\n\n /** The 'ended' player state maps here with reason ENDED. */\n noteEnded(): void {\n this.end('END_REASON_ENDED');\n }\n\n /** Player torn down by the embedding page. */\n noteDestroyed(): void {\n this.end('END_REASON_STOPPED');\n }\n\n /** Fatal error path. */\n noteFatal(): void {\n this.end('END_REASON_FATAL_ERROR');\n }\n\n private heartbeat(): void {\n if (this.endedFlag) return;\n const t = this.now();\n // The first tick after a suspension ends the session instead of\n // reporting an interval that spans the whole gap.\n if (this.checkSuspension(t)) return;\n const s = this.opts.sample();\n\n // Close open accounting windows into the totals without ending them.\n let playing = this.playingTotalMs;\n if (this.playingSinceMs !== null) playing += t - this.playingSinceMs;\n let hidden = this.hiddenTotalMs;\n if (this.hiddenSinceMs !== null) hidden += t - this.hiddenSinceMs;\n // Stall, per interval. bufferingSinceMs is opened and closed by\n // syncBuffering, which holds it ONLY while the stall is visible and not\n // seek-induced — so both exclusions come for free here and, more to the\n // point, cannot drift from the session totals that share that window.\n // A stall spanning two heartbeats is split across them and counted once.\n let rebuffer = this.rebufferTotalMs;\n if (this.bufferingSinceMs !== null) rebuffer += t - this.bufferingSinceMs;\n // Active watch (playing AND visible AND unmuted), per interval, off the\n // window syncActive already maintains. MEASURED, not derived: the server\n // has been reconstructing this as playing - hidden - muted, and those\n // three overlap, so an interval that was both hidden and muted is\n // subtracted twice. The overlap is not recoverable after the fact, which\n // is why it has to be observed here rather than computed there.\n let active = this.activeTotalMs;\n if (this.activeSinceMs !== null) active += t - this.activeSinceMs;\n\n const hb: Record<string, number | string | boolean> = {};\n const bytes = s.bytesLoaded ?? 0;\n if (bytes > this.lastBytes) hb.bytesDownloadedDelta = bytes - this.lastBytes;\n this.lastBytes = bytes;\n const pl = s.playlistBytesLoaded ?? 0;\n if (pl > this.lastPlaylistBytes) hb.playlistBytesDelta = pl - this.lastPlaylistBytes;\n this.lastPlaylistBytes = pl;\n const dropped = s.droppedFrames ?? 0;\n if (dropped > this.lastDropped) hb.droppedFramesDelta = dropped - this.lastDropped;\n this.lastDropped = dropped;\n const switches = s.renditionSwitches ?? 0;\n if (switches > this.lastSwitches) hb.renditionSwitchesDelta = switches - this.lastSwitches;\n this.lastSwitches = switches;\n if (playing > this.lastHbPlayingMs)\n hb.playingMsDelta = Math.round(playing - this.lastHbPlayingMs);\n this.lastHbPlayingMs = playing;\n if (hidden > this.lastHbHiddenMs) hb.hiddenMsDelta = Math.round(hidden - this.lastHbHiddenMs);\n this.lastHbHiddenMs = hidden;\n if (rebuffer > this.lastHbRebufferMs) {\n hb.rebufferMsDelta = Math.round(rebuffer - this.lastHbRebufferMs);\n }\n this.lastHbRebufferMs = rebuffer;\n // The COUNT is already gated on the same two exclusions where it is\n // incremented, so this is a plain watermark diff. A stall that opens in\n // one interval and closes in a later one counts in the interval it\n // OPENED, which is where the viewer felt it.\n if (this.rebufferCount > this.lastHbRebufferCount) {\n hb.rebufferCountDelta = this.rebufferCount - this.lastHbRebufferCount;\n }\n this.lastHbRebufferCount = this.rebufferCount;\n // Deltas cover FULL heartbeat intervals only — end() emits no final\n // heartbeat, so a sub-interval tail after the last tick is carried by\n // the sessionEnd totals alone. sum(activeMsDelta) therefore reconciles\n // with activeWatchTimeMs through the last tick; the remainder is always\n // < heartbeatMs. Same policy as every other per-interval figure above.\n if (active > this.lastHbActiveMs) hb.activeMsDelta = Math.round(active - this.lastHbActiveMs);\n this.lastHbActiveMs = active;\n\n if (typeof s.latencySeconds === 'number') hb.latencyMs = Math.round(s.latencySeconds * 1000);\n if (typeof s.targetLatencySeconds === 'number') {\n hb.targetLatencyMs = Math.round(s.targetLatencySeconds * 1000);\n }\n if (typeof s.bandwidthKbps === 'number') hb.bandwidthEstKbps = s.bandwidthKbps;\n if (typeof s.levelHeight === 'number') hb.renditionHeight = s.levelHeight;\n if (typeof s.renditionBitrateKbps === 'number') {\n hb.renditionBitrateKbps = s.renditionBitrateKbps;\n }\n if (s.atLiveEdge === true) hb.atLiveEdge = true;\n if (typeof s.bufferedMs === 'number' && s.bufferedMs > 0) {\n hb.bufferedMs = Math.round(s.bufferedMs);\n }\n hb.playerState = STATE_TO_WIRE[s.state] ?? 'PLAYER_STATE_UNSPECIFIED';\n\n // ---- Contract v2.\n let muted = this.mutedTotalMs;\n if (this.mutedSinceMs !== null) muted += t - this.mutedSinceMs;\n if (muted > this.lastHbMutedMs) hb.mutedMsDelta = Math.round(muted - this.lastHbMutedMs);\n this.lastHbMutedMs = muted;\n\n const up = s.upshifts ?? 0;\n if (up > this.lastUpshifts) hb.upshiftsDelta = up - this.lastUpshifts;\n this.lastUpshifts = up;\n const down = s.downshifts ?? 0;\n if (down > this.lastDownshifts) hb.downshiftsDelta = down - this.lastDownshifts;\n this.lastDownshifts = down;\n const decoded = s.decodedFrames ?? 0;\n if (decoded > this.lastDecoded) hb.decodedFramesDelta = decoded - this.lastDecoded;\n this.lastDecoded = decoded;\n const reqErrors = s.requestErrors ?? 0;\n if (reqErrors > this.lastRequestErrors) {\n hb.requestErrorsDelta = reqErrors - this.lastRequestErrors;\n }\n this.lastRequestErrors = reqErrors;\n\n // Request timing: the engine keeps a ring of recent durations and a\n // cumulative count, so the tail this interval added is exactly the\n // slice to summarize — no per-request beacon, no unbounded array.\n const reqCount = s.requestCount ?? 0;\n const reqDelta = reqCount - this.lastRequestCount;\n this.lastRequestCount = reqCount;\n if (reqDelta > 0) {\n hb.requestCountDelta = reqDelta;\n const times = s.requestTimesMs ?? [];\n const recent = times.slice(Math.max(0, times.length - reqDelta)).sort((a, b) => a - b);\n if (recent.length > 0) {\n hb.requestTimeP50Ms = percentile(recent, 50);\n hb.requestTimeP95Ms = percentile(recent, 95);\n }\n }\n\n if (typeof s.latencySeconds === 'number' && typeof s.targetLatencySeconds === 'number') {\n // Signed: behind the target is positive, ahead of it negative.\n hb.driftMs = Math.round((s.latencySeconds - s.targetLatencySeconds) * 1000);\n }\n if (typeof s.playbackRate === 'number' && s.playbackRate > 0) {\n hb.playbackRate = s.playbackRate;\n }\n if (s.qualityPinned === true) hb.qualityPinned = true;\n if (s.cdnHost) hb.cdnHost = s.cdnHost;\n\n this.maybeEmitMediaInfo(s);\n this.enqueue({ heartbeat: hb });\n this.flush();\n\n if (bytes > 0) this.noteFirstBytes();\n }\n\n /**\n * MediaInfo once per session, as soon as the engine has negotiated what it\n * will play. SessionStart cannot carry this: it is emitted at engine\n * selection, before the manifest is parsed or a DRM session exists.\n */\n private maybeEmitMediaInfo(s: AnalyticsSample): void {\n const known = s.videoCodec ?? s.keySystem ?? s.ladderHeights;\n if (!known) return;\n const info: NonNullable<WireBeacon['mediaInfo']> = {};\n if (s.videoCodec) info.videoCodec = s.videoCodec;\n if (s.audioCodec) info.audioCodec = s.audioCodec;\n if (s.keySystem) info.keySystem = KEY_SYSTEM_TO_WIRE[s.keySystem];\n if (s.drmSecurityLevel) info.drmSecurityLevel = s.drmSecurityLevel;\n if (s.ladderHeights?.length) info.ladderHeights = s.ladderHeights;\n if (s.ladderTopBitrateKbps) info.ladderTopBitrateKbps = s.ladderTopBitrateKbps;\n if (s.lowLatency) info.lowLatency = true;\n // The contract says MediaInfo is emitted again if the negotiated facts\n // change (an audio-track switch changes the codec; a rebuilt engine can\n // change the ladder), so this compares rather than firing once. A\n // session whose media never changes still sends exactly one.\n const signature = JSON.stringify(info);\n if (signature === this.mediaInfoSignature) return;\n this.mediaInfoSignature = signature;\n this.enqueue({ mediaInfo: info });\n }\n\n /**\n * End the session exactly once: totals beacon + final flush. On pagehide\n * the flush rides navigator.sendBeacon — the only delivery a closing page\n * guarantees.\n */\n end(reason: WireEndReason, at?: number): void {\n if (this.endedFlag) return;\n this.endedFlag = true;\n // `at`: the instant the session is closed AS OF. Only the suspension\n // path passes one — the last tick before the page stopped — so a slept\n // laptop closes every open window (playing, active, muted, hidden) there\n // and books none of the sleep.\n const t = at ?? this.now();\n if (this.playingSinceMs !== null) {\n this.playingTotalMs += t - this.playingSinceMs;\n this.playingSinceMs = null;\n }\n if (this.hiddenSinceMs !== null) {\n this.hiddenTotalMs += t - this.hiddenSinceMs;\n this.hiddenSinceMs = null;\n }\n if (this.bufferingSinceMs !== null) {\n this.rebufferTotalMs += t - this.bufferingSinceMs;\n this.bufferingSinceMs = null;\n }\n if (this.mutedSinceMs !== null) {\n this.mutedTotalMs += t - this.mutedSinceMs;\n this.mutedSinceMs = null;\n }\n if (this.waitingSinceMs !== null) {\n this.waitingTotalMs += t - this.waitingSinceMs;\n this.waitingSinceMs = null;\n }\n this.isPlaying = false;\n // At `t`, like every window above: the active-watch and presentation\n // windows are the two that read the clock themselves.\n this.syncActive(t);\n this.closePresentation(t);\n if (this.firstFlushTimer) {\n clearTimeout(this.firstFlushTimer);\n this.firstFlushTimer = null;\n }\n if (this.timer) {\n clearInterval(this.timer);\n this.timer = null;\n }\n if (this.idleTimer) {\n clearTimeout(this.idleTimer);\n this.idleTimer = null;\n }\n if (typeof window !== 'undefined') {\n window.removeEventListener('pagehide', this.onPagehide);\n }\n if (typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', this.onVisibility);\n document.removeEventListener('freeze', this.onFreeze);\n document.removeEventListener('resume', this.onResume);\n }\n\n const s = this.safeSample();\n // A session that ends before its first heartbeat — an early error, a\n // quick bounce, a short preview — would otherwise never report what it\n // was playing, even though the engine has known since the manifest\n // parsed.\n if (s) this.maybeEmitMediaInfo(s);\n const endTotals: Record<string, number | string> = {\n reason,\n };\n const bytes = s?.bytesLoaded ?? this.lastBytes;\n if (bytes > 0) endTotals.bytesDownloadedTotal = bytes;\n if (this.playingTotalMs > 0) endTotals.watchTimeMs = Math.round(this.playingTotalMs);\n if (this.rebufferCount > 0) endTotals.rebufferCount = this.rebufferCount;\n if (this.rebufferTotalMs > 0) endTotals.rebufferTimeMs = Math.round(this.rebufferTotalMs);\n if (this.startupTimeMs > 0) endTotals.startupTimeMs = Math.round(this.startupTimeMs);\n if (this.manifestLoadMs > 0) endTotals.manifestLoadMs = Math.round(this.manifestLoadMs);\n if (this.firstFragLoadMs > 0) endTotals.firstFragLoadMs = Math.round(this.firstFragLoadMs);\n const license = s?.licenseTimeMs ?? 0;\n if (license > 0) endTotals.licenseTimeMs = Math.round(license);\n const dropped = s?.droppedFrames ?? this.lastDropped;\n if (dropped > 0) endTotals.droppedFramesTotal = dropped;\n if (this.errorCount > 0) endTotals.errorCount = this.errorCount;\n if (this.hiddenTotalMs > 0) endTotals.hiddenTimeMs = Math.round(this.hiddenTotalMs);\n if (this.seekCount > 0) endTotals.seekCount = this.seekCount;\n if (this.pauseCount > 0) endTotals.pauseCount = this.pauseCount;\n\n // ---- Contract v2 totals.\n if (this.firstAudioMs > 0) endTotals.firstAudioMs = Math.round(this.firstAudioMs);\n if (this.waitingTotalMs > 0) endTotals.waitingForLiveMs = Math.round(this.waitingTotalMs);\n if (this.timeToFirstStallMs > 0) {\n endTotals.timeToFirstStallMs = Math.round(this.timeToFirstStallMs);\n }\n const exit = s?.positionMs ?? 0;\n if (exit > 0) endTotals.exitPositionMs = Math.round(exit);\n if (this.activeTotalMs > 0) endTotals.activeWatchTimeMs = Math.round(this.activeTotalMs);\n if (this.mutedTotalMs > 0) endTotals.mutedTimeMs = Math.round(this.mutedTotalMs);\n // Abandon cohorts: only when the session actually ended NEAR the event,\n // so an unrelated stall an hour earlier never reads as an abandonment.\n // Not on a clean end: a viewer who stalled, recovered and watched to\n // the finish has not abandoned anything.\n const abandoned = reason !== 'END_REASON_ENDED';\n if (abandoned && this.lastStallMs > 0 && t - this.lastStallMs <= ABANDON_WINDOW_MS) {\n endTotals.abandonAfterStallMs = Math.max(MIN_STAGE_MS, Math.round(t - this.lastStallMs));\n }\n if (abandoned && this.lastErrorMs > 0 && t - this.lastErrorMs <= ABANDON_WINDOW_MS) {\n endTotals.abandonAfterErrorMs = Math.max(MIN_STAGE_MS, Math.round(t - this.lastErrorMs));\n }\n if (this.fullscreenTotalMs > 0) {\n endTotals.fullscreenTimeMs = Math.round(this.fullscreenTotalMs);\n }\n if (this.pipTotalMs > 0) endTotals.pipTimeMs = Math.round(this.pipTotalMs);\n if (this.volumeChanges > 0) endTotals.volumeChanges = this.volumeChanges;\n if (this.muteChanges > 0) endTotals.muteChanges = this.muteChanges;\n const decodedTotal = s?.decodedFrames ?? this.lastDecoded;\n if (decodedTotal > 0) endTotals.decodedFramesTotal = decodedTotal;\n // Only for a session that never played: which stage it died at. The last\n // error's stage when there was one, otherwise the first startup stage\n // that never closed.\n if (this.startupTimeMs === 0) {\n endTotals.failedStage =\n this.lastErrorStage ??\n (this.manifestLoadMs === 0\n ? 'ERROR_STAGE_MANIFEST'\n : this.firstFragLoadMs === 0\n ? 'ERROR_STAGE_FIRST_SEGMENT'\n : 'ERROR_STAGE_OTHER');\n }\n\n this.pending.push(this.wrap({ sessionEnd: endTotals }));\n this.flush(reason === 'END_REASON_PAGEHIDE');\n }\n\n private safeSample(): AnalyticsSample | null {\n try {\n return this.opts.sample();\n } catch {\n return null; // engine already torn down — totals fall back to last-seen\n }\n }\n\n private wrap(event: Omit<WireBeacon, 'sid' | 'clientTime' | 'seq'>): WireBeacon {\n this.seq += 1;\n return {\n sid: this.opts.sid,\n clientTime: new Date(this.now()).toISOString(),\n seq: this.seq,\n ...event,\n };\n }\n\n private enqueue(event: Omit<WireBeacon, 'sid' | 'clientTime' | 'seq'>): void {\n this.pending.push(this.wrap(event));\n while (this.pending.length > MAX_PENDING) {\n // Evict the oldest routine beacon (heartbeat/state change) first;\n // lifecycle and error beacons carry the session's story and only go\n // when nothing routine is left to shed.\n const i = this.pending.findIndex((b) => b.heartbeat || b.stateChange);\n this.pending.splice(i >= 0 ? i : 0, 1);\n }\n }\n\n private flush(useSendBeacon = false): void {\n if (this.firstFlushTimer) {\n clearTimeout(this.firstFlushTimer);\n this.firstFlushTimer = null;\n }\n this.pendingStart = null;\n if (this.pending.length === 0) return;\n // The batch carries the beacons and nothing else. No api key rides a\n // beacon in any form — not as a header, not in the envelope: the\n // ingest has no origin gate to resolve it against (the origin was\n // enforced by havik-streams when it minted the sid, and the ingest's\n // reads join the mints), and a publishable key is never an identity.\n // What identifies the batch is the sid on every beacon.\n const body = JSON.stringify({ beacons: this.pending });\n this.pending = [];\n\n if (useSendBeacon) {\n // Deliberately a STRING body: sendBeacon with a string posts\n // text/plain, which is CORS-safelisted — no preflight, and a\n // preflight never completes on an unloading page. A Blob typed\n // application/json would trigger one and lose the final batch. The\n // ingest parses the body regardless of Content-Type by design (D9).\n const send =\n this.opts.sendBeacon ??\n (typeof navigator !== 'undefined' && navigator.sendBeacon\n ? navigator.sendBeacon.bind(navigator)\n : undefined);\n if (send && send(this.url, body)) return;\n // sendBeacon refused (quota) — fall through to keepalive fetch, which\n // may or may not complete on an unloading page; acceptable loss.\n }\n const doFetch = this.opts.fetchFn ?? (typeof fetch !== 'undefined' ? fetch : undefined);\n if (!doFetch) return;\n void doFetch(this.url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body,\n keepalive: true,\n }).catch(() => {\n // Fire-and-forget: a failed batch is dropped by design.\n });\n }\n}\n","// Session environment for the QoE plane (contract v2, SessionStart): the\n// IP-free facts about where the player runs, read once at session start.\n//\n// Model and OS version come from UA client hints ONLY — the high-entropy\n// values a browser is willing to hand over, on request. The raw user agent\n// string is never read or sent from here: the ingest already derives\n// browser family and major from the transport header and drops it (plan\n// D11), and parsing it a second time in the client would put a\n// fingerprint-grade string on the wire to no benefit. On a browser without\n// client hints (Safari, Firefox) both fields are simply absent.\n\nexport type DeviceClass =\n | 'DEVICE_CLASS_PHONE'\n | 'DEVICE_CLASS_TABLET'\n | 'DEVICE_CLASS_DESKTOP'\n | 'DEVICE_CLASS_TV'\n | 'DEVICE_CLASS_OTHER';\nexport type ConnectionType =\n | 'CONNECTION_TYPE_UNKNOWN'\n | 'CONNECTION_TYPE_WIFI'\n | 'CONNECTION_TYPE_CELLULAR'\n | 'CONNECTION_TYPE_ETHERNET'\n | 'CONNECTION_TYPE_OTHER';\n/**\n * The generation of the access network, separate from the bearer above.\n *\n * The web can only ever report the ESTIMATED_ half. `effectiveType` is not\n * a radio reading: it names the cellular generation whose typical\n * performance the connection RESEMBLES, derived from observed rtt and\n * downlink. A fast WiFi link reports '4g', which is exactly why these\n * values may not share a value space with the measured ones the native\n * SDKs send — merged, every Chrome-desktop WiFi session would be charted as\n * cellular traffic that does not exist.\n *\n * There is no ESTIMATED_5G: the API is specified to top out at '4g'. That\n * is a ceiling, not a mapping — a congested 5G link reports '3g', because\n * the value follows throughput rather than the radio.\n */\nexport type RadioAccess =\n | 'RADIO_ACCESS_ESTIMATED_SLOW_2G'\n | 'RADIO_ACCESS_ESTIMATED_2G'\n | 'RADIO_ACCESS_ESTIMATED_3G'\n | 'RADIO_ACCESS_ESTIMATED_4G';\nexport type IntegrationKind = 'INTEGRATION_KIND_SDK' | 'INTEGRATION_KIND_IFRAME';\n\n/**\n * Where the player is embedded, and how.\n *\n * Not simply location.origin: inside an iframe that is the PLAYER's own\n * origin, which is the same constant for every hosted-embed session and\n * answers a question nobody asked. The embedding page is what a customer\n * means by \"where\", so a framed player reports the top-level ancestor —\n * from ancestorOrigins where the browser has it, else the referrer's\n * origin — and says so with INTEGRATION_KIND_IFRAME.\n *\n * A cross-origin embedder that reveals neither leaves the host empty\n * rather than substituting the player's own origin: a wrong attribution\n * is worse than a missing one.\n */\nfunction embedding(): { host?: string; integration: IntegrationKind } {\n if (typeof window === 'undefined') return { integration: 'INTEGRATION_KIND_SDK' };\n let framed: boolean;\n try {\n framed = window.top !== window.self;\n } catch {\n // A cross-origin ancestor can make even the comparison throw, and that\n // it threw is itself proof of being framed.\n framed = true;\n }\n const own = window.location?.origin;\n if (!framed) {\n return { host: own && own !== 'null' ? own : undefined, integration: 'INTEGRATION_KIND_SDK' };\n }\n let host: string | undefined;\n try {\n const ancestors = (window.location as Location & { ancestorOrigins?: DOMStringList })\n .ancestorOrigins;\n // The LAST entry is the top-level document — the page the viewer is\n // actually on; [0] would be an intermediate wrapper frame.\n if (ancestors && ancestors.length > 0) host = ancestors[ancestors.length - 1] ?? undefined;\n if (!host && typeof document !== 'undefined' && document.referrer) {\n host = new URL(document.referrer).origin;\n }\n } catch {\n host = undefined;\n }\n if (host === 'null') host = undefined;\n return { host, integration: 'INTEGRATION_KIND_IFRAME' };\n}\n\nexport interface SessionEnvironment {\n deviceClass: DeviceClass;\n /** Marketing model name, e.g. \"Pixel 8\". Empty on desktop and wherever\n * client hints are unavailable. */\n deviceModel?: string;\n /** Platform version as the browser reports it, e.g. \"14.0.0\". */\n osVersion?: string;\n screenWidth?: number;\n screenHeight?: number;\n devicePixelRatio?: number;\n viewportWidth?: number;\n viewportHeight?: number;\n connectionType: ConnectionType;\n /** The generation, INDEPENDENT of connectionType: Chrome on Android sets\n * both, Chrome on desktop sets only this one, Safari and Firefox set\n * neither. Absent when the browser reports no effectiveType. */\n radioAccess?: RadioAccess;\n downlinkKbps?: number;\n /** Round-trip estimate the browser reports, beside downlinkKbps. Absent\n * when unreported; never sent as 0, which would read as \"measured, and\n * instant\". */\n rttMs?: number;\n saveData?: boolean;\n /** The page the player is embedded in: the document's own origin when the\n * SDK runs top-level, the TOP-LEVEL ancestor when it runs framed. Absent\n * when a cross-origin embedder reveals neither. */\n host?: string;\n integration: IntegrationKind;\n}\n\ninterface UAData {\n mobile?: boolean;\n platform?: string;\n getHighEntropyValues?: (hints: string[]) => Promise<{ model?: string; platformVersion?: string }>;\n}\ninterface NetInfo {\n type?: string;\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n}\n\n/** Coarse form factor from UA client hints where they exist, else from the viewport. */\nfunction deviceClass(): DeviceClass {\n if (typeof navigator === 'undefined') return 'DEVICE_CLASS_OTHER';\n const ua = (navigator as Navigator & { userAgentData?: UAData }).userAgentData;\n const platform = ua?.platform ?? '';\n if (/tv|tizen|webos/i.test(platform)) return 'DEVICE_CLASS_TV';\n const shortSide = typeof screen !== 'undefined' ? Math.min(screen.width, screen.height) : 0;\n const mobile =\n ua?.mobile ??\n (typeof navigator.maxTouchPoints === 'number' &&\n navigator.maxTouchPoints > 1 &&\n shortSide > 0 &&\n shortSide < 900);\n if (mobile) return shortSide >= 600 ? 'DEVICE_CLASS_TABLET' : 'DEVICE_CLASS_PHONE';\n if (ua || shortSide >= 900 || /mac|win|linux|chrome os/i.test(platform))\n return 'DEVICE_CLASS_DESKTOP';\n return 'DEVICE_CLASS_OTHER';\n}\n\n/**\n * The Network Information API, read as TWO independent facts.\n *\n * `type` is the bearer and `effectiveType` is the generation estimate, and\n * a browser may report either, both, or neither — they are not fallbacks\n * for one another. Chrome on Android sets both; Chrome on DESKTOP sets\n * `effectiveType`, `downlink` and `rtt` but leaves `type` undefined; Safari\n * and Firefox implement none of it. Reading only `type` therefore threw\n * away a populated reading on every Chrome-desktop session and reported\n * CONNECTION_TYPE_UNKNOWN beside a perfectly good downlink figure.\n *\n * The two are mapped into two fields rather than one: a WiFi session whose\n * effectiveType is '4g' is WIFI and ESTIMATED_4G, never 4G. See RadioAccess.\n */\nfunction connection(): Pick<\n SessionEnvironment,\n 'connectionType' | 'radioAccess' | 'downlinkKbps' | 'rttMs' | 'saveData'\n> {\n const c =\n typeof navigator !== 'undefined'\n ? (navigator as Navigator & { connection?: NetInfo }).connection\n : undefined;\n if (!c) return { connectionType: 'CONNECTION_TYPE_UNKNOWN' };\n const map: Record<string, ConnectionType> = {\n wifi: 'CONNECTION_TYPE_WIFI',\n cellular: 'CONNECTION_TYPE_CELLULAR',\n ethernet: 'CONNECTION_TYPE_ETHERNET',\n };\n // The four values the spec defines, and only those: an unrecognised\n // string is left ABSENT rather than mapped to a nearest neighbour, so a\n // future spec value cannot silently land in the wrong bucket.\n const radio: Record<string, RadioAccess> = {\n 'slow-2g': 'RADIO_ACCESS_ESTIMATED_SLOW_2G',\n '2g': 'RADIO_ACCESS_ESTIMATED_2G',\n '3g': 'RADIO_ACCESS_ESTIMATED_3G',\n '4g': 'RADIO_ACCESS_ESTIMATED_4G',\n };\n // Own-property lookups, not a bare index. `navigator.connection` is\n // replaceable by the embedding page, so these strings are not guaranteed\n // to be the spec's — and a bare `radio['constructor']` resolves to an\n // INHERITED property of Object.prototype, which is truthy. That would put\n // a non-enum value on the beacon and break the rule directly above: an\n // unrecognised reading stays ABSENT.\n //\n // hasOwnProperty.call, not Object.hasOwn: the global build targets\n // firefox91 and safari15 (vite.global.config.ts), and Object.hasOwn needs\n // Firefox 92 / Safari 15.4. Vite downlevels syntax but does not polyfill\n // built-in methods, so it would be undefined there — and the page that\n // defines its own navigator.connection, which is the whole reason for\n // this guard, is exactly the case that would then throw out of session\n // start.\n const own = <T>(table: Record<string, T>, key: string): T | undefined =>\n Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;\n const out: ReturnType<typeof connection> = {\n connectionType: c.type\n ? (own(map, c.type) ?? 'CONNECTION_TYPE_OTHER')\n : 'CONNECTION_TYPE_UNKNOWN',\n };\n if (c.effectiveType) {\n const r = own(radio, c.effectiveType);\n if (r) out.radioAccess = r;\n }\n if (typeof c.downlink === 'number' && c.downlink > 0)\n out.downlinkKbps = Math.round(c.downlink * 1000);\n // Same guard as downlink: an unreported rtt must stay absent, not become\n // a measured zero.\n if (typeof c.rtt === 'number' && c.rtt > 0) out.rttMs = Math.round(c.rtt);\n if (c.saveData) out.saveData = true;\n return out;\n}\n\nexport function collectEnvironment(video: HTMLVideoElement): SessionEnvironment {\n const where = embedding();\n const env: SessionEnvironment = {\n deviceClass: deviceClass(),\n integration: where.integration,\n ...connection(),\n };\n if (where.host) env.host = where.host;\n if (typeof screen !== 'undefined' && screen.width > 0) {\n env.screenWidth = screen.width;\n env.screenHeight = screen.height;\n }\n if (typeof window !== 'undefined' && window.devicePixelRatio > 0) {\n env.devicePixelRatio = Math.round(window.devicePixelRatio * 100) / 100;\n }\n const rect = video.getBoundingClientRect?.();\n if (rect && rect.width > 0) {\n env.viewportWidth = Math.round(rect.width);\n env.viewportHeight = Math.round(rect.height);\n }\n return env;\n}\n\n/**\n * The two high-entropy hints, which the API only exposes asynchronously.\n * Resolves from data the browser already holds, so it settles in well under\n * the window SessionStart waits before it goes out; a browser that declines\n * or lacks the API yields an empty object and the fields stay absent.\n */\nexport async function collectHighEntropy(): Promise<\n Pick<SessionEnvironment, 'deviceModel' | 'osVersion'>\n> {\n const ua =\n typeof navigator !== 'undefined'\n ? (navigator as Navigator & { userAgentData?: UAData }).userAgentData\n : undefined;\n if (!ua?.getHighEntropyValues) return {};\n try {\n const v = await ua.getHighEntropyValues(['model', 'platformVersion']);\n const out: Pick<SessionEnvironment, 'deviceModel' | 'osVersion'> = {};\n if (v.model) out.deviceModel = v.model;\n if (v.platformVersion) out.osVersion = v.platformVersion;\n return out;\n } catch {\n return {};\n }\n}\n","// SDK version for the analytics SessionStart beacon (build-time injected).\n//\n// `__HAVIK_SDK_VERSION__` is defined by the vite configs from\n// npm_package_version at build time; `typeof` on an undeclared identifier is\n// safe, so test runners and consumers that bypass the define get 'dev'.\n// Fidelity note: the value is only as good as package.json at build time —\n// semantic-release stamps it during the release pipeline, local builds say\n// whatever the placeholder version is.\ndeclare const __HAVIK_SDK_VERSION__: string | undefined;\n\nexport const SDK_VERSION: string =\n typeof __HAVIK_SDK_VERSION__ === 'string' ? __HAVIK_SDK_VERSION__ : 'dev';\n","// Player theming. The control bar is styled entirely from CSS custom properties\n// (--havik-*), so a host can re-skin it by passing a `theme` to createPlayer OR\n// by overriding the variables in their own stylesheet targeting `.havik-player`.\n\nexport interface HavikTheme {\n /** Primary accent (play button, progress fill, live dot, focus). */\n accent: string;\n /** Text/icon color that sits ON the accent (e.g. on the play button). */\n accentText: string;\n /** Letterbox / behind-video background. */\n background: string;\n /** Control bar + menu surface color. */\n surface: string;\n /** Hover/active surface color. */\n surfaceMuted: string;\n /** Primary text/icon color. */\n text: string;\n /** Secondary text color (timestamps, inactive). */\n textMuted: string;\n /** Hairline borders. */\n border: string;\n /** Corner radius for buttons/menus. */\n radius: string;\n /** Font stack for control-bar text. */\n fontFamily: string;\n /** Optional logo shown as a small corner watermark (URL). */\n logoUrl?: string;\n}\n\n/**\n * Default skin — Oddin.gg brand identity: gold accent on dark navy, white text.\n */\nexport const ODDIN_THEME: HavikTheme = {\n accent: '#E1B600',\n accentText: '#1B1C23',\n background: '#0e0f13',\n surface: '#1B1C23',\n surfaceMuted: '#2a2c36',\n text: '#ffffff',\n textMuted: '#9aa0ad',\n border: 'rgba(255, 255, 255, 0.1)',\n radius: '8px',\n fontFamily:\n \"ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif\",\n};\n\nconst VAR: Record<keyof Omit<HavikTheme, 'logoUrl'>, string> = {\n accent: '--havik-accent',\n accentText: '--havik-accent-text',\n background: '--havik-bg',\n surface: '--havik-surface',\n surfaceMuted: '--havik-surface-muted',\n text: '--havik-text',\n textMuted: '--havik-text-muted',\n border: '--havik-border',\n radius: '--havik-radius',\n fontFamily: '--havik-font',\n};\n\n/** Apply a (possibly partial) theme to a player root element as CSS variables. */\nexport function applyTheme(root: HTMLElement, theme?: Partial<HavikTheme>): void {\n const merged: HavikTheme = { ...ODDIN_THEME, ...theme };\n for (const key of Object.keys(VAR) as Array<keyof typeof VAR>) {\n root.style.setProperty(VAR[key], merged[key]);\n }\n if (merged.logoUrl) root.style.setProperty('--havik-logo', `url(\"${merged.logoUrl}\")`);\n}\n","// Control-bar styles, injected once. Every color/radius/font reads from a\n// --havik-* CSS variable (set by applyTheme), so the whole skin is themeable.\n\nconst STYLE_ID = 'havik-player-styles';\n\nconst CSS = `\n.havik-player{position:relative;display:block;width:100%;background:var(--havik-bg,#0e0f13);\n color:var(--havik-text,#fff);font-family:var(--havik-font,system-ui,sans-serif);\n overflow:hidden;line-height:1.4;-webkit-tap-highlight-color:transparent}\n.havik-player video{display:block;width:100%;height:auto;aspect-ratio:16/9;\n object-fit:contain;background:#000;outline:none}\n.havik-player:focus{outline:none}\n.havik-player *{box-sizing:border-box}\n\n.havik-player:fullscreen,.havik-player:-webkit-full-screen{display:flex;align-items:center;\n justify-content:center;width:100vw;height:100vh;aspect-ratio:auto}\n.havik-player:fullscreen video,.havik-player:-webkit-full-screen video{width:100%;height:100%;\n aspect-ratio:auto;object-fit:contain}\n\n.havik-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;cursor:pointer}\n.havik-gradient{position:absolute;left:0;right:0;bottom:0;height:120px;pointer-events:none;\n background:linear-gradient(to top,rgba(0,0,0,.78),transparent);\n opacity:1;transition:opacity .25s}\n.havik-player.havik-hide .havik-gradient,\n.havik-player.havik-hide .havik-bar{opacity:0;pointer-events:none}\n.havik-player.havik-hide{cursor:none}\n\n.havik-center{width:74px;height:74px;border-radius:50%;border:0;cursor:pointer;\n background:var(--havik-accent,#E1B600);color:var(--havik-accent-text,#1B1C23);\n display:flex;align-items:center;justify-content:center;\n box-shadow:0 6px 24px rgba(0,0,0,.45);transition:transform .15s,opacity .15s}\n.havik-center:hover{transform:scale(1.06)}\n.havik-center svg{width:38px;height:38px;margin-left:2px}\n.havik-center.havik-gone{opacity:0;pointer-events:none;transform:scale(.8)}\n\n.havik-spinner{width:56px;height:56px;border-radius:50%;\n border:4px solid rgba(255,255,255,.2);border-top-color:var(--havik-accent,#E1B600);\n animation:havik-spin .8s linear infinite}\n@keyframes havik-spin{to{transform:rotate(360deg)}}\n\n/* Status card — branded full-bleed overlay for the stopped/pre-play states\n (ended / fatal error / pre-play / armed-waiting). */\n.havik-status{position:absolute;inset:0;display:flex;flex-direction:column;gap:16px;\n align-items:center;justify-content:center;background:rgba(14,15,19,.82);text-align:center;padding:24px}\n.havik-status__logo{width:128px;height:46px;background:var(--havik-logo) center/contain no-repeat;opacity:.95}\n.havik-status__msg{color:var(--havik-text,#fff);font-size:15px;font-weight:600;max-width:480px}\n.havik-status__btn{display:inline-flex;align-items:center;gap:8px;border:0;cursor:pointer;\n padding:10px 18px;border-radius:var(--havik-radius,8px);font-weight:700;font-size:14px;\n background:var(--havik-accent,#E1B600);color:var(--havik-accent-text,#1B1C23);transition:transform .15s}\n.havik-status__btn:hover{transform:scale(1.04)}\n.havik-status__btn svg{width:18px;height:18px}\n/* Pre-play: lighter scrim so the poster shows through, and a round play CTA. */\n.havik-status--preplay{background:rgba(14,15,19,.4)}\n.havik-status--preplay .havik-status__btn--play{width:74px;height:74px;border-radius:50%;padding:0;\n justify-content:center;box-shadow:0 6px 24px rgba(0,0,0,.45)}\n.havik-status--preplay .havik-status__btn--play svg{width:38px;height:38px;margin-left:3px}\n\n.havik-watermark{position:absolute;top:12px;right:14px;height:22px;opacity:.85;pointer-events:none;\n content:'';background:var(--havik-logo) center/contain no-repeat;width:90px}\n/* A status card carries its own logo — the corner watermark would duplicate it. */\n.havik-player.havik-has-status .havik-watermark{display:none}\n\n.havik-bar{position:absolute;left:0;right:0;bottom:0;padding:0 12px 8px;\n opacity:1;transition:opacity .25s;z-index:2}\n.havik-seek{position:relative;height:16px;display:flex;align-items:center;cursor:pointer;margin-bottom:2px}\n.havik-seek__track{position:relative;width:100%;height:4px;border-radius:3px;background:rgba(255,255,255,.25)}\n.havik-seek__buffered{position:absolute;left:0;top:0;height:100%;border-radius:3px;background:rgba(255,255,255,.4)}\n.havik-seek__played{position:absolute;left:0;top:0;height:100%;border-radius:3px;background:var(--havik-accent,#E1B600)}\n.havik-seek__thumb{position:absolute;top:50%;width:13px;height:13px;border-radius:50%;\n background:var(--havik-accent,#E1B600);transform:translate(-50%,-50%) scale(0);transition:transform .1s}\n.havik-seek:hover .havik-seek__thumb{transform:translate(-50%,-50%) scale(1)}\n\n.havik-controls{display:flex;align-items:center;gap:6px;height:40px}\n.havik-spacer{flex:1}\n.havik-btn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;\n border:0;background:transparent;color:var(--havik-text,#fff);cursor:pointer;border-radius:var(--havik-radius,8px);\n padding:0;transition:background .12s,color .12s}\n.havik-btn:hover{background:rgba(255,255,255,.12)}\n.havik-btn:focus-visible{outline:2px solid var(--havik-accent,#E1B600);outline-offset:1px}\n.havik-btn svg{width:22px;height:22px}\n\n/* The volume level is filled in accent up to the thumb, like the seek bar's\n played span — a native range input paints no \"elapsed\" side, which left the\n level readable only from the dot's position. --havik-vol (0..1) is set by\n syncVolume(); the track width and thumb size live here as variables because\n the WebKit fill has to compute the thumb centre from both. */\n.havik-vol{display:flex;align-items:center}\n.havik-vol__slider{--havik-vol-w:70px;--havik-vol-thumb:12px;--havik-vol:1;\n --havik-vol-pos:calc(var(--havik-vol-thumb) / 2 + var(--havik-vol) * (var(--havik-vol-w) - var(--havik-vol-thumb)));\n width:0;opacity:0;transition:width .18s,opacity .18s;height:4px;cursor:pointer;\n appearance:none;-webkit-appearance:none;background:transparent;border-radius:3px;margin:0 4px}\n.havik-vol:hover .havik-vol__slider,.havik-vol:focus-within .havik-vol__slider{width:var(--havik-vol-w);opacity:1}\n/* WebKit/Blink have no progress pseudo-element: paint the fill as a hard-stop\n gradient that ends at the thumb centre. */\n.havik-vol__slider::-webkit-slider-runnable-track{height:4px;border-radius:3px;\n background:linear-gradient(to right,var(--havik-accent,#E1B600) var(--havik-vol-pos),\n rgba(255,255,255,.3) var(--havik-vol-pos))}\n/* Firefox fills natively, and tracks the thumb exactly. */\n.havik-vol__slider::-moz-range-track{height:4px;border-radius:3px;background:rgba(255,255,255,.3)}\n.havik-vol__slider::-moz-range-progress{height:4px;border-radius:3px;background:var(--havik-accent,#E1B600)}\n.havik-vol__slider::-webkit-slider-thumb{-webkit-appearance:none;width:var(--havik-vol-thumb);\n height:var(--havik-vol-thumb);border-radius:50%;background:var(--havik-accent,#E1B600);\n margin-top:calc((4px - var(--havik-vol-thumb)) / 2)}\n.havik-vol__slider::-moz-range-thumb{width:var(--havik-vol-thumb);height:var(--havik-vol-thumb);\n border:0;border-radius:50%;background:var(--havik-accent,#E1B600)}\n\n.havik-time{font-size:12px;color:var(--havik-text-muted,#9aa0ad);\n font-variant-numeric:tabular-nums;padding:0 6px;white-space:nowrap}\n.havik-live{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;\n letter-spacing:.4px;padding:0 8px;color:var(--havik-text,#fff)}\n.havik-live svg{width:9px;height:9px;color:var(--havik-text-muted,#9aa0ad)}\n.havik-live.is-live svg{color:var(--havik-accent,#E1B600)}\n.havik-live__go{margin-left:2px;border:0;cursor:pointer;background:transparent;color:var(--havik-text-muted,#9aa0ad);\n font:inherit;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 6px;border-radius:6px}\n.havik-live.is-live .havik-live__go{display:none}\n.havik-live__go:hover{color:var(--havik-accent,#E1B600)}\n\n.havik-menu{position:absolute;bottom:54px;right:12px;min-width:170px;max-height:230px;overflow:auto;\n background:var(--havik-surface,#1B1C23);border:1px solid var(--havik-border,rgba(255,255,255,.1));\n border-radius:var(--havik-radius,8px);box-shadow:0 10px 30px rgba(0,0,0,.5);padding:6px;z-index:3}\n.havik-menu[hidden]{display:none}\n.havik-menu__title{font-size:11px;text-transform:uppercase;letter-spacing:.6px;\n color:var(--havik-text-muted,#9aa0ad);padding:6px 8px 4px}\n.havik-menu__item{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;\n border:0;background:transparent;color:var(--havik-text,#fff);cursor:pointer;font:inherit;font-size:13px;\n text-align:left;padding:7px 8px;border-radius:6px}\n.havik-menu__item:hover{background:var(--havik-surface-muted,#2a2c36)}\n.havik-menu__item[aria-checked=\"true\"]{color:var(--havik-accent,#E1B600);font-weight:600}\n.havik-menu__item[aria-checked=\"true\"]::after{content:'✓'}\n`;\n\nexport function injectStyles(doc: Document = document): void {\n if (doc.getElementById(STYLE_ID)) return;\n const style = doc.createElement('style');\n style.id = STYLE_ID;\n style.textContent = CSS;\n doc.head.appendChild(style);\n}\n","// Compact inline SVG icons for the control bar (currentColor-filled, 24x24).\nexport const ICONS = {\n play: '<path d=\"M8 5v14l11-7z\"/>',\n pause: '<path d=\"M6 5h4v14H6zM14 5h4v14h-4z\"/>',\n volumeHigh:\n '<path d=\"M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zm-2.5-9v2.06A7 7 0 0 1 14 19v2a9 9 0 0 0 0-18z\"/>',\n volumeMute:\n '<path d=\"M3 10v4h4l5 5V5L7 10H3zm16.5 2 2.5-2.5-1.4-1.4L18 10.6 15.4 8 14 9.4l2.6 2.6L14 14.6l1.4 1.4 2.6-2.6 2.6 2.6 1.4-1.4z\"/>',\n cc: '<path d=\"M19 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zM10 11H8.5v-.5h-2v3h2V13H10v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H17v1a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1z\"/>',\n settings:\n '<path d=\"M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.11-.2.06-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z\"/>',\n pip: '<path d=\"M19 7h-8v6h8V7zm4 12V5a2 2 0 0 0-2-2H3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2zm-2 .02H3V4.98h18v14.04z\"/>',\n fullscreen:\n '<path d=\"M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z\"/>',\n fullscreenExit:\n '<path d=\"M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z\"/>',\n liveDot: '<circle cx=\"12\" cy=\"12\" r=\"6\"/>',\n retry:\n '<path d=\"M17.65 6.35A8 8 0 1 0 19.73 13h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z\"/>',\n} as const;\n\nexport type IconName = keyof typeof ICONS;\n\nexport function svgIcon(name: IconName): string {\n return `<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\">${ICONS[name]}</svg>`;\n}\n","// A full, skinnable control bar rendered in vanilla DOM over a managed Player.\n// Everything visual is driven by --havik-* CSS variables (see theme.ts), so a\n// customer re-skins it purely with a `theme` object or their own stylesheet.\n//\n// It speaks only the public Player surface (play/pause/setMuted/…/on), so it has\n// no privileged access to engine internals and could equally wrap a BYO player.\n\nimport { injectStyles } from './styles';\nimport { svgIcon, type IconName } from './icons';\nimport {\n enterVideoFullscreen,\n exitVideoFullscreen,\n videoFullscreenActive,\n} from '../core/fullscreen';\nimport type { Player } from '../managed';\nimport type { AudioTrackInfo, PlayerState, QualityLevel, TextTrackInfo } from '../core/stats';\nimport type { PlaybackError } from '../core/errors';\n\nexport interface ControlBarOptions {\n /** The `.havik-player` container that wraps the <video>. */\n root: HTMLElement;\n video: HTMLVideoElement;\n player: Player;\n /** Optional corner watermark + status-card logo (logo URL). */\n logoUrl?: string;\n /** Milliseconds of pointer inactivity before the bar auto-hides. Default 3000. */\n autoHideMs?: number;\n /**\n * Branded full-bleed cards for the stopped/pre-play states (ended, fatal\n * error, pre-play, armed-waiting) instead of a frozen frame + dead play\n * button. Default true. Set false if you render your own end/error UI from\n * the player events.\n */\n statusOverlays?: boolean;\n /** Message on the end-of-stream card. Default \"This stream has ended\". */\n endedMessage?: string;\n /**\n * Message shown INSTEAD of endedMessage when the server says the stream\n * stopped but the match is still running (`endedReason: 'interrupted'` —\n * an ingest outage that outlived the bridge's hold). Telling a viewer\n * mid-match that the stream \"has ended\" is what this exists to prevent.\n *\n * Default \"Reconnecting — please stand by\", which matches what the player\n * does WHEN auto-rejoin is on (`rejoinOnLive`, the default, and it needs\n * `liveStateEvents` too). A host that turns rejoin off should pass its own\n * wording here: nothing will re-attach on its own, so the default would\n * promise the viewer a reconnect that never comes.\n */\n interruptedMessage?: string;\n /** Message on the fatal-error card. Default: the error's own message. */\n errorMessage?: string;\n}\n\nexport interface ControlBarHandle {\n destroy(): void;\n}\n\ntype MenuKind = 'quality' | 'audio' | 'cc';\n\nconst HIDE_AFTER_MS = 3000;\n\n// Terminal error codes get NO Retry button — re-resolving returns the same\n// outcome (stream gone / unknown URN / bad key / malformed URN). Everything else\n// (network / media / DRM / internal / transient) keeps Retry, since retry()\n// re-resolves AND re-attaches and can recover — including refreshing an expired\n// signed DRM license URL.\nconst NON_RETRYABLE_ERROR_CODES = new Set(['GONE', 'NOT_FOUND', 'INVALID_URN', 'UNAUTHORIZED']);\n\n/** Mount the Oddin control bar onto a managed player. Returns a teardown handle. */\nexport function mountControlBar(opts: ControlBarOptions): ControlBarHandle {\n return new ControlBar(opts);\n}\n\nclass ControlBar implements ControlBarHandle {\n private readonly root: HTMLElement;\n private readonly video: HTMLVideoElement;\n private readonly player: Player;\n private readonly doc: Document;\n private readonly autoHideMs: number;\n\n // Elements\n private readonly overlay: HTMLElement;\n private readonly center: HTMLButtonElement;\n private readonly spinner: HTMLElement;\n private readonly bar: HTMLElement;\n private readonly gradient: HTMLElement;\n private readonly seek: HTMLElement;\n private readonly seekBuffered: HTMLElement;\n private readonly seekPlayed: HTMLElement;\n private readonly seekThumb: HTMLElement;\n private readonly playBtn: HTMLButtonElement;\n private readonly muteBtn: HTMLButtonElement;\n private readonly volSlider: HTMLInputElement;\n private readonly live: HTMLElement;\n private readonly liveGo: HTMLButtonElement;\n private readonly time: HTMLElement;\n private readonly ccBtn: HTMLButtonElement;\n private readonly audioBtn: HTMLButtonElement;\n private readonly qualityBtn: HTMLButtonElement;\n private readonly pipBtn: HTMLButtonElement;\n private readonly fsBtn: HTMLButtonElement;\n private readonly menu: HTMLElement;\n\n // Status card (ended / error / pre-play / waiting) — see syncStatus().\n private statusBox: HTMLElement | null = null;\n private statusKind: 'ended' | 'error' | 'preplay' | 'waiting' | null = null;\n /** Copy currently rendered on the card; part of the dedup key (see\n * renderStatus) so a same-kind message change still repaints. */\n private statusMsg: string | null = null;\n private hasPlayed = false;\n private lastError: PlaybackError | null = null;\n private readonly logoUrl?: string;\n private readonly statusOverlays: boolean;\n private readonly endedMessage: string;\n private readonly interruptedMessage: string;\n private readonly errorMessage?: string;\n\n private hideTimer?: ReturnType<typeof setTimeout>;\n private openMenu: MenuKind | null = null;\n private scrubbing = false;\n private readonly offFns: Array<() => void> = [];\n\n constructor(o: ControlBarOptions) {\n this.root = o.root;\n this.video = o.video;\n this.player = o.player;\n this.doc = o.root.ownerDocument ?? document;\n this.autoHideMs = o.autoHideMs ?? HIDE_AFTER_MS;\n this.logoUrl = o.logoUrl;\n this.statusOverlays = o.statusOverlays !== false;\n this.endedMessage = o.endedMessage ?? 'This stream has ended';\n this.interruptedMessage = o.interruptedMessage ?? 'Reconnecting — please stand by';\n this.errorMessage = o.errorMessage;\n injectStyles(this.doc);\n\n this.root.classList.add('havik-player');\n if (!this.root.hasAttribute('tabindex')) this.root.tabIndex = 0;\n this.root.setAttribute('role', 'region');\n this.root.setAttribute('aria-label', 'Video player');\n\n const el = <K extends keyof HTMLElementTagNameMap>(\n tag: K,\n cls: string,\n attrs?: Record<string, string>,\n ): HTMLElementTagNameMap[K] => {\n const node = this.doc.createElement(tag);\n node.className = cls;\n if (attrs) for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);\n return node;\n };\n const btn = (cls: string, icon: IconName, label: string): HTMLButtonElement => {\n const b = el('button', cls, { type: 'button', 'aria-label': label, title: label });\n b.innerHTML = svgIcon(icon);\n return b;\n };\n\n // Overlay (click = play/pause) + big center button + spinner.\n this.overlay = el('div', 'havik-overlay');\n this.center = el('button', 'havik-center', { type: 'button', 'aria-label': 'Play' });\n this.center.innerHTML = svgIcon('play');\n this.spinner = el('div', 'havik-spinner');\n this.spinner.style.display = 'none';\n this.overlay.append(this.center, this.spinner);\n\n this.gradient = el('div', 'havik-gradient');\n\n // Seek bar.\n this.bar = el('div', 'havik-bar');\n this.seek = el('div', 'havik-seek', {\n role: 'slider',\n 'aria-label': 'Seek',\n 'aria-valuemin': '0',\n 'aria-valuemax': '100',\n tabindex: '0',\n });\n const track = el('div', 'havik-seek__track');\n this.seekBuffered = el('div', 'havik-seek__buffered');\n this.seekPlayed = el('div', 'havik-seek__played');\n this.seekThumb = el('div', 'havik-seek__thumb');\n track.append(this.seekBuffered, this.seekPlayed, this.seekThumb);\n this.seek.append(track);\n\n // Controls row.\n const controls = el('div', 'havik-controls');\n this.playBtn = btn('havik-btn', 'play', 'Play');\n this.muteBtn = btn('havik-btn', 'volumeHigh', 'Mute');\n this.volSlider = el('input', 'havik-vol__slider', {\n type: 'range',\n min: '0',\n max: '1',\n step: '0.05',\n 'aria-label': 'Volume',\n });\n const vol = el('div', 'havik-vol');\n vol.append(this.muteBtn, this.volSlider);\n\n this.live = el('div', 'havik-live');\n this.live.innerHTML = svgIcon('liveDot');\n const liveLabel = this.doc.createElement('span');\n liveLabel.textContent = 'LIVE';\n this.liveGo = el('button', 'havik-live__go', { type: 'button' });\n this.liveGo.textContent = 'Go live';\n this.live.append(liveLabel, this.liveGo);\n this.time = el('div', 'havik-time');\n this.time.textContent = '0:00';\n this.time.style.display = 'none';\n\n const spacer = el('div', 'havik-spacer');\n this.ccBtn = btn('havik-btn', 'cc', 'Subtitles');\n this.audioBtn = btn('havik-btn', 'settings', 'Audio track');\n this.qualityBtn = btn('havik-btn', 'settings', 'Quality');\n this.pipBtn = btn('havik-btn', 'pip', 'Picture in picture');\n this.fsBtn = btn('havik-btn', 'fullscreen', 'Fullscreen');\n this.ccBtn.style.display = 'none';\n this.audioBtn.style.display = 'none';\n this.qualityBtn.style.display = 'none';\n if (!this.pipSupported()) this.pipBtn.style.display = 'none';\n\n controls.append(\n this.playBtn,\n vol,\n this.live,\n this.time,\n spacer,\n this.ccBtn,\n this.audioBtn,\n this.qualityBtn,\n this.pipBtn,\n this.fsBtn,\n );\n this.bar.append(this.seek, controls);\n\n this.menu = el('div', 'havik-menu', { role: 'menu' });\n this.menu.hidden = true;\n\n this.root.append(this.gradient, this.overlay, this.bar, this.menu);\n if (o.logoUrl) {\n const wm = el('div', 'havik-watermark');\n wm.style.backgroundImage = `url(\"${o.logoUrl}\")`;\n this.root.append(wm);\n }\n\n this.wire();\n this.syncPlayPause();\n this.syncVolume();\n this.scheduleHide();\n }\n\n // ---- wiring -------------------------------------------------------------\n\n private on<T extends EventTarget>(\n target: T,\n type: string,\n handler: EventListenerOrEventListenerObject,\n options?: AddEventListenerOptions,\n ): void {\n target.addEventListener(type, handler, options);\n this.offFns.push(() => target.removeEventListener(type, handler, options));\n }\n\n private wire(): void {\n const v = this.video;\n\n // Buttons → player.\n this.on(this.playBtn, 'click', () => this.togglePlay());\n this.on(this.center, 'click', () => this.togglePlay());\n this.on(this.overlay, 'click', (e) => {\n if (e.target === this.overlay) this.togglePlay();\n });\n this.on(this.muteBtn, 'click', () => this.player.setMuted(!v.muted));\n this.on(this.volSlider, 'input', () => {\n const value = Number(this.volSlider.value);\n // Paint the fill from the input event rather than waiting for the\n // 'volumechange' round-trip, so it tracks the thumb during a drag.\n this.setVolumeFill(value);\n this.player.setVolume(value);\n if (value > 0 && v.muted) this.player.setMuted(false);\n });\n this.on(this.liveGo, 'click', () => this.player.seekToLive());\n this.on(this.pipBtn, 'click', () => void this.togglePip());\n this.on(this.fsBtn, 'click', () => void this.toggleFullscreen());\n this.on(this.ccBtn, 'click', () => this.toggleMenu('cc'));\n this.on(this.audioBtn, 'click', () => this.toggleMenu('audio'));\n this.on(this.qualityBtn, 'click', () => this.toggleMenu('quality'));\n\n // Seek.\n this.on(this.seek, 'pointerdown', (e) => this.onSeekDown(e as PointerEvent));\n this.on(this.seek, 'keydown', (e) => this.onSeekKey(e as KeyboardEvent));\n\n // Video element → UI.\n this.on(v, 'play', () => this.syncPlayPause());\n this.on(v, 'pause', () => this.syncPlayPause());\n this.on(v, 'volumechange', () => this.syncVolume());\n this.on(v, 'timeupdate', () => this.syncProgress());\n this.on(v, 'progress', () => this.syncProgress());\n this.on(v, 'durationchange', () => this.syncProgress());\n\n // Player events → UI.\n this.offFns.push(this.player.on('statechange', () => this.syncState()));\n // `ended` can fire a second time with a refined reason (interrupted →\n // match_ended) WITHOUT a state transition, so statechange alone would\n // never repaint the card and the viewer would keep being promised a\n // reconnect for a match that is over. syncState re-reads player.state\n // (still 'ended') and the copy-aware dedup does the rest.\n this.offFns.push(this.player.on('ended', () => this.syncState()));\n this.offFns.push(this.player.on('ready', () => this.refreshTracks()));\n this.offFns.push(this.player.on('qualitychange', () => this.onTracksChanged()));\n this.offFns.push(this.player.on('audiotrackchange', () => this.onTracksChanged()));\n this.offFns.push(this.player.on('texttrackchange', () => this.onTracksChanged()));\n this.offFns.push(this.player.on('pipchange', (active) => this.syncPip(active)));\n this.offFns.push(this.player.on('stats', () => this.syncLive()));\n this.offFns.push(\n this.player.on('error', (err) => {\n this.lastError = err;\n this.statusKind = null; // force the error card to re-render with this error\n this.syncState();\n }),\n );\n\n // Auto-hide + keyboard.\n this.on(this.root, 'pointermove', () => this.poke());\n this.on(this.root, 'pointerleave', () => this.scheduleHide(0));\n this.on(this.root, 'focusin', () => this.poke());\n this.on(this.root, 'keydown', (e) => this.onKey(e as KeyboardEvent));\n this.on(this.doc, 'pointerdown', (e) => this.onDocPointer(e), { capture: true });\n this.on(this.doc, 'fullscreenchange', () => this.syncFullscreen());\n // Prefixed/native-fullscreen state changes: older Safari fires the\n // prefixed document event for element fullscreen, and iPhone Safari's\n // video-native fullscreen (webkitEnterFullscreen, see toggleFullscreen)\n // reports only through these video-scoped events.\n this.on(this.doc, 'webkitfullscreenchange', () => this.syncFullscreen());\n this.on(v, 'webkitbeginfullscreen', () => this.syncFullscreen());\n this.on(v, 'webkitendfullscreen', () => this.syncFullscreen());\n\n this.syncState();\n }\n\n // ---- play / pause -------------------------------------------------------\n\n private togglePlay(): void {\n if (this.video.paused || this.video.ended) void this.player.play();\n else this.player.pause();\n }\n\n private syncPlayPause(): void {\n const playing = !this.video.paused && !this.video.ended;\n this.setIcon(this.playBtn, playing ? 'pause' : 'play');\n this.playBtn.setAttribute('aria-label', playing ? 'Pause' : 'Play');\n this.setIcon(this.center, playing ? 'pause' : 'play');\n // A status card (pre-play / ended / error) owns the CTA, so keep the\n // mid-playback center toggle hidden while one is shown.\n this.center.classList.toggle('havik-gone', playing || !!this.statusBox);\n if (playing) this.scheduleHide();\n else this.show();\n }\n\n private syncState(): void {\n const state = this.player.state;\n if (state === 'playing' || state === 'buffering') this.hasPlayed = true;\n this.syncStatus(state);\n // Spinner only while genuinely buffering with no card up (the card owns the\n // visual otherwise); the center play/pause button is hidden whenever a card\n // is shown (the card carries its own CTA) or while busy.\n const busy = state === 'loading' || state === 'buffering';\n this.spinner.style.display = busy && !this.statusBox ? '' : 'none';\n if (busy || this.statusBox) this.center.classList.add('havik-gone');\n this.syncLive();\n }\n\n /** Map the player state to a branded status card (ended / fatal error /\n * pre-play / armed-waiting), or none. Disabled via `statusOverlays: false`. */\n private syncStatus(state: PlayerState): void {\n let kind: ControlBar['statusKind'] = null;\n if (this.statusOverlays) {\n if (state === 'ended') kind = 'ended';\n else if (state === 'error') kind = 'error';\n else if (state === 'waiting') kind = 'waiting';\n else if (!this.hasPlayed && (state === 'idle' || state === 'paused')) kind = 'preplay';\n }\n this.renderStatus(kind);\n }\n\n /** Copy for a card kind. Split out of renderStatus so the dedup below can\n * compare the TEXT, not just the kind — an \"ended\" card can change its\n * message without changing kind (interrupted → match_ended). */\n private statusMessageFor(kind: ControlBar['statusKind']): string | null {\n if (kind === 'ended') {\n // An \"interrupted\" end is a live match whose stream dropped. With\n // auto-rejoin on (the default) the player keeps its live-state\n // subscription open and re-attaches when the bridge returns, so the\n // default copy promises exactly what it will do; a host that disabled\n // rejoin overrides interruptedMessage (see the option's docs).\n return this.player.endedReason === 'interrupted'\n ? this.interruptedMessage\n : this.endedMessage;\n }\n if (kind === 'waiting') {\n // A waiting card after playback has run is a mid-session rejoin (ingest\n // outage → session recreated), not a pre-kickoff wait — \"Starting soon\"\n // would misdescribe a live match recovering.\n return this.hasPlayed ? 'Reconnecting — please stand by' : 'Starting soon…';\n }\n if (kind === 'error') {\n return this.errorMessage ?? this.lastError?.message ?? 'Playback unavailable';\n }\n return null; // preplay carries a CTA, not copy; null = no card\n }\n\n /** Build (or tear down) the status card for `kind`. No-op when neither the\n * kind NOR its copy changed, so repeated statechange events don't\n * rebuild/flicker the DOM — but a same-kind message change (the\n * interrupted → match_ended upgrade) still re-renders, otherwise a viewer\n * keeps being promised a reconnect for a match that is over. */\n private renderStatus(kind: ControlBar['statusKind']): void {\n const msgText = this.statusMessageFor(kind);\n if (kind === this.statusKind && msgText === this.statusMsg) return;\n this.statusKind = kind;\n this.statusMsg = msgText;\n this.statusBox?.remove();\n this.statusBox = null;\n // The card carries its own logo, so the corner watermark is redundant\n // (and reads as a duplicate) while one is up.\n this.root.classList.toggle('havik-has-status', !!kind);\n if (!kind) return;\n\n const box = this.doc.createElement('div');\n box.className = `havik-status havik-status--${kind}`;\n if (this.logoUrl) {\n const logo = this.doc.createElement('div');\n logo.className = 'havik-status__logo';\n box.append(logo);\n }\n\n if (kind === 'preplay') {\n // Poster shows through a light scrim; a single branded play CTA.\n const play = this.doc.createElement('button');\n play.type = 'button';\n play.className = 'havik-status__btn havik-status__btn--play';\n play.setAttribute('aria-label', 'Play');\n play.innerHTML = svgIcon('play');\n play.addEventListener('click', () => void this.player.play());\n box.append(play);\n } else {\n const msg = this.doc.createElement('div');\n msg.className = 'havik-status__msg';\n msg.textContent = msgText ?? '';\n box.append(msg);\n // Retry only when re-resolving can plausibly succeed (terminal errors\n // would just fail again).\n if (\n kind === 'error' &&\n this.lastError &&\n !NON_RETRYABLE_ERROR_CODES.has(this.lastError.code)\n ) {\n const retry = this.doc.createElement('button');\n retry.type = 'button';\n retry.className = 'havik-status__btn';\n retry.innerHTML = `${svgIcon('retry')}<span>Retry</span>`;\n retry.addEventListener('click', () => void this.player.retry());\n box.append(retry);\n }\n }\n\n this.root.append(box);\n this.statusBox = box;\n this.show();\n }\n\n // ---- volume -------------------------------------------------------------\n\n private syncVolume(): void {\n const v = this.video;\n const muted = v.muted || v.volume === 0;\n this.setIcon(this.muteBtn, muted ? 'volumeMute' : 'volumeHigh');\n this.muteBtn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');\n const level = muted ? 0 : v.volume;\n this.volSlider.value = String(level);\n this.setVolumeFill(level);\n }\n\n /** Drives the accent fill on the volume track (see --havik-vol in styles). */\n private setVolumeFill(level: number): void {\n this.volSlider.style.setProperty('--havik-vol', String(Math.min(1, Math.max(0, level))));\n }\n\n // ---- progress / live ----------------------------------------------------\n\n /**\n * The span the scrub bar maps onto, and the window a seek may land in.\n *\n * On a live stream the right end is the live-edge TARGET, not `seekable.end()`\n * — the bleeding edge, where the newest parts aren't published yet and\n * playback stutters frame-by-frame (see the over-seek clamp in the hls.js\n * engine for why hls.js can't recover from it). Mapping 100% to the target\n * means dragging to the far right is \"go live\", the same spot seekToLive()\n * uses, and the thumb reads full at the live edge instead of parking ~2s short\n * of an end the player deliberately never reaches.\n *\n * `targetLatencySeconds` is engine-reported and hls.js-only, so the native\n * (Safari) path keeps the raw range — AVPlayer runs its own live-edge policy.\n */\n private seekableRange(): { start: number; end: number } | null {\n const v = this.video;\n let start: number;\n let end: number;\n if (v.seekable && v.seekable.length > 0) {\n start = v.seekable.start(0);\n end = v.seekable.end(v.seekable.length - 1);\n } else if (Number.isFinite(v.duration) && v.duration > 0) {\n start = 0;\n end = v.duration;\n } else {\n return null;\n }\n const stats = this.player.getStats();\n // isLive !== false = live or not yet known; targetLatencySeconds is only\n // reported once level details exist, i.e. once isLive is actually known, so\n // VOD never pulls the end back.\n if (stats.isLive !== false && stats.targetLatencySeconds && stats.targetLatencySeconds > 0) {\n end = Math.max(start, end - stats.targetLatencySeconds);\n }\n if (end > start) return { start, end };\n return null;\n }\n\n private syncProgress(): void {\n if (this.scrubbing) return;\n const range = this.seekableRange();\n if (!range) {\n this.seekPlayed.style.width = '100%';\n return;\n }\n const span = range.end - range.start;\n const played = Math.min(1, Math.max(0, (this.video.currentTime - range.start) / span));\n this.seekPlayed.style.width = `${(played * 100).toFixed(2)}%`;\n this.seekThumb.style.left = `${(played * 100).toFixed(2)}%`;\n this.seek.setAttribute('aria-valuenow', String(Math.round(played * 100)));\n\n const b = this.video.buffered;\n if (b && b.length > 0) {\n const end = b.end(b.length - 1);\n const buf = Math.min(1, Math.max(0, (end - range.start) / span));\n this.seekBuffered.style.width = `${(buf * 100).toFixed(2)}%`;\n }\n\n // Time readout only matters for non-live (finite, non-DVR) content.\n const stats = this.player.getStats();\n if (!stats.isLive && Number.isFinite(this.video.duration)) {\n this.time.textContent = `${clock(this.video.currentTime)} / ${clock(this.video.duration)}`;\n }\n }\n\n private syncLive(): void {\n const stats = this.player.getStats();\n const isLive = stats.isLive !== false; // default to live (these are live streams)\n this.live.style.display = isLive ? '' : 'none';\n this.time.style.display = isLive ? 'none' : '';\n this.live.classList.toggle('is-live', stats.atLiveEdge !== false && isLive);\n }\n\n // ---- seeking ------------------------------------------------------------\n\n private onSeekDown(e: PointerEvent): void {\n const range = this.seekableRange();\n if (!range) return;\n e.preventDefault();\n this.scrubbing = true;\n const move = (ev: PointerEvent) => this.seekToPointer(ev, range);\n const up = () => {\n this.scrubbing = false;\n this.doc.removeEventListener('pointermove', move);\n this.doc.removeEventListener('pointerup', up);\n };\n this.doc.addEventListener('pointermove', move);\n this.doc.addEventListener('pointerup', up);\n this.seekToPointer(e, range);\n }\n\n private seekToPointer(e: PointerEvent, range: { start: number; end: number }): void {\n const rect = this.seek.getBoundingClientRect();\n const pct = rect.width > 0 ? Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)) : 0;\n this.seekPlayed.style.width = `${(pct * 100).toFixed(2)}%`;\n this.seekThumb.style.left = `${(pct * 100).toFixed(2)}%`;\n this.video.currentTime = range.start + pct * (range.end - range.start);\n }\n\n private onSeekKey(e: KeyboardEvent): void {\n if (e.key === 'ArrowLeft') {\n this.nudge(-5);\n e.preventDefault();\n } else if (e.key === 'ArrowRight') {\n this.nudge(5);\n e.preventDefault();\n }\n }\n\n private nudge(seconds: number): void {\n const range = this.seekableRange();\n if (!range) return;\n this.video.currentTime = Math.min(\n range.end,\n Math.max(range.start, this.video.currentTime + seconds),\n );\n }\n\n // ---- menus --------------------------------------------------------------\n\n private toggleMenu(kind: MenuKind): void {\n if (this.openMenu === kind) {\n this.closeMenu();\n return;\n }\n this.openMenu = kind;\n this.renderMenu(kind);\n this.menu.hidden = false;\n this.show();\n }\n\n private closeMenu(): void {\n this.openMenu = null;\n this.menu.hidden = true;\n this.menu.replaceChildren();\n }\n\n private renderMenu(kind: MenuKind): void {\n if (kind === 'quality') this.renderQualityMenu();\n else if (kind === 'audio') this.renderAudioMenu();\n else this.renderCcMenu();\n }\n\n private menuItem(label: string, checked: boolean, onSelect: () => void): HTMLButtonElement {\n const item = this.doc.createElement('button');\n item.type = 'button';\n item.className = 'havik-menu__item';\n item.setAttribute('role', 'menuitemradio');\n item.setAttribute('aria-checked', checked ? 'true' : 'false');\n const text = this.doc.createElement('span');\n text.textContent = label;\n item.append(text);\n item.addEventListener('click', () => {\n onSelect();\n this.closeMenu();\n });\n return item;\n }\n\n private renderQualityMenu(): void {\n const levels = this.player.getQualityLevels();\n const current = this.player.getCurrentQuality();\n const title = this.doc.createElement('div');\n title.className = 'havik-menu__title';\n title.textContent = 'Quality';\n this.menu.replaceChildren(\n title,\n this.menuItem('Auto', current === -1, () => this.player.setQuality('auto')),\n );\n for (const lvl of levels) {\n this.menu.append(\n this.menuItem(qualityLabel(lvl), current === lvl.index, () =>\n this.player.setQuality(lvl.index),\n ),\n );\n }\n }\n\n private renderAudioMenu(): void {\n const tracks = this.player.getAudioTracks();\n const title = this.doc.createElement('div');\n title.className = 'havik-menu__title';\n title.textContent = 'Audio';\n this.menu.replaceChildren(title);\n for (const t of tracks) {\n this.menu.append(\n this.menuItem(audioLabel(t), t.default, () => this.player.setAudioTrack(t.id)),\n );\n }\n }\n\n private renderCcMenu(): void {\n const tracks = this.player.getTextTracks();\n const title = this.doc.createElement('div');\n title.className = 'havik-menu__title';\n title.textContent = 'Subtitles';\n this.menu.replaceChildren(\n title,\n this.menuItem('Off', false, () => this.player.setTextTrack(-1)),\n );\n for (const t of tracks) {\n this.menu.append(this.menuItem(textLabel(t), false, () => this.player.setTextTrack(t.id)));\n }\n }\n\n private onTracksChanged(): void {\n if (this.openMenu) this.renderMenu(this.openMenu);\n }\n\n private refreshTracks(): void {\n const quality = this.player.getQualityLevels();\n const audio = this.player.getAudioTracks();\n const text = this.player.getTextTracks();\n this.qualityBtn.style.display = quality.length > 1 ? '' : 'none';\n this.audioBtn.style.display = audio.length > 1 ? '' : 'none';\n this.ccBtn.style.display = text.length > 0 ? '' : 'none';\n this.onTracksChanged();\n }\n\n // ---- pip / fullscreen ---------------------------------------------------\n\n private pipSupported(): boolean {\n return (\n 'pictureInPictureEnabled' in this.doc &&\n typeof (this.video as unknown as { requestPictureInPicture?: unknown })\n .requestPictureInPicture === 'function'\n );\n }\n\n private async togglePip(): Promise<void> {\n const doc = this.doc as Document & { pictureInPictureElement?: Element | null };\n if (doc.pictureInPictureElement === this.video) await this.player.exitPictureInPicture();\n else await this.player.enterPictureInPicture();\n }\n\n private syncPip(active: boolean): void {\n this.pipBtn.setAttribute('aria-pressed', active ? 'true' : 'false');\n }\n\n /**\n * Delegates to the shared capability ladder (core/fullscreen.ts): element\n * fullscreen on the player root where the API exists (keeps this control\n * bar on screen), video-native webkitEnterFullscreen on iPhone (NATIVE\n * controls; the user exits via the native Done button and\n * webkitendfullscreen re-syncs the icon).\n */\n private async toggleFullscreen(): Promise<void> {\n try {\n if (videoFullscreenActive(this.video, this.root)) {\n await exitVideoFullscreen(this.video, this.root);\n } else {\n await enterVideoFullscreen(this.video, this.root);\n }\n } catch {\n // fullscreen can be blocked by policy; ignore\n }\n }\n\n private syncFullscreen(): void {\n const full = videoFullscreenActive(this.video, this.root);\n this.setIcon(this.fsBtn, full ? 'fullscreenExit' : 'fullscreen');\n this.fsBtn.setAttribute('aria-label', full ? 'Exit fullscreen' : 'Fullscreen');\n }\n\n // ---- auto-hide ----------------------------------------------------------\n\n private poke(): void {\n this.show();\n this.scheduleHide();\n }\n\n private show(): void {\n this.root.classList.remove('havik-hide');\n }\n\n private scheduleHide(delay = this.autoHideMs): void {\n if (this.hideTimer) clearTimeout(this.hideTimer);\n this.hideTimer = setTimeout(() => {\n const playing = !this.video.paused && !this.video.ended;\n if (playing && !this.openMenu && !this.statusBox) this.root.classList.add('havik-hide');\n }, delay);\n }\n\n private onDocPointer(e: Event): void {\n if (this.openMenu && !this.menu.contains(e.target as Node)) {\n // a click on the toggle button is handled by its own handler\n const onToggle = [this.qualityBtn, this.audioBtn, this.ccBtn].some((b) =>\n b.contains(e.target as Node),\n );\n if (!onToggle) this.closeMenu();\n }\n }\n\n // ---- keyboard -----------------------------------------------------------\n\n private onKey(e: KeyboardEvent): void {\n const target = e.target as HTMLElement | null;\n if (target && target !== this.root && target !== this.seek && target.tagName === 'INPUT')\n return;\n switch (e.key) {\n case ' ':\n case 'k':\n this.togglePlay();\n break;\n case 'm':\n this.player.setMuted(!this.video.muted);\n break;\n case 'f':\n void this.toggleFullscreen();\n break;\n case 'c':\n if (this.ccBtn.style.display !== 'none') this.toggleMenu('cc');\n break;\n case 'l':\n this.player.seekToLive();\n break;\n case 'ArrowUp':\n this.player.setVolume(Math.min(1, this.video.volume + 0.1));\n break;\n case 'ArrowDown':\n this.player.setVolume(Math.max(0, this.video.volume - 0.1));\n break;\n case 'ArrowLeft':\n this.nudge(-5);\n break;\n case 'ArrowRight':\n this.nudge(5);\n break;\n default:\n return;\n }\n this.poke();\n e.preventDefault();\n }\n\n // ---- helpers ------------------------------------------------------------\n\n private setIcon(btnEl: HTMLElement, icon: IconName): void {\n btnEl.innerHTML = svgIcon(icon);\n }\n\n destroy(): void {\n if (this.hideTimer) clearTimeout(this.hideTimer);\n for (const off of this.offFns) {\n try {\n off();\n } catch {\n // best effort\n }\n }\n this.offFns.length = 0;\n this.closeMenu();\n this.statusBox?.remove();\n for (const node of [this.overlay, this.gradient, this.bar, this.menu]) node.remove();\n this.root.classList.remove('havik-player', 'havik-hide', 'havik-has-status');\n }\n}\n\nfunction clock(seconds: number): string {\n if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;\n const s = Math.floor(seconds % 60);\n const m = Math.floor((seconds / 60) % 60);\n const h = Math.floor(seconds / 3600);\n const mm = h > 0 ? String(m).padStart(2, '0') : String(m);\n const ss = String(s).padStart(2, '0');\n return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;\n}\n\nfunction qualityLabel(lvl: QualityLevel): string {\n if (lvl.height) return `${lvl.height}p`;\n return `${Math.round(lvl.bitrate / 1000)} kbps`;\n}\n\nfunction audioLabel(t: AudioTrackInfo): string {\n return t.name || t.lang || `Track ${t.id}`;\n}\n\nfunction textLabel(t: TextTrackInfo): string {\n return t.name || t.lang || `Track ${t.id}`;\n}\n","import { type HlsConfig } from 'hls.js';\nimport { isWaitable, PlaybackError } from '../core/errors';\nimport { baseUrlForEnv, type HavikEnv } from '../core/env';\nimport { resolvePlaybackOnce, resolveStream, type ResolveOptions } from '../core/resolve';\nimport { HlsEngine, hlsSupported } from '../core/engine/hls';\nimport { NativeHlsEngine, nativeHlsSupported, webkitMediaStack } from '../core/engine/native';\nimport { type EngineEvent, type PlaybackEngine } from '../core/engine/types';\nimport {\n attachMediaStateListeners,\n EMPTY_STATS,\n type AudioTrackInfo,\n type PlaybackStats,\n type PlayerState,\n type QualityLevel,\n type TextTrackInfo,\n} from '../core/stats';\nimport { getDeviceId } from '../core/drm/deviceId';\nimport { enterVideoFullscreen, exitVideoFullscreen } from '../core/fullscreen';\nimport {\n resolveCredential,\n type Credential,\n type CredentialSource,\n type StreamDescriptor,\n} from '../core/types';\nimport { type WaitForLive, type WaitForLiveOptions, type WaitState } from '../core/waitForLive';\nimport {\n subscribeLiveState,\n deriveEventsBaseUrl,\n type EndedReason,\n type LiveStateSubscription,\n} from '../core/events';\nimport { AnalyticsSession, resolveBeaconsEndpoint, type Presentation } from '../core/analytics';\nimport { collectEnvironment, collectHighEntropy } from '../core/environment';\n\n// Re-exported: it names the `ended` event payload and Player.endedReason, so\n// a managed-player consumer must be able to refer to it without reaching into\n// the BYO entry point.\nexport type { EndedReason } from '../core/events';\nimport { SDK_VERSION } from '../core/version';\nimport { applyTheme, type HavikTheme } from '../ui/theme';\nimport { mountControlBar, type ControlBarHandle } from '../ui/controls';\n\n// Armed-phase SSE tuning. While SSE is healthy the player does NOT poll\n// /v1/playback — it waits for the pushed `live`. These bound the fallback:\nconst SSE_ARM_CONNECT_GRACE_MS = 6_000; // no SSE frame by now → start polling\nconst SSE_ARM_LIVENESS_MS = 45_000; // heartbeat is 20s; 2 missed → SSE stale → poll\nconst SSE_ARM_RESOLVE_JITTER_MS = 2_000; // stagger the go-live resolve so 1000s of armed viewers don't resolve in lockstep\nconst LIVE_STATE_WARN_AFTER = 3; // consecutive no-data failures before the one-time degraded-end-detection warning\n\nexport interface CreatePlayerOptions {\n /** The <video> element the SDK will own. */\n video: HTMLVideoElement;\n /**\n * Which Oddin environment to play against. Prefer this over\n * {@link baseUrl}: the host is then correct by construction, and so is\n * everything derived from it (the SSE events host, the QoE beacons host).\n *\n * Exactly one of `env` or {@link baseUrl} is required. Passing both is an\n * error rather than a precedence rule — the two disagreeing is precisely\n * the misconfiguration worth refusing, and silently preferring one would\n * point a player at an environment nobody chose.\n */\n env?: HavikEnv;\n /**\n * API base URL, when {@link env} does not name it. Use this for a\n * deployment the named environments do not cover: your own CDN or gateway\n * fronting the streams API, the CN plane, or a pinned build that must\n * outlive a domain change.\n *\n * Note that a base which is not a `feed[-dev].<domain>` host derives no QoE\n * beacons endpoint, so pair it with `analytics: { endpoint }`.\n */\n baseUrl?: string;\n matchUrn: string;\n credential: CredentialSource;\n /**\n * Start playback once ready. Default: true. With false the player resolves\n * playback, then holds in a pre-play 'paused' state (poster + play card,\n * no spinner) and defers media loading — segments and DRM license traffic —\n * until the first play(). The hls.js engine still parses the manifest\n * pre-play ('ready', track lists); the native engine defers all fetching\n * and emits 'ready' at attach. A policy-blocked programmatic play()\n * ('autoplayblocked') leaves the loader warming the buffer — media and DRM\n * traffic flows from that kick, not from a viewer gesture.\n */\n autoplay?: boolean;\n /** Mute the element (required for reliable autoplay with sound policies). Default: false. */\n muted?: boolean;\n /** Arm on an upcoming match and auto-attach at go-live. See WaitForLive. */\n waitForLive?: WaitForLive;\n /** 'auto' lets hls.js decide from the manifest (safe default). Default: 'auto'. */\n lowLatency?: 'auto' | boolean;\n /** Poster image shown before playback starts. */\n poster?: string;\n /** Initial rendition index (-1 = auto, the default). */\n startLevel?: number;\n /** Cap ABR to renditions at/below this bitrate (bps). */\n maxBitrate?: number;\n /**\n * Target live-edge latency in seconds (maps to hls.js liveSyncDuration).\n * Default: 2 (a balance the engine ships — close to live but back from the\n * bleeding PART-HOLD-BACK edge, where parts aren't reliably published yet).\n * Lower = closer to live but more rebuffer-prone; higher = steadier. The\n * engine derives a matching latency ceiling internally, so this is the only\n * live-latency knob you need. Live/LL only.\n */\n liveLatencyTarget?: number;\n /**\n * Snap straight to the live edge when a backgrounded tab is refocused and\n * playback has fallen well behind. Default: true. Live/LL only.\n */\n snapToLiveOnRefocus?: boolean;\n /** Force an engine; 'auto' prefers hls.js wherever supported (incl. modern\n * Safari/iOS via ManagedMediaSource) and falls back to native HLS. Default: 'auto'. */\n engine?: 'auto' | 'hls' | 'native';\n /** Optional viewer id (X-User-Id on the license POST). */\n userId?: string;\n /**\n * Which controls to render:\n * - 'custom' (default): the Oddin-branded, skinnable control bar.\n * - 'native': the browser's built-in `<video controls>` UI.\n * - 'none': no controls (drive the player via its API).\n */\n controls?: 'custom' | 'native' | 'none';\n /**\n * Re-skin the custom control bar. Merged over the Oddin default; any omitted\n * key keeps the brand value. Equivalent to overriding the `--havik-*` CSS\n * variables on the player element. Ignored unless controls is 'custom'.\n */\n theme?: Partial<HavikTheme>;\n /**\n * Show branded full-bleed cards for the stopped/pre-play states — ended\n * (\"This stream has ended\"), fatal error (message + Retry when retryable),\n * pre-play (poster + play CTA), and armed-waiting (\"Starting soon…\") — instead\n * of a frozen frame + a dead play button. Themed via the control-bar tokens +\n * `theme.logoUrl`. Default: true. Set false if you render your own end/error\n * UI from the player events (`ended`, `error`). Ignored unless controls is\n * 'custom'.\n */\n statusOverlays?: boolean;\n /** Message on the end-of-stream card. Default: \"This stream has ended\". */\n endedMessage?: string;\n /**\n * Message shown instead of {@link endedMessage} when the stream stopped but\n * the match is still running (the server's `interrupted` reason — an ingest\n * outage that outlived the bridge's reconnect hold). Default:\n * \"Reconnecting — please stand by\" — a promise the player keeps while\n * {@link rejoinOnLive} is on (the default; it also needs\n * {@link liveStateEvents}). Override it if you disable auto-rejoin, since\n * nothing will then re-attach on its own.\n */\n interruptedMessage?: string;\n /** Message on the fatal-error card. Default: the error's own message. */\n errorMessage?: string;\n /**\n * Turn on hls.js's internal console logging — segment/part scheduling, ABR\n * switches, and EME key-session + license traffic. Default: false.\n *\n * For diagnosing a playback problem with Oddin support; leave it off in\n * production (it logs on every fragment, and the EME lines include license\n * request/response metadata). hls.js engine only — a no-op on the native\n * engine, which has no equivalent. Equivalent to `hlsConfig: { debug: true }`,\n * which still wins if you set both.\n */\n debug?: boolean;\n /** Escape hatch for low-level hls.js tuning. */\n hlsConfig?: Partial<HlsConfig>;\n /** Stats sampling cadence. Default: 2000ms. */\n statsIntervalMs?: number;\n /**\n * How often to silently re-resolve playback so the signed DRM license URL\n * the player would use next stays inside its signature lifetime (~10 min).\n * Default: 8 min. Set 0 to disable.\n *\n * This refreshes the URL, not the license. It gives the player a newer URL\n * for the next license request it makes, and issues none of its own, so it\n * cannot extend a license the CDM already holds: how long that stays valid\n * is set when it is issued, server-side. A session that outlasts its license\n * ends with a fatal `DRM_CLIENT` error whatever this is set to.\n */\n licenseRefreshMs?: number;\n /**\n * Subscribe to push-based live-state over SSE (`events.<domain>/v1/events/{urn}`)\n * to detect end-of-stream the instant the bridge flips, instead of waiting on\n * the frozen Tencent manifest (which never emits #EXT-X-ENDLIST). On `ended`/\n * `gone` the player stops buffering and emits `ended`; a later pushed `live`\n * rejoins automatically (see {@link rejoinOnLive}). Default: true — and\n * fully graceful: if the endpoint is unreachable the subscription retries\n * quietly in the background while the HLS staleness watchdog remains the\n * layer-fallback, so playback is never affected. Set false to disable.\n */\n liveStateEvents?: boolean;\n /**\n * Rejoin automatically when a stream that ended comes back live. An ingest\n * outage longer than the bridge's reconnect grace tears the session down\n * and — when the encoder reconnects — recreates it under a NEW manifest\n * URL, while the old playlist freezes at HTTP 200 forever. A player that\n * latched `ended` can therefore only recover by re-resolving playback.\n * With this on (the default), the live-state subscription stays open after\n * `ended` (it closes on `gone`, the past-catchup terminal), and a pushed\n * `live` re-arms and re-attaches automatically through the same jittered\n * go-live path the pre-live wait uses. Set false to keep `ended` terminal\n * (the pre-1.13 behavior). No effect when {@link liveStateEvents} is\n * disabled — without the push channel there is nothing to rejoin on.\n */\n rejoinOnLive?: boolean;\n /**\n * Override the SSE endpoint base URL. Default: derived from {@link baseUrl} by\n * prefixing the host with `events.` (e.g. `feed.<domain>` →\n * `events.<domain>`, served ALB-direct). Set this if your events subdomain\n * differs from that convention.\n */\n eventsBaseUrl?: string;\n /**\n * Extra headers attached to every request the SDK makes to {@link baseUrl}\n * (playback resolution, live-status polling) — and ONLY there: never to\n * CDN manifest/segment hosts, DRM license endpoints, the SSE events host,\n * or the beacons endpoint. For integrations whose API front door wants\n * its own auth signal on top of the publishable key — an operator console\n * proving a session to its reverse proxy, or a customer gateway in front\n * of the streams API. `x-api-key` cannot be overridden through this map.\n */\n apiHeaders?: Record<string, string>;\n /**\n * QoE beacons + CMCD session attribution. Active only when the playback\n * response carries an analytics sid (the server-side flag) — without one\n * this option does nothing. Default: true, with the beacons endpoint\n * derived from {@link baseUrl} by hostname convention (`feed[-dev].…` →\n * `beacons[-dev].…`). Pass `false` to opt out entirely, or\n * `{ endpoint }` to point at a non-conventional beacons host.\n *\n * If {@link baseUrl} is NOT a `feed[-dev].<domain>` host — a CDN or gateway\n * fronting the streams API, say — nothing can be derived and beacons are\n * disabled. That is reported, not silent: a `console.warn` naming the host\n * (once per host) plus a non-fatal `warning` event. Supply `{ endpoint }`.\n *\n * Privacy: the sid is an opaque per-session id, held in memory only and\n * never persisted. Beacons carry playback QoE numbers plus the session's\n * environment (form factor, device model and OS version where the browser\n * offers client hints, screen, connection, embedding origin) — never an\n * IP, never the raw user agent, and no identifier beyond the sid.\n */\n analytics?:\n | boolean\n | {\n endpoint?: string;\n /**\n * How long the player may sit paused before the session closes itself\n * (`END_REASON_IDLE`), and the suspension gap (OS sleep, a frozen tab)\n * after which it does the same on waking. Default 10 minutes; `0`\n * disables the idle close; the suspension gap never goes below five\n * minutes, whatever this is set to (a hidden tab's throttled timers\n * legitimately gap to a minute). After an idle close the next play()\n * reloads the stream — a fresh session, a fresh sid, the live edge.\n */\n idleMs?: number;\n };\n}\n\nexport type PlayerEvent =\n | 'ready'\n | 'waiting'\n | 'playing'\n | 'buffering'\n | 'paused'\n | 'ended'\n | 'error'\n | 'warning'\n | 'autoplayblocked'\n | 'qualitychange'\n | 'audiotrackchange'\n | 'texttrackchange'\n | 'pipchange'\n | 'stats'\n | 'statechange';\n\nexport interface PlayerEventMap {\n ready: void;\n waiting: WaitState;\n playing: void;\n buffering: void;\n paused: void;\n /**\n * End of stream. The payload says WHY when the server told us\n * (`interrupted` = the match is still running and play should resume, so\n * do NOT say \"ended\"; `match_ended` = a real end); `undefined` means the\n * end came from the HLS staleness watchdog or a server predating the\n * field — treat it as a real end, the pre-existing behavior. Fires again\n * with `match_ended` if an interruption later turns out to be the end.\n */\n ended: EndedReason | undefined;\n error: PlaybackError;\n /** Non-fatal condition (e.g. FairPlay-not-implemented passthrough, a failed license refresh). Playback continues. */\n warning: PlaybackError;\n /** The browser blocked autoplay (NotAllowedError). Prompt a tap-to-play / tap-to-unmute affordance. */\n autoplayblocked: void;\n /** Active rendition changed; payload is the level index (-1 = auto). */\n qualitychange: number;\n /** Active audio track changed; payload is the track id. */\n audiotrackchange: number;\n /** Active text track changed; payload is the track id (-1 = off). */\n texttrackchange: number;\n /** Picture-in-Picture entered (true) or exited (false). */\n pipchange: boolean;\n stats: PlaybackStats;\n statechange: PlayerState;\n}\n\nexport interface Player {\n readonly state: PlayerState;\n readonly descriptor: StreamDescriptor | null;\n /** Why the stream ended, when the server said so; null otherwise. Cleared\n * when fresh playback commits (including a rejoin). See EndedReason. */\n readonly endedReason: EndedReason | null;\n play(): Promise<void>;\n pause(): void;\n setMuted(muted: boolean): void;\n setVolume(volume: number): void;\n getStats(): PlaybackStats;\n /** Available renditions (empty on the native engine, which does ABR internally). */\n getQualityLevels(): QualityLevel[];\n /** Current rendition index, or -1 when ABR is choosing automatically. */\n getCurrentQuality(): number;\n /** Pick a rendition by index, or 'auto'/-1 to re-enable ABR. */\n setQuality(index: number | 'auto'): void;\n /** Cap ABR to renditions at/below this bitrate (bps), or null to clear. */\n setMaxBitrate(bitrate: number | null): void;\n getAudioTracks(): AudioTrackInfo[];\n setAudioTrack(id: number): void;\n getTextTracks(): TextTrackInfo[];\n /** Select a text track by id, or -1 to disable captions. */\n setTextTrack(id: number): void;\n /** Jump to the live edge (live streams). */\n seekToLive(): void;\n enterPictureInPicture(): Promise<void>;\n exitPictureInPicture(): Promise<void>;\n /**\n * Enter fullscreen: element fullscreen on the player container where the\n * API exists (custom controls stay on screen); on iPhone Safari falls back\n * to the video-native presentation with NATIVE controls (there is no\n * element Fullscreen API there at all). Call from a user gesture; on\n * iPhone it takes effect once media is loaded. Resolves false when no\n * fullscreen mechanism exists on the platform.\n */\n enterFullscreen(): Promise<boolean>;\n /** Leave fullscreen (element or video-native). Safe when not fullscreen. */\n exitFullscreen(): Promise<void>;\n /** Retry playback after an error (re-resolves and re-attaches). */\n retry(): Promise<void>;\n /** Re-resolve playback (refreshes the signed license URL) and re-attach. */\n reload(): Promise<void>;\n on<E extends PlayerEvent>(event: E, cb: (payload: PlayerEventMap[E]) => void): () => void;\n destroy(): void;\n}\n\nconst DEFAULT_STATS_INTERVAL_MS = 2000;\n/**\n * How often to re-resolve playback so the signed license URL the engine would\n * use is younger than havik-streams' DRM_SIGN_TTL (10m).\n *\n * This refreshes the URL, NOT the license: it hands the engine a new string\n * (see refreshLicense → engine.setLicenseUrl), and licenseXhrSetup reads that\n * string when hls.js next POSTs. It issues no license request of its own, so it\n * cannot extend a license the CDM has already taken — the licence's own\n * expiration governs that, and it is set server-side\n * (havik-drm LICENSE_EXPIRATION_DURATION). Do not \"fix\" that by rebuilding the\n * instance on a timer: a rebuild is a visible interruption, and the CDM it\n * would want to re-key is not replaceable anyway (see core/drm/cdm.ts).\n */\nconst DEFAULT_LICENSE_REFRESH_MS = 8 * 60 * 1000;\n\n/**\n * Options with the endpoint decision already made. The player reads\n * `opts.baseUrl` in a dozen places (resolve, SSE derivation, beacons\n * derivation); resolving once at the boundary keeps every one of them a\n * plain string instead of pushing an `env`-or-`baseUrl` branch inward.\n */\ntype ResolvedPlayerOptions = CreatePlayerOptions & { baseUrl: string };\n\n/**\n * Settle `env` / `baseUrl` into the one base URL the player will use, or\n * throw. Kept separate from the other createPlayer guards because it is the\n * only one that has to reject a combination rather than an absence.\n */\nfunction resolveEndpoint(opts: CreatePlayerOptions): string {\n if (opts.env !== undefined && opts.baseUrl !== undefined) {\n throw new PlaybackError(\n 'INTERNAL',\n 0,\n `createPlayer: pass env OR baseUrl, not both (got env=${JSON.stringify(opts.env)} ` +\n `and baseUrl=${JSON.stringify(opts.baseUrl)})`,\n );\n }\n if (opts.env !== undefined) return baseUrlForEnv(opts.env);\n if (!opts.baseUrl) {\n throw new PlaybackError(\n 'INTERNAL',\n 0,\n \"createPlayer: env or baseUrl is required (env: 'integration' | 'production')\",\n );\n }\n return opts.baseUrl;\n}\n\n/**\n * Endpoint selection, enforced at COMPILE time.\n *\n * `env` and `baseUrl` are both optional on {@link CreatePlayerOptions} so it\n * stays an interface others can `extend` and `Partial<>`. That alone would\n * make \"neither given\" type-check and fail only in the browser, which is a\n * guarantee this SDK used to have while `baseUrl` was required. These two\n * overloads put it back at the call site without changing the interface: one\n * names `env` and forbids `baseUrl`, the other the reverse, so neither and\n * both are both \"no overload matches this call\".\n *\n * resolveEndpoint still enforces the same rule at runtime — the overloads do\n * nothing for a JavaScript caller, and that is most of the embed surface.\n */\nexport function createPlayer(\n opts: CreatePlayerOptions & { env: HavikEnv; baseUrl?: never },\n): Promise<Player>;\nexport function createPlayer(\n opts: CreatePlayerOptions & { baseUrl: string; env?: never },\n): Promise<Player>;\n/**\n * Mode A — managed player. Owns the <video> and a bundled hls.js, wiring\n * LL-HLS + DRM automatically. With `waitForLive`, arms on an upcoming match and\n * auto-plays the instant it goes live. Resolves to a Player as soon as it is\n * armed/attached — terminal errors (404/410/401/...) reject; the live wait does\n * not block and is surfaced via 'waiting' events.\n */\nexport async function createPlayer(opts: CreatePlayerOptions): Promise<Player> {\n // Fail fast with typed errors on misuse, before any async/DOM work.\n if (!opts.video || typeof (opts.video as HTMLVideoElement).play !== 'function') {\n throw new PlaybackError('INTERNAL', 0, 'createPlayer: opts.video must be an HTMLVideoElement');\n }\n const baseUrl = resolveEndpoint(opts);\n if (!opts.matchUrn)\n throw new PlaybackError('INVALID_URN', 0, 'createPlayer: matchUrn is required');\n if (!opts.credential) {\n throw new PlaybackError('INTERNAL', 0, 'createPlayer: credential is required');\n }\n const player = new ManagedPlayer({ ...opts, baseUrl });\n await player.start();\n return player;\n}\n\nclass ManagedPlayer implements Player {\n private readonly opts: ResolvedPlayerOptions;\n private readonly abort = new AbortController();\n private readonly listeners: { [E in PlayerEvent]?: Set<(p: never) => void> } = {};\n private engine: PlaybackEngine | null = null;\n private controlBar: ControlBarHandle | null = null;\n private container: HTMLElement | null = null;\n private detachMedia?: () => void;\n private detachPip?: () => void;\n private statsTimer?: ReturnType<typeof setInterval>;\n private refreshTimer?: ReturnType<typeof setInterval>;\n private _state: PlayerState = 'idle';\n private _descriptor: StreamDescriptor | null = null;\n private destroyed = false;\n /** Monotonic load generation; a load whose generation is stale must not commit. */\n private loadGen = 0;\n /** Push-based live-state subscription (SSE), active during committed playback. */\n private liveStateSub: LiveStateSubscription | null = null;\n /** Guards the end-of-stream handler so SSE and the HLS watchdog can't double-fire. */\n private endedHandled = false;\n /** Why the current end happened, when the server said (see EndedReason).\n * null = ended without a reason (an older server, or the HLS staleness\n * watchdog getting there first) — callers must read that as \"unknown\",\n * which is exactly the pre-#191 behavior: assume a real end. */\n private _endedReason: EndedReason | null = null;\n /** Pending post-ended rejoin (the jitter timer; see scheduleRejoin). */\n private rejoinTimer: ReturnType<typeof setTimeout> | null = null;\n /** Live-state subscription used during the pre-live armed/waiting phase. */\n private armingSub: LiveStateSubscription | null = null;\n /** Connect-grace / heartbeat-liveness timer for the armed SSE subscription. */\n private armSseTimer: ReturnType<typeof setTimeout> | null = null;\n /** Abort handle for the SSE arm's polling fallback — closeArming() (and thus\n * reload()/retry()/destroy()) must cancel it, or an orphaned poll keeps\n * hitting /v1/playback and emitting stale 'waiting' for a dead generation. */\n private armFallbackCtrl: AbortController | null = null;\n /** Last fatal error routed through fail() — start() turns a fatal engine\n * load into a createPlayer rejection with it (the emit happens before the\n * caller can attach listeners, so the event alone is unobservable there). */\n private _lastError: PlaybackError | null = null;\n /** QoE beacon session (plan A2); one per sid, created at engine commit. */\n private analyticsSession: AnalyticsSession | null = null;\n private detachMediaListeners?: () => void;\n /** True once play() was ever asked for (user gesture or API). Ends the\n * autoplay-off deferral, and lets a post-play reload/rejoin resume without\n * a second gesture — playback the viewer started should come back on its\n * own after a recovery re-attach. */\n private playRequested = false;\n /**\n * The beacon session closed itself for inactivity (see the `idleMs`\n * option): the sid is finished and the engine was stopped, so the next\n * play() is a rejoin — reload() resolves a new sid and re-plays from the\n * live edge — not a resume of a session that no longer exists.\n */\n private idleClosed = false;\n /** A deferLoad engine is attached and waits for the first play() to start\n * media loading (autoplay-off pre-play). */\n private pendingStartLoad = false;\n\n constructor(opts: ResolvedPlayerOptions) {\n this.opts = opts;\n }\n\n get state(): PlayerState {\n return this._state;\n }\n get descriptor(): StreamDescriptor | null {\n return this._descriptor;\n }\n get endedReason(): EndedReason | null {\n return this._endedReason;\n }\n\n async start(): Promise<void> {\n if (this.opts.muted) this.opts.video.muted = true;\n if (this.opts.poster) this.opts.video.poster = this.opts.poster;\n this.mountControls();\n this.attachPipListeners();\n this.setState('loading');\n const gen = ++this.loadGen;\n const resolveOpts = this.resolveOptions();\n try {\n const descriptor = await resolvePlaybackOnce(resolveOpts);\n await this.onResolved(descriptor, gen);\n if (gen === this.loadGen && this._state === 'error' && this._lastError) {\n // Fatal engine load (already routed through fail()) — reject so the\n // first-load caller observes the typed error; the 'error' event fired\n // before any listener could exist. Unwind FULLY first: the caller\n // never receives a Player to destroy(), so leaving the mounted\n // control bar / PiP listeners / video wrapper behind would leak DOM\n // for the life of the page.\n const err = this._lastError;\n this.destroy();\n throw err;\n }\n } catch (err) {\n if (this.opts.waitForLive && err instanceof PlaybackError && isWaitable(err.code)) {\n // Don't block: return the (armed) player; keep polling in the background.\n this.setState('waiting');\n this.beginArming(gen, err.liveStartsAtMs);\n return;\n }\n throw err;\n }\n }\n\n private resolveOptions(): ResolveOptions {\n return {\n baseUrl: this.opts.baseUrl,\n matchUrn: this.opts.matchUrn,\n credential: this.opts.credential,\n signal: this.abort.signal,\n apiHeaders: this.opts.apiHeaders,\n };\n }\n\n private beginArming(gen: number, liveStartsAtMs?: number, reconnect = false): void {\n const base =\n this.opts.liveStateEvents === false\n ? undefined\n : (this.opts.eventsBaseUrl ?? deriveEventsBaseUrl(this.opts.baseUrl));\n if (base) this.armWithSse(gen, base, liveStartsAtMs, reconnect);\n else this.beginArmingByPolling(gen);\n }\n\n /** SSE-disabled path: the original kickoff-aware /v1/playback poll. */\n private beginArmingByPolling(gen: number): void {\n resolveStream({\n ...this.resolveOptions(),\n signal: this.abort.signal,\n waitForLive: this.buildWaitConfig(gen),\n })\n .then((descriptor) => {\n // Same staleness guard as the catch: a resolve that SETTLED before a\n // supersede (reload/destroy bumps loadGen) must not build an engine.\n if (this.destroyed || this.abort.signal.aborted || gen !== this.loadGen) return;\n return this.onResolved(descriptor, gen);\n })\n .catch((err) => {\n if (this.destroyed || this.abort.signal.aborted || gen !== this.loadGen) return;\n // Wrap non-PlaybackErrors (e.g. a rejecting credential function) so\n // the 'error' event payload honors its documented type.\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n });\n }\n\n /**\n * Armed wait, SSE-first. While the live-state stream is healthy the player\n * does NOT poll /v1/playback — it waits for the pushed `live`, then resolves\n * exactly once (jittered, to stagger the go-live herd). Polling only starts\n * if SSE fails to connect within a grace, goes stale (missed heartbeats), or\n * errors — so an unreachable events endpoint is no worse than today.\n */\n private armWithSse(\n gen: number,\n eventsBaseUrl: string,\n liveStartsAtMs?: number,\n reconnect = false,\n ): void {\n let committed = false;\n let fallbackStarted = false;\n const fallbackCtrl = new AbortController();\n // No abort-listener chain to this.abort here: every abandonment path\n // (reload()/retry()/destroy()/commit) runs closeArming(), which aborts\n // this controller — a per-arm listener on the player-lifetime signal\n // would just accumulate one captured closure per re-arm cycle.\n this.armFallbackCtrl = fallbackCtrl;\n this.setState('waiting');\n // Kickoff hint precedence mirrors the polling path: the server's 425\n // liveStartsAt wins, the caller's waitForLive.kickoffAt is the fallback.\n const wf = this.opts.waitForLive;\n const kickoffOpt = wf && wf !== true ? wf.kickoffAt : undefined;\n const kickoffOptMs =\n typeof kickoffOpt === 'number'\n ? kickoffOpt\n : typeof kickoffOpt === 'string'\n ? Date.parse(kickoffOpt)\n : NaN;\n const startsAtMs = liveStartsAtMs ?? (Number.isFinite(kickoffOptMs) ? kickoffOptMs : undefined);\n // Emit an observable 'waiting' for the SSE arm. The polling path emits\n // one per poll via buildWaitConfig, but a healthy SSE wait makes no\n // /v1/playback requests at all — without this, an armed player is\n // invisible to hosts (the pre-resolve statechange is deduped and\n // createPlayer resolves already armed; the ops Demo Player had to probe\n // player.state synchronously to find out). Deferred a MACROTASK: this\n // runs synchronously inside start(), before createPlayer resolves — a\n // synchronous (or microtask) emit would fire before the caller can\n // attach listeners and be missed, recreating the exact gap this closes.\n // Synthetic-but-honest shape: the next self-check is the SSE liveness\n // window.\n // No clearTimeout-on-abort bookkeeping: the callback's committed/\n // destroyed/gen guard makes a post-teardown firing a no-op, and the\n // timer is 0 ms — long gone by any later re-arm. (A per-arm abort\n // listener on the player-lifetime signal would leak one closure per\n // re-arm cycle.)\n setTimeout(() => {\n if (committed || this.destroyed || gen !== this.loadGen) return;\n this.emit('waiting', {\n // A rejoin arm is a mid-match reconnect, not a pre-kickoff wait —\n // 'unavailable' is the phase hosts map to \"temporarily unavailable\"\n // copy; 'tooEarly' would read as \"starting soon\" while a live match\n // recovers from an ingest outage.\n phase: reconnect ? 'unavailable' : 'tooEarly',\n retryInMs: SSE_ARM_LIVENESS_MS,\n attempt: 1,\n elapsedMs: 0,\n ...(!reconnect && startsAtMs !== undefined && startsAtMs > Date.now()\n ? { ttkMs: startsAtMs - Date.now() }\n : {}),\n });\n }, 0);\n\n const stale = (): boolean =>\n this.destroyed || this.abort.signal.aborted || gen !== this.loadGen;\n\n const commit = async (descriptor: StreamDescriptor): Promise<void> => {\n if (committed || stale()) return;\n committed = true;\n this.closeArming();\n fallbackCtrl.abort(); // supersede any running poll fallback\n try {\n await this.onResolved(descriptor, gen);\n } catch (err) {\n // onResolved can reject (credential mint at go-live, engine\n // construction). By now committed=true, so the callers' .catch\n // handlers early-return — an unhandled rejection here would strand\n // the player in 'waiting' forever with arming already closed.\n // Surface it: listeners exist by now (this is post-createPlayer).\n if (stale()) return;\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n }\n };\n\n const startFallback = (): void => {\n if (committed || fallbackStarted || stale()) return;\n fallbackStarted = true;\n // Route the fallback's success through commit(), NOT straight to\n // onResolved: SSE can recover and push 'live' while the poll's resolve\n // has already settled (abort can't retract a settled promise), and two\n // unguarded onResolved calls for the same generation would build two\n // engines on one video — the first leaks its watchdog + media\n // listeners. commit() is idempotent, so whichever side wins, the other\n // becomes a no-op.\n resolveStream({\n ...this.resolveOptions(),\n signal: fallbackCtrl.signal,\n waitForLive: this.buildWaitConfig(gen),\n })\n .then((descriptor) => commit(descriptor))\n .catch((err) => {\n if (committed || stale() || fallbackCtrl.signal.aborted) return;\n committed = true;\n this.closeArming();\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n });\n };\n\n const resolveOnce = (): void => {\n if (committed || stale()) return;\n resolvePlaybackOnce(this.resolveOptions())\n .then((d) => commit(d))\n .catch((err) => {\n if (committed || stale()) return;\n // Not live yet (425/503/transient). We only get here because SSE\n // PUSHED live/gone, so playback lagging the push is a consistency\n // race, not \"keep waiting for the next push\": a healthy SSE never\n // re-pushes an unchanged state, and its heartbeats keep deferring\n // the staleness fallback — returning here wedged the post-ended\n // rejoin until the SSE lifetime-cap reconnect replayed the snapshot\n // (observed live 2026-08-25 on a 10s-delayed stream, where the\n // recreated session's /v1/playback view lagged the bridge's `live`\n // flip by ~5s). Hand the wait to the polling ladder instead: it\n // retries with backoff and commits through the same idempotent\n // commit(), and a terminal answer still surfaces through its catch.\n if (err instanceof PlaybackError && isWaitable(err.code)) {\n startFallback();\n return;\n }\n // Terminal (e.g. 410 GONE — cancelled before go-live): surface it.\n committed = true;\n this.closeArming();\n fallbackCtrl.abort();\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n });\n };\n\n const rearmStale = (ms: number): void => {\n if (this.armSseTimer) clearTimeout(this.armSseTimer);\n this.armSseTimer = setTimeout(() => {\n if (!committed && !stale()) startFallback(); // SSE didn't connect / went silent\n }, ms);\n };\n rearmStale(SSE_ARM_CONNECT_GRACE_MS);\n\n this.armingSub = subscribeLiveState({\n eventsBaseUrl,\n matchUrn: this.opts.matchUrn,\n credential: this.opts.credential,\n onAlive: () => rearmStale(SSE_ARM_LIVENESS_MS),\n onState: (ev) => {\n if (committed || stale()) return;\n // live → go play; gone → resolve (will 410) so the terminal surfaces.\n if (ev.state === 'live' || ev.state === 'gone') {\n setTimeout(resolveOnce, Math.random() * SSE_ARM_RESOLVE_JITTER_MS);\n }\n },\n onError: () => startFallback(),\n });\n }\n\n private closeArming(): void {\n if (this.armSseTimer) {\n clearTimeout(this.armSseTimer);\n this.armSseTimer = null;\n }\n // Cancel the SSE arm's polling fallback too — reload()/retry()/destroy()\n // all route through here, and an orphaned poll would keep hitting\n // /v1/playback and emitting stale 'waiting' for a dead generation.\n this.armFallbackCtrl?.abort();\n this.armFallbackCtrl = null;\n this.armingSub?.close();\n this.armingSub = null;\n }\n\n private buildWaitConfig(gen: number): WaitForLive {\n const base: WaitForLiveOptions =\n this.opts.waitForLive && this.opts.waitForLive !== true ? this.opts.waitForLive : {};\n return {\n ...base,\n onState: (s) => {\n // Generation guard: an orphaned poll (superseded by reload/destroy)\n // must not flip a newer generation's state back to 'waiting'.\n if (this.destroyed || gen !== this.loadGen) return;\n this.setState('waiting');\n this.emit('waiting', s);\n base.onState?.(s);\n },\n };\n }\n\n /**\n * Build and attach the engine for a resolved descriptor. `gen` is the load\n * generation this attempt belongs to: it is re-checked after every await so a\n * destroy()/reload() that lands mid-flight cannot leave an orphaned engine or\n * timers behind (the continuation cleans up its locals and returns).\n */\n private async onResolved(descriptor: StreamDescriptor, gen: number): Promise<void> {\n if (this.destroyed || gen !== this.loadGen) return;\n this._descriptor = descriptor;\n this.endedHandled = false; // fresh playback can end again\n this._endedReason = null;\n\n const credential = await resolveCredential(this.opts.credential);\n if (this.destroyed || gen !== this.loadGen) return; // superseded during credential mint\n this.setState('loading');\n\n // Autoplay off and no play() yet: attach a deferLoad engine — it parses\n // the manifest ('ready', track lists) but fetches no media and mints no\n // DRM license until the first play(). Without this the pre-play player\n // buffered the live stream forever: state never left 'loading' (spinner,\n // no poster card) and hls.js force-seeked the paused video to the live\n // edge every ~12s (the drift ceiling), repainting the frame (AV-202).\n const deferLoad = this.opts.autoplay === false && !this.playRequested;\n const engine = this.createEngine(descriptor, credential, deferLoad);\n const detachMedia = attachMediaStateListeners(this.opts.video, (s) => {\n // Terminal latch: after 'ended'/'error' the media element stays attached\n // and its queued events — the 'pause' from engine.stop(), buffer-drain\n // 'waiting'/'stalled' after a fatal error — would flip the state right\n // back off the terminal value (a host rendering an overlay from\n // statechange sees it dismiss itself). Explicit transitions\n // (retry/reload/load → 'loading', destroy → 'idle') call setState\n // directly and are unaffected.\n if (this._state === 'ended' || this._state === 'error') return;\n this.setState(s);\n });\n await engine.load();\n if (this.destroyed || gen !== this.loadGen) {\n // Torn down or superseded while loading — release what we just built.\n detachMedia();\n engine.destroy();\n return;\n }\n if (this._state === 'error') {\n // A FATAL engine error was emitted during load() (e.g. the platform\n // fail-fast: DRM required but no key system usable here). Do not wire\n // the dead engine — no stats ticker, no 8-minute license-refresh\n // resolves, no SSE subscription. start() additionally turns this into\n // a createPlayer rejection: the emit fired before the caller could\n // attach listeners, so the event alone is unobservable on first load.\n detachMedia();\n engine.destroy();\n return;\n }\n\n this.engine = engine;\n this.detachMedia = detachMedia;\n if (this.opts.maxBitrate != null) engine.setMaxBitrate(this.opts.maxBitrate);\n this.startStats();\n this.scheduleLicenseRefresh();\n this.startLiveState(gen);\n this.startAnalytics(descriptor, engine, gen);\n if (deferLoad) {\n // Pre-play: 'paused' is what renders the poster + play card (and hides\n // the spinner — 'loading' means work is happening, and none is). No\n // media event will fire to move the state until play() starts loading.\n this.pendingStartLoad = true;\n this.setState('paused');\n // Native engine: its 'ready' (loadedmetadata) cannot fire pre-play\n // under preload=\"none\", and the engine-side emit is suppressed for a\n // deferred instance — emit it here instead, so host event timing\n // matches the hls.js engine (ready from MANIFEST_PARSED, which still\n // loads). Deferred a MACROTASK: this runs synchronously inside\n // start()/reload(), before createPlayer resolves — a synchronous emit\n // would fire before the host can attach listeners and before the\n // analytics session exists to stamp noteReady(). Same pattern as the\n // SSE arm's 'waiting' emit.\n if (engine.name === 'native') {\n setTimeout(() => {\n if (this.destroyed || gen !== this.loadGen || this.engine !== engine) return;\n this.analyticsSession?.noteReady();\n this.emit('ready');\n }, 0);\n }\n }\n }\n\n /**\n * Start the QoE beacon session for a committed engine (plan A2). This is\n * deliberately AFTER engine selection: player identity is only truthful\n * once the engine is chosen (the SessionStart contract). A reload/retry\n * resolves a NEW sid, so the superseded session ends as STOPPED first.\n */\n /**\n * The beacon session ended itself (END_REASON_IDLE). Stop the engine with\n * it: a paused player has nothing to keep loading, and a player that\n * slept through the idle limit while playing would otherwise carry on\n * unbeaconed, miles behind live. stop() pauses the element, so the viewer\n * sees a paused player and the next play() rejoins (see idleClosed). Only\n * for the engine the session belonged to — a reload that raced the timer\n * has already replaced both.\n */\n private onAnalyticsIdle(engine: PlaybackEngine): void {\n if (this.destroyed || this.engine !== engine) return;\n this.idleClosed = true;\n this.pendingStartLoad = false; // a deferred engine is stopped too; play() reloads instead\n engine.stop();\n }\n\n private startAnalytics(descriptor: StreamDescriptor, engine: PlaybackEngine, gen: number): void {\n this.analyticsSession?.noteDestroyed();\n this.analyticsSession = null;\n this.detachMediaListeners?.();\n\n if (this.opts.analytics === false) return;\n const sid = descriptor.analytics?.sid;\n if (!sid) return; // server-side analytics flag is off — nothing to do\n const explicit =\n typeof this.opts.analytics === 'object' ? this.opts.analytics.endpoint : undefined;\n const { endpoint, disabledReason } = resolveBeaconsEndpoint(this.opts.baseUrl, explicit);\n if (!endpoint) {\n // The server minted a sid — analytics is ON for this client — but we have\n // nowhere to put the beacons. resolveBeaconsEndpoint has already said so\n // on the console; mirror it on the 'warning' event so an integrator can\n // alert on it without reading a browser console. Deferred a MACROTASK for\n // the same reason the native pre-play 'ready' is: startAnalytics runs\n // synchronously inside start(), before createPlayer resolves, so a\n // synchronous emit would fire before the host can attach listeners —\n // unobservable on exactly the first load that matters.\n if (disabledReason) {\n setTimeout(() => {\n if (this.destroyed || gen !== this.loadGen) return;\n this.emit('warning', new PlaybackError('INTERNAL', 0, disabledReason));\n }, 0);\n }\n return;\n }\n\n const video = this.opts.video;\n const session = new AnalyticsSession({\n endpoint,\n sid,\n player: engine.name === 'native' ? 'PLAYER_SAFARI_NATIVE' : 'PLAYER_HLSJS',\n sdkVersion: SDK_VERSION,\n environment: collectEnvironment(video),\n idleMs: typeof this.opts.analytics === 'object' ? this.opts.analytics.idleMs : undefined,\n onIdle: () => this.onAnalyticsIdle(engine),\n sample: () => {\n const stats = this.engine?.getStats() ?? EMPTY_STATS;\n const buffered = video.buffered.length\n ? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)\n : 0;\n return {\n ...stats,\n bufferedMs: Math.round(buffered * 1000),\n state: this._state,\n positionMs: Math.round(video.currentTime * 1000),\n playbackRate: video.playbackRate,\n };\n },\n });\n this.analyticsSession = session;\n const onSeeking = () => session.noteSeek();\n const onVolume = () => session.noteVolume(video.muted, video.volume);\n video.addEventListener('seeking', onSeeking);\n video.addEventListener('volumechange', onVolume);\n this.detachMediaListeners = () => {\n video.removeEventListener('seeking', onSeeking);\n video.removeEventListener('volumechange', onVolume);\n };\n session.start();\n // Seeds the muted/active-watch accounting for a player that starts muted\n // (the autoplay-friendly default), which fires no volumechange of its\n // own. seedVolume, not noteVolume: the starting state is not something\n // the viewer did, and counting it would put a phantom change on every\n // session.\n session.seedVolume(video.muted, video.volume);\n session.notePresentation(this.currentPresentation());\n // Model and OS version resolve asynchronously; SessionStart is held\n // briefly for exactly this, and a browser without client hints simply\n // never patches anything.\n void collectHighEntropy().then((extra) => session.noteEnvironment(extra));\n // Autoplay disabled is a verdict this session already has: nothing will\n // be attempted, so say so rather than leaving the field unset.\n if (this.opts.autoplay === false) session.noteAutoplay('AUTOPLAY_RESULT_NOT_ATTEMPTED');\n }\n\n /** Which surface the video is showing on right now. */\n private currentPresentation(): Presentation {\n const video = this.opts.video as HTMLVideoElement & {\n webkitDisplayingFullscreen?: boolean;\n };\n if (typeof document !== 'undefined' && document.pictureInPictureElement === video) return 'pip';\n const fs = typeof document !== 'undefined' ? document.fullscreenElement : null;\n if (video.webkitDisplayingFullscreen === true) return 'fullscreen';\n if (fs && (fs === video || fs.contains(video))) return 'fullscreen';\n return 'inline';\n }\n\n private createEngine(\n descriptor: StreamDescriptor,\n credential: Credential,\n deferLoad = false,\n ): PlaybackEngine {\n const hlsConfig: Partial<HlsConfig> = { ...this.opts.hlsConfig };\n if (this.opts.startLevel != null) hlsConfig.startLevel = this.opts.startLevel;\n // liveLatencyTarget -> hls.js liveSyncDuration. The engine's normalizeLiveSync\n // derives the matching drift ceiling (liveMaxLatencyDuration, kept strictly\n // above the target) on the final merged config, so we set only the target\n // here and never risk an illegal pair. When unset, the engine default target\n // (2s) applies. liveCatchUpRate is no longer a public knob — the engine's\n // 1.5x catch-up + that ceiling handle drift; advanced callers can still set\n // maxLiveSyncPlaybackRate (or the raw live-sync keys) via hlsConfig.\n if (this.opts.liveLatencyTarget != null)\n hlsConfig.liveSyncDuration = this.opts.liveLatencyTarget;\n\n const deps = {\n video: this.opts.video,\n descriptor,\n credential,\n deviceId: getDeviceId(),\n userId: this.opts.userId,\n lowLatency:\n this.opts.lowLatency === undefined || this.opts.lowLatency === 'auto'\n ? true\n : this.opts.lowLatency,\n snapToLiveOnRefocus: this.opts.snapToLiveOnRefocus,\n debug: this.opts.debug,\n deferLoad,\n hlsConfig,\n emit: (ev: EngineEvent) => this.onEngineEvent(ev),\n };\n const choice = this.opts.engine ?? 'auto';\n if (choice === 'native') return new NativeHlsEngine(deps);\n if (choice === 'hls') return new HlsEngine(deps);\n // auto: FairPlay content on an Apple browser goes to the NATIVE engine.\n // hls.js/MSE cannot decrypt FairPlay here — WebKit binds the key through\n // the fragment `sinf` box, so a session built from a synthesized skd asset\n // ID reports 'usable' and still renders nothing. Measured on macOS Safari\n // 26.5 against live DRM (2026-08-06): hls.js+MSE 1 pass / 6, native 4 / 4.\n // Everything else keeps hls.js, which gives Widevine + LL-HLS control.\n //\n // webkitMediaStack() is what makes \"on an Apple browser\" true: Chromium\n // answers canPlayType('application/vnd.apple.mpegurl') with \"maybe\" while\n // being unable to play HLS natively, so nativeHlsSupported() alone sent\n // every DRM-enabled Chrome session into the native engine and failed it.\n const fairplayOnApple =\n descriptor.drmEnabled &&\n Boolean(descriptor.drm?.fairplay?.licenseUrl && descriptor.drm.fairplay.certificateUrl) &&\n nativeHlsSupported(this.opts.video) &&\n webkitMediaStack();\n if (fairplayOnApple) return new NativeHlsEngine(deps);\n if (hlsSupported()) return new HlsEngine(deps);\n if (nativeHlsSupported(this.opts.video)) return new NativeHlsEngine(deps);\n throw new PlaybackError('INTERNAL', 0, 'no supported HLS playback engine in this browser');\n }\n\n private onEngineEvent(ev: EngineEvent): void {\n if (this.destroyed) return;\n switch (ev.type) {\n case 'ready':\n this.analyticsSession?.noteReady();\n this.emit('ready');\n // playRequested keeps autoplay-off resume semantics: once the viewer\n // pressed play, a re-attach (rejoin after an interruption, a play()\n // during the armed wait, reload) resumes without a second gesture —\n // otherwise it would sit in 'loading' forever, exactly AV-202 again.\n if (this.opts.autoplay !== false || this.playRequested) void this.play();\n break;\n case 'lowLatencyDetected':\n if (this._descriptor) this._descriptor.isLowLatency = ev.value;\n break;\n case 'qualitychange':\n this.emit('qualitychange', ev.index);\n break;\n case 'audiotrackchange':\n this.emit('audiotrackchange', ev.id);\n break;\n case 'texttrackchange':\n this.emit('texttrackchange', ev.id);\n break;\n case 'ended':\n // HLS-layer end signal (ENDLIST, or the staleness watchdog catching a\n // frozen Tencent manifest). The SSE live-state path can reach the same\n // handler first; endedHandled dedupes.\n this.onStreamEnded();\n break;\n case 'error':\n this.analyticsSession?.noteError(\n ev.error.code,\n ev.error.message,\n ev.fatal,\n ev.error.httpStatus ?? 0,\n Math.round(this.opts.video.currentTime * 1000),\n ev.detail,\n );\n if (ev.fatal) this.fail(ev.error);\n else this.emit('warning', ev.error);\n break;\n }\n }\n\n /**\n * Start the push-based live-state subscription for the committed playback.\n * On `ended`/`gone` it routes to onStreamEnded — the fast, clean end signal\n * (fires when the bridge flips, before the manifest would even 503). Failures\n * are non-fatal: the subscription retries in the background and the HLS\n * staleness watchdog (engine 'ended') is the layer-fallback, so a missing or\n * unreachable events endpoint never affects playback. But a PERSISTENTLY\n * failing subscription (blocked origin, wrong events host, CSP) used to be\n * fully invisible — end detection silently degraded to the watchdog with no\n * way to tell from the outside. After LIVE_STATE_WARN_AFTER consecutive\n * failures with no data ever flowing, a one-time non-fatal 'warning' is\n * emitted so integrators can see it; a later successful connect resets the\n * counter (and re-arms the warning) since the outage evidently healed.\n */\n private startLiveState(gen: number): void {\n if (this.opts.liveStateEvents === false) return;\n const base = this.opts.eventsBaseUrl ?? deriveEventsBaseUrl(this.opts.baseUrl);\n if (!base) return;\n this.closeLiveState();\n let failures = 0;\n let warned = false;\n this.liveStateSub = subscribeLiveState({\n eventsBaseUrl: base,\n matchUrn: this.opts.matchUrn,\n credential: this.opts.credential,\n onState: (ev) => {\n if (this.destroyed || gen !== this.loadGen) return;\n if (ev.state === 'gone') {\n // Past the catch-up window — truly terminal, nothing left to rejoin.\n this.onStreamEnded(true, ev.reason ?? 'match_ended');\n } else if (ev.state === 'ended') {\n this.onStreamEnded(false, ev.reason);\n } else if (ev.state === 'live' && this.endedHandled) {\n // A live push AFTER end-of-stream means the bridge recreated the\n // session (encoder reconnected past the grace window) — and it now\n // serves under a NEW manifest URL, so only a re-resolve gets back.\n this.scheduleRejoin(gen);\n }\n },\n onAlive: () => {\n // Data flowed — the subscription is healthy. Reset so only a NEW\n // sustained outage (not routine reconnects) can trigger the warning.\n failures = 0;\n warned = false;\n },\n onError: () => {\n if (this.destroyed || gen !== this.loadGen || warned) return;\n // After end-of-stream the subscription's only remaining job is the\n // rejoin trigger — end detection already happened, so the degraded-\n // detection warning below would be misinformation there.\n if (this.endedHandled) return;\n failures += 1;\n if (failures >= LIVE_STATE_WARN_AFTER) {\n warned = true;\n this.emit(\n 'warning',\n new PlaybackError(\n 'NETWORK',\n 0,\n `live-state events unreachable at ${base} after ${failures} attempts — ` +\n `end-of-stream detection degraded to the HLS staleness watchdog. ` +\n `Check the events host, the api-key's allowed origins, and CSP connect-src.`,\n ),\n );\n }\n },\n });\n }\n\n private closeLiveState(): void {\n this.liveStateSub?.close();\n this.liveStateSub = null;\n }\n\n /**\n * End-of-stream: stop the engine's buffer/poll loop (keeping the last\n * frame) and surface `ended` (via setState). Idempotent across the SSE and\n * HLS-watchdog signals. The live-state subscription closes only when the\n * end is `terminal` (`gone` — past the catch-up window) or rejoin is off;\n * otherwise it stays open as the rejoin trigger — a later pushed `live`\n * means the session was recreated and playback can re-arm (scheduleRejoin).\n */\n private onStreamEnded(terminal = false, reason?: EndedReason): void {\n if (this.destroyed) return;\n if (terminal || !this.rejoinEnabled()) {\n this.cancelRejoin();\n this.closeLiveState();\n }\n if (this.endedHandled) {\n // Already ended, but the SERVER may have refined WHY: an outage that\n // outlived the bridge's hold reports `interrupted` first and flips to\n // `match_ended` when the match actually finishes (havik-streams #191\n // publishes that as a second event — the state never changes, only the\n // reason). Upgrade the reason and re-render so a viewer stops being\n // told \"reconnecting\" for a match that is over. Never downgrade: once\n // we know the match ended, a late `interrupted` cannot un-end it.\n if (reason === 'match_ended' && this._endedReason !== 'match_ended') {\n this._endedReason = reason;\n // Only `ended` — NOT `statechange`. The state does not transition\n // here (it was, and stays, 'ended'), and `statechange` is documented\n // as a transition signal: re-emitting it would make an\n // edge-triggered host run its end-of-stream work twice. `ended`\n // re-firing with a refined reason IS part of its contract (see\n // PlayerEventMap), and the built-in control bar re-renders off it.\n this.emit('ended', reason);\n }\n return;\n }\n this.endedHandled = true;\n this._endedReason = reason ?? null;\n this.stopRefresh();\n this.pendingStartLoad = false; // the engine is stopped; a later play() must not re-kick it\n this.engine?.stop();\n this.setState('ended');\n }\n\n private rejoinEnabled(): boolean {\n return this.opts.rejoinOnLive !== false && this.opts.liveStateEvents !== false;\n }\n\n /**\n * A `live` push arrived after end-of-stream. Jittered like the armed\n * go-live resolve, so a popular stream's stranded viewers don't all\n * re-resolve in lockstep when the encoder comes back.\n */\n private scheduleRejoin(gen: number): void {\n if (!this.rejoinEnabled() || this.rejoinTimer) return;\n this.rejoinTimer = setTimeout(() => {\n this.rejoinTimer = null;\n if (this.destroyed || gen !== this.loadGen || this._state !== 'ended') return;\n this.rejoin();\n }, Math.random() * SSE_ARM_RESOLVE_JITTER_MS);\n }\n\n private cancelRejoin(): void {\n if (this.rejoinTimer) {\n clearTimeout(this.rejoinTimer);\n this.rejoinTimer = null;\n }\n }\n\n /**\n * Tear down the ended playback and re-enter the armed wait under a fresh\n * load generation. beginArming (SSE-first) re-resolves off the events\n * snapshot / `live` push, which keeps this robust to the recreate race: if\n * the pushed `live` slightly precedes /v1/playback consistency, the arming\n * ladder keeps waiting through waitable errors instead of failing the\n * player, and a terminal answer (`gone` → 410) still surfaces as an error.\n */\n private rejoin(): void {\n const gen = ++this.loadGen; // supersedes anything stale for the old playback\n this.teardownPlayback();\n this.setState('waiting');\n this.beginArming(gen, undefined, true);\n }\n\n /**\n * Stop and release the committed-playback plumbing: stats, license refresh,\n * any pending rejoin, arming, the live-state subscription, media listeners,\n * and the engine. Callers bump loadGen first so in-flight continuations for\n * the old playback go stale. destroy() keeps its own sequence — it\n * interleaves these with controls/PiP/analytics teardown in a load-bearing\n * order.\n */\n private teardownPlayback(): void {\n this.stopStats();\n this.stopRefresh();\n this.cancelRejoin();\n this.closeArming();\n this.closeLiveState();\n this.detachMedia?.();\n this.engine?.destroy();\n this.engine = null;\n this.pendingStartLoad = false; // the deferred engine is gone with it\n this.idleClosed = false; // and so is the idle-closed session; the next engine starts clean\n }\n\n /**\n * Set up the chosen controls. 'custom' (default) wraps the <video> in a\n * `.havik-player` container, applies the (themeable) Oddin skin, and mounts\n * the control bar. 'native' shows the browser UI; 'none' shows nothing.\n */\n private mountControls(): void {\n const mode = this.opts.controls ?? 'custom';\n const video = this.opts.video;\n if (mode === 'native') {\n video.controls = true;\n return;\n }\n if (mode === 'none') {\n video.controls = false;\n return;\n }\n video.controls = false;\n const doc = video.ownerDocument ?? document;\n const container = doc.createElement('div');\n const parent = video.parentNode;\n if (parent) parent.insertBefore(container, video);\n container.appendChild(video);\n applyTheme(container, this.opts.theme);\n this.container = container;\n this.controlBar = mountControlBar({\n root: container,\n video,\n player: this,\n logoUrl: this.opts.theme?.logoUrl,\n statusOverlays: this.opts.statusOverlays,\n endedMessage: this.opts.endedMessage,\n interruptedMessage: this.opts.interruptedMessage,\n errorMessage: this.opts.errorMessage,\n });\n }\n\n private unmountControls(): void {\n this.controlBar?.destroy();\n this.controlBar = null;\n const container = this.container;\n if (container) {\n if (container.parentNode) {\n container.parentNode.insertBefore(this.opts.video, container);\n container.remove();\n } else if (this.opts.video.parentNode === container) {\n // Mounted while the video was still detached (host creates the\n // element before inserting it): there is no parent to restore into,\n // but the video must still be released from the wrapper — otherwise\n // a destroyed player (or a rejected createPlayer, where the caller\n // never gets a handle to destroy) leaves an orphan container holding\n // the element.\n container.removeChild(this.opts.video);\n }\n }\n this.container = null;\n }\n\n private attachPipListeners(): void {\n const v = this.opts.video;\n const sync = () => this.analyticsSession?.notePresentation(this.currentPresentation());\n const onEnter = () => {\n sync();\n this.emit('pipchange', true);\n };\n const onLeave = () => {\n sync();\n this.emit('pipchange', false);\n };\n v.addEventListener('enterpictureinpicture', onEnter);\n v.addEventListener('leavepictureinpicture', onLeave);\n // Fullscreen is a document-level event and the QoE plane wants the same\n // answer for both surfaces, so one sync serves them: how long a viewer\n // watched fullscreen, in PiP, or inline.\n const doc = typeof document !== 'undefined' ? document : undefined;\n doc?.addEventListener('fullscreenchange', sync);\n doc?.addEventListener('webkitfullscreenchange', sync);\n this.detachPip = () => {\n v.removeEventListener('enterpictureinpicture', onEnter);\n v.removeEventListener('leavepictureinpicture', onLeave);\n doc?.removeEventListener('fullscreenchange', sync);\n doc?.removeEventListener('webkitfullscreenchange', sync);\n };\n }\n\n private startStats(): void {\n this.stopStats();\n const interval = Math.max(500, this.opts.statsIntervalMs ?? DEFAULT_STATS_INTERVAL_MS);\n this.statsTimer = setInterval(() => {\n if (!this.engine) return;\n const stats = this.engine.getStats();\n // First-segment startup stage at stats granularity (~2s), not the 15s\n // beacon cadence — first_frag_load_ms would otherwise overreport badly.\n if ((stats.bytesLoaded ?? 0) > 0) this.analyticsSession?.noteFirstBytes();\n this.emit('stats', stats);\n }, interval);\n }\n\n private stopStats(): void {\n if (this.statsTimer) clearInterval(this.statsTimer);\n this.statsTimer = undefined;\n }\n\n private scheduleLicenseRefresh(): void {\n this.stopRefresh();\n const interval = this.opts.licenseRefreshMs ?? DEFAULT_LICENSE_REFRESH_MS;\n if (!interval || !this._descriptor?.drmEnabled) return;\n this.refreshTimer = setInterval(() => void this.refreshLicense(), interval);\n }\n\n private stopRefresh(): void {\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.refreshTimer = undefined;\n }\n\n private async refreshLicense(): Promise<void> {\n const engine = this.engine;\n if (this.destroyed || !engine) return;\n try {\n const fresh = await resolvePlaybackOnce(this.resolveOptions());\n // A reload()/destroy() may have swapped or dropped the engine while the\n // refresh GET was in flight — don't apply a stale URL to a newer engine.\n if (this.destroyed || this.engine !== engine) return;\n const widevine = fresh.drm?.widevine?.licenseUrl;\n const fairplay = fresh.drm?.fairplay?.licenseUrl;\n if (widevine || fairplay) {\n engine.setLicenseUrls({ widevine, fairplay });\n this._descriptor = fresh;\n }\n } catch (err) {\n // A terminal error (e.g. 410 ended) means there's nothing left to refresh;\n // surface it as a non-fatal warning so a stalling refresh is observable.\n if (err instanceof PlaybackError && !isWaitable(err.code)) {\n this.stopRefresh();\n if (!this.destroyed) this.emit('warning', err);\n }\n }\n }\n\n async play(): Promise<void> {\n if (this.destroyed) return;\n const firstRequest = !this.playRequested;\n this.playRequested = true;\n // After an idle close the session and the engine are gone: rejoin. With\n // playRequested set, reload() → 'ready' re-enters play() on the fresh\n // engine, where the ordinary path below runs.\n // Not from a terminal state: an `ended` push that landed after the idle\n // close owns the UI exactly as it does below, and a reload would resolve\n // a finished match into an error card.\n if (this.idleClosed && this._state !== 'ended' && this._state !== 'error') {\n this.idleClosed = false;\n await this.reload();\n return;\n }\n // Terminal states own the UI; a play() arriving there (the control-bar\n // button stays clickable on the ended card) must not kick the loader and\n // resurrect 'loading' over an ended/error stream — the pre-PR player\n // resumed buffered frames there, and a dead session has nothing to load.\n const kickedDeferred =\n this.pendingStartLoad && this._state !== 'ended' && this._state !== 'error';\n // Startup is measured from HERE for the autoplay-off path, not from\n // load(): the player has been sitting behind a poster with the manifest\n // already parsed, and the time the viewer spent deciding is theirs.\n //\n // Gated on the PLAYER being autoplay-off, not on kickedDeferred, and\n // that difference is load-bearing. A gestureless play() that the policy\n // blocks still clears pendingStartLoad, and the blocked path\n // deliberately leaves the loader warm rather than re-latching — so the\n // eventual real gesture does NOT take the kicked branch. Anchoring there\n // would have pinned startup to the failed probe and measured the whole\n // wait for the gesture as latency, which is the bug this is here to fix.\n // notePlayIntent's own guard stops a post-playback resume re-anchoring a\n // startup that already finished; before the first frame, the LAST intent\n // is the right one. The blocked branch below says the same thing in\n // words: \"a policy-blocked play() is not viewer intent\".\n //\n // autoplay:true never reaches this: its intent is at load(), where the\n // manifest fetch genuinely is part of startup, so its ladder keeps\n // measuring from bornMs.\n if (this.opts.autoplay === false) this.analyticsSession?.notePlayIntent();\n if (kickedDeferred) {\n // First play() on an autoplay-off player: start media loading now.\n // 'loading' is honest again from here — the spinner shows while the\n // first segments and (when enabled) the DRM license are fetched, until\n // the media element's own events take over.\n this.pendingStartLoad = false;\n this.setState('loading');\n this.engine?.startLoad();\n }\n try {\n await this.opts.video.play();\n // The autoplay verdict, which only exists once play() has resolved.\n // Reported on the FIRST attempt of a session; SessionStart has gone by\n // the time a later user gesture plays, so a hand-started session can\n // never overwrite it.\n if (firstRequest) {\n this.analyticsSession?.noteAutoplay(\n this.opts.video.muted ? 'AUTOPLAY_RESULT_ALLOWED_MUTED' : 'AUTOPLAY_RESULT_ALLOWED',\n );\n }\n } catch (err) {\n // Autoplay blocked by browser policy → emit 'autoplayblocked' so the host\n // can show a tap-to-play / tap-to-unmute affordance. Other rejections\n // (e.g. a competing-load AbortError) are benign; genuine playback failures\n // arrive via the 'error' event from the engine.\n if (err instanceof DOMException && err.name === 'NotAllowedError') {\n this.analyticsSession?.noteAutoplay('AUTOPLAY_RESULT_BLOCKED');\n // A policy-blocked play() is not viewer intent: nothing ever played.\n // Un-latch, so a later reload/rejoin does not keep attempting\n // gestureless auto-plays — but only when THIS call set the latch; a\n // block after real playback must keep resume semantics.\n if (firstRequest) this.playRequested = false;\n // And put the pre-play card back instead of the spinner the blocked\n // kick left up. The loader is not stopped HERE: the warmed buffer\n // makes a prompt gesture play() start instantly; the hls.js engine's\n // own pause suspension stops it after its grace (PAUSE_SUSPEND_MS)\n // so a play card nobody clicks does not pull the live stream forever.\n // The state guard keeps a mid-await teardown/reload/ended transition\n // untouched.\n if (kickedDeferred && !this.destroyed && this._state === 'loading') {\n this.setState('paused');\n }\n this.emit('autoplayblocked');\n }\n }\n }\n\n pause(): void {\n if (this.destroyed) return;\n this.opts.video.pause();\n }\n setMuted(muted: boolean): void {\n if (this.destroyed) return;\n this.opts.video.muted = muted;\n }\n setVolume(volume: number): void {\n if (this.destroyed) return;\n this.opts.video.volume = Math.min(1, Math.max(0, volume));\n }\n\n getStats(): PlaybackStats {\n return this.engine?.getStats() ?? EMPTY_STATS;\n }\n\n getQualityLevels(): QualityLevel[] {\n return this.engine?.getQualityLevels() ?? [];\n }\n getCurrentQuality(): number {\n return this.engine?.getCurrentQuality() ?? -1;\n }\n setQuality(index: number | 'auto'): void {\n if (this.destroyed) return;\n this.engine?.setQuality(index === 'auto' ? -1 : index);\n }\n setMaxBitrate(bitrate: number | null): void {\n if (this.destroyed) return;\n this.engine?.setMaxBitrate(bitrate);\n }\n getAudioTracks(): AudioTrackInfo[] {\n return this.engine?.getAudioTracks() ?? [];\n }\n setAudioTrack(id: number): void {\n if (this.destroyed) return;\n this.engine?.setAudioTrack(id);\n }\n getTextTracks(): TextTrackInfo[] {\n return this.engine?.getTextTracks() ?? [];\n }\n setTextTrack(id: number): void {\n if (this.destroyed) return;\n this.engine?.setTextTrack(id);\n }\n seekToLive(): void {\n if (this.destroyed) return;\n this.engine?.seekToLive();\n }\n async enterPictureInPicture(): Promise<void> {\n if (this.destroyed) return;\n const v = this.opts.video as HTMLVideoElement & {\n requestPictureInPicture?: () => Promise<unknown>;\n };\n if (typeof v.requestPictureInPicture === 'function') await v.requestPictureInPicture();\n }\n async exitPictureInPicture(): Promise<void> {\n const doc = document as Document & {\n exitPictureInPicture?: () => Promise<void>;\n pictureInPictureElement?: Element | null;\n };\n if (\n doc.pictureInPictureElement === this.opts.video &&\n typeof doc.exitPictureInPicture === 'function'\n ) {\n await doc.exitPictureInPicture();\n }\n }\n async enterFullscreen(): Promise<boolean> {\n if (this.destroyed) return false;\n // The custom-controls wrapper (when mounted) is the element-fullscreen\n // target so the branded UI stays visible; native/none modes present the\n // bare video, where the browser's own fullscreen controls appear.\n return enterVideoFullscreen(this.opts.video, this.container ?? undefined);\n }\n async exitFullscreen(): Promise<void> {\n await exitVideoFullscreen(this.opts.video, this.container ?? undefined);\n }\n retry(): Promise<void> {\n return this.reload();\n }\n\n async reload(): Promise<void> {\n if (this.destroyed) return;\n const gen = ++this.loadGen; // supersedes any in-flight load/arming\n this.teardownPlayback();\n try {\n const descriptor = await resolveStream({\n ...this.resolveOptions(),\n waitForLive: this.opts.waitForLive ? this.buildWaitConfig(gen) : undefined,\n });\n await this.onResolved(descriptor, gen);\n if (gen === this.loadGen && this._state === 'error' && this._lastError) {\n // Fatal engine load — already routed through fail() by the engine\n // event channel; reject so the awaiting caller observes it too.\n throw this._lastError;\n }\n } catch (err) {\n if (this.destroyed || this.abort.signal.aborted || gen !== this.loadGen) return;\n // Route a failed reload through the error channel (consistent with start/arming)\n // AND reject so an awaiting caller observes it — unless it already went\n // through fail() above (double-emitting 'error' would fire host handlers twice).\n if (err !== this._lastError) {\n this.fail(\n err instanceof PlaybackError ? err : new PlaybackError('INTERNAL', 0, String(err)),\n );\n }\n throw err;\n }\n }\n\n on<E extends PlayerEvent>(event: E, cb: (payload: PlayerEventMap[E]) => void): () => void {\n const set = (this.listeners[event] ??= new Set()) as Set<(p: PlayerEventMap[E]) => void>;\n set.add(cb);\n return () => set.delete(cb);\n }\n\n destroy(): void {\n if (this.destroyed) return;\n this.destroyed = true;\n this.loadGen += 1; // invalidate any in-flight load/arming continuation\n this.abort.abort();\n this.stopStats();\n this.stopRefresh();\n // Tear the control bar down (and unsubscribe its listeners) before the final\n // 'idle' emit so it never touches a half-dismantled DOM.\n this.unmountControls();\n this.cancelRejoin();\n this.closeArming();\n this.closeLiveState();\n this.detachMedia?.();\n this.detachPip?.();\n // End the beacon session BEFORE the engine dies so the final sample can\n // still read totals; no-op if 'ended'/fatal already closed it.\n this.analyticsSession?.noteDestroyed();\n this.analyticsSession = null;\n this.detachMediaListeners?.();\n this.engine?.destroy();\n this.engine = null;\n this.setState('idle'); // final terminal signal to any still-registered listeners\n this.clearListeners();\n }\n\n private clearListeners(): void {\n for (const key of Object.keys(this.listeners)) {\n (this.listeners as Record<string, Set<unknown> | undefined>)[key]?.clear();\n }\n }\n\n private fail(err: PlaybackError): void {\n this._lastError = err;\n this.setState('error');\n // After the terminal StateChange: SessionEnd(FATAL_ERROR) closes the\n // beacon session — further events for this sid are meaningless.\n this.analyticsSession?.noteFatal();\n this.emit('error', err);\n }\n\n private setState(state: PlayerState): void {\n if (state === this._state) return;\n const from = this._state;\n this._state = state;\n if (this.analyticsSession) {\n const video = this.opts.video;\n this.analyticsSession.noteStateChange(\n from,\n state,\n Math.round(video.currentTime * 1000),\n this.engine?.getStats().levelHeight,\n );\n if (state === 'ended') this.analyticsSession.noteEnded();\n }\n this.emit('statechange', state);\n switch (state) {\n case 'playing':\n this.emit('playing');\n break;\n case 'buffering':\n this.emit('buffering');\n break;\n case 'paused':\n this.emit('paused');\n break;\n case 'ended':\n this.emit('ended', this._endedReason ?? undefined);\n break;\n default:\n break;\n }\n }\n\n private emit<E extends PlayerEvent>(event: E, payload?: PlayerEventMap[E]): void {\n const set = this.listeners[event] as\n Set<(p: PlayerEventMap[E] | undefined) => void> | undefined;\n set?.forEach((cb) => {\n try {\n cb(payload);\n } catch {\n // never let a listener break the player\n }\n });\n }\n}\n","import { type PlaybackErrorCode } from '../core/errors';\nimport {\n type AudioTrackInfo,\n type PlayerState,\n type QualityLevel,\n type TextTrackInfo,\n} from '../core/stats';\n\n/** postMessage protocol version. Bump on a breaking change; sent in `oddin:ready`. */\nexport const PROTOCOL_VERSION = 1;\n\n/** Commands the host page sends down to the iframe. */\nexport type HostToFrame =\n | { type: 'oddin:play' }\n | { type: 'oddin:pause' }\n | { type: 'oddin:setMuted'; muted: boolean }\n | { type: 'oddin:setVolume'; volume: number }\n | { type: 'oddin:setQuality'; index: number } // -1 = auto\n | { type: 'oddin:setMaxBitrate'; bitrate: number | null }\n | { type: 'oddin:setAudioTrack'; id: number }\n | { type: 'oddin:setTextTrack'; id: number } // -1 = off\n | { type: 'oddin:seekToLive' }\n | { type: 'oddin:enterPip' }\n | { type: 'oddin:exitPip' }\n | { type: 'oddin:retry' }\n | { type: 'oddin:load'; matchUrn: string }\n | { type: 'oddin:destroy' };\n\n/** Events the iframe sends up to the host page. */\nexport type FrameToHost =\n | { type: 'oddin:ready'; protocol: number }\n | { type: 'oddin:autoplayblocked' }\n | { type: 'oddin:state'; state: PlayerState }\n | { type: 'oddin:waiting'; retryInMs: number; attempt: number; phase: string }\n | {\n type: 'oddin:tracks';\n quality: QualityLevel[];\n audio: AudioTrackInfo[];\n text: TextTrackInfo[];\n }\n | { type: 'oddin:qualitychange'; index: number }\n | { type: 'oddin:audiotrackchange'; id: number }\n | { type: 'oddin:texttrackchange'; id: number }\n | {\n type: 'oddin:stats';\n droppedFrames: number;\n latencySeconds?: number;\n bandwidthKbps?: number;\n levelHeight?: number;\n lowLatency?: boolean;\n isLive?: boolean;\n atLiveEdge?: boolean;\n }\n | { type: 'oddin:error'; code: PlaybackErrorCode; message: string };\n\nexport function isOddinMessage(data: unknown): data is { type: string } {\n return (\n !!data &&\n typeof data === 'object' &&\n typeof (data as { type?: unknown }).type === 'string' &&\n (data as { type: string }).type.startsWith('oddin:')\n );\n}\n","import { type FrameToHost, type HostToFrame, isOddinMessage, PROTOCOL_VERSION } from './protocol';\n\nexport interface MountEmbedOptions {\n /** Element to mount the iframe into. */\n container: HTMLElement;\n /** URL of the hosted embed page, e.g. `https://player-dev.oddin-video.gg/embed/`. */\n src: string;\n /**\n * API base URL, forwarded to the embed page as a query parameter.\n *\n * ⚠️ QoE beacons: the beacons endpoint is derived from this host by\n * convention (`feed[-dev].<domain>` → `beacons[-dev].<domain>`). If your\n * base is NOT a feed host, nothing derives and analytics is off — and\n * `mountEmbed` CANNOT fix it, because the only way to name an endpoint is\n * {@link EmbedConfig.analytics}, which the embed page reads from injected\n * config and deliberately never from the query string (a link must not be\n * able to redirect a session's telemetry). Host the embed page yourself\n * and inject `window.HAVIK_EMBED_CONFIG` with `analytics: { endpoint }`.\n */\n baseUrl: string;\n matchUrn: string;\n /**\n * DEV/DEMO ONLY: appended as a query param so the embed page can read it.\n * In production the embed page is hosted by you and injects the key\n * server-side — do not pass it here.\n */\n apiKey?: string;\n /** Origin of the embed page used to verify its messages. Defaults to new URL(src).origin. */\n allowedFrameOrigin?: string;\n muted?: boolean;\n /**\n * iframe sandbox attribute. Default keeps playback + EME + fullscreen working\n * while blocking top-frame navigation and popups. Override only if you know\n * what you're doing.\n */\n sandbox?: string;\n /** Convenience callback for every event from the iframe. */\n onEvent?: (msg: FrameToHost) => void;\n}\n\nexport interface EmbedHandle {\n readonly iframe: HTMLIFrameElement;\n play(): void;\n pause(): void;\n setMuted(muted: boolean): void;\n setVolume(volume: number): void;\n setQuality(index: number): void; // -1 = auto\n setMaxBitrate(bitrate: number | null): void;\n setAudioTrack(id: number): void;\n setTextTrack(id: number): void; // -1 = off\n seekToLive(): void;\n enterPip(): void;\n exitPip(): void;\n retry(): void;\n load(matchUrn: string): void;\n on(cb: (msg: FrameToHost) => void): () => void;\n destroy(): void;\n}\n\n/**\n * Mode C — mount the hosted iframe player and bridge it over postMessage. Both\n * ends verify event.origin on every message; commands are posted only to the\n * embed page's origin.\n */\nexport function mountEmbed(opts: MountEmbedOptions): EmbedHandle {\n if (!opts.container || typeof opts.container.appendChild !== 'function') {\n throw new Error('mountEmbed: opts.container must be a DOM element');\n }\n if (!opts.src) throw new Error('mountEmbed: opts.src is required');\n if (!opts.baseUrl || !opts.matchUrn) {\n throw new Error('mountEmbed: opts.baseUrl and opts.matchUrn are required');\n }\n const url = new URL(opts.src, location.href);\n const frameOrigin = opts.allowedFrameOrigin ?? url.origin;\n\n url.searchParams.set('baseUrl', opts.baseUrl);\n url.searchParams.set('matchUrn', opts.matchUrn);\n url.searchParams.set('parentOrigin', location.origin);\n if (opts.muted != null) url.searchParams.set('muted', String(opts.muted));\n if (opts.apiKey) {\n url.searchParams.set('apiKey', opts.apiKey);\n console.warn(\n 'havik-player: passing apiKey to mountEmbed places it in the iframe URL — DEV/DEMO only. ' +\n 'In production inject it server-side into window.HAVIK_EMBED_CONFIG on the embed page.',\n );\n }\n\n const iframe = document.createElement('iframe');\n iframe.src = url.toString();\n iframe.allow = 'autoplay; encrypted-media; fullscreen';\n iframe.setAttribute('allowfullscreen', '');\n // Keep any api-key-bearing URL out of Referer, and sandbox the embed so a\n // compromised embed page can't navigate the top frame or open popups.\n // allow-same-origin is required for EME/MediaKeys and the embed's own storage.\n iframe.referrerPolicy = 'no-referrer';\n iframe.setAttribute(\n 'sandbox',\n opts.sandbox ?? 'allow-scripts allow-same-origin allow-presentation',\n );\n iframe.style.cssText = 'border:0;width:100%;height:100%;display:block';\n opts.container.appendChild(iframe);\n\n const listeners = new Set<(msg: FrameToHost) => void>();\n\n const onMessage = (ev: MessageEvent) => {\n if (ev.source !== iframe.contentWindow) return;\n if (ev.origin !== frameOrigin) return;\n if (!isOddinMessage(ev.data)) return;\n const msg = ev.data as FrameToHost;\n if (msg.type === 'oddin:ready' && msg.protocol !== PROTOCOL_VERSION) {\n console.warn(\n `havik-player: embed protocol v${msg.protocol} != host v${PROTOCOL_VERSION}. ` +\n 'Pin the embed page version to the SDK version to avoid skew.',\n );\n }\n opts.onEvent?.(msg);\n listeners.forEach((cb) => cb(msg));\n };\n window.addEventListener('message', onMessage);\n\n const send = (msg: HostToFrame) => iframe.contentWindow?.postMessage(msg, frameOrigin);\n\n return {\n iframe,\n play: () => send({ type: 'oddin:play' }),\n pause: () => send({ type: 'oddin:pause' }),\n setMuted: (muted) => send({ type: 'oddin:setMuted', muted }),\n setVolume: (volume) => send({ type: 'oddin:setVolume', volume }),\n setQuality: (index) => send({ type: 'oddin:setQuality', index }),\n setMaxBitrate: (bitrate) => send({ type: 'oddin:setMaxBitrate', bitrate }),\n setAudioTrack: (id) => send({ type: 'oddin:setAudioTrack', id }),\n setTextTrack: (id) => send({ type: 'oddin:setTextTrack', id }),\n seekToLive: () => send({ type: 'oddin:seekToLive' }),\n enterPip: () => send({ type: 'oddin:enterPip' }),\n exitPip: () => send({ type: 'oddin:exitPip' }),\n retry: () => send({ type: 'oddin:retry' }),\n load: (matchUrn) => send({ type: 'oddin:load', matchUrn }),\n on: (cb) => {\n listeners.add(cb);\n return () => listeners.delete(cb);\n },\n destroy: () => {\n send({ type: 'oddin:destroy' });\n window.removeEventListener('message', onMessage);\n iframe.remove();\n },\n };\n}\n","import { createPlayer, type Player } from '../managed';\nimport { PlaybackError } from '../core/errors';\nimport { type WaitForLive } from '../core/waitForLive';\nimport { type FrameToHost, type HostToFrame, isOddinMessage, PROTOCOL_VERSION } from './protocol';\n\nexport interface EmbedConfig {\n baseUrl: string;\n matchUrn: string;\n /** DEV/DEMO ONLY when read from the query string — in production inject server-side. */\n apiKey: string;\n /** Origin to post events to and accept commands from. Required for the bridge. */\n parentOrigin?: string;\n autoplay?: boolean;\n muted?: boolean;\n waitForLive?: WaitForLive;\n /**\n * QoE beacons, forwarded verbatim to {@link createPlayer}. Same meaning as\n * there: `false` opts out, `{ endpoint }` names a beacons host.\n *\n * Mode C needs this for the same reason Mode A does, and until now had no\n * way to say it. An embed whose `baseUrl` is not a `feed[-dev].<domain>`\n * host — a CDN or gateway fronting the streams API — derives no beacons\n * endpoint, so analytics is off and stays off. Without this option the\n * only remedy was to move `baseUrl` back onto a feed host, which is not a\n * choice an integrator who fronts the API deliberately can make.\n *\n * INJECTED CONFIG ONLY: unlike every other field here, this is not read\n * from the query string. See {@link readConfig}.\n */\n analytics?: boolean | { endpoint?: string };\n}\n\ndeclare global {\n interface Window {\n HAVIK_EMBED_CONFIG?: Partial<EmbedConfig>;\n }\n}\n\nconst URN_RE = /^[A-Za-z0-9:_.-]{1,128}$/;\n\n/**\n * Normalize an injected `analytics` value.\n *\n * The host page writes this object, so it is trusted — but it is plain JS\n * with no type checking at the boundary, and a wrong shape is worse than a\n * missing one here: `analytics: 'yes'` reaches createPlayer as a truthy\n * non-object, reads as `{ endpoint: undefined }`, and silently falls back to\n * derivation. Anything unrecognized becomes undefined instead, so the\n * documented default applies and the SDK's own diagnostics see an absent\n * option rather than a malformed one.\n */\nfunction readAnalytics(v: unknown): EmbedConfig['analytics'] {\n if (typeof v === 'boolean') return v;\n if (typeof v === 'object' && v !== null) {\n const endpoint = (v as { endpoint?: unknown }).endpoint;\n return typeof endpoint === 'string' ? { endpoint } : {};\n }\n return undefined;\n}\n\n/**\n * Config is read in ONE of two mutually-exclusive modes:\n * - Production: when window.HAVIK_EMBED_CONFIG is injected server-side, ONLY it is\n * trusted. The query string is ignored entirely, so a tampered `?baseUrl=` can\n * never redirect the injected api-key to an attacker origin.\n * - Dev/demo: when no injected config is present, the query string is used.\n *\n * `analytics` is deliberately absent from the query-string branch. It names a\n * telemetry sink, and a link is the wrong place to set one: `?analytics.\n * endpoint=` would let any URL that opens this page redirect a session's QoE\n * beacons somewhere else while `baseUrl` still looks legitimate. There is no\n * demo case that needs it — a dev embed points `baseUrl` at a feed host and\n * gets the derived endpoint for free. Injected config only.\n */\nfunction readConfig(): EmbedConfig {\n const injected = window.HAVIK_EMBED_CONFIG;\n if (injected) {\n return {\n baseUrl: injected.baseUrl ?? '',\n matchUrn: injected.matchUrn ?? '',\n apiKey: injected.apiKey ?? '',\n parentOrigin: injected.parentOrigin,\n autoplay: injected.autoplay ?? true,\n muted: injected.muted ?? true,\n waitForLive: injected.waitForLive ?? true,\n analytics: readAnalytics(injected.analytics),\n };\n }\n const q = new URLSearchParams(location.search);\n return {\n baseUrl: q.get('baseUrl') ?? '',\n matchUrn: q.get('matchUrn') ?? '',\n apiKey: q.get('apiKey') ?? '',\n parentOrigin: q.get('parentOrigin') ?? undefined,\n autoplay: true,\n muted: q.get('muted') !== 'false',\n waitForLive: true,\n };\n}\n\n/**\n * Boots the iframe-side player and speaks the postMessage protocol with the host.\n * The api-key is NEVER accepted over postMessage. The bridge fails CLOSED: with no\n * trusted parentOrigin it neither accepts commands nor broadcasts events.\n */\nexport async function bootEmbed(): Promise<void> {\n const cfg = readConfig();\n applyFullBleedStyles();\n\n const video = document.createElement('video');\n video.setAttribute('playsinline', '');\n // createPlayer mounts the branded custom control bar (controls: 'custom' default).\n video.muted = cfg.muted ?? true;\n video.style.cssText =\n 'width:100%;height:100%;object-fit:contain;background:#000;aspect-ratio:auto';\n document.body.appendChild(video);\n\n const trustedOrigin = cfg.parentOrigin && cfg.parentOrigin !== 'null' ? cfg.parentOrigin : null;\n if (!trustedOrigin) {\n console.warn(\n 'havik-player: embed has no parentOrigin — the postMessage bridge is disabled ' +\n '(no events posted, no commands accepted). mountEmbed sets parentOrigin automatically.',\n );\n }\n\n const post = (msg: FrameToHost) => {\n if (!trustedOrigin) return; // never broadcast to '*'\n try {\n window.parent?.postMessage(msg, trustedOrigin);\n } catch {\n // parent may be gone\n }\n };\n\n if (!cfg.baseUrl || !cfg.matchUrn || !cfg.apiKey) {\n post({\n type: 'oddin:error',\n code: 'INTERNAL',\n message: 'embed missing baseUrl/matchUrn/apiKey',\n });\n return;\n }\n\n let player: Player | null = null;\n\n const wire = (p: Player) => {\n p.on('statechange', (state) => post({ type: 'oddin:state', state }));\n p.on('ready', () =>\n post({\n type: 'oddin:tracks',\n quality: p.getQualityLevels(),\n audio: p.getAudioTracks(),\n text: p.getTextTracks(),\n }),\n );\n p.on('waiting', (w) =>\n post({ type: 'oddin:waiting', retryInMs: w.retryInMs, attempt: w.attempt, phase: w.phase }),\n );\n p.on('qualitychange', (index) => post({ type: 'oddin:qualitychange', index }));\n p.on('audiotrackchange', (id) => post({ type: 'oddin:audiotrackchange', id }));\n p.on('texttrackchange', (id) => post({ type: 'oddin:texttrackchange', id }));\n p.on('stats', (s) =>\n post({\n type: 'oddin:stats',\n droppedFrames: s.droppedFrames,\n latencySeconds: s.latencySeconds,\n bandwidthKbps: s.bandwidthKbps,\n levelHeight: s.levelHeight,\n lowLatency: s.lowLatency,\n isLive: s.isLive,\n atLiveEdge: s.atLiveEdge,\n }),\n );\n p.on('error', (e) => post({ type: 'oddin:error', code: e.code, message: e.message }));\n p.on('autoplayblocked', () => {\n post({ type: 'oddin:autoplayblocked' });\n showTapToPlay(video, p);\n });\n };\n\n // Switch matches in place (destroy + re-create) rather than re-navigating — this\n // preserves injected config and keeps the api-key out of the URL/history.\n const startPlayer = async (matchUrn: string): Promise<void> => {\n player?.destroy();\n player = null;\n try {\n const p = await createPlayer({\n video,\n baseUrl: cfg.baseUrl,\n matchUrn,\n credential: { apiKey: cfg.apiKey },\n autoplay: cfg.autoplay,\n muted: cfg.muted,\n waitForLive: cfg.waitForLive,\n analytics: cfg.analytics,\n });\n player = p;\n // The custom bar wraps <video> in a `.havik-player` container; make it\n // fill the iframe (the default skin is content-sized 16:9 otherwise).\n const container = video.parentElement;\n if (container?.classList.contains('havik-player')) {\n container.style.cssText =\n 'position:absolute;inset:0;width:100%;height:100%;aspect-ratio:auto';\n }\n wire(p);\n } catch (e) {\n const code = e instanceof PlaybackError ? e.code : 'INTERNAL';\n const message = e instanceof Error ? e.message : String(e);\n post({ type: 'oddin:error', code, message });\n }\n };\n\n const dispose = () => {\n window.removeEventListener('message', onMessage);\n window.removeEventListener('pagehide', dispose);\n player?.destroy();\n player = null;\n };\n\n const handleCommand = (msg: HostToFrame): void => {\n switch (msg.type) {\n case 'oddin:play':\n void player?.play();\n break;\n case 'oddin:pause':\n player?.pause();\n break;\n case 'oddin:setMuted':\n if (typeof msg.muted === 'boolean') player?.setMuted(msg.muted);\n break;\n case 'oddin:setVolume':\n if (typeof msg.volume === 'number' && Number.isFinite(msg.volume)) {\n player?.setVolume(msg.volume);\n }\n break;\n case 'oddin:setQuality':\n if (typeof msg.index === 'number') player?.setQuality(msg.index);\n break;\n case 'oddin:setMaxBitrate':\n if (\n msg.bitrate === null ||\n (typeof msg.bitrate === 'number' && Number.isFinite(msg.bitrate))\n ) {\n player?.setMaxBitrate(msg.bitrate);\n }\n break;\n case 'oddin:setAudioTrack':\n if (typeof msg.id === 'number') player?.setAudioTrack(msg.id);\n break;\n case 'oddin:setTextTrack':\n if (typeof msg.id === 'number') player?.setTextTrack(msg.id);\n break;\n case 'oddin:seekToLive':\n player?.seekToLive();\n break;\n case 'oddin:enterPip':\n void player?.enterPictureInPicture();\n break;\n case 'oddin:exitPip':\n void player?.exitPictureInPicture();\n break;\n case 'oddin:retry':\n void player?.retry();\n break;\n case 'oddin:load':\n if (typeof msg.matchUrn === 'string' && URN_RE.test(msg.matchUrn)) {\n void startPlayer(msg.matchUrn);\n }\n break;\n case 'oddin:destroy':\n dispose();\n break;\n }\n };\n\n const onMessage = (ev: MessageEvent) => {\n if (!trustedOrigin || ev.origin !== trustedOrigin) return;\n if (!isOddinMessage(ev.data)) return;\n handleCommand(ev.data as HostToFrame);\n };\n\n if (trustedOrigin) window.addEventListener('message', onMessage);\n window.addEventListener('pagehide', dispose, { once: true });\n\n await startPlayer(cfg.matchUrn);\n post({ type: 'oddin:ready', protocol: PROTOCOL_VERSION });\n}\n\nfunction showTapToPlay(video: HTMLVideoElement, player: Player): void {\n if (document.getElementById('havik-tap')) return;\n const overlay = document.createElement('button');\n overlay.id = 'havik-tap';\n overlay.textContent = '▶ Tap to play';\n overlay.style.cssText =\n 'position:fixed;inset:0;width:100%;height:100%;border:0;background:rgba(0,0,0,.55);' +\n 'color:#fff;font:600 18px system-ui,sans-serif;cursor:pointer;z-index:9';\n overlay.addEventListener('click', () => {\n overlay.remove();\n void player.play(); // a user gesture — play() now succeeds\n });\n document.body.appendChild(overlay);\n void video; // overlay covers the element; kept for signature clarity\n}\n\nfunction applyFullBleedStyles(): void {\n const style = document.createElement('style');\n style.textContent =\n 'html,body{margin:0;height:100%;background:#000;overflow:hidden}body{display:flex}';\n document.head.appendChild(style);\n}\n"],"mappings":"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"}