Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions scripts/prairie-invariants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ internal/playback/audio_select.go 1 func SelectClientPlayableAudioTrack decodabl

# --- Wire names (decided 2026-09-29: prairie.* / X-Prairie-* everywhere).
web/src/api/client.ts 1 X-Prairie- client identity headers

# --- Stats for nerds (event log + planner detail).
web/src/player/components/PlaybackInfoOverlay.tsx 1 Recent events web stats overlay shows the player event log
web/src/player/components/VideoPlayer.tsx 1 new PlaybackEventLog\( web player records events for the stats overlay
80 changes: 73 additions & 7 deletions web/src/player/components/PlaybackInfoOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { X } from "lucide-react";
import type HlsType from "hls.js";
import { buildPlaybackInfoSections, type RuntimePlaybackStats } from "../playback-info";
import {
formatPlaybackEventTime,
type PlaybackEventEntry,
type PlaybackEventLog,
} from "../playback-events";
import type { PlanV3 } from "../protocol-v3";
import type { PlayerFileVersion } from "../types";
import type { PlayerAudioTrack, PlayerFileVersion } from "../types";

interface PlaybackInfoOverlayProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
Expand All @@ -12,6 +18,11 @@ interface PlaybackInfoOverlayProps {
plan: PlanV3;
currentSourceVersion?: PlayerFileVersion;
requestedVersion?: PlayerFileVersion;
activeAudioTrack?: PlayerAudioTrack;
qualityPreference?: string;
hlsRef?: React.RefObject<HlsType | null>;
/** Recent player events, recorded whether or not the overlay is open. */
eventLog?: PlaybackEventLog;
onClose: () => void;
}

Expand All @@ -22,9 +33,14 @@ export function PlaybackInfoOverlay({
plan,
currentSourceVersion,
requestedVersion,
activeAudioTrack,
qualityPreference,
hlsRef,
eventLog,
onClose,
}: PlaybackInfoOverlayProps) {
const [runtimeStats, setRuntimeStats] = useState<RuntimePlaybackStats>({});
const [events, setEvents] = useState<PlaybackEventEntry[]>(() => eventLog?.snapshot() ?? []);

// Poll runtime stats every second.
useEffect(() => {
Expand All @@ -39,20 +55,27 @@ export function PlaybackInfoOverlay({
}
).getVideoPlaybackQuality?.();

const bandwidth = hlsRef?.current?.bandwidthEstimate;
setRuntimeStats({
playerWidth: container?.clientWidth,
playerHeight: container?.clientHeight,
videoWidth: video.videoWidth || undefined,
videoHeight: video.videoHeight || undefined,
droppedFrames: quality?.droppedVideoFrames ?? null,
corruptedFrames: quality?.corruptedVideoFrames ?? null,
bufferAheadSeconds: bufferAhead(video),
bandwidthEstimateKbps:
Number.isFinite(bandwidth) && bandwidth != null && bandwidth > 0
? Math.round(bandwidth / 1000)
: undefined,
});
if (eventLog) setEvents(eventLog.snapshot());
}

collect();
const id = setInterval(collect, 1000);
return () => clearInterval(id);
}, [videoRef, containerRef]);
}, [videoRef, containerRef, hlsRef, eventLog]);

const sections = useMemo(
() =>
Expand All @@ -62,12 +85,22 @@ export function PlaybackInfoOverlay({
currentSourceVersion,
requestedVersion,
runtimeStats,
activeAudioTrack,
qualityPreference,
}),
[streamUrl, plan, currentSourceVersion, requestedVersion, runtimeStats],
[
streamUrl,
plan,
currentSourceVersion,
requestedVersion,
runtimeStats,
activeAudioTrack,
qualityPreference,
],
);

return (
<div className="absolute top-12 right-3 left-3 z-50 max-h-[calc(100%-6rem)] w-auto overflow-y-auto rounded-lg bg-black/85 text-sm text-white shadow-lg backdrop-blur-sm sm:right-auto sm:left-4 sm:w-80">
<div className="absolute top-12 right-3 left-3 z-50 max-h-[calc(100%-6rem)] w-auto overflow-y-auto rounded-lg bg-black/85 text-sm text-white shadow-lg backdrop-blur-sm sm:right-auto sm:left-4 sm:w-96">
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<span className="font-medium text-white/90">Stats for nerds</span>
<button
Expand All @@ -86,15 +119,48 @@ export function PlaybackInfoOverlay({
<div className="mb-1 text-xs font-semibold tracking-wider text-white/50 uppercase">
{section.title}
</div>
{section.rows.map((row) => (
<div key={row.label} className="flex justify-between gap-4 py-0.5">
{section.rows.map((row, index) => (
<div key={`${row.label}-${index}`} className="flex justify-between gap-4 py-0.5">
<span className="shrink-0 text-white/60">{row.label}</span>
<span className="truncate text-right text-white/90">{row.value}</span>
<span className="truncate text-right text-white/90" title={row.value}>
{row.value}
</span>
</div>
))}
</div>
))}
{eventLog && (
<div className="mt-3">
<div className="mb-1 text-xs font-semibold tracking-wider text-white/50 uppercase">
Recent events
</div>
{events.length === 0 ? (
<div className="py-0.5 text-white/60">None yet</div>
) : (
<ol aria-label="Recent player events">
{[...events].reverse().map((entry, index) => (
<li key={`${entry.at}-${index}`} className="flex gap-3 py-0.5">
<span className="shrink-0 font-mono text-xs leading-5 text-white/50">
{formatPlaybackEventTime(entry.at)}
</span>
<span className="break-words text-white/90">{entry.message}</span>
</li>
))}
</ol>
)}
</div>
)}
</div>
</div>
);
}

function bufferAhead(video: HTMLVideoElement): number | undefined {
const { buffered, currentTime } = video;
for (let index = 0; index < buffered.length; index++) {
if (buffered.start(index) <= currentTime + 0.25 && currentTime <= buffered.end(index)) {
return Math.max(0, buffered.end(index) - currentTime);
}
}
return buffered.length ? 0 : undefined;
}
41 changes: 40 additions & 1 deletion web/src/player/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type { WatchTogetherRoomConnectionResult } from "../hooks/useWatchTogethe
import { getPersistedVolume, persistVolume } from "./VolumeControl";
import { playerV2 } from "../player-v2";
import { usePlayerConfig } from "../context/PlayerConfigContext";
import { lowerQualityOption, qualityOptionsFromPlanV3 } from "../playback-info";
import { formatDelivery, lowerQualityOption, qualityOptionsFromPlanV3 } from "../playback-info";
import { preconnectToStreamOrigin } from "../stream-url";
import { WatchTogetherPanel } from "./WatchTogetherPanel";
import { readPlanInvalidatedPayload, VIDEO_PLAYBACK_COMMANDS } from "../realtime-protocol";
Expand Down Expand Up @@ -103,6 +103,7 @@ import {
setWatchTogetherGuestControl,
} from "@/lib/watchTogetherActions";
import { toast } from "sonner";
import { describeMediaEvent, LOGGED_MEDIA_EVENTS, PlaybackEventLog } from "../playback-events";

let hlsJSModule: Promise<typeof HlsType> | null = null;

Expand Down Expand Up @@ -398,6 +399,8 @@ export function VideoPlayer({
const hlsRef = useRef<HlsType | null>(null);
const hlsStartupGuardRef = useRef<HlsStartupGuard | null>(null);
const mediaRecoveryAttemptsRef = useRef(0);
// Kept for the stats overlay, which can be opened after the fact.
const [eventLog] = useState(() => new PlaybackEventLog());
const lastRecoveryRef = useRef(0);
const reportedPlanFailureKeyRef = useRef<string | null>(null);
const transportFailedForPlanRevisionRef = useRef<number | null>(null);
Expand Down Expand Up @@ -639,6 +642,25 @@ export function VideoPlayer({
const activeQualityId = qualityPreference;
const qualityOptions = useMemo(() => qualityOptionsFromPlanV3(plan), [plan]);

useEffect(() => {
eventLog.add(
`plan ${formatDelivery(plan.delivery)}${plan.decision_reason ? ` (${plan.decision_reason})` : ""}`,
);
}, [eventLog, plan.plan_id, plan.delivery, plan.decision_reason]);

useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onMediaEvent = (event: Event) => {
const message = describeMediaEvent(event.type, video);
if (message) eventLog.add(message);
};
for (const type of LOGGED_MEDIA_EVENTS) video.addEventListener(type, onMediaEvent);
return () => {
for (const type of LOGGED_MEDIA_EVENTS) video.removeEventListener(type, onMediaEvent);
};
}, [eventLog, plan.plan_id]);

// The file the server actually planned against, which is not necessarily the
// one that was asked for — a fallback to an alternate version shows up here.
const effectiveVersion = useMemo(
Expand Down Expand Up @@ -1968,6 +1990,9 @@ export function VideoPlayer({
});

hls.on(Hls.Events.ERROR, (_event, data) => {
if (!destroyed) {
eventLog.add(`hls ${data.fatal ? "fatal " : ""}${data.type}: ${data.details}`);
}
if (!data.fatal || destroyed) return;

console.error("[hls.js] Fatal error:", {
Expand Down Expand Up @@ -2026,6 +2051,16 @@ export function VideoPlayer({
}
});

hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
if (destroyed) return;
const level = hls?.levels[data.level];
if (level) {
eventLog.add(
`hls level ${level.height ? `${level.height}p ` : ""}${Math.round(level.bitrate / 1000)} kbps`,
);
}
});

hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (destroyed) return;
attemptAutoplayWhenReady();
Expand Down Expand Up @@ -3958,6 +3993,10 @@ export function VideoPlayer({
plan={plan}
currentSourceVersion={effectiveVersion}
requestedVersion={selectedVersion}
activeAudioTrack={audioTracks[activeAudioIndex]}
qualityPreference={activeQualityId}
hlsRef={hlsRef}
eventLog={eventLog}
onClose={() => setShowPlaybackInfo(false)}
/>
)}
Expand Down
72 changes: 72 additions & 0 deletions web/src/player/playback-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { describeMediaEvent, formatPlaybackEventTime, PlaybackEventLog } from "./playback-events";

describe("PlaybackEventLog", () => {
it("keeps only the newest entries, oldest first", () => {
const log = new PlaybackEventLog(3);
for (let i = 1; i <= 5; i++) log.add(`event ${i}`, i * 10_000);
expect(log.snapshot().map((entry) => entry.message)).toEqual(["event 3", "event 4", "event 5"]);
});

it("collapses a burst of the same event into one line", () => {
const log = new PlaybackEventLog();
log.add("buffering @ 1.0s", 1_000);
log.add("buffering @ 1.0s", 1_500);
log.add("buffering @ 1.0s", 5_000);
expect(log.snapshot()).toEqual([
{ at: 1_500, message: "buffering @ 1.0s" },
{ at: 5_000, message: "buffering @ 1.0s" },
]);
});

it("ignores blank messages and can be cleared", () => {
const log = new PlaybackEventLog();
log.add(" ");
expect(log.snapshot()).toEqual([]);
log.add("playing");
log.clear();
expect(log.snapshot()).toEqual([]);
});

it("returns a snapshot that later adds do not mutate", () => {
const log = new PlaybackEventLog();
log.add("one", 1);
const before = log.snapshot();
log.add("two", 5_000);
expect(before).toHaveLength(1);
});
});

describe("formatPlaybackEventTime", () => {
it("pads hours, minutes and seconds", () => {
const at = new Date(2026, 0, 2, 3, 4, 5).getTime();
expect(formatPlaybackEventTime(at)).toBe("03:04:05");
});
});

describe("describeMediaEvent", () => {
function fakeVideo(overrides: Partial<HTMLVideoElement> = {}): HTMLVideoElement {
return {
currentTime: 12.34,
videoWidth: 1920,
videoHeight: 1080,
error: null,
...overrides,
} as HTMLVideoElement;
}

it("describes playback state changes with the playhead", () => {
expect(describeMediaEvent("waiting", fakeVideo())).toBe("buffering @ 12.3s");
expect(describeMediaEvent("seeking", fakeVideo())).toBe("seeking to 12.3s");
expect(describeMediaEvent("loadedmetadata", fakeVideo())).toBe("metadata 1920x1080");
});

it("names media errors", () => {
const video = fakeVideo({ error: { code: 3, message: "PIPELINE_ERROR_DECODE" } as MediaError });
expect(describeMediaEvent("error", video)).toBe("media error (decode): PIPELINE_ERROR_DECODE");
});

it("skips events it does not log", () => {
expect(describeMediaEvent("timeupdate", fakeVideo())).toBeNull();
});
});
Loading
Loading