feat: add player loading state machine with perf tracking
This commit is contained in:
179
static/player/loading.ts
Normal file
179
static/player/loading.ts
Normal file
@@ -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<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;
|
||||
};
|
||||
Reference in New Issue
Block a user