fix(player): fade the controls when idle and copy crossorigin to the audio it plays (#6509)

* fix(player): fade the controls when idle and copy crossorigin to the audio it plays

* fix(player): only keyboard focus keeps the controls on screen
This commit is contained in:
Miguel Ángel
2026-10-27 04:09:05 +00:00
committed by GitHub
parent 202bed36c9
commit ac90833cc6
4 changed files with 116 additions and 11 deletions
+62
View File
@@ -103,3 +103,65 @@ describe("createControls playback speed name", () => {
api.destroy();
});
});
describe("createControls idle hide", () => {
function setup() {
vi.useFakeTimers();
const host = document.createElement("div");
document.body.appendChild(host);
const api = createControls(host, noopCallbacks());
const controls = host.querySelector<HTMLElement>(".hfp-controls")!;
return { host, api, controls };
}
it("hides after 3 s of no input while playing, and returns on movement", () => {
const { host, api, controls } = setup();
api.updatePlaying(true);
host.dispatchEvent(new Event("mousemove"));
vi.advanceTimersByTime(3000);
expect(controls.classList.contains("hfp-hidden")).toBe(true);
host.dispatchEvent(new Event("mousemove"));
expect(controls.classList.contains("hfp-hidden")).toBe(false);
api.destroy();
host.remove();
vi.useRealTimers();
});
it("stays visible while paused or while a control has focus", () => {
const { host, api, controls } = setup();
vi.advanceTimersByTime(5000);
expect(controls.classList.contains("hfp-hidden")).toBe(false);
api.updatePlaying(true);
controls.querySelector<HTMLElement>("button")!.focus();
vi.advanceTimersByTime(5000);
expect(controls.classList.contains("hfp-hidden")).toBe(false);
api.destroy();
host.remove();
vi.useRealTimers();
});
it("comes back on a key press and stays visible until idle again", () => {
const { host, api, controls } = setup();
api.updatePlaying(true);
vi.advanceTimersByTime(3000);
host.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" }));
expect(controls.classList.contains("hfp-hidden")).toBe(false);
vi.advanceTimersByTime(2999);
expect(controls.classList.contains("hfp-hidden")).toBe(false);
api.destroy();
host.remove();
vi.useRealTimers();
});
it("restarting playback state does not push the hide back", () => {
const { host, api, controls } = setup();
api.updatePlaying(true);
vi.advanceTimersByTime(2000);
api.updatePlaying(true);
vi.advanceTimersByTime(1000);
expect(controls.classList.contains("hfp-hidden")).toBe(true);
api.destroy();
host.remove();
vi.useRealTimers();
});
});
+19 -11
View File
@@ -338,22 +338,25 @@ export function createControls(
document.addEventListener("touchmove", onTouchMove, { passive: true });
document.addEventListener("touchend", onTouchEnd);
const startHideTimer = () => {
const canHide = () =>
isPlaying && !scrubbing && !volumeScrubbing && !controls.querySelector(":focus-visible");
const reveal = () => {
controls.classList.remove("hfp-hidden");
if (hideTimeout) clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
if (isPlaying) controls.classList.add("hfp-hidden");
if (canHide()) controls.classList.add("hfp-hidden");
}, 3000);
};
const host = parent instanceof ShadowRoot ? (parent.host as HTMLElement) : parent;
const onHostMouseMove = () => {
controls.classList.remove("hfp-hidden");
startHideTimer();
};
const onHostActivity = () => reveal();
const onHostMouseLeave = () => {
if (isPlaying) controls.classList.add("hfp-hidden");
if (canHide()) controls.classList.add("hfp-hidden");
};
host.addEventListener("mousemove", onHostMouseMove);
host.addEventListener("mousemove", onHostActivity);
host.addEventListener("keydown", onHostActivity);
host.addEventListener("focusin", onHostActivity);
host.addEventListener("mouseleave", onHostMouseLeave);
return {
@@ -365,11 +368,14 @@ export function createControls(
time.textContent = `${formatTime(clampedCurrent)} / ${formatTime(duration)}`;
},
updatePlaying(playing: boolean) {
const changed = playing !== isPlaying;
isPlaying = playing;
playBtn.classList.toggle("hfp-playing", playing);
playBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
if (playing) startHideTimer();
else controls.classList.remove("hfp-hidden");
if (!playing) {
if (hideTimeout) clearTimeout(hideTimeout);
controls.classList.remove("hfp-hidden");
} else if (changed) reveal();
},
updateSpeed(speed: number) {
const idx = presets.indexOf(speed);
@@ -408,7 +414,9 @@ export function createControls(
document.removeEventListener("touchmove", onVolumeTouchMove);
document.removeEventListener("touchend", onVolumeTouchEnd);
document.removeEventListener("click", onDocClick);
host.removeEventListener("mousemove", onHostMouseMove);
host.removeEventListener("mousemove", onHostActivity);
host.removeEventListener("keydown", onHostActivity);
host.removeEventListener("focusin", onHostActivity);
host.removeEventListener("mouseleave", onHostMouseLeave);
if (hideTimeout) clearTimeout(hideTimeout);
controls.remove();
+33
View File
@@ -597,3 +597,36 @@ describe("ParentMediaManager drift policy", () => {
expect(writes[1]).toBeCloseTo(mediaElementTime(5.43 + 0.23), 9);
});
});
describe("proxy crossorigin", () => {
it.each(["audio", "video"] as const)("a %s proxy carries its source's crossorigin", (tag) => {
const created: HTMLMediaElement[] = [];
const real = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((t: string) => {
const el = real(t);
if (t === "video") created.push(el as HTMLMediaElement);
return el;
});
const OrigAudio = globalThis.Audio;
globalThis.Audio = function () {
const el = real("audio");
created.push(el);
return el;
} as unknown as typeof Audio;
const source = real(tag);
source.setAttribute("crossorigin", "anonymous");
const parent = Object.create(ParentMediaManager.prototype) as ParentMediaManager;
Object.assign(parent, {
_entries: [],
_urlTaken: () => false,
_getMuted: () => false,
_getVolume: () => 1,
_getPlaybackRate: () => 1,
});
(parent as any)._createEntry("https://x/a.mp3", tag, 0, 5, source);
const proxy = created.find((e) => e.tagName.toLowerCase() === tag)!;
globalThis.Audio = OrigAudio;
vi.restoreAllMocks();
expect(proxy.getAttribute("crossorigin")).toBe("anonymous");
});
});
+2
View File
@@ -413,6 +413,8 @@ export class ParentMediaManager {
const el = tag === "video" ? document.createElement("video") : new Audio();
el.preload = "auto";
const crossOrigin = source?.getAttribute("crossorigin");
if (crossOrigin != null) el.setAttribute("crossorigin", crossOrigin);
el.src = src;
el.load();
el.muted = this._getMuted();