diff --git a/static/player/loading.ts b/static/player/loading.ts new file mode 100644 index 00000000..9786955c --- /dev/null +++ b/static/player/loading.ts @@ -0,0 +1,179 @@ +import { state } from "./state"; + +export type PlayerLoadState = + | "idle" + | "resolving_source" + | "loading_media" + | "buffering" + | "retrying" + | "ready" + | "unavailable"; + +type PlayerLoadProfile = { + bufferingCount: number; + lastBufferingMs: number; + lastMediaMetadataMs: number; + lastSourceResolutionMs: number; + retryCount: number; + totalBufferingMs: number; + unavailableCount: number; +}; + +declare global { + interface Window { + __malPlayerLoadProfile?: PlayerLoadProfile; + } +} + +let currentState: PlayerLoadState = "idle"; +let phaseStartedAt = 0; +let retryController: AbortController | null = null; +let retryInFlight = false; + +const profile = (): PlayerLoadProfile => { + window.__malPlayerLoadProfile ??= { + bufferingCount: 0, + lastBufferingMs: 0, + lastMediaMetadataMs: 0, + lastSourceResolutionMs: 0, + retryCount: 0, + totalBufferingMs: 0, + unavailableCount: 0, + }; + return window.__malPlayerLoadProfile; +}; + +const measure = (name: string, startedAt: number, duration: number): void => { + try { + performance.measure(`mal.player.${name}`, { start: startedAt, duration }); + } catch (error) { + console.debug("failed to measure player loading phase:", error); + } +}; + +const finishPhase = (): void => { + if (phaseStartedAt <= 0) { + return; + } + const duration = performance.now() - phaseStartedAt; + const metrics = profile(); + if (currentState === "resolving_source") { + metrics.lastSourceResolutionMs = duration; + measure("source_resolution", phaseStartedAt, duration); + } else if (currentState === "loading_media") { + metrics.lastMediaMetadataMs = duration; + measure("media_metadata", phaseStartedAt, duration); + } else if (currentState === "buffering") { + metrics.lastBufferingMs = duration; + metrics.totalBufferingMs += duration; + measure("buffering", phaseStartedAt, duration); + } +}; + +const isBusy = (loadState: PlayerLoadState): boolean => + loadState === "resolving_source" || + loadState === "loading_media" || + loadState === "buffering" || + loadState === "retrying"; + +const elements = (): { + back: HTMLElement | null; + loading: HTMLElement | null; + retry: HTMLButtonElement | null; + spinner: HTMLElement | null; +} => ({ + back: state.elements.container.querySelector("[data-loading-back]"), + loading: state.elements.container.querySelector("[data-loading]"), + retry: state.elements.container.querySelector("[data-loading-retry]"), + spinner: state.elements.container.querySelector("[data-loading-spinner]"), +}); + +const showRecovery = (): void => { + const { back, retry } = elements(); + retry?.classList.remove("hidden"); + back?.classList.remove("hidden"); +}; + +export const setPlayerLoadState = (nextState: PlayerLoadState): void => { + if (nextState === currentState) { + return; + } + + finishPhase(); + currentState = nextState; + phaseStartedAt = isBusy(nextState) ? performance.now() : 0; + + const { back, loading, retry, spinner } = elements(); + state.elements.container.dataset.loadState = nextState; + state.elements.container.setAttribute("aria-busy", String(isBusy(nextState))); + + const visible = isBusy(nextState) || nextState === "unavailable"; + if (loading) { + loading.style.display = visible ? "flex" : "none"; + } + spinner?.classList.toggle("hidden", nextState === "unavailable"); + retry?.classList.add("hidden"); + back?.classList.add("hidden"); + + if (nextState === "buffering") { + profile().bufferingCount += 1; + } else if (nextState === "retrying") { + profile().retryCount += 1; + } else if (nextState === "unavailable") { + profile().unavailableCount += 1; + } + + if (nextState === "unavailable") { + showRecovery(); + retry?.focus({ preventScroll: true }); + return; + } + if (!isBusy(nextState)) { + return; + } +}; + +export const setupPlayerLoading = (retry: () => Promise, signal: AbortSignal): void => { + retryController?.abort(); + retryController = new AbortController(); + const retryButton = elements().retry; + retryButton?.addEventListener( + "click", + () => { + if (retryInFlight) { + return; + } + retryInFlight = true; + retryButton.disabled = true; + setPlayerLoadState("retrying"); + retry() + .then((refreshed) => { + if (!refreshed) { + setPlayerLoadState("unavailable"); + } + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + console.error("failed to retry video source:", error); + setPlayerLoadState("unavailable"); + }) + .finally(() => { + retryInFlight = false; + retryButton.disabled = false; + }); + }, + { signal: retryController.signal }, + ); + signal.addEventListener("abort", () => retryController?.abort(), { once: true }); +}; + +export const teardownPlayerLoading = (): void => { + finishPhase(); + retryController?.abort(); + retryController = null; + retryInFlight = false; + currentState = "idle"; + phaseStartedAt = 0; +};