180 lines
4.9 KiB
TypeScript
180 lines
4.9 KiB
TypeScript
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<boolean>, 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;
|
|
};
|