Skip to content

Commit 0e7d9d3

Browse files
committed
feat: show html5 buffering as a single word
1 parent c22a375 commit 0e7d9d3

3 files changed

Lines changed: 20 additions & 156 deletions

File tree

‎src/components/canvas/buffering-notice.ts‎

Lines changed: 11 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -17,59 +17,31 @@ export function visibleBounds(content: ContentBounds, width: number, height: num
1717
// Long enough that one late frame doesn't flash it, short enough that a real stutter gets explained.
1818
const SHOW_AFTER_MS = 500;
1919
const HIDE_AFTER_MS = 1500;
20-
const EXPANDED_FOR_MS = 4000;
21-
// Narrower than this, the full message would cover too much of the picture.
22-
const NARROW_VIDEO_PX = 400;
2320
const INSET_PX = 12;
2421
// A longer gap between ticks means ticking paused, as in an export or a hidden tab, and says nothing about stutter.
2522
const MAX_TICK_GAP_MS = 100;
26-
const LABEL = "Buffering";
27-
const MESSAGE = "You can keep watching. It'll be smoother once it's loaded.";
2823

2924
/**
30-
* Explains stuttering html5 playback from the video's bottom-left corner, then collapses to a word. It holds only its
31-
* own display state, and the canvas feeds it on every tick.
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.
3227
* @internal
3328
*/
3429
export class BufferingNotice {
35-
public readonly element: HTMLButtonElement;
30+
public readonly element: HTMLDivElement;
3631
private readonly now: () => number;
3732
private bufferingSince: number | null = null;
3833
private smoothSince: number | null = null;
3934
private lastTickAt: number | null = null;
40-
private shownAt = 0;
41-
private shownBefore = false;
42-
private opensExpanded = false;
43-
private hovered = false;
44-
private pinned = false;
4535

4636
constructor(now: () => number = () => performance.now()) {
4737
this.now = now;
48-
this.element = document.createElement("button");
49-
this.element.type = "button";
38+
this.element = document.createElement("div");
5039
this.element.className = "ss-buffering-notice";
5140
this.element.hidden = true;
52-
this.element.setAttribute("aria-label", `${LABEL}. ${MESSAGE}`);
53-
this.element.setAttribute("aria-expanded", "false");
54-
this.element.innerHTML =
55-
'<span class="ss-buffering-notice-spinner" aria-hidden="true"></span><span class="ss-buffering-notice-label"></span><span class="ss-buffering-notice-message"></span>';
56-
this.element.querySelector(".ss-buffering-notice-label")!.textContent = LABEL;
57-
this.element.querySelector(".ss-buffering-notice-message")!.textContent = MESSAGE;
58-
// The canvas root pans on pointerdown, and open popups close on outside clicks.
59-
this.element.addEventListener("pointerdown", event => event.stopPropagation());
60-
this.element.addEventListener("click", event => {
61-
event.stopPropagation();
62-
this.pinned = !this.pinned;
63-
this.render();
64-
});
65-
this.element.addEventListener("pointerenter", () => {
66-
this.hovered = true;
67-
this.render();
68-
});
69-
this.element.addEventListener("pointerleave", () => {
70-
this.hovered = false;
71-
this.render();
72-
});
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");
7345
}
7446

7547
public mount(root: HTMLElement): void {
@@ -85,45 +57,24 @@ export class BufferingNotice {
8557
if (idle || !bounds) {
8658
this.bufferingSince = null;
8759
this.smoothSince = null;
88-
this.hide();
60+
this.element.hidden = true;
8961
return;
9062
}
9163
if (buffering) {
9264
this.smoothSince = null;
9365
this.bufferingSince ??= previous;
94-
if (this.element.hidden && now - this.bufferingSince >= SHOW_AFTER_MS) this.show(now, bounds);
66+
if (now - this.bufferingSince >= SHOW_AFTER_MS) this.element.hidden = false;
9567
} else {
9668
this.bufferingSince = null;
9769
this.smoothSince ??= previous;
98-
if (!this.element.hidden && now - this.smoothSince >= HIDE_AFTER_MS) this.hide();
70+
if (now - this.smoothSince >= HIDE_AFTER_MS) this.element.hidden = true;
9971
}
10072
if (this.element.hidden) return;
10173
this.element.style.left = `${bounds.left + INSET_PX}px`;
10274
this.element.style.top = `${bounds.bottom - INSET_PX}px`;
103-
this.render();
10475
}
10576

10677
public dispose(): void {
10778
this.element.remove();
10879
}
109-
110-
private show(now: number, bounds: ContentBounds): void {
111-
this.shownAt = now;
112-
// Once read, a later stutter only needs the word.
113-
this.opensExpanded = !this.shownBefore && bounds.right - bounds.left >= NARROW_VIDEO_PX;
114-
this.shownBefore = true;
115-
this.pinned = false;
116-
this.element.hidden = false;
117-
}
118-
119-
private hide(): void {
120-
this.pinned = false;
121-
this.element.hidden = true;
122-
}
123-
124-
private render(): void {
125-
const expanded = this.pinned || this.hovered || (this.opensExpanded && this.now() - this.shownAt < EXPANDED_FOR_MS);
126-
this.element.classList.toggle("ss-buffering-notice--expanded", expanded);
127-
this.element.setAttribute("aria-expanded", String(expanded));
128-
}
12980
}

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

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,25 @@
66
display: flex;
77
align-items: center;
88
gap: 7px;
9-
max-width: 280px;
109
padding: 6px 11px;
11-
border: 0;
1210
border-radius: 999px;
1311
background: rgb(17 24 39 / 72%);
1412
-webkit-backdrop-filter: blur(4px);
1513
backdrop-filter: blur(4px);
1614
color: #fff;
1715
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
1816
font-size: 12px;
17+
font-weight: 600;
1918
line-height: 1.3;
20-
text-align: left;
21-
cursor: pointer;
19+
white-space: nowrap;
20+
/* A label, not a control: clicks go through to the canvas. */
21+
pointer-events: none;
2222
}
2323

2424
.ss-buffering-notice[hidden] {
2525
display: none;
2626
}
2727

28-
.ss-buffering-notice:focus-visible {
29-
outline: 2px solid #3b82f6;
30-
outline-offset: 2px;
31-
}
32-
3328
.ss-buffering-notice-spinner {
3429
flex: none;
3530
width: 12px;
@@ -40,23 +35,6 @@
4035
animation: ss-buffering-spin 0.9s linear infinite;
4136
}
4237

43-
.ss-buffering-notice-label {
44-
font-weight: 600;
45-
white-space: nowrap;
46-
}
47-
48-
.ss-buffering-notice-message {
49-
opacity: 0.85;
50-
}
51-
52-
.ss-buffering-notice-message::before {
53-
content: "· ";
54-
}
55-
56-
.ss-buffering-notice:not(.ss-buffering-notice--expanded) .ss-buffering-notice-message {
57-
display: none;
58-
}
59-
6038
@keyframes ss-buffering-spin {
6139
to {
6240
transform: rotate(360deg);

‎tests/buffering-notice.test.ts‎

Lines changed: 5 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { BufferingNotice, type ContentBounds, visibleBounds } from "@canvas/buffering-notice";
33

44
const wide: ContentBounds = { left: 100, right: 740, top: 50, bottom: 410 };
5-
const narrow: ContentBounds = { left: 20, right: 320, top: 10, bottom: 180 };
65

76
function mountNotice() {
87
let now = 0;
@@ -19,11 +18,10 @@ function mountNotice() {
1918
notice.update(buffering, idle, bounds);
2019
}
2120
};
22-
const expanded = () => element.getAttribute("aria-expanded") === "true";
2321
const pauseTicking = (ms: number) => {
2422
now += ms;
2523
};
26-
return { notice, element, root, play, expanded, pauseTicking };
24+
return { notice, element, root, play, pauseTicking };
2725
}
2826

2927
afterEach(() => {
@@ -57,66 +55,13 @@ it("goes after 1.5 s of smooth playback, and at once when paused or fully loaded
5755
expect(element.hidden).toBe(true);
5856
});
5957

60-
it("shows the full message for 4 s the first time, then collapses to the word", () => {
61-
const { element, play, expanded } = mountNotice();
62-
play(500, true);
63-
expect(expanded()).toBe(true);
64-
expect(element.textContent).toContain("You can keep watching. It'll be smoother once it's loaded.");
65-
66-
play(3950, true);
67-
expect(expanded()).toBe(true);
68-
play(50, true);
69-
expect(expanded()).toBe(false);
70-
expect(element.textContent).toContain("Buffering");
71-
});
72-
73-
it("starts collapsed when it appears again", () => {
74-
const { play, expanded } = mountNotice();
75-
play(500, true);
76-
play(1500, false);
77-
78-
play(500, true);
79-
80-
expect(expanded()).toBe(false);
81-
});
82-
83-
it("starts collapsed on a video narrower than 400 px", () => {
84-
const { play, expanded } = mountNotice();
85-
86-
play(500, true, false, narrow);
87-
88-
expect(expanded()).toBe(false);
89-
});
90-
91-
it("opens while hovered, and stays open once clicked until clicked again", () => {
92-
const { element, play, expanded } = mountNotice();
93-
play(4500, true);
94-
expect(expanded()).toBe(false);
95-
96-
element.dispatchEvent(new Event("pointerenter"));
97-
expect(expanded()).toBe(true);
98-
element.dispatchEvent(new Event("pointerleave"));
99-
expect(expanded()).toBe(false);
100-
101-
element.click();
102-
element.dispatchEvent(new Event("pointerleave"));
103-
play(100, true);
104-
expect(expanded()).toBe(true);
105-
element.click();
106-
expect(expanded()).toBe(false);
107-
});
58+
it("says only Buffering, and leaves it to the toolbar's status to tell assistive tech", () => {
59+
const { element, play } = mountNotice();
10860

109-
it("keeps its clicks and presses from reaching the canvas under it", () => {
110-
const { element, root, play } = mountNotice();
11161
play(500, true);
112-
const reached = jest.fn();
113-
root.addEventListener("pointerdown", reached);
114-
root.addEventListener("click", reached);
11562

116-
element.dispatchEvent(new Event("pointerdown", { bubbles: true }));
117-
element.click();
118-
119-
expect(reached).not.toHaveBeenCalled();
63+
expect(element.textContent).toBe("Buffering");
64+
expect(element.getAttribute("aria-hidden")).toBe("true");
12065
});
12166

12267
it("sits inside the video's bottom-left corner", () => {
@@ -128,16 +73,6 @@ it("sits inside the video's bottom-left corner", () => {
12873
expect(element.style.top).toBe("398px");
12974
});
13075

131-
it("gives assistive tech the whole message as a button", () => {
132-
const { element, play } = mountNotice();
133-
134-
play(4500, true);
135-
136-
expect(element.tagName).toBe("BUTTON");
137-
expect(element.type).toBe("button");
138-
expect(element.getAttribute("aria-label")).toBe("Buffering. You can keep watching. It'll be smoother once it's loaded.");
139-
});
140-
14176
it("leaves the canvas when disposed", () => {
14277
const { notice, root } = mountNotice();
14378

0 commit comments

Comments
 (0)