Skip to content

Commit 65098cf

Browse files
authored
[3/3] feat: tell viewers when html5 playback is buffering (#274)
* feat: report when html5 playback is buffering * feat: tell viewers when html5 playback is buffering * fix: judge html5 buffering by the bake and keep the notice in view * feat: show html5 buffering as a single word
1 parent 4f50166 commit 65098cf

9 files changed

Lines changed: 316 additions & 1 deletion

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
export interface ContentBounds {
2+
left: number;
3+
right: number;
4+
top: number;
5+
bottom: number;
6+
}
7+
8+
/** The part of the video inside a preview of this size, or null when none of it is. */
9+
export function visibleBounds(content: ContentBounds, width: number, height: number): ContentBounds | null {
10+
const left = Math.max(content.left, 0);
11+
const right = Math.min(content.right, width);
12+
const top = Math.max(content.top, 0);
13+
const bottom = Math.min(content.bottom, height);
14+
return right > left && bottom > top ? { left, right, top, bottom } : null;
15+
}
16+
17+
// Long enough that one late frame doesn't flash it, short enough that a real stutter gets explained.
18+
const SHOW_AFTER_MS = 500;
19+
const HIDE_AFTER_MS = 1500;
20+
const INSET_PX = 12;
21+
// A longer gap between ticks means ticking paused, as in an export or a hidden tab, and says nothing about stutter.
22+
const MAX_TICK_GAP_MS = 100;
23+
24+
/**
25+
* Labels stuttering html5 playback "Buffering" in the video's bottom-left corner. It holds only its own display
26+
* state, and the canvas feeds it on every tick.
27+
* @internal
28+
*/
29+
export class BufferingNotice {
30+
public readonly element: HTMLDivElement;
31+
private readonly now: () => number;
32+
private bufferingSince: number | null = null;
33+
private smoothSince: number | null = null;
34+
private lastTickAt: number | null = null;
35+
36+
constructor(now: () => number = () => performance.now()) {
37+
this.now = now;
38+
this.element = document.createElement("div");
39+
this.element.className = "ss-buffering-notice";
40+
this.element.hidden = true;
41+
// The toolbar's status line already tells assistive tech that playback is preparing.
42+
this.element.setAttribute("aria-hidden", "true");
43+
this.element.innerHTML = '<span class="ss-buffering-notice-spinner"></span>';
44+
this.element.append("Buffering");
45+
}
46+
47+
public mount(root: HTMLElement): void {
48+
root.appendChild(this.element);
49+
}
50+
51+
/** `idle` is paused or fully loaded, and `bounds` is null when none of the video is in view. Either hides it at once. */
52+
public update(buffering: boolean, idle: boolean, bounds: ContentBounds | null): void {
53+
const now = this.now();
54+
// A change happened somewhere after the last tick, so its timer starts there.
55+
const previous = this.lastTickAt !== null && now - this.lastTickAt <= MAX_TICK_GAP_MS ? this.lastTickAt : now;
56+
this.lastTickAt = now;
57+
if (idle || !bounds) {
58+
this.bufferingSince = null;
59+
this.smoothSince = null;
60+
this.element.hidden = true;
61+
return;
62+
}
63+
if (buffering) {
64+
this.smoothSince = null;
65+
this.bufferingSince ??= previous;
66+
if (now - this.bufferingSince >= SHOW_AFTER_MS) this.element.hidden = false;
67+
} else {
68+
this.bufferingSince = null;
69+
this.smoothSince ??= previous;
70+
if (now - this.smoothSince >= HIDE_AFTER_MS) this.element.hidden = true;
71+
}
72+
if (this.element.hidden) return;
73+
this.element.style.left = `${bounds.left + INSET_PX}px`;
74+
this.element.style.top = `${bounds.bottom - INSET_PX}px`;
75+
}
76+
77+
public dispose(): void {
78+
this.element.remove();
79+
}
80+
}

‎src/components/canvas/players/html5-player.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,12 @@ export class Html5Player extends Player {
972972
return this.edit.getHtml5Prerender() === "on-load" || this.hasTriggeredCapture ? "pending" : null;
973973
}
974974

975+
// A baked frame reaches the screen a tick or two after the playhead does, so the bake decides, not the screen.
976+
public override isPlaybackBuffering(): boolean {
977+
if (!this.edit.isPlaying || !this.isActive() || this.getPlaybackReadiness() !== "pending") return false;
978+
return !this.bakingFrames?.[Math.floor(this.getPlaybackTime() * this.captureFps)];
979+
}
980+
975981
public override getCaptureProgress(): number {
976982
if (this.getPlaybackReadiness() !== "pending") return 1;
977983
return this.captureFramesTotal > 0 ? this.captureFramesDone / this.captureFramesTotal : 0;

‎src/components/canvas/players/player.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,10 @@ export abstract class Player extends Entity {
585585
return 1;
586586
}
587587

588+
public isPlaybackBuffering(): boolean {
589+
return false;
590+
}
591+
588592
public isActive(): boolean {
589593
// playbackTime is in seconds, matching clip start/end
590594
return this.edit.playbackTime >= this.getStart() && this.edit.playbackTime < this.getEnd();

‎src/components/canvas/shotstack-canvas.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { BufferingNotice, visibleBounds } from "@canvas/buffering-notice";
12
import type { Player } from "@canvas/players/player";
23
import { AlignmentGuides } from "@canvas/system/alignment-guides";
34
import { createWebGLErrorOverlay } from "@canvas/webgl-error-overlay";
45
import { Edit } from "@core/edit-session";
5-
import { InternalEvent } from "@core/events/edit-events";
6+
import { EditEvent, InternalEvent } from "@core/events/edit-events";
67
import { sanitizeColor } from "@core/shared/color-utils";
78
import { ms } from "@core/timing/types";
89
import type { UIController } from "@core/ui/ui-controller";
@@ -61,6 +62,7 @@ export class Canvas {
6162
private onBackgroundClickBound: (event: pixi.FederatedPointerEvent) => void;
6263
private onWheelBound: (e: WheelEvent) => void;
6364
private canvasRoot: HTMLDivElement | null = null;
65+
private readonly bufferingNotice = new BufferingNotice();
6466

6567
constructor(edit: Edit) {
6668
this.application = new pixi.Application();
@@ -141,6 +143,7 @@ export class Canvas {
141143

142144
root.style.position = "relative";
143145
root.appendChild(this.application.canvas);
146+
this.bufferingNotice.mount(root);
144147

145148
// Auto-mount UIController to canvas root (toolbars sit inside canvas)
146149
// mount() handles deferred positioning via double rAF
@@ -568,6 +571,7 @@ export class Canvas {
568571
internalEvents.on(InternalEvent.PlayerRemovedFromTrack, this.onPlayerRemovedFromTrack);
569572
internalEvents.on(InternalEvent.TrackContainerRemoved, this.onTrackContainerRemoved);
570573
internalEvents.on(InternalEvent.ViewportSizeChanged, this.onViewportSizeChanged);
574+
this.edit.events.on(EditEvent.PlaybackPause, this.onPlaybackPause);
571575
internalEvents.on(InternalEvent.ViewportNeedsZoomToFit, this.onViewportNeedsZoomToFit);
572576
}
573577

@@ -603,6 +607,11 @@ export class Canvas {
603607
this.removeTrackContainer(trackIndex);
604608
};
605609

610+
// An export stops the ticker before it pauses, so no tick would be left to hide the notice.
611+
private onPlaybackPause = (): void => {
612+
this.bufferingNotice.update(false, true, null);
613+
};
614+
606615
private onViewportSizeChanged = ({ width, height, backgroundColor }: { width: number; height: number; backgroundColor: string }): void => {
607616
this.updateViewportForSize(width, height, backgroundColor);
608617
};
@@ -648,6 +657,9 @@ export class Canvas {
648657

649658
private onTick(ticker: pixi.Ticker): void {
650659
this.edit.update(ticker.deltaTime, ms(ticker.deltaMS));
660+
const idle = !this.edit.isPlaying || this.edit.getPendingCaptures().pending === 0;
661+
const shown = visibleBounds(this.getContentBounds(), this.viewportSize.width, this.viewportSize.height);
662+
this.bufferingNotice.update(this.edit.isPlaybackBuffering(), idle, shown);
651663

652664
// Update canvas overlays (selection handles, guides, etc.)
653665
this.uiController?.updateOverlays(ticker.deltaTime, ticker.deltaMS);
@@ -693,13 +705,15 @@ export class Canvas {
693705
}
694706

695707
public dispose(): void {
708+
this.bufferingNotice.dispose();
696709
// Unsubscribe from Edit internal events
697710
const internalEvents = this.edit.getInternalEvents();
698711
internalEvents.off(InternalEvent.PlayerAddedToTrack, this.onPlayerAddedToTrack);
699712
internalEvents.off(InternalEvent.PlayerMovedBetweenTracks, this.onPlayerMovedBetweenTracks);
700713
internalEvents.off(InternalEvent.PlayerRemovedFromTrack, this.onPlayerRemovedFromTrack);
701714
internalEvents.off(InternalEvent.TrackContainerRemoved, this.onTrackContainerRemoved);
702715
internalEvents.off(InternalEvent.ViewportSizeChanged, this.onViewportSizeChanged);
716+
this.edit.events.off(EditEvent.PlaybackPause, this.onPlaybackPause);
703717
internalEvents.off(InternalEvent.ViewportNeedsZoomToFit, this.onViewportNeedsZoomToFit);
704718

705719
const root = document.querySelector<HTMLDivElement>(Canvas.CanvasSelector);

‎src/core/edit-session.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,14 @@ export class Edit {
584584
return captureProgressOf(this.getPlayerByClipId(clipId));
585585
}
586586

587+
/**
588+
* Whether a playing html5 clip is showing a held frame, a late still or nothing.
589+
* @internal
590+
*/
591+
public isPlaybackBuffering(): boolean {
592+
return this.clips.some(player => player.isPlaybackBuffering());
593+
}
594+
587595
/**
588596
* Transient generation state for a clip, or undefined when idle. Not part of the
589597
* edit: never saved, never undone, cleared on reload.

‎src/styles/index.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
@import "./ui/background-color-picker.css";
2323
@import "./ui/font-picker.css";
2424
@import "./ui/webgl-error.css";
25+
@import "./ui/buffering-notice.css";
2526

2627
/* Canvas Styles */
2728
@import "./canvas/canvas-pan.css";

‎src/styles/ui/buffering-notice.css‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* Sits in the video's corner while html5 playback stutters. It's editor chrome, so it never reaches an export. */
2+
.ss-buffering-notice {
3+
position: absolute;
4+
z-index: 8;
5+
transform: translateY(-100%);
6+
display: flex;
7+
align-items: center;
8+
gap: 7px;
9+
padding: 6px 11px;
10+
border-radius: 999px;
11+
background: rgb(17 24 39 / 72%);
12+
-webkit-backdrop-filter: blur(4px);
13+
backdrop-filter: blur(4px);
14+
color: #fff;
15+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
16+
font-size: 12px;
17+
font-weight: 600;
18+
line-height: 1.3;
19+
white-space: nowrap;
20+
/* A label, not a control: clicks go through to the canvas. */
21+
pointer-events: none;
22+
}
23+
24+
.ss-buffering-notice[hidden] {
25+
display: none;
26+
}
27+
28+
.ss-buffering-notice-spinner {
29+
flex: none;
30+
width: 12px;
31+
height: 12px;
32+
border: 2px solid rgb(255 255 255 / 35%);
33+
border-top-color: #fff;
34+
border-radius: 50%;
35+
animation: ss-buffering-spin 0.9s linear infinite;
36+
}
37+
38+
@keyframes ss-buffering-spin {
39+
to {
40+
transform: rotate(360deg);
41+
}
42+
}
43+
44+
@media (prefers-reduced-motion: reduce) {
45+
.ss-buffering-notice-spinner {
46+
animation-duration: 3s;
47+
}
48+
}

‎tests/buffering-notice.test.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/** @jest-environment jsdom */
2+
import { BufferingNotice, type ContentBounds, visibleBounds } from "@canvas/buffering-notice";
3+
4+
const wide: ContentBounds = { left: 100, right: 740, top: 50, bottom: 410 };
5+
6+
function mountNotice() {
7+
let now = 0;
8+
const root = document.createElement("div");
9+
document.body.appendChild(root);
10+
const notice = new BufferingNotice(() => now);
11+
notice.mount(root);
12+
notice.update(false, false, wide);
13+
const { element } = notice;
14+
// Ticks at 50 ms, the way the canvas would call it every frame.
15+
const play = (ms: number, buffering: boolean, idle = false, bounds: ContentBounds | null = wide) => {
16+
for (let t = 0; t < ms; t += 50) {
17+
now += 50;
18+
notice.update(buffering, idle, bounds);
19+
}
20+
};
21+
const pauseTicking = (ms: number) => {
22+
now += ms;
23+
};
24+
return { notice, element, root, play, pauseTicking };
25+
}
26+
27+
afterEach(() => {
28+
document.body.replaceChildren();
29+
});
30+
31+
it("appears after half a second of buffering, not for a single late frame", () => {
32+
const { element, play } = mountNotice();
33+
34+
play(100, true);
35+
play(100, false);
36+
play(450, true);
37+
expect(element.hidden).toBe(true);
38+
39+
play(50, true);
40+
expect(element.hidden).toBe(false);
41+
});
42+
43+
it("goes after 1.5 s of smooth playback, and at once when paused or fully loaded", () => {
44+
const { element, play } = mountNotice();
45+
play(500, true);
46+
47+
play(1450, false);
48+
expect(element.hidden).toBe(false);
49+
play(50, false);
50+
expect(element.hidden).toBe(true);
51+
52+
play(500, true);
53+
expect(element.hidden).toBe(false);
54+
play(50, true, true);
55+
expect(element.hidden).toBe(true);
56+
});
57+
58+
it("says only Buffering, and leaves it to the toolbar's status to tell assistive tech", () => {
59+
const { element, play } = mountNotice();
60+
61+
play(500, true);
62+
63+
expect(element.textContent).toBe("Buffering");
64+
expect(element.getAttribute("aria-hidden")).toBe("true");
65+
});
66+
67+
it("sits inside the video's bottom-left corner", () => {
68+
const { element, play } = mountNotice();
69+
70+
play(500, true);
71+
72+
expect(element.style.left).toBe("112px");
73+
expect(element.style.top).toBe("398px");
74+
});
75+
76+
it("leaves the canvas when disposed", () => {
77+
const { notice, root } = mountNotice();
78+
79+
notice.dispose();
80+
81+
expect(root.querySelector(".ss-buffering-notice")).toBeNull();
82+
});
83+
84+
it("keeps to the part of the video inside the preview", () => {
85+
const { element, play } = mountNotice();
86+
const shown = visibleBounds({ left: -50, right: 600, top: -20, bottom: 900 }, 800, 500);
87+
expect(shown).toEqual({ left: 0, right: 600, top: 0, bottom: 500 });
88+
89+
play(500, true, false, shown);
90+
91+
expect(element.style.left).toBe("12px");
92+
expect(element.style.top).toBe("488px");
93+
});
94+
95+
it("hides while none of the video is in view", () => {
96+
const { element, play } = mountNotice();
97+
play(500, true);
98+
const offscreen = visibleBounds({ left: 900, right: 1500, top: 0, bottom: 300 }, 800, 500);
99+
expect(offscreen).toBeNull();
100+
101+
play(50, true, false, offscreen);
102+
103+
expect(element.hidden).toBe(true);
104+
});
105+
106+
it("dates no timer from before a pause in ticking", () => {
107+
const { element, play, pauseTicking } = mountNotice();
108+
pauseTicking(5000);
109+
110+
play(50, true);
111+
112+
expect(element.hidden).toBe(true);
113+
});

0 commit comments

Comments
 (0)