diff --git a/packages/player/src/controls.test.ts b/packages/player/src/controls.test.ts index ffc86f83a5..d41e29565c 100644 --- a/packages/player/src/controls.test.ts +++ b/packages/player/src/controls.test.ts @@ -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(".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("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(); + }); +}); diff --git a/packages/player/src/controls.ts b/packages/player/src/controls.ts index 2cef825a68..b6485b63b9 100644 --- a/packages/player/src/controls.ts +++ b/packages/player/src/controls.ts @@ -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(); diff --git a/packages/player/src/parent-media.test.ts b/packages/player/src/parent-media.test.ts index 2182813c3f..3a34d5329b 100644 --- a/packages/player/src/parent-media.test.ts +++ b/packages/player/src/parent-media.test.ts @@ -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"); + }); +}); diff --git a/packages/player/src/parent-media.ts b/packages/player/src/parent-media.ts index 3781b341a9..3c73bcb478 100644 --- a/packages/player/src/parent-media.ts +++ b/packages/player/src/parent-media.ts @@ -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();