From 61fa73d87d2e52f8d8a9eaa1a5dbc8a33ca861ad Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 29 Oct 2026 11:11:51 +1100 Subject: [PATCH 1/4] feat: report when html5 playback is buffering --- src/components/canvas/players/html5-player.ts | 7 +++++ src/components/canvas/players/player.ts | 4 +++ src/core/edit-session.ts | 8 +++++ tests/html5-player.test.ts | 29 +++++++++++++++++++ 4 files changed, 48 insertions(+) diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index ba0a6216..aa4071dd 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -972,6 +972,13 @@ export class Html5Player extends Player { return this.edit.getHtml5Prerender() === "on-load" || this.hasTriggeredCapture ? "pending" : null; } + // A playing clip stutters whenever it shows anything but the playhead's own frame. + public override isPlaybackBuffering(): boolean { + if (this.disposed || !this.edit.isPlaying || !this.isActive()) return false; + if (this.mode === "playback" || this.mode === "failed") return false; + return this.liveStillShown !== Math.floor(this.getPlaybackTime() * this.captureFps); + } + public override getCaptureProgress(): number { if (this.getPlaybackReadiness() !== "pending") return 1; return this.captureFramesTotal > 0 ? this.captureFramesDone / this.captureFramesTotal : 0; diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index d82724bd..160782ad 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -585,6 +585,10 @@ export abstract class Player extends Entity { return 1; } + public isPlaybackBuffering(): boolean { + return false; + } + public isActive(): boolean { // playbackTime is in seconds, matching clip start/end return this.edit.playbackTime >= this.getStart() && this.edit.playbackTime < this.getEnd(); diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 70e13fa4..b4e92b7d 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -584,6 +584,14 @@ export class Edit { return captureProgressOf(this.getPlayerByClipId(clipId)); } + /** + * Whether a playing html5 clip is showing a held frame, a late still or nothing. + * @internal + */ + public isPlaybackBuffering(): boolean { + return this.clips.some(player => player.isPlaybackBuffering()); + } + /** * Transient generation state for a clip, or undefined when idle. Not part of the * edit: never saved, never undone, cleared on reload. diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts index 4abb328c..09673193 100644 --- a/tests/html5-player.test.ts +++ b/tests/html5-player.test.ts @@ -1626,6 +1626,35 @@ const playingAhead = async (...baked: number[]) => { return created; }; +it("reports buffering while playing a held frame, a late still or nothing", async () => { + const held = await playingAhead(8); + expect(held.player.isPlaybackBuffering()).toBe(true); + + const late = await playingAhead(0); // a still of frame 12, the playhead's own + expect(late.player.isPlaybackBuffering()).toBe(false); + Object.assign(late.player, { getPlaybackTime: () => 13 / 30 }); + expect(late.player.isPlaybackBuffering()).toBe(true); + + const empty = createStillPlayer(); + Object.assign(empty.edit, { isPlaying: true }); + baking(empty.harness); + empty.harness.beginCapture(); + expect(empty.player.isPlaybackBuffering()).toBe(true); +}); + +it("reports no buffering while paused, on the playhead's own baked frame, or once the bake is complete", async () => { + const { player, harness, edit } = await playingAhead(12); + expect(player.isPlaybackBuffering()).toBe(false); + + Object.assign(player, { getPlaybackTime: () => 13 / 30 }); + Object.assign(edit, { isPlaying: false }); + expect(player.isPlaybackBuffering()).toBe(false); + + Object.assign(edit, { isPlaying: true }); + Object.assign(harness, { mode: "playback" }); + expect(player.isPlaybackBuffering()).toBe(false); +}); + it("holds the nearest baked frame while playing ahead of the bake", async () => { const { harness, seeks } = await playingAhead(8, 16); From 11d35f6cde9efe1bb29848b912f64aa0e4eb4e68 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 29 Oct 2026 11:14:58 +1100 Subject: [PATCH 2/4] feat: tell viewers when html5 playback is buffering --- src/components/canvas/buffering-notice.ts | 118 ++++++++++++++++++ src/components/canvas/shotstack-canvas.ts | 6 + src/styles/index.css | 1 + src/styles/ui/buffering-notice.css | 69 +++++++++++ tests/buffering-notice.test.ts | 144 ++++++++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 src/components/canvas/buffering-notice.ts create mode 100644 src/styles/ui/buffering-notice.css create mode 100644 tests/buffering-notice.test.ts diff --git a/src/components/canvas/buffering-notice.ts b/src/components/canvas/buffering-notice.ts new file mode 100644 index 00000000..095b0b4a --- /dev/null +++ b/src/components/canvas/buffering-notice.ts @@ -0,0 +1,118 @@ +export interface ContentBounds { + left: number; + right: number; + top: number; + bottom: number; +} + +// Long enough that one late frame doesn't flash it, short enough that a real stutter gets explained. +const SHOW_AFTER_MS = 500; +const HIDE_AFTER_MS = 1500; +const EXPANDED_FOR_MS = 4000; +// Narrower than this, the full message would cover too much of the picture. +const NARROW_VIDEO_PX = 400; +const INSET_PX = 12; +const LABEL = "Buffering"; +const MESSAGE = "You can keep watching. It'll be smoother once it's loaded."; + +/** + * Explains stuttering html5 playback from the video's bottom-left corner, then collapses to a word. It holds only its + * own display state, and the canvas feeds it on every tick. + * @internal + */ +export class BufferingNotice { + public readonly element: HTMLButtonElement; + private readonly now: () => number; + private bufferingSince: number | null = null; + private smoothSince: number | null = null; + private lastTickAt: number | null = null; + private shownAt = 0; + private shownBefore = false; + private opensExpanded = false; + private hovered = false; + private pinned = false; + + constructor(now: () => number = () => performance.now()) { + this.now = now; + this.element = document.createElement("button"); + this.element.type = "button"; + this.element.className = "ss-buffering-notice"; + this.element.hidden = true; + this.element.setAttribute("aria-label", `${LABEL}. ${MESSAGE}`); + this.element.setAttribute("aria-expanded", "false"); + this.element.innerHTML = + ''; + this.element.querySelector(".ss-buffering-notice-label")!.textContent = LABEL; + this.element.querySelector(".ss-buffering-notice-message")!.textContent = MESSAGE; + // The canvas pans and selects on its own pointer events. + this.element.addEventListener("pointerdown", event => event.stopPropagation()); + this.element.addEventListener("click", event => { + event.stopPropagation(); + this.pinned = !this.pinned; + this.render(); + }); + this.element.addEventListener("pointerenter", () => { + this.hovered = true; + this.render(); + }); + this.element.addEventListener("pointerleave", () => { + this.hovered = false; + this.render(); + }); + } + + public mount(root: HTMLElement): void { + root.appendChild(this.element); + } + + /** `idle` is paused or fully loaded, which hides it at once. */ + public update(buffering: boolean, idle: boolean, bounds: ContentBounds): void { + const now = this.now(); + // A change happened somewhere after the last tick, so its timer starts there. + const previous = this.lastTickAt ?? now; + this.lastTickAt = now; + if (idle) { + this.bufferingSince = null; + this.smoothSince = null; + this.hide(); + return; + } + if (buffering) { + this.smoothSince = null; + this.bufferingSince ??= previous; + if (this.element.hidden && now - this.bufferingSince >= SHOW_AFTER_MS) this.show(now, bounds); + } else { + this.bufferingSince = null; + this.smoothSince ??= previous; + if (!this.element.hidden && now - this.smoothSince >= HIDE_AFTER_MS) this.hide(); + } + if (this.element.hidden) return; + this.element.style.left = `${bounds.left + INSET_PX}px`; + this.element.style.top = `${bounds.bottom - INSET_PX}px`; + this.render(); + } + + public dispose(): void { + this.element.remove(); + } + + private show(now: number, bounds: ContentBounds): void { + this.shownAt = now; + // Once read, a later stutter only needs the word. + this.opensExpanded = !this.shownBefore && bounds.right - bounds.left >= NARROW_VIDEO_PX; + this.shownBefore = true; + this.pinned = false; + this.element.hidden = false; + } + + private hide(): void { + this.pinned = false; + this.element.hidden = true; + } + + private render(): void { + const expanded = this.pinned || this.hovered || (this.opensExpanded && this.now() - this.shownAt < EXPANDED_FOR_MS); + this.element.classList.toggle("ss-buffering-notice--expanded", expanded); + this.element.setAttribute("aria-expanded", String(expanded)); + } +} diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index e0ac9251..3b7af53b 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -1,3 +1,4 @@ +import { BufferingNotice } from "@canvas/buffering-notice"; import type { Player } from "@canvas/players/player"; import { AlignmentGuides } from "@canvas/system/alignment-guides"; import { createWebGLErrorOverlay } from "@canvas/webgl-error-overlay"; @@ -61,6 +62,7 @@ export class Canvas { private onBackgroundClickBound: (event: pixi.FederatedPointerEvent) => void; private onWheelBound: (e: WheelEvent) => void; private canvasRoot: HTMLDivElement | null = null; + private readonly bufferingNotice = new BufferingNotice(); constructor(edit: Edit) { this.application = new pixi.Application(); @@ -141,6 +143,7 @@ export class Canvas { root.style.position = "relative"; root.appendChild(this.application.canvas); + this.bufferingNotice.mount(root); // Auto-mount UIController to canvas root (toolbars sit inside canvas) // mount() handles deferred positioning via double rAF @@ -648,6 +651,8 @@ export class Canvas { private onTick(ticker: pixi.Ticker): void { this.edit.update(ticker.deltaTime, ms(ticker.deltaMS)); + const idle = !this.edit.isPlaying || this.edit.getPendingCaptures().pending === 0; + this.bufferingNotice.update(this.edit.isPlaybackBuffering(), idle, this.getContentBounds()); // Update canvas overlays (selection handles, guides, etc.) this.uiController?.updateOverlays(ticker.deltaTime, ticker.deltaMS); @@ -693,6 +698,7 @@ export class Canvas { } public dispose(): void { + this.bufferingNotice.dispose(); // Unsubscribe from Edit internal events const internalEvents = this.edit.getInternalEvents(); internalEvents.off(InternalEvent.PlayerAddedToTrack, this.onPlayerAddedToTrack); diff --git a/src/styles/index.css b/src/styles/index.css index 8bb59087..e3b6ecf8 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -22,6 +22,7 @@ @import "./ui/background-color-picker.css"; @import "./ui/font-picker.css"; @import "./ui/webgl-error.css"; +@import "./ui/buffering-notice.css"; /* Canvas Styles */ @import "./canvas/canvas-pan.css"; diff --git a/src/styles/ui/buffering-notice.css b/src/styles/ui/buffering-notice.css new file mode 100644 index 00000000..4320cffb --- /dev/null +++ b/src/styles/ui/buffering-notice.css @@ -0,0 +1,69 @@ +/* Sits in the video's corner while html5 playback stutters. It's editor chrome, so it never reaches an export. */ +.ss-buffering-notice { + position: absolute; + z-index: 8; + transform: translateY(-100%); + display: flex; + align-items: center; + gap: 7px; + max-width: 280px; + padding: 6px 11px; + border: 0; + border-radius: 999px; + background: rgb(17 24 39 / 72%); + backdrop-filter: blur(4px); + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-size: 12px; + line-height: 1.3; + text-align: left; + cursor: pointer; +} + +.ss-buffering-notice[hidden] { + display: none; +} + +.ss-buffering-notice:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +.ss-buffering-notice-spinner { + flex: none; + width: 12px; + height: 12px; + border: 2px solid rgb(255 255 255 / 35%); + border-top-color: #fff; + border-radius: 50%; + animation: ss-buffering-spin 0.9s linear infinite; +} + +.ss-buffering-notice-label { + font-weight: 600; + white-space: nowrap; +} + +.ss-buffering-notice-message { + opacity: 0.85; +} + +.ss-buffering-notice-message::before { + content: "· "; +} + +.ss-buffering-notice:not(.ss-buffering-notice--expanded) .ss-buffering-notice-message { + display: none; +} + +@keyframes ss-buffering-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .ss-buffering-notice-spinner { + animation-duration: 3s; + } +} diff --git a/tests/buffering-notice.test.ts b/tests/buffering-notice.test.ts new file mode 100644 index 00000000..a0e1cad2 --- /dev/null +++ b/tests/buffering-notice.test.ts @@ -0,0 +1,144 @@ +/** @jest-environment jsdom */ +import { BufferingNotice, type ContentBounds } from "@canvas/buffering-notice"; + +const wide: ContentBounds = { left: 100, right: 740, top: 50, bottom: 410 }; +const narrow: ContentBounds = { left: 20, right: 320, top: 10, bottom: 180 }; + +function mountNotice() { + let now = 0; + const root = document.createElement("div"); + document.body.appendChild(root); + const notice = new BufferingNotice(() => now); + notice.mount(root); + notice.update(false, false, wide); + const { element } = notice; + // Ticks at 50 ms, the way the canvas would call it every frame. + const play = (ms: number, buffering: boolean, idle = false, bounds = wide) => { + for (let t = 0; t < ms; t += 50) { + now += 50; + notice.update(buffering, idle, bounds); + } + }; + const expanded = () => element.getAttribute("aria-expanded") === "true"; + return { notice, element, root, play, expanded }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +it("appears after half a second of buffering, not for a single late frame", () => { + const { element, play } = mountNotice(); + + play(100, true); + play(100, false); + play(450, true); + expect(element.hidden).toBe(true); + + play(50, true); + expect(element.hidden).toBe(false); +}); + +it("goes after 1.5 s of smooth playback, and at once when paused or fully loaded", () => { + const { element, play } = mountNotice(); + play(500, true); + + play(1450, false); + expect(element.hidden).toBe(false); + play(50, false); + expect(element.hidden).toBe(true); + + play(500, true); + expect(element.hidden).toBe(false); + play(50, true, true); + expect(element.hidden).toBe(true); +}); + +it("shows the full message for 4 s the first time, then collapses to the word", () => { + const { element, play, expanded } = mountNotice(); + play(500, true); + expect(expanded()).toBe(true); + expect(element.textContent).toContain("You can keep watching. It'll be smoother once it's loaded."); + + play(3950, true); + expect(expanded()).toBe(true); + play(50, true); + expect(expanded()).toBe(false); + expect(element.textContent).toContain("Buffering"); +}); + +it("starts collapsed when it appears again", () => { + const { play, expanded } = mountNotice(); + play(500, true); + play(1500, false); + + play(500, true); + + expect(expanded()).toBe(false); +}); + +it("starts collapsed on a video narrower than 400 px", () => { + const { play, expanded } = mountNotice(); + + play(500, true, false, narrow); + + expect(expanded()).toBe(false); +}); + +it("opens while hovered, and stays open once clicked until clicked again", () => { + const { element, play, expanded } = mountNotice(); + play(4500, true); + expect(expanded()).toBe(false); + + element.dispatchEvent(new Event("pointerenter")); + expect(expanded()).toBe(true); + element.dispatchEvent(new Event("pointerleave")); + expect(expanded()).toBe(false); + + element.click(); + element.dispatchEvent(new Event("pointerleave")); + play(100, true); + expect(expanded()).toBe(true); + element.click(); + expect(expanded()).toBe(false); +}); + +it("keeps its clicks and presses from reaching the canvas under it", () => { + const { element, root, play } = mountNotice(); + play(500, true); + const reached = jest.fn(); + root.addEventListener("pointerdown", reached); + root.addEventListener("click", reached); + + element.dispatchEvent(new Event("pointerdown", { bubbles: true })); + element.click(); + + expect(reached).not.toHaveBeenCalled(); +}); + +it("sits inside the video's bottom-left corner", () => { + const { element, play } = mountNotice(); + + play(500, true); + + expect(element.style.left).toBe("112px"); + expect(element.style.top).toBe("398px"); +}); + +it("gives assistive tech the whole message as a button", () => { + const { element, play } = mountNotice(); + + play(4500, true); + + expect(element.tagName).toBe("BUTTON"); + expect(element.type).toBe("button"); + expect(element.getAttribute("aria-label")).toBe("Buffering. You can keep watching. It'll be smoother once it's loaded."); +}); + +it("leaves the canvas when disposed", () => { + const { notice, root } = mountNotice(); + + notice.dispose(); + + expect(root.querySelector(".ss-buffering-notice")).toBeNull(); +}); From c22a3754702021b8a19b9b6dfa9dd61b543061e2 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 29 Oct 2026 11:34:10 +1100 Subject: [PATCH 3/4] fix: judge html5 buffering by the bake and keep the notice in view --- src/components/canvas/buffering-notice.ts | 21 +++++++--- src/components/canvas/players/html5-player.ts | 7 ++-- src/components/canvas/shotstack-canvas.ts | 14 +++++-- src/styles/ui/buffering-notice.css | 1 + tests/buffering-notice.test.ts | 40 +++++++++++++++++-- tests/html5-player.test.ts | 26 ++++++++---- 6 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/components/canvas/buffering-notice.ts b/src/components/canvas/buffering-notice.ts index 095b0b4a..f83b2496 100644 --- a/src/components/canvas/buffering-notice.ts +++ b/src/components/canvas/buffering-notice.ts @@ -5,6 +5,15 @@ export interface ContentBounds { bottom: number; } +/** The part of the video inside a preview of this size, or null when none of it is. */ +export function visibleBounds(content: ContentBounds, width: number, height: number): ContentBounds | null { + const left = Math.max(content.left, 0); + const right = Math.min(content.right, width); + const top = Math.max(content.top, 0); + const bottom = Math.min(content.bottom, height); + return right > left && bottom > top ? { left, right, top, bottom } : null; +} + // Long enough that one late frame doesn't flash it, short enough that a real stutter gets explained. const SHOW_AFTER_MS = 500; const HIDE_AFTER_MS = 1500; @@ -12,6 +21,8 @@ const EXPANDED_FOR_MS = 4000; // Narrower than this, the full message would cover too much of the picture. const NARROW_VIDEO_PX = 400; const INSET_PX = 12; +// A longer gap between ticks means ticking paused, as in an export or a hidden tab, and says nothing about stutter. +const MAX_TICK_GAP_MS = 100; const LABEL = "Buffering"; const MESSAGE = "You can keep watching. It'll be smoother once it's loaded."; @@ -44,7 +55,7 @@ export class BufferingNotice { ''; this.element.querySelector(".ss-buffering-notice-label")!.textContent = LABEL; this.element.querySelector(".ss-buffering-notice-message")!.textContent = MESSAGE; - // The canvas pans and selects on its own pointer events. + // The canvas root pans on pointerdown, and open popups close on outside clicks. this.element.addEventListener("pointerdown", event => event.stopPropagation()); this.element.addEventListener("click", event => { event.stopPropagation(); @@ -65,13 +76,13 @@ export class BufferingNotice { root.appendChild(this.element); } - /** `idle` is paused or fully loaded, which hides it at once. */ - public update(buffering: boolean, idle: boolean, bounds: ContentBounds): void { + /** `idle` is paused or fully loaded, and `bounds` is null when none of the video is in view. Either hides it at once. */ + public update(buffering: boolean, idle: boolean, bounds: ContentBounds | null): void { const now = this.now(); // A change happened somewhere after the last tick, so its timer starts there. - const previous = this.lastTickAt ?? now; + const previous = this.lastTickAt !== null && now - this.lastTickAt <= MAX_TICK_GAP_MS ? this.lastTickAt : now; this.lastTickAt = now; - if (idle) { + if (idle || !bounds) { this.bufferingSince = null; this.smoothSince = null; this.hide(); diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index aa4071dd..eee960b3 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -972,11 +972,10 @@ export class Html5Player extends Player { return this.edit.getHtml5Prerender() === "on-load" || this.hasTriggeredCapture ? "pending" : null; } - // A playing clip stutters whenever it shows anything but the playhead's own frame. + // A baked frame reaches the screen a tick or two after the playhead does, so the bake decides, not the screen. public override isPlaybackBuffering(): boolean { - if (this.disposed || !this.edit.isPlaying || !this.isActive()) return false; - if (this.mode === "playback" || this.mode === "failed") return false; - return this.liveStillShown !== Math.floor(this.getPlaybackTime() * this.captureFps); + if (!this.edit.isPlaying || !this.isActive() || this.getPlaybackReadiness() !== "pending") return false; + return !this.bakingFrames?.[Math.floor(this.getPlaybackTime() * this.captureFps)]; } public override getCaptureProgress(): number { diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 3b7af53b..1fe03d64 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -1,9 +1,9 @@ -import { BufferingNotice } from "@canvas/buffering-notice"; +import { BufferingNotice, visibleBounds } from "@canvas/buffering-notice"; import type { Player } from "@canvas/players/player"; import { AlignmentGuides } from "@canvas/system/alignment-guides"; import { createWebGLErrorOverlay } from "@canvas/webgl-error-overlay"; import { Edit } from "@core/edit-session"; -import { InternalEvent } from "@core/events/edit-events"; +import { EditEvent, InternalEvent } from "@core/events/edit-events"; import { sanitizeColor } from "@core/shared/color-utils"; import { ms } from "@core/timing/types"; import type { UIController } from "@core/ui/ui-controller"; @@ -571,6 +571,7 @@ export class Canvas { internalEvents.on(InternalEvent.PlayerRemovedFromTrack, this.onPlayerRemovedFromTrack); internalEvents.on(InternalEvent.TrackContainerRemoved, this.onTrackContainerRemoved); internalEvents.on(InternalEvent.ViewportSizeChanged, this.onViewportSizeChanged); + this.edit.events.on(EditEvent.PlaybackPause, this.onPlaybackPause); internalEvents.on(InternalEvent.ViewportNeedsZoomToFit, this.onViewportNeedsZoomToFit); } @@ -606,6 +607,11 @@ export class Canvas { this.removeTrackContainer(trackIndex); }; + // An export stops the ticker before it pauses, so no tick would be left to hide the notice. + private onPlaybackPause = (): void => { + this.bufferingNotice.update(false, true, null); + }; + private onViewportSizeChanged = ({ width, height, backgroundColor }: { width: number; height: number; backgroundColor: string }): void => { this.updateViewportForSize(width, height, backgroundColor); }; @@ -652,7 +658,8 @@ export class Canvas { private onTick(ticker: pixi.Ticker): void { this.edit.update(ticker.deltaTime, ms(ticker.deltaMS)); const idle = !this.edit.isPlaying || this.edit.getPendingCaptures().pending === 0; - this.bufferingNotice.update(this.edit.isPlaybackBuffering(), idle, this.getContentBounds()); + const shown = visibleBounds(this.getContentBounds(), this.viewportSize.width, this.viewportSize.height); + this.bufferingNotice.update(this.edit.isPlaybackBuffering(), idle, shown); // Update canvas overlays (selection handles, guides, etc.) this.uiController?.updateOverlays(ticker.deltaTime, ticker.deltaMS); @@ -706,6 +713,7 @@ export class Canvas { internalEvents.off(InternalEvent.PlayerRemovedFromTrack, this.onPlayerRemovedFromTrack); internalEvents.off(InternalEvent.TrackContainerRemoved, this.onTrackContainerRemoved); internalEvents.off(InternalEvent.ViewportSizeChanged, this.onViewportSizeChanged); + this.edit.events.off(EditEvent.PlaybackPause, this.onPlaybackPause); internalEvents.off(InternalEvent.ViewportNeedsZoomToFit, this.onViewportNeedsZoomToFit); const root = document.querySelector(Canvas.CanvasSelector); diff --git a/src/styles/ui/buffering-notice.css b/src/styles/ui/buffering-notice.css index 4320cffb..dc4ebca0 100644 --- a/src/styles/ui/buffering-notice.css +++ b/src/styles/ui/buffering-notice.css @@ -11,6 +11,7 @@ border: 0; border-radius: 999px; background: rgb(17 24 39 / 72%); + -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); color: #fff; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; diff --git a/tests/buffering-notice.test.ts b/tests/buffering-notice.test.ts index a0e1cad2..a0b39eb2 100644 --- a/tests/buffering-notice.test.ts +++ b/tests/buffering-notice.test.ts @@ -1,5 +1,5 @@ /** @jest-environment jsdom */ -import { BufferingNotice, type ContentBounds } from "@canvas/buffering-notice"; +import { BufferingNotice, type ContentBounds, visibleBounds } from "@canvas/buffering-notice"; const wide: ContentBounds = { left: 100, right: 740, top: 50, bottom: 410 }; const narrow: ContentBounds = { left: 20, right: 320, top: 10, bottom: 180 }; @@ -13,14 +13,17 @@ function mountNotice() { notice.update(false, false, wide); const { element } = notice; // Ticks at 50 ms, the way the canvas would call it every frame. - const play = (ms: number, buffering: boolean, idle = false, bounds = wide) => { + const play = (ms: number, buffering: boolean, idle = false, bounds: ContentBounds | null = wide) => { for (let t = 0; t < ms; t += 50) { now += 50; notice.update(buffering, idle, bounds); } }; const expanded = () => element.getAttribute("aria-expanded") === "true"; - return { notice, element, root, play, expanded }; + const pauseTicking = (ms: number) => { + now += ms; + }; + return { notice, element, root, play, expanded, pauseTicking }; } afterEach(() => { @@ -142,3 +145,34 @@ it("leaves the canvas when disposed", () => { expect(root.querySelector(".ss-buffering-notice")).toBeNull(); }); + +it("keeps to the part of the video inside the preview", () => { + const { element, play } = mountNotice(); + const shown = visibleBounds({ left: -50, right: 600, top: -20, bottom: 900 }, 800, 500); + expect(shown).toEqual({ left: 0, right: 600, top: 0, bottom: 500 }); + + play(500, true, false, shown); + + expect(element.style.left).toBe("12px"); + expect(element.style.top).toBe("488px"); +}); + +it("hides while none of the video is in view", () => { + const { element, play } = mountNotice(); + play(500, true); + const offscreen = visibleBounds({ left: 900, right: 1500, top: 0, bottom: 300 }, 800, 500); + expect(offscreen).toBeNull(); + + play(50, true, false, offscreen); + + expect(element.hidden).toBe(true); +}); + +it("dates no timer from before a pause in ticking", () => { + const { element, play, pauseTicking } = mountNotice(); + pauseTicking(5000); + + play(50, true); + + expect(element.hidden).toBe(true); +}); diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts index 09673193..90399796 100644 --- a/tests/html5-player.test.ts +++ b/tests/html5-player.test.ts @@ -1626,14 +1626,12 @@ const playingAhead = async (...baked: number[]) => { return created; }; -it("reports buffering while playing a held frame, a late still or nothing", async () => { +it("reports buffering while the playhead's frame isn't baked, whatever is on screen", async () => { const held = await playingAhead(8); expect(held.player.isPlaybackBuffering()).toBe(true); - const late = await playingAhead(0); // a still of frame 12, the playhead's own - expect(late.player.isPlaybackBuffering()).toBe(false); - Object.assign(late.player, { getPlaybackTime: () => 13 / 30 }); - expect(late.player.isPlaybackBuffering()).toBe(true); + const still = await playingAhead(0); + expect(still.player.isPlaybackBuffering()).toBe(true); const empty = createStillPlayer(); Object.assign(empty.edit, { isPlaying: true }); @@ -1642,17 +1640,31 @@ it("reports buffering while playing a held frame, a late still or nothing", asyn expect(empty.player.isPlaybackBuffering()).toBe(true); }); -it("reports no buffering while paused, on the playhead's own baked frame, or once the bake is complete", async () => { - const { player, harness, edit } = await playingAhead(12); +it("reports no buffering once the playhead's frame is baked, before it reaches the screen", async () => { + const { player, harness } = await playingAhead(12); expect(player.isPlaybackBuffering()).toBe(false); + // The playhead moves before the frame on screen catches up. Object.assign(player, { getPlaybackTime: () => 13 / 30 }); + harness.bakingFrames![13] = new Blob(["baked 13"]); + expect(player.isPlaybackBuffering()).toBe(false); + + Object.assign(player, { getPlaybackTime: () => 14 / 30 }); + expect(player.isPlaybackBuffering()).toBe(true); +}); + +it("reports no buffering while paused, once the bake is complete, or for a clip that failed", async () => { + const { player, harness, edit } = await playingAhead(8); Object.assign(edit, { isPlaying: false }); expect(player.isPlaybackBuffering()).toBe(false); Object.assign(edit, { isPlaying: true }); Object.assign(harness, { mode: "playback" }); expect(player.isPlaybackBuffering()).toBe(false); + + const failed = await playingAhead(8); + Object.assign(failed.harness, { fallbackGraphic: { destroy: jest.fn() } }); + expect(failed.player.isPlaybackBuffering()).toBe(false); }); it("holds the nearest baked frame while playing ahead of the bake", async () => { From 0e7d9d3ab05aa07a2f6d80100969f0f635cc4ecf Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 29 Oct 2026 11:47:45 +1100 Subject: [PATCH 4/4] feat: show html5 buffering as a single word --- src/components/canvas/buffering-notice.ts | 71 ++++----------------- src/styles/ui/buffering-notice.css | 30 ++------- tests/buffering-notice.test.ts | 75 ++--------------------- 3 files changed, 20 insertions(+), 156 deletions(-) diff --git a/src/components/canvas/buffering-notice.ts b/src/components/canvas/buffering-notice.ts index f83b2496..5ffa71d2 100644 --- a/src/components/canvas/buffering-notice.ts +++ b/src/components/canvas/buffering-notice.ts @@ -17,59 +17,31 @@ export function visibleBounds(content: ContentBounds, width: number, height: num // Long enough that one late frame doesn't flash it, short enough that a real stutter gets explained. const SHOW_AFTER_MS = 500; const HIDE_AFTER_MS = 1500; -const EXPANDED_FOR_MS = 4000; -// Narrower than this, the full message would cover too much of the picture. -const NARROW_VIDEO_PX = 400; const INSET_PX = 12; // A longer gap between ticks means ticking paused, as in an export or a hidden tab, and says nothing about stutter. const MAX_TICK_GAP_MS = 100; -const LABEL = "Buffering"; -const MESSAGE = "You can keep watching. It'll be smoother once it's loaded."; /** - * Explains stuttering html5 playback from the video's bottom-left corner, then collapses to a word. It holds only its - * own display state, and the canvas feeds it on every tick. + * Labels stuttering html5 playback "Buffering" in the video's bottom-left corner. It holds only its own display + * state, and the canvas feeds it on every tick. * @internal */ export class BufferingNotice { - public readonly element: HTMLButtonElement; + public readonly element: HTMLDivElement; private readonly now: () => number; private bufferingSince: number | null = null; private smoothSince: number | null = null; private lastTickAt: number | null = null; - private shownAt = 0; - private shownBefore = false; - private opensExpanded = false; - private hovered = false; - private pinned = false; constructor(now: () => number = () => performance.now()) { this.now = now; - this.element = document.createElement("button"); - this.element.type = "button"; + this.element = document.createElement("div"); this.element.className = "ss-buffering-notice"; this.element.hidden = true; - this.element.setAttribute("aria-label", `${LABEL}. ${MESSAGE}`); - this.element.setAttribute("aria-expanded", "false"); - this.element.innerHTML = - ''; - this.element.querySelector(".ss-buffering-notice-label")!.textContent = LABEL; - this.element.querySelector(".ss-buffering-notice-message")!.textContent = MESSAGE; - // The canvas root pans on pointerdown, and open popups close on outside clicks. - this.element.addEventListener("pointerdown", event => event.stopPropagation()); - this.element.addEventListener("click", event => { - event.stopPropagation(); - this.pinned = !this.pinned; - this.render(); - }); - this.element.addEventListener("pointerenter", () => { - this.hovered = true; - this.render(); - }); - this.element.addEventListener("pointerleave", () => { - this.hovered = false; - this.render(); - }); + // The toolbar's status line already tells assistive tech that playback is preparing. + this.element.setAttribute("aria-hidden", "true"); + this.element.innerHTML = ''; + this.element.append("Buffering"); } public mount(root: HTMLElement): void { @@ -85,45 +57,24 @@ export class BufferingNotice { if (idle || !bounds) { this.bufferingSince = null; this.smoothSince = null; - this.hide(); + this.element.hidden = true; return; } if (buffering) { this.smoothSince = null; this.bufferingSince ??= previous; - if (this.element.hidden && now - this.bufferingSince >= SHOW_AFTER_MS) this.show(now, bounds); + if (now - this.bufferingSince >= SHOW_AFTER_MS) this.element.hidden = false; } else { this.bufferingSince = null; this.smoothSince ??= previous; - if (!this.element.hidden && now - this.smoothSince >= HIDE_AFTER_MS) this.hide(); + if (now - this.smoothSince >= HIDE_AFTER_MS) this.element.hidden = true; } if (this.element.hidden) return; this.element.style.left = `${bounds.left + INSET_PX}px`; this.element.style.top = `${bounds.bottom - INSET_PX}px`; - this.render(); } public dispose(): void { this.element.remove(); } - - private show(now: number, bounds: ContentBounds): void { - this.shownAt = now; - // Once read, a later stutter only needs the word. - this.opensExpanded = !this.shownBefore && bounds.right - bounds.left >= NARROW_VIDEO_PX; - this.shownBefore = true; - this.pinned = false; - this.element.hidden = false; - } - - private hide(): void { - this.pinned = false; - this.element.hidden = true; - } - - private render(): void { - const expanded = this.pinned || this.hovered || (this.opensExpanded && this.now() - this.shownAt < EXPANDED_FOR_MS); - this.element.classList.toggle("ss-buffering-notice--expanded", expanded); - this.element.setAttribute("aria-expanded", String(expanded)); - } } diff --git a/src/styles/ui/buffering-notice.css b/src/styles/ui/buffering-notice.css index dc4ebca0..ddbdff4c 100644 --- a/src/styles/ui/buffering-notice.css +++ b/src/styles/ui/buffering-notice.css @@ -6,9 +6,7 @@ display: flex; align-items: center; gap: 7px; - max-width: 280px; padding: 6px 11px; - border: 0; border-radius: 999px; background: rgb(17 24 39 / 72%); -webkit-backdrop-filter: blur(4px); @@ -16,20 +14,17 @@ color: #fff; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-size: 12px; + font-weight: 600; line-height: 1.3; - text-align: left; - cursor: pointer; + white-space: nowrap; + /* A label, not a control: clicks go through to the canvas. */ + pointer-events: none; } .ss-buffering-notice[hidden] { display: none; } -.ss-buffering-notice:focus-visible { - outline: 2px solid #3b82f6; - outline-offset: 2px; -} - .ss-buffering-notice-spinner { flex: none; width: 12px; @@ -40,23 +35,6 @@ animation: ss-buffering-spin 0.9s linear infinite; } -.ss-buffering-notice-label { - font-weight: 600; - white-space: nowrap; -} - -.ss-buffering-notice-message { - opacity: 0.85; -} - -.ss-buffering-notice-message::before { - content: "· "; -} - -.ss-buffering-notice:not(.ss-buffering-notice--expanded) .ss-buffering-notice-message { - display: none; -} - @keyframes ss-buffering-spin { to { transform: rotate(360deg); diff --git a/tests/buffering-notice.test.ts b/tests/buffering-notice.test.ts index a0b39eb2..36961bf6 100644 --- a/tests/buffering-notice.test.ts +++ b/tests/buffering-notice.test.ts @@ -2,7 +2,6 @@ import { BufferingNotice, type ContentBounds, visibleBounds } from "@canvas/buffering-notice"; const wide: ContentBounds = { left: 100, right: 740, top: 50, bottom: 410 }; -const narrow: ContentBounds = { left: 20, right: 320, top: 10, bottom: 180 }; function mountNotice() { let now = 0; @@ -19,11 +18,10 @@ function mountNotice() { notice.update(buffering, idle, bounds); } }; - const expanded = () => element.getAttribute("aria-expanded") === "true"; const pauseTicking = (ms: number) => { now += ms; }; - return { notice, element, root, play, expanded, pauseTicking }; + return { notice, element, root, play, pauseTicking }; } afterEach(() => { @@ -57,66 +55,13 @@ it("goes after 1.5 s of smooth playback, and at once when paused or fully loaded expect(element.hidden).toBe(true); }); -it("shows the full message for 4 s the first time, then collapses to the word", () => { - const { element, play, expanded } = mountNotice(); - play(500, true); - expect(expanded()).toBe(true); - expect(element.textContent).toContain("You can keep watching. It'll be smoother once it's loaded."); - - play(3950, true); - expect(expanded()).toBe(true); - play(50, true); - expect(expanded()).toBe(false); - expect(element.textContent).toContain("Buffering"); -}); - -it("starts collapsed when it appears again", () => { - const { play, expanded } = mountNotice(); - play(500, true); - play(1500, false); - - play(500, true); - - expect(expanded()).toBe(false); -}); - -it("starts collapsed on a video narrower than 400 px", () => { - const { play, expanded } = mountNotice(); - - play(500, true, false, narrow); - - expect(expanded()).toBe(false); -}); - -it("opens while hovered, and stays open once clicked until clicked again", () => { - const { element, play, expanded } = mountNotice(); - play(4500, true); - expect(expanded()).toBe(false); - - element.dispatchEvent(new Event("pointerenter")); - expect(expanded()).toBe(true); - element.dispatchEvent(new Event("pointerleave")); - expect(expanded()).toBe(false); - - element.click(); - element.dispatchEvent(new Event("pointerleave")); - play(100, true); - expect(expanded()).toBe(true); - element.click(); - expect(expanded()).toBe(false); -}); +it("says only Buffering, and leaves it to the toolbar's status to tell assistive tech", () => { + const { element, play } = mountNotice(); -it("keeps its clicks and presses from reaching the canvas under it", () => { - const { element, root, play } = mountNotice(); play(500, true); - const reached = jest.fn(); - root.addEventListener("pointerdown", reached); - root.addEventListener("click", reached); - element.dispatchEvent(new Event("pointerdown", { bubbles: true })); - element.click(); - - expect(reached).not.toHaveBeenCalled(); + expect(element.textContent).toBe("Buffering"); + expect(element.getAttribute("aria-hidden")).toBe("true"); }); it("sits inside the video's bottom-left corner", () => { @@ -128,16 +73,6 @@ it("sits inside the video's bottom-left corner", () => { expect(element.style.top).toBe("398px"); }); -it("gives assistive tech the whole message as a button", () => { - const { element, play } = mountNotice(); - - play(4500, true); - - expect(element.tagName).toBe("BUTTON"); - expect(element.type).toBe("button"); - expect(element.getAttribute("aria-label")).toBe("Buffering. You can keep watching. It'll be smoother once it's loaded."); -}); - it("leaves the canvas when disposed", () => { const { notice, root } = mountNotice();