diff --git a/packages/player/package.json b/packages/player/package.json index 6b97a8f03..cb2c70890 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -16,9 +16,9 @@ "exports": { ".": { "types": "./src/hyperframes-player.ts", + "script": "./dist/hyperframes-player.global.js", "import": "./src/hyperframes-player.ts", - "require": "./dist/hyperframes-player.cjs", - "script": "./dist/hyperframes-player.global.js" + "require": "./dist/hyperframes-player.cjs" } }, "scripts": { diff --git a/packages/player/src/controls.ts b/packages/player/src/controls.ts index 518793d28..fd2300abd 100644 --- a/packages/player/src/controls.ts +++ b/packages/player/src/controls.ts @@ -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 | 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); }, }; diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 29020bacc..3841bc422 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1,5 +1,29 @@ import { describe, it, expect } from "vitest"; -import { formatTime } from "./controls.js"; +import { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js"; + +describe("SPEED_PRESETS", () => { + it("contains logarithmic speed steps", () => { + expect(SPEED_PRESETS).toEqual([0.25, 0.5, 1, 1.5, 2, 4]); + }); + + it("includes 1x as default speed", () => { + expect(SPEED_PRESETS).toContain(1); + }); +}); + +describe("formatSpeed", () => { + it("formats integer speeds", () => { + expect(formatSpeed(1)).toBe("1x"); + expect(formatSpeed(2)).toBe("2x"); + expect(formatSpeed(4)).toBe("4x"); + }); + + it("formats fractional speeds", () => { + expect(formatSpeed(0.25)).toBe("0.25x"); + expect(formatSpeed(0.5)).toBe("0.5x"); + expect(formatSpeed(1.5)).toBe("1.5x"); + }); +}); describe("formatTime", () => { it("formats 0 seconds", () => { diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 260f7fcf5..4ff1b5934 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -1,4 +1,4 @@ -import { createControls, type ControlsCallbacks } from "./controls.js"; +import { createControls, SPEED_PRESETS, type ControlsCallbacks } from "./controls.js"; import { PLAYER_STYLES } from "./styles.js"; const DEFAULT_FPS = 30; @@ -105,9 +105,13 @@ class HyperframesPlayer extends HTMLElement { case "poster": this._setupPoster(); break; - case "playback-rate": - this._sendControl("set-playback-rate", { playbackRate: parseFloat(val || "1") }); + case "playback-rate": { + const rate = parseFloat(val || "1"); + this._sendControl("set-playback-rate", { playbackRate: rate }); + this.controlsApi?.updateSpeed(rate); + this.dispatchEvent(new Event("ratechange")); break; + } case "muted": this._sendControl("set-muted", { muted: val !== null }); break; @@ -367,8 +371,18 @@ class HyperframesPlayer extends HTMLElement { onPlay: () => this.play(), onPause: () => this.pause(), onSeek: (fraction) => this.seek(fraction * this._duration), + onSpeedChange: (speed) => { + this.playbackRate = speed; + }, }; - this.controlsApi = createControls(this.shadow, callbacks); + const presetsAttr = this.getAttribute("speed-presets"); + const speedPresets = presetsAttr + ? presetsAttr + .split(",") + .map(Number) + .filter((n) => !isNaN(n) && n > 0) + : undefined; + this.controlsApi = createControls(this.shadow, callbacks, { speedPresets }); } private _setupPoster() { @@ -397,4 +411,5 @@ if (!customElements.get("hyperframes-player")) { } export { HyperframesPlayer }; -export { formatTime } from "./controls.js"; +export { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js"; +export type { ControlsCallbacks, ControlsOptions } from "./controls.js"; diff --git a/packages/player/src/styles.ts b/packages/player/src/styles.ts index 2755be4e0..2ae4e650a 100644 --- a/packages/player/src/styles.ts +++ b/packages/player/src/styles.ts @@ -31,6 +31,16 @@ export const PLAYER_STYLES = /* css */ ` pointer-events: none; } + /* ── Theming via CSS custom properties ── + * + * Override from outside the shadow DOM: + * hyperframes-player { + * --hfp-controls-bg: linear-gradient(transparent, rgba(0,0,0,0.9)); + * --hfp-accent: #ff6b6b; + * --hfp-font: "Inter", sans-serif; + * } + */ + .hfp-controls { position: absolute; bottom: 0; @@ -38,12 +48,12 @@ export const PLAYER_STYLES = /* css */ ` right: 0; display: flex; align-items: center; - gap: 12px; - padding: 8px 16px; - background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); - color: #fff; - font-family: system-ui, -apple-system, sans-serif; - font-size: 13px; + gap: var(--hfp-controls-gap, 12px); + padding: var(--hfp-controls-padding, 8px 16px); + background: var(--hfp-controls-bg, linear-gradient(transparent, rgba(0, 0, 0, 0.7))); + color: var(--hfp-color, #fff); + font-family: var(--hfp-font, system-ui, -apple-system, sans-serif); + font-size: var(--hfp-font-size, 13px); z-index: 10; pointer-events: auto; opacity: 1; @@ -59,7 +69,7 @@ export const PLAYER_STYLES = /* css */ ` .hfp-play-btn { background: none; border: none; - color: #fff; + color: var(--hfp-color, #fff); cursor: pointer; padding: 8px; display: flex; @@ -82,15 +92,15 @@ export const PLAYER_STYLES = /* css */ ` .hfp-scrubber { flex: 1; - height: 4px; - background: rgba(255, 255, 255, 0.3); - border-radius: 2px; + height: var(--hfp-scrubber-height, 4px); + background: var(--hfp-scrubber-bg, rgba(255, 255, 255, 0.3)); + border-radius: var(--hfp-scrubber-radius, 2px); cursor: pointer; position: relative; } .hfp-scrubber:hover { - height: 6px; + height: var(--hfp-scrubber-height-hover, 6px); } .hfp-progress { @@ -98,8 +108,8 @@ export const PLAYER_STYLES = /* css */ ` top: 0; left: 0; height: 100%; - background: #fff; - border-radius: 2px; + background: var(--hfp-accent, #fff); + border-radius: var(--hfp-scrubber-radius, 2px); pointer-events: none; } @@ -108,6 +118,83 @@ export const PLAYER_STYLES = /* css */ ` font-variant-numeric: tabular-nums; opacity: 0.9; } + + .hfp-speed-wrap { + position: relative; + flex-shrink: 0; + } + + .hfp-speed-btn { + background: var(--hfp-speed-btn-bg, rgba(255, 255, 255, 0.15)); + border: none; + border-radius: var(--hfp-speed-btn-radius, 4px); + color: var(--hfp-color, #fff); + cursor: pointer; + font-family: var(--hfp-font, system-ui, -apple-system, sans-serif); + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: 600; + padding: 4px 8px; + min-width: 40px; + text-align: center; + transition: background 0.15s ease; + } + + .hfp-speed-btn:hover { + background: var(--hfp-speed-btn-bg-hover, rgba(255, 255, 255, 0.3)); + } + + .hfp-speed-menu { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + background: var(--hfp-menu-bg, rgba(20, 20, 20, 0.95)); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--hfp-menu-border, rgba(255, 255, 255, 0.1)); + border-radius: var(--hfp-menu-radius, 8px); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 80px; + opacity: 0; + visibility: hidden; + transform: translateY(4px); + transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s; + box-shadow: var(--hfp-menu-shadow, 0 8px 24px rgba(0, 0, 0, 0.4)); + } + + .hfp-speed-menu.hfp-open { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + + .hfp-speed-option { + background: none; + border: none; + border-radius: 4px; + color: var(--hfp-menu-color, rgba(255, 255, 255, 0.7)); + cursor: pointer; + font-family: var(--hfp-font, system-ui, -apple-system, sans-serif); + font-size: 13px; + font-variant-numeric: tabular-nums; + padding: 6px 12px; + text-align: left; + transition: background 0.1s ease, color 0.1s ease; + white-space: nowrap; + } + + .hfp-speed-option:hover { + background: var(--hfp-menu-hover-bg, rgba(255, 255, 255, 0.1)); + color: var(--hfp-color, #fff); + } + + .hfp-speed-option.hfp-active { + color: var(--hfp-accent, #fff); + font-weight: 600; + } `; export const PLAY_ICON = ``;