Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/packages/player.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,41 @@ player.addEventListener("timeupdate", (event) => {
| `error` | `{ message }` | Load or runtime error; a video adds its `MediaError` `code` |
| `playbackerror` | `{ source, error }` | The browser blocked playback, for example unmuted autoplay |

## Custom icons and loading mark

Put an element with a `slot` attribute inside the player to replace a control glyph or the loading mark. Anything you leave out keeps the default.

| Slot | Shown when | Default size |
| ------------------- | ----------------------------------------------------------- | ------------ |
| `play-icon` | Paused | 24 px |
| `pause-icon` | Playing | 24 px |
| `volume-high-icon` | Unmuted, volume 0.5 or higher | 20 px |
| `volume-low-icon` | Unmuted, volume below 0.5 | 20 px |
| `volume-muted-icon` | Muted | 20 px |
| `loading-mark` | Preparing shader transitions, loading assets, and buffering | 78 px |

```html title="index.html"
<hyperframes-player src="/compositions/intro.html" controls>
<svg slot="play-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
<svg slot="pause-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
<img slot="loading-mark" src="/brand-mark.svg" width="78" height="78" alt="" />
</hyperframes-player>
```

Glyphs take the control bar's color through `currentColor`, and you set their size. Put `slot` on the `<svg>` or `<img>` itself, as a direct child of the player; a wrapper element needs `display: contents` to keep the glyph centered. A custom mark replaces the HyperFrames light sweep and glow, so add your own CSS animation if you want motion.

Until the element is defined, its children show as plain content. Hide them:

```css title="styles.css"
hyperframes-player:not(:defined) > [slot] {
display: none;
}
```

## Advanced: iframe access

The composition runs inside an `<iframe>` in the player's Shadow DOM. For most
Expand Down
49 changes: 49 additions & 0 deletions packages/player/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,55 @@ player.addEventListener("ended", () => {
});
```

## Custom icons and loading mark

Put an element with a `slot` attribute inside the player to replace a control glyph or the loading mark. Anything you leave out keeps the default.

| Slot | Shown when | Default size |
| ------------------- | ----------------------------------------------------------- | ------------ |
| `play-icon` | Paused | 24 px |
| `pause-icon` | Playing | 24 px |
| `volume-high-icon` | Unmuted, volume 0.5 or higher | 20 px |
| `volume-low-icon` | Unmuted, volume below 0.5 | 20 px |
| `volume-muted-icon` | Muted | 20 px |
| `loading-mark` | Preparing shader transitions, loading assets, and buffering | 78 px |

```html
<hyperframes-player src="./my-composition/index.html" controls>
<svg
slot="play-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
<svg
slot="pause-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
<img slot="loading-mark" src="./brand-mark.svg" width="78" height="78" alt="" />
</hyperframes-player>
```

Glyphs take the control bar's color through `currentColor`, and you set their size. Put `slot` on the `<svg>` or `<img>` itself, as a direct child of the player; a wrapper element needs `display: contents` to keep the glyph centered. A custom mark replaces the HyperFrames light sweep and glow, so add your own CSS animation if you want motion.

Until the element is defined, its children show as plain content. Hide them:

```css
hyperframes-player:not(:defined) > [slot] {
display: none;
}
```

## Sizing

The player fills its container and scales the composition to fit while preserving aspect ratio. Set a size on the element or its parent:
Expand Down
118 changes: 118 additions & 0 deletions packages/player/src/controls.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, it, expect, vi } from "vitest";
import { type ControlsCallbacks, createControls } from "./controls";
import {
PAUSE_ICON,
PLAY_ICON,
VOLUME_HIGH_ICON,
VOLUME_LOW_ICON,
VOLUME_MUTED_ICON,
} from "./styles";

function noopCallbacks(): ControlsCallbacks {
return {
Expand Down Expand Up @@ -165,3 +172,114 @@ describe("createControls idle hide", () => {
vi.useRealTimers();
});
});

describe("createControls page glyph slots", () => {
function setup(...glyphSlots: string[]) {
const host = document.createElement("div");
const glyphs = glyphSlots.map((name) => {
const glyph = document.createElement("span");
glyph.slot = name;
host.appendChild(glyph);
return glyph;
});
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
const api = createControls(shadow, noopCallbacks());
const cleanup = () => {
api.destroy();
host.remove();
};
return { shadow, api, glyphs, cleanup };
}

function slotAt(root: ParentNode, selector: string): HTMLSlotElement {
const slot = root.querySelector(selector);
if (!(slot instanceof HTMLSlotElement)) throw new Error(`no slot at ${selector}`);
return slot;
}

function onlyAssigned(slot: HTMLSlotElement): Element | undefined {
const assigned = slot.assignedElements();
return assigned.length === 1 ? assigned[0] : undefined;
}

function markup(html: string): string {
const parsed = document.createElement("div");
parsed.innerHTML = html;
return parsed.innerHTML;
}

it("shows today's glyphs in every named control slot when the page supplies none", () => {
const { shadow, api, cleanup } = setup();
const seen = new Map<string, HTMLSlotElement>();
const record = (selector: string) => {
const slot = slotAt(shadow, selector);
seen.set(slot.name, slot);
};
record(".hfp-ico-play slot");
record(".hfp-ico-pause slot");
record(".hfp-mute-btn slot");
api.updateVolume(0.2);
record(".hfp-mute-btn slot");
api.updateMuted(true);
record(".hfp-mute-btn slot");

const expected: Record<string, string> = {
"play-icon": PLAY_ICON,
"pause-icon": PAUSE_ICON,
"volume-high-icon": VOLUME_HIGH_ICON,
"volume-low-icon": VOLUME_LOW_ICON,
"volume-muted-icon": VOLUME_MUTED_ICON,
};
expect([...seen.keys()].sort()).toEqual(Object.keys(expected).sort());
for (const [name, icon] of Object.entries(expected)) {
const slot = seen.get(name);
expect(slot?.assignedElements()).toEqual([]);
expect(slot?.innerHTML).toBe(markup(icon));
}
cleanup();
});

it("puts the page's play and pause glyphs in the play button and still marks it playing", () => {
const {
shadow,
api,
glyphs: [play, pause],
cleanup,
} = setup("play-icon", "pause-icon");
const playSlot = slotAt(shadow, ".hfp-ico-play slot");

expect(onlyAssigned(playSlot)).toBe(play);
expect(onlyAssigned(slotAt(shadow, ".hfp-ico-pause slot"))).toBe(pause);

api.updatePlaying(true);
expect(playSlot.closest(".hfp-play-btn")?.classList.contains("hfp-playing")).toBe(true);
cleanup();
});

it("keeps the default volume glyphs the page left out and shows its own muted glyph", () => {
const {
shadow,
api,
glyphs: [muted],
cleanup,
} = setup("volume-muted-icon");
const muteSlot = () => slotAt(shadow, ".hfp-mute-btn slot");

expect(muteSlot().name).toBe("volume-high-icon");
expect(muteSlot().assignedElements()).toEqual([]);

api.updateVolume(0.2);
expect(muteSlot().name).toBe("volume-low-icon");
expect(muteSlot().assignedElements()).toEqual([]);

api.updateVolume(0);
expect(muteSlot().name).toBe("volume-low-icon");
expect(muteSlot().assignedElements()).toEqual([]);

api.updateMuted(true);
expect(muteSlot().name).toBe("volume-muted-icon");
expect(onlyAssigned(muteSlot())).toBe(muted);
cleanup();
});
});
35 changes: 22 additions & 13 deletions packages/player/src/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ export function formatTime(seconds: number): string {
return `${m}:${sec.toString().padStart(2, "0")}`;
}

function iconSlot(name: string, fallbackSvg: string): HTMLSlotElement {
const slot = document.createElement("slot");
slot.name = name;
slot.innerHTML = fallbackSvg;
return slot;
}

export function createControls(
parent: ShadowRoot | HTMLElement,
callbacks: ControlsCallbacks,
Expand Down Expand Up @@ -80,7 +87,7 @@ export function createControls(
// Both glyphs stay in the DOM, stacked; toggling `hfp-playing` crossfade-morphs
// between them (see styles.ts) instead of swapping innerHTML, which would kill
// the transition.
playBtn.innerHTML = `<span class="hfp-ico hfp-ico-play">${PLAY_ICON}</span><span class="hfp-ico hfp-ico-pause">${PAUSE_ICON}</span>`;
playBtn.innerHTML = `<span class="hfp-ico hfp-ico-play"><slot name="play-icon">${PLAY_ICON}</slot></span><span class="hfp-ico hfp-ico-pause"><slot name="pause-icon">${PAUSE_ICON}</slot></span>`;
playBtn.setAttribute("aria-label", "Play");

const scrubber = document.createElement("div");
Expand Down Expand Up @@ -126,8 +133,17 @@ export function createControls(
const muteBtn = document.createElement("button");
muteBtn.className = "hfp-mute-btn";
muteBtn.type = "button";
muteBtn.innerHTML = VOLUME_HIGH_ICON;
muteBtn.setAttribute("aria-label", "Mute");
const volumeIcons = {
high: iconSlot("volume-high-icon", VOLUME_HIGH_ICON),
low: iconSlot("volume-low-icon", VOLUME_LOW_ICON),
muted: iconSlot("volume-muted-icon", VOLUME_MUTED_ICON),
};
const showVolumeIcon = (muted: boolean, volume: number) => {
const icon = muted ? volumeIcons.muted : volume < 0.5 ? volumeIcons.low : volumeIcons.high;
if (muteBtn.firstChild !== icon) muteBtn.replaceChildren(icon);
};
showVolumeIcon(false, 1);

const volumeSliderWrap = document.createElement("div");
volumeSliderWrap.className = "hfp-volume-slider-wrap";
Expand Down Expand Up @@ -168,13 +184,6 @@ export function createControls(
let speedIndex = presets.indexOf(1); // start at 1x
if (speedIndex === -1) speedIndex = 0;

const getVolumeIcon = (muted: boolean, volume: number): string => {
if (muted) return VOLUME_MUTED_ICON;
if (volume === 0) return VOLUME_LOW_ICON;
if (volume < 0.5) return VOLUME_LOW_ICON;
return VOLUME_HIGH_ICON;
};

playBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (isPlaying) callbacks.onPause();
Expand All @@ -195,7 +204,7 @@ export function createControls(
volumeFill.style.width = `${fraction * 100}%`;
volumeSlider.setAttribute("aria-valuenow", String(Math.round(fraction * 100)));
if (isMuted && fraction > 0) callbacks.onMuteToggle();
muteBtn.innerHTML = getVolumeIcon(isMuted, fraction);
showVolumeIcon(isMuted, fraction);
callbacks.onVolumeChange(fraction);
};

Expand Down Expand Up @@ -250,7 +259,7 @@ export function createControls(
volumeFill.style.width = `${newVol * 100}%`;
volumeSlider.setAttribute("aria-valuenow", String(Math.round(newVol * 100)));
if (isMuted && newVol > 0) callbacks.onMuteToggle();
muteBtn.innerHTML = getVolumeIcon(isMuted, newVol);
showVolumeIcon(isMuted, newVol);
callbacks.onVolumeChange(newVol);
});

Expand Down Expand Up @@ -386,14 +395,14 @@ export function createControls(
},
updateMuted(muted: boolean) {
isMuted = muted;
muteBtn.innerHTML = getVolumeIcon(muted, currentVolume);
showVolumeIcon(muted, currentVolume);
muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute");
},
updateVolume(volume: number) {
currentVolume = volume;
volumeFill.style.width = `${volume * 100}%`;
volumeSlider.setAttribute("aria-valuenow", String(Math.round(volume * 100)));
muteBtn.innerHTML = getVolumeIcon(isMuted, volume);
showVolumeIcon(isMuted, volume);
},
setVolumeControlsHidden(hidden: boolean) {
volumeWrap.style.display = hidden ? "none" : "";
Expand Down
54 changes: 54 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,60 @@ describe("HyperframesPlayer adoptedStyleSheets", () => {
});
});

describe("HyperframesPlayer page glyph slots", () => {
function slotAt(root: ParentNode | null, selector: string): HTMLSlotElement {
const slot = root?.querySelector(selector);
if (!(slot instanceof HTMLSlotElement)) throw new Error(`no slot at ${selector}`);
return slot;
}

function onlyAssigned(slot: HTMLSlotElement): Element | undefined {
const assigned = slot.assignedElements();
return assigned.length === 1 ? assigned[0] : undefined;
}

beforeEach(async () => {
await import("./hyperframes-player.js");
});

afterEach(() => {
document.body.innerHTML = "";
});

it("keeps the page's play glyph in the controls after they are rebuilt or the player moves", () => {
const player = document.createElement("hyperframes-player");
player.setAttribute("controls", "");
const glyph = document.createElement("span");
glyph.slot = "play-icon";
player.appendChild(glyph);
document.body.appendChild(player);
const expectGlyphShown = () => {
expect(glyph.parentElement).toBe(player);
expect(onlyAssigned(slotAt(player.shadowRoot, ".hfp-ico-play slot"))).toBe(glyph);
};

expectGlyphShown();

player.removeAttribute("controls");
player.setAttribute("controls", "");
expectGlyphShown();

player.remove();
document.body.appendChild(player);
expectGlyphShown();
});

it("shows the page's loading mark in the loader card", () => {
const player = document.createElement("hyperframes-player");
const mark = document.createElement("img");
mark.slot = "loading-mark";
player.appendChild(mark);
document.body.appendChild(player);

expect(onlyAssigned(slotAt(player.shadowRoot, ".hfp-shader-loader-mark slot"))).toBe(mark);
});
});

// ── Media MutationObserver scoping ──
//
// The observer that catches late-attached `<audio data-start>` from
Expand Down
7 changes: 5 additions & 2 deletions packages/player/src/shader-loader-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ export function createShaderLoader(): ShaderLoaderElements {
const markFrame = document.createElement("div");
markFrame.className = "hfp-shader-loader-mark";
markFrame.draggable = false;
markFrame.innerHTML = [
const markSlot = document.createElement("slot");
markSlot.name = "loading-mark";
markSlot.innerHTML = [
'<svg width="78" height="78" viewBox="0 0 100 100" fill="none" aria-hidden="true" draggable="false">',
`<path d="${MARK_LEFT}" fill="url(#hfp-shader-loader-grad-left)"/>`,
`<path d="${MARK_RIGHT}" fill="url(#hfp-shader-loader-grad-right)"/>`,
Expand All @@ -83,7 +85,8 @@ export function createShaderLoader(): ShaderLoaderElements {
sheen.className = "hfp-shader-loader-sheen";
sheen.style.setProperty("mask-image", MARK_SHAPE_MASK);
sheen.style.setProperty("-webkit-mask-image", MARK_SHAPE_MASK);
markFrame.appendChild(sheen);
markSlot.appendChild(sheen);
markFrame.appendChild(markSlot);

const titleContainer = document.createElement("div");
titleContainer.className = "hfp-shader-loader-title";
Expand Down
Loading
Loading