From c3b3c606dbd30565c46af5ee49bdde9d73fbf95b Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 16 Jun 2026 01:34:42 +0200 Subject: [PATCH] feat: profile hls playback --- static/player/hls_profile.ts | 115 +++++++++++++++++++++++++++++++++++ static/player/video.ts | 5 ++ 2 files changed, 120 insertions(+) create mode 100644 static/player/hls_profile.ts diff --git a/static/player/hls_profile.ts b/static/player/hls_profile.ts new file mode 100644 index 0000000..10ac32a --- /dev/null +++ b/static/player/hls_profile.ts @@ -0,0 +1,115 @@ +import Hls from "hls.js"; +import type { ErrorData } from "hls.js"; + +interface HLSPlaybackProfile { + sourceLoads: number; + manifestParsed: number; + stalls: number; + totalStallMs: number; + seeks: number; + totalSeekMs: number; + errors: number; + fatalErrors: number; + lastErrorType: string; +} + +declare global { + interface Window { + __malHlsProfile?: HLSPlaybackProfile; + } +} + +const createProfile = (): HLSPlaybackProfile => ({ + sourceLoads: 0, + manifestParsed: 0, + stalls: 0, + totalStallMs: 0, + seeks: 0, + totalSeekMs: 0, + errors: 0, + fatalErrors: 0, + lastErrorType: "", +}); + +const mark = (name: string): void => { + performance.mark(`mal.hls.${name}`); +}; + +const measure = (name: string, startMark: string): void => { + try { + performance.measure(`mal.hls.${name}`, `mal.hls.${startMark}`); + } catch { + // Missing marks can happen if HLS.js emits a later event after cleanup. + } +}; + +export const attachHLSProfile = (hls: Hls, video: HTMLVideoElement): (() => void) => { + const profile = createProfile(); + window.__malHlsProfile = profile; + + let stallStartedAt: number | null = null; + let seekStartedAt: number | null = null; + + const onManifestLoading = (): void => { + profile.sourceLoads += 1; + mark("manifest_loading"); + }; + + const onManifestParsed = (): void => { + profile.manifestParsed += 1; + measure("manifest_load", "manifest_loading"); + }; + + const onHLSError = (_event: string, data: ErrorData): void => { + profile.errors += 1; + if (data.fatal) { + profile.fatalErrors += 1; + } + profile.lastErrorType = data.type; + }; + + const onWaiting = (): void => { + if (stallStartedAt !== null) return; + stallStartedAt = performance.now(); + profile.stalls += 1; + mark("stall_start"); + }; + + const onPlaying = (): void => { + if (stallStartedAt === null) return; + profile.totalStallMs += performance.now() - stallStartedAt; + stallStartedAt = null; + measure("stall", "stall_start"); + }; + + const onSeeking = (): void => { + seekStartedAt = performance.now(); + mark("seek_start"); + }; + + const onSeeked = (): void => { + if (seekStartedAt === null) return; + profile.seeks += 1; + profile.totalSeekMs += performance.now() - seekStartedAt; + seekStartedAt = null; + measure("seek", "seek_start"); + }; + + hls.on(Hls.Events.MANIFEST_LOADING, onManifestLoading); + hls.on(Hls.Events.MANIFEST_PARSED, onManifestParsed); + hls.on(Hls.Events.ERROR, onHLSError); + video.addEventListener("waiting", onWaiting); + video.addEventListener("playing", onPlaying); + video.addEventListener("seeking", onSeeking); + video.addEventListener("seeked", onSeeked); + + return () => { + hls.off(Hls.Events.MANIFEST_LOADING, onManifestLoading); + hls.off(Hls.Events.MANIFEST_PARSED, onManifestParsed); + hls.off(Hls.Events.ERROR, onHLSError); + video.removeEventListener("waiting", onWaiting); + video.removeEventListener("playing", onPlaying); + video.removeEventListener("seeking", onSeeking); + video.removeEventListener("seeked", onSeeked); + }; +}; diff --git a/static/player/video.ts b/static/player/video.ts index 6b0df55..741d98d 100644 --- a/static/player/video.ts +++ b/static/player/video.ts @@ -1,10 +1,14 @@ import Hls from "hls.js"; +import { attachHLSProfile } from "./hls_profile"; import { state } from "./state"; import { absoluteTimeFromDisplay, displayTimeFromAbsolute, invalidateBounds } from "./timeline"; let hls: Hls | null = null; +let stopHLSProfile: (() => void) | null = null; const destroyHLS = (): void => { + stopHLSProfile?.(); + stopHLSProfile = null; hls?.destroy(); hls = null; }; @@ -44,6 +48,7 @@ export const loadVideoSource = (url: string, type?: string): void => { if (shouldUseHLS(type, url) && Hls.isSupported()) { hls = new Hls(); + stopHLSProfile = attachHLSProfile(hls, state.video); hls.loadSource(url); hls.attachMedia(state.video); } else {