mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(player): add speed control with popup menu and CSS theming (#241)
Add playback speed control to the player controls bar: - Popup menu with logarithmic presets (0.25x-4x) - Custom presets via speed-presets attribute - Full CSS custom property theming (--hfp-accent, --hfp-controls-bg, etc.) - ratechange event dispatch - Exports: SPEED_PRESETS, formatSpeed, ControlsOptions - Fix package.json export condition ordering
This commit is contained in:
@@ -4,6 +4,19 @@ export interface ControlsCallbacks {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSeek: (fraction: number) => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
/** Default logarithmic speed presets — each step roughly doubles/halves. */
|
||||
export const SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
|
||||
|
||||
export interface ControlsOptions {
|
||||
/** Speed presets shown in the menu. Defaults to SPEED_PRESETS. */
|
||||
speedPresets?: readonly number[];
|
||||
}
|
||||
|
||||
export function formatSpeed(speed: number): string {
|
||||
return Number.isInteger(speed) ? `${speed}x` : `${speed}x`;
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
@@ -16,13 +29,17 @@ export function formatTime(seconds: number): string {
|
||||
export function createControls(
|
||||
parent: ShadowRoot | HTMLElement,
|
||||
callbacks: ControlsCallbacks,
|
||||
options: ControlsOptions = {},
|
||||
): {
|
||||
updateTime: (current: number, duration: number) => void;
|
||||
updatePlaying: (playing: boolean) => void;
|
||||
updateSpeed: (speed: number) => void;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
destroy: () => void;
|
||||
} {
|
||||
const presets = options.speedPresets ?? SPEED_PRESETS;
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "hfp-controls";
|
||||
// Keep overlay interactions from falling through to the host-level click toggle.
|
||||
@@ -47,13 +64,42 @@ export function createControls(
|
||||
time.className = "hfp-time";
|
||||
time.textContent = "0:00 / 0:00";
|
||||
|
||||
const speedWrap = document.createElement("div");
|
||||
speedWrap.className = "hfp-speed-wrap";
|
||||
|
||||
const speedBtn = document.createElement("button");
|
||||
speedBtn.className = "hfp-speed-btn";
|
||||
speedBtn.type = "button";
|
||||
speedBtn.textContent = "1x";
|
||||
speedBtn.setAttribute("aria-label", "Playback speed");
|
||||
|
||||
const speedMenu = document.createElement("div");
|
||||
speedMenu.className = "hfp-speed-menu";
|
||||
speedMenu.setAttribute("role", "menu");
|
||||
for (const preset of presets) {
|
||||
const item = document.createElement("button");
|
||||
item.className = "hfp-speed-option";
|
||||
item.type = "button";
|
||||
item.setAttribute("role", "menuitem");
|
||||
item.dataset.speed = String(preset);
|
||||
item.textContent = formatSpeed(preset);
|
||||
if (preset === 1) item.classList.add("hfp-active");
|
||||
speedMenu.appendChild(item);
|
||||
}
|
||||
|
||||
speedWrap.appendChild(speedMenu);
|
||||
speedWrap.appendChild(speedBtn);
|
||||
|
||||
controls.appendChild(playBtn);
|
||||
controls.appendChild(scrubber);
|
||||
controls.appendChild(time);
|
||||
controls.appendChild(speedWrap);
|
||||
parent.appendChild(controls);
|
||||
|
||||
let isPlaying = false;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let speedIndex = presets.indexOf(1); // start at 1x
|
||||
if (speedIndex === -1) speedIndex = 0;
|
||||
|
||||
playBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -61,6 +107,38 @@ export function createControls(
|
||||
else callbacks.onPlay();
|
||||
});
|
||||
|
||||
const setActiveOption = (speed: number) => {
|
||||
for (const opt of speedMenu.querySelectorAll(".hfp-speed-option")) {
|
||||
opt.classList.toggle("hfp-active", (opt as HTMLElement).dataset.speed === String(speed));
|
||||
}
|
||||
};
|
||||
|
||||
speedBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = speedMenu.classList.toggle("hfp-open");
|
||||
speedBtn.setAttribute("aria-expanded", String(isOpen));
|
||||
});
|
||||
|
||||
speedMenu.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const target = (e.target as HTMLElement).closest(".hfp-speed-option") as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const newSpeed = parseFloat(target.dataset.speed!);
|
||||
speedIndex = presets.indexOf(newSpeed);
|
||||
speedBtn.textContent = formatSpeed(newSpeed);
|
||||
setActiveOption(newSpeed);
|
||||
speedMenu.classList.remove("hfp-open");
|
||||
speedBtn.setAttribute("aria-expanded", "false");
|
||||
callbacks.onSpeedChange(newSpeed);
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
const onDocClick = () => {
|
||||
speedMenu.classList.remove("hfp-open");
|
||||
speedBtn.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
document.addEventListener("click", onDocClick);
|
||||
|
||||
const handleScrubAt = (clientX: number) => {
|
||||
const rect = scrubber.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
@@ -133,6 +211,12 @@ export function createControls(
|
||||
if (playing) startHideTimer();
|
||||
else controls.classList.remove("hfp-hidden");
|
||||
},
|
||||
updateSpeed(speed: number) {
|
||||
const idx = presets.indexOf(speed);
|
||||
if (idx !== -1) speedIndex = idx;
|
||||
speedBtn.textContent = formatSpeed(speed);
|
||||
setActiveOption(speed);
|
||||
},
|
||||
show() {
|
||||
controls.style.display = "";
|
||||
},
|
||||
@@ -144,6 +228,7 @@ export function createControls(
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
document.removeEventListener("touchend", onTouchEnd);
|
||||
document.removeEventListener("click", onDocClick);
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user