From 98a57ab0ec40888048f02a055e5ce02ed8771f9e Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 24 May 2026 11:42:35 -0400
Subject: [PATCH 1/9] Initial commit
---
.changeset/video-initial.md | 19 ++
packages/video/LICENSE | 21 ++
packages/video/README.md | 178 ++++++++++++++
packages/video/dev/index.tsx | 80 +++++++
packages/video/package.json | 68 ++++++
packages/video/src/index.ts | 363 +++++++++++++++++++++++++++++
packages/video/test/index.test.ts | 347 +++++++++++++++++++++++++++
packages/video/test/server.test.ts | 54 +++++
packages/video/test/setup.ts | 176 ++++++++++++++
packages/video/tsconfig.json | 19 ++
pnpm-lock.yaml | 16 ++
11 files changed, 1341 insertions(+)
create mode 100644 .changeset/video-initial.md
create mode 100644 packages/video/LICENSE
create mode 100644 packages/video/README.md
create mode 100644 packages/video/dev/index.tsx
create mode 100644 packages/video/package.json
create mode 100644 packages/video/src/index.ts
create mode 100644 packages/video/test/index.test.ts
create mode 100644 packages/video/test/server.test.ts
create mode 100644 packages/video/test/setup.ts
create mode 100644 packages/video/tsconfig.json
diff --git a/.changeset/video-initial.md b/.changeset/video-initial.md
new file mode 100644
index 000000000..51754b91b
--- /dev/null
+++ b/.changeset/video-initial.md
@@ -0,0 +1,19 @@
+---
+"@solid-primitives/video": minor
+---
+
+New package: `@solid-primitives/video`
+
+Layered primitives for managing HTML video playback.
+
+### `makeVideo`
+
+Non-reactive base primitive. Creates an `HTMLVideoElement` with optional event handlers and returns a `[player, cleanup]` tuple. No Solid owner required.
+
+### `makeVideoPlayer`
+
+Wraps `makeVideo` with playback and fullscreen controls: `play`, `pause`, `seek`, `setVolume`, `setMuted`, `setPlaybackRate`, `requestFullscreen`, `exitFullscreen`, `toggleFullscreen`.
+
+### `createVideo`
+
+Reactive primitive that tracks all media state as signals: `playing`, `currentTime`, `volume`, `muted`, `playbackRate`, `ended`, `buffered`, `readyState`, `videoWidth`, `videoHeight`, `fullscreen`, and `duration`. The `duration` accessor throws `NotReadyError` until metadata loads, integrating naturally with ``. Accepts a static or reactive `VideoSource`.
diff --git a/packages/video/LICENSE b/packages/video/LICENSE
new file mode 100644
index 000000000..38b41d975
--- /dev/null
+++ b/packages/video/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/video/README.md b/packages/video/README.md
new file mode 100644
index 000000000..5b6a170cb
--- /dev/null
+++ b/packages/video/README.md
@@ -0,0 +1,178 @@
+
+
+
+
+# @solid-primitives/video
+
+[](https://bundlephobia.com/package/@solid-primitives/video)
+[](https://www.npmjs.com/package/@solid-primitives/video)
+[](https://github.com/solidjs-community/solid-primitives#contribution-process)
+
+Primitives to manage HTML video playback in the browser. The primitives are layered: `make*` variants are non-reactive base primitives that require no Solid owner, while `createVideo` integrates with Solid's reactive system.
+
+Within an SSR context these primitives perform noops and never interrupt the process.
+
+## Installation
+
+```bash
+npm install @solid-primitives/video
+# or
+yarn add @solid-primitives/video
+# or
+pnpm add @solid-primitives/video
+```
+
+## How to use it
+
+### makeVideo
+
+A foundational non-reactive primitive that creates a raw `HTMLVideoElement` with optional event handlers. No Solid owner required.
+
+```ts
+const [player, cleanup] = makeVideo("clip.mp4");
+// later:
+cleanup();
+```
+
+#### Definition
+
+```ts
+function makeVideo(
+ src: VideoSource | HTMLVideoElement,
+ handlers?: VideoEventHandlers,
+): [player: HTMLVideoElement, cleanup: VoidFunction];
+```
+
+### makeVideoPlayer
+
+Wraps `makeVideo` with playback and fullscreen controls. No Solid owner required.
+
+```ts
+const [{ play, pause, seek, setVolume, setMuted, setPlaybackRate, player }, cleanup] =
+ makeVideoPlayer("clip.mp4");
+
+await play();
+seek(30);
+setPlaybackRate(1.5);
+await requestFullscreen();
+cleanup();
+```
+
+#### Definition
+
+```ts
+function makeVideoPlayer(
+ src: VideoSource | HTMLVideoElement,
+ handlers?: VideoEventHandlers,
+): [controls: VideoControls, cleanup: VoidFunction];
+```
+
+`VideoControls`:
+
+```ts
+type VideoControls = {
+ play: () => Promise;
+ pause: VoidFunction;
+ seek: (time: number) => void;
+ setVolume: (volume: number) => void;
+ setMuted: (muted: boolean) => void;
+ setPlaybackRate: (rate: number) => void;
+ requestFullscreen: () => Promise;
+ exitFullscreen: () => Promise;
+ toggleFullscreen: () => Promise;
+ player: HTMLVideoElement;
+};
+```
+
+The `seek` function uses `fastSeek` on [supporting browsers](https://caniuse.com/?search=fastseek).
+
+### createVideo
+
+A reactive video primitive. Returns a flat object with writable signal accessors for `playing`, `volume`, `muted`, and `playbackRate`; reactive signals for `currentTime`, `ended`, `buffered`, `readyState`, `videoWidth`, `videoHeight`, and `fullscreen`; and an async `duration` that suspends until metadata is loaded — integrating with ``.
+
+```ts
+const video = createVideo("clip.mp4");
+// or with a reactive source:
+const video = createVideo(() => selectedUrl());
+
+video.playing() // boolean
+video.setPlaying(true) // plays
+video.volume() // 0–1
+video.setVolume(0.5)
+video.muted() // boolean
+video.setMuted(true)
+video.playbackRate() // number
+video.setPlaybackRate(1.5)
+video.currentTime() // seconds
+video.seek(30)
+video.ended() // boolean
+video.readyState() // 0–4
+video.videoWidth() // intrinsic pixel width
+video.videoHeight() // intrinsic pixel height
+video.fullscreen() // boolean
+video.requestFullscreen()
+video.exitFullscreen()
+video.toggleFullscreen()
+```
+
+Attach the `player` to a `` element via `ref`:
+
+```tsx
+const video = createVideo("clip.mp4");
+
+ { /* video.player === el */ }} src="clip.mp4" />
+// or pass an existing element:
+const video = createVideo(videoEl);
+```
+
+The `duration` accessor throws `NotReadyError` until video metadata has loaded, making it work naturally with Solid 2.0's `` boundary. The pending state resets whenever the source changes.
+
+```tsx
+
+ {video.duration()}s
+
+```
+
+#### Definition
+
+```ts
+function createVideo(src: VideoSource | Accessor): VideoReturn;
+```
+
+`VideoReturn`:
+
+```ts
+type VideoReturn = {
+ player: HTMLVideoElement;
+ playing: Accessor;
+ setPlaying: (v: boolean) => void;
+ currentTime: Accessor;
+ seek: (time: number) => void;
+ volume: Accessor;
+ setVolume: (v: number) => void;
+ muted: Accessor;
+ setMuted: (v: boolean) => void;
+ playbackRate: Accessor;
+ setPlaybackRate: (rate: number) => void;
+ ended: Accessor;
+ buffered: Accessor;
+ readyState: Accessor;
+ videoWidth: Accessor;
+ videoHeight: Accessor;
+ fullscreen: Accessor;
+ requestFullscreen: () => Promise;
+ exitFullscreen: () => Promise;
+ toggleFullscreen: () => Promise;
+ duration: Accessor;
+};
+```
+
+`VideoSource`:
+
+```ts
+type VideoSource = string | undefined | MediaProvider;
+```
+
+## Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md)
diff --git a/packages/video/dev/index.tsx b/packages/video/dev/index.tsx
new file mode 100644
index 000000000..95baff22a
--- /dev/null
+++ b/packages/video/dev/index.tsx
@@ -0,0 +1,80 @@
+import { type Component, createSignal } from "solid-js";
+import { createVideo } from "../src/index.js";
+
+const DEMO_URL =
+ "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
+
+const App: Component = () => {
+ const [src, setSrc] = createSignal(DEMO_URL);
+ const video = createVideo(src);
+
+ return (
+
+
+
Video Primitive Demo
+
{
+ // sync the reactive player into the DOM element
+ (video as any).player = el;
+ }}
+ src={src()}
+ style={{ width: "640px", "max-width": "100%" }}
+ />
+
+
+ video.setPlaying(!video.playing())}>
+ {video.playing() ? "Pause" : "Play"}
+
+ video.setMuted(!video.muted())}>
+ {video.muted() ? "Unmute" : "Mute"}
+
+ video.toggleFullscreen()}>
+ {video.fullscreen() ? "Exit Fullscreen" : "Fullscreen"}
+
+
+
+
+
+ Time: {video.currentTime().toFixed(1)}s — Ended: {String(video.ended())}
+
+
+ Volume: {video.volume().toFixed(2)} — Muted: {String(video.muted())}
+
+
+ Playback rate: {video.playbackRate()}x — Ready state: {video.readyState()}
+
+
+ Dimensions: {video.videoWidth()} × {video.videoHeight()}
+
+
+
+
+ Volume
+ video.setVolume(Number((e.target as HTMLInputElement).value))}
+ />
+
+
+
+ Speed
+ video.setPlaybackRate(Number((e.target as HTMLSelectElement).value))}
+ >
+ 0.5×
+ 1×
+ 1.5×
+ 2×
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/packages/video/package.json b/packages/video/package.json
new file mode 100644
index 000000000..7ce7bffdf
--- /dev/null
+++ b/packages/video/package.json
@@ -0,0 +1,68 @@
+{
+ "name": "@solid-primitives/video",
+ "version": "0.0.100",
+ "description": "Primitives to manage HTML video playback.",
+ "author": "David Di Biase ",
+ "contributors": [],
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/video",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "bugs": {
+ "url": "https://github.com/solidjs-community/solid-primitives/issues"
+ },
+ "primitive": {
+ "name": "video",
+ "stage": 0,
+ "list": [
+ "makeVideo",
+ "makeVideoPlayer",
+ "createVideo"
+ ],
+ "category": "Display & Media"
+ },
+ "keywords": [
+ "solid",
+ "primitives",
+ "video",
+ "media"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "import": {
+ "@solid-primitives/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "typesVersions": {},
+ "scripts": {
+ "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
+ "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts",
+ "vitest": "vitest -c ../../configs/vitest.config.ts",
+ "test": "pnpm run vitest",
+ "test:ssr": "pnpm run vitest --mode ssr"
+ },
+ "dependencies": {
+ "@solid-primitives/event-listener": "workspace:^",
+ "@solid-primitives/utils": "workspace:^"
+ },
+ "devDependencies": {
+ "@solidjs/web": "2.0.0-beta.14",
+ "solid-js": "2.0.0-beta.14"
+ },
+ "peerDependencies": {
+ "@solidjs/web": "^2.0.0-beta.14",
+ "solid-js": "^2.0.0-beta.14"
+ }
+}
diff --git a/packages/video/src/index.ts b/packages/video/src/index.ts
new file mode 100644
index 000000000..4d6695e50
--- /dev/null
+++ b/packages/video/src/index.ts
@@ -0,0 +1,363 @@
+import { type Accessor, createEffect, createSignal, NotReadyError, onCleanup } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils";
+import { createEventListenerMap } from "@solid-primitives/event-listener";
+
+const NOT_SET: unique symbol = Symbol();
+
+export type VideoSource = string | undefined | MediaProvider;
+
+export type VideoEventHandlers = {
+ [K in keyof HTMLMediaElementEventMap]?: (event: HTMLMediaElementEventMap[K]) => void;
+};
+
+export type VideoControls = {
+ play: () => Promise;
+ pause: VoidFunction;
+ seek: (time: number) => void;
+ setVolume: (volume: number) => void;
+ setMuted: (muted: boolean) => void;
+ setPlaybackRate: (rate: number) => void;
+ requestFullscreen: () => Promise;
+ exitFullscreen: () => Promise;
+ toggleFullscreen: () => Promise;
+ player: HTMLVideoElement;
+};
+
+export type VideoReturn = {
+ player: HTMLVideoElement;
+ /** `true` while the video is actively playing. */
+ playing: Accessor;
+ setPlaying: (v: boolean) => void;
+ currentTime: Accessor;
+ seek: (time: number) => void;
+ volume: Accessor;
+ setVolume: (v: number) => void;
+ muted: Accessor;
+ setMuted: (v: boolean) => void;
+ playbackRate: Accessor;
+ setPlaybackRate: (rate: number) => void;
+ /** `true` once playback has reached the end of the media. */
+ ended: Accessor;
+ /** The current `TimeRanges` of buffered media, or `undefined` before first progress. */
+ buffered: Accessor;
+ /** `HTMLMediaElement.readyState` — 0 (HAVE_NOTHING) through 4 (HAVE_ENOUGH_DATA). */
+ readyState: Accessor;
+ videoWidth: Accessor;
+ videoHeight: Accessor;
+ /** `true` when this player is the active fullscreen element. */
+ fullscreen: Accessor;
+ requestFullscreen: () => Promise;
+ exitFullscreen: () => Promise;
+ toggleFullscreen: () => Promise;
+ /**
+ * Throws `NotReadyError` until video metadata has loaded (integrates with
+ * ``). After the first `loadeddata` event returns the duration in
+ * seconds reactively. Resets to pending whenever the source changes.
+ */
+ duration: Accessor;
+};
+
+function setVideoSrc(el: HTMLVideoElement, src: VideoSource): void {
+ if (typeof src === "string") {
+ el.src = src;
+ } else {
+ el.srcObject = (src as MediaProvider | null) ?? null;
+ }
+}
+
+const unwrapSource = (src: VideoSource | HTMLVideoElement): HTMLVideoElement => {
+ if (src instanceof HTMLVideoElement) return src;
+ const player = document.createElement("video");
+ setVideoSrc(player, src);
+ return player;
+};
+
+/**
+ * Creates a raw `HTMLVideoElement` with optional event handlers.
+ * Non-reactive — no Solid owner required. Returns a cleanup function.
+ *
+ * @param src Video URL, MediaProvider, or existing HTMLVideoElement
+ * @param handlers Event handlers to bind against the player
+ * @returns Tuple of `[player, cleanup]`
+ *
+ * @example
+ * ```ts
+ * const [player, cleanup] = makeVideo('https://example.com/clip.mp4');
+ * ```
+ */
+export const makeVideo = (
+ src: VideoSource | HTMLVideoElement,
+ handlers: VideoEventHandlers = {},
+): [player: HTMLVideoElement, cleanup: VoidFunction] => {
+ if (isServer) return [{} as HTMLVideoElement, noop];
+
+ const player = unwrapSource(src);
+
+ for (const [name, handler] of Object.entries(handlers)) {
+ player.addEventListener(name, handler as EventListener);
+ }
+
+ const cleanup = () => {
+ player.pause();
+ for (const [name, handler] of Object.entries(handlers)) {
+ player.removeEventListener(name, handler as EventListener);
+ }
+ };
+
+ return [player, cleanup];
+};
+
+/**
+ * Wraps `makeVideo` with playback and fullscreen controls.
+ * Non-reactive — no Solid owner required. Returns a cleanup function.
+ *
+ * @param src Video URL, MediaProvider, or existing HTMLVideoElement
+ * @param handlers Event handlers to bind against the player
+ * @returns Tuple of `[controls, cleanup]`
+ *
+ * @example
+ * ```ts
+ * const [{ play, pause, seek }, cleanup] = makeVideoPlayer('clip.mp4');
+ * ```
+ */
+export const makeVideoPlayer = (
+ src: VideoSource | HTMLVideoElement,
+ handlers: VideoEventHandlers = {},
+): [controls: VideoControls, cleanup: VoidFunction] => {
+ if (isServer) {
+ return [
+ {
+ play: async () => noop(),
+ pause: noop,
+ seek: noop,
+ setVolume: noop,
+ setMuted: noop,
+ setPlaybackRate: noop,
+ requestFullscreen: () => Promise.resolve(),
+ exitFullscreen: () => Promise.resolve(),
+ toggleFullscreen: () => Promise.resolve(),
+ player: {} as HTMLVideoElement,
+ },
+ noop,
+ ];
+ }
+
+ const [player, cleanup] = makeVideo(src, handlers);
+
+ const controls: VideoControls = {
+ player,
+ play: () => player.play(),
+ pause: () => player.pause(),
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ seek: player.fastSeek
+ ? (time: number) => player.fastSeek(time)
+ : (time: number) => {
+ player.currentTime = time;
+ },
+ setVolume: (volume: number) => {
+ player.volume = volume;
+ },
+ setMuted: (muted: boolean) => {
+ player.muted = muted;
+ },
+ setPlaybackRate: (rate: number) => {
+ player.playbackRate = rate;
+ },
+ requestFullscreen: async () => {
+ if (!document.fullscreenElement) await player.requestFullscreen();
+ },
+ exitFullscreen: async () => {
+ if (document.fullscreenElement === player) await document.exitFullscreen();
+ },
+ toggleFullscreen: async () => {
+ if (document.fullscreenElement === player) {
+ await document.exitFullscreen();
+ } else {
+ await player.requestFullscreen();
+ }
+ },
+ };
+
+ return [controls, cleanup];
+};
+
+/**
+ * A reactive video primitive.
+ *
+ * Returns a flat object with writable derived signals for `playing`, `volume`,
+ * `muted`, and `playbackRate`; reactive `currentTime`, `ended`, `buffered`,
+ * `readyState`, `videoWidth`, `videoHeight`, and `fullscreen`; and a `duration`
+ * that throws `NotReadyError` until metadata loads — integrating with ``.
+ *
+ * @param src Video URL, MediaProvider, or a reactive accessor returning either
+ *
+ * @example
+ * ```ts
+ * const video = createVideo('clip.mp4');
+ * // or reactive source:
+ * const video = createVideo(() => selectedUrl());
+ *
+ * video.playing() // boolean
+ * video.setPlaying(true) // plays
+ * video.volume() // 0–1
+ * video.setVolume(0.5)
+ * video.muted() // boolean
+ * video.setMuted(true)
+ * video.playbackRate() // number
+ * video.setPlaybackRate(1.5)
+ * video.seek(30)
+ * video.requestFullscreen()
+ *
+ * // duration() throws NotReadyError until metadata loads:
+ * Loading…
}>
+ * {video.duration()}s
+ *
+ * ```
+ */
+export const createVideo = (src: VideoSource | Accessor): VideoReturn => {
+ if (isServer) {
+ return {
+ player: {} as HTMLVideoElement,
+ playing: () => false,
+ setPlaying: noop,
+ currentTime: () => 0,
+ seek: noop,
+ volume: () => 1,
+ setVolume: noop,
+ muted: () => false,
+ setMuted: noop,
+ playbackRate: () => 1,
+ setPlaybackRate: noop,
+ ended: () => false,
+ buffered: () => undefined,
+ readyState: () => 0,
+ videoWidth: () => 0,
+ videoHeight: () => 0,
+ fullscreen: () => false,
+ requestFullscreen: () => Promise.resolve(),
+ exitFullscreen: () => Promise.resolve(),
+ toggleFullscreen: () => Promise.resolve(),
+ duration: () => {
+ throw new NotReadyError("Video duration not available on the server");
+ },
+ };
+ }
+
+ const player = unwrapSource(access(src));
+ const [controls, cleanup] = makeVideoPlayer(player);
+ onCleanup(cleanup);
+
+ // playing — writable derived signal; DOM events keep it in sync
+ const [playing, setPlayingSignal] = createSignal(!player.paused, INTERNAL_OPTIONS);
+ const setPlaying = (v: boolean) => (v ? controls.play() : controls.pause());
+
+ // volume — writable derived signal; volumechange event keeps it in sync
+ const [volume, setVolumeSignal] = createSignal(player.volume, INTERNAL_OPTIONS);
+ const setVolume = (v: number) => {
+ player.volume = v;
+ };
+
+ // muted — writable derived signal; volumechange event keeps it in sync
+ const [muted, setMutedSignal] = createSignal(player.muted, INTERNAL_OPTIONS);
+ const setMuted = (v: boolean) => {
+ player.muted = v;
+ };
+
+ // playbackRate — writable derived signal; ratechange event keeps it in sync
+ const [playbackRate, setPlaybackRateSignal] = createSignal(player.playbackRate, INTERNAL_OPTIONS);
+ const setPlaybackRate = (v: number) => {
+ player.playbackRate = v;
+ };
+
+ const [currentTime, setCurrentTime] = createSignal(0, INTERNAL_OPTIONS);
+ const [ended, setEnded] = createSignal(false, INTERNAL_OPTIONS);
+ const [buffered, setBuffered] = createSignal(undefined, INTERNAL_OPTIONS);
+ const [readyState, setReadyState] = createSignal(player.readyState, INTERNAL_OPTIONS);
+ const [videoWidth, setVideoWidth] = createSignal(player.videoWidth, INTERNAL_OPTIONS);
+ const [videoHeight, setVideoHeight] = createSignal(player.videoHeight, INTERNAL_OPTIONS);
+ const [fullscreen, setFullscreen] = createSignal(false, INTERNAL_OPTIONS);
+
+ // duration — NOT_SET until loadeddata fires; resets to NOT_SET on loadstart (new source)
+ const [rawDuration, setRawDuration] = createSignal(
+ NOT_SET,
+ INTERNAL_OPTIONS,
+ );
+
+ const syncReadyState = () => setReadyState(player.readyState);
+ const syncVideoDimensions = () => {
+ setVideoWidth(player.videoWidth);
+ setVideoHeight(player.videoHeight);
+ };
+
+ createEventListenerMap(player, {
+ playing: () => setPlayingSignal(true),
+ pause: () => setPlayingSignal(false),
+ ended: () => {
+ setPlayingSignal(false);
+ setEnded(true);
+ },
+ play: () => setEnded(false),
+ timeupdate: () => setCurrentTime(player.currentTime),
+ volumechange: () => {
+ setVolumeSignal(player.volume);
+ setMutedSignal(player.muted);
+ },
+ ratechange: () => setPlaybackRateSignal(player.playbackRate),
+ progress: () => setBuffered(player.buffered),
+ loadedmetadata: () => {
+ syncReadyState();
+ syncVideoDimensions();
+ },
+ loadeddata: () => {
+ syncReadyState();
+ setRawDuration(player.duration);
+ },
+ canplay: syncReadyState,
+ canplaythrough: syncReadyState,
+ emptied: syncReadyState,
+ loadstart: () => setRawDuration(NOT_SET),
+ resize: syncVideoDimensions,
+ });
+
+ createEventListenerMap(document, {
+ fullscreenchange: () => setFullscreen(document.fullscreenElement === player),
+ });
+ const duration = (): number => {
+ const val = rawDuration();
+ if (val === NOT_SET) throw new NotReadyError("Video duration not yet available");
+ return val;
+ };
+
+ // Reactive src — update player source when accessor changes
+ if (src instanceof Function) {
+ createEffect(src, (newSrc: VideoSource) => {
+ setVideoSrc(player, newSrc);
+ controls.seek(0);
+ });
+ }
+
+ return {
+ player,
+ playing,
+ setPlaying,
+ currentTime,
+ seek: controls.seek,
+ volume,
+ setVolume,
+ muted,
+ setMuted,
+ playbackRate,
+ setPlaybackRate,
+ ended,
+ buffered,
+ readyState,
+ videoWidth,
+ videoHeight,
+ fullscreen,
+ requestFullscreen: controls.requestFullscreen,
+ exitFullscreen: controls.exitFullscreen,
+ toggleFullscreen: controls.toggleFullscreen,
+ duration,
+ };
+};
diff --git a/packages/video/test/index.test.ts b/packages/video/test/index.test.ts
new file mode 100644
index 000000000..dd40ede5e
--- /dev/null
+++ b/packages/video/test/index.test.ts
@@ -0,0 +1,347 @@
+import "./setup";
+import { createRoot, createSignal, flush } from "solid-js";
+import { describe, expect, it } from "vitest";
+import { makeVideo, makeVideoPlayer, createVideo } from "../src/index.js";
+
+const testUrl = "https://example.com/clip.mp4";
+
+/** Yield to the microtask queue — used alongside flush() to drain Solid 2.0 effects. */
+const tick = () => Promise.resolve();
+
+// ── makeVideo ─────────────────────────────────────────────────────────────────
+
+describe("makeVideo", () => {
+ it("returns a player and cleanup tuple", () => {
+ const [player, cleanup] = makeVideo(testUrl);
+ expect(player.src).toBe(testUrl);
+ expect(player._mock.paused).toBe(true);
+ cleanup();
+ });
+
+ it("cleanup pauses the player and removes listeners", () => {
+ let fired = false;
+ const [player, cleanup] = makeVideo(testUrl, { play: () => (fired = true) });
+ cleanup();
+ player.dispatchEvent(new Event("play"));
+ expect(fired).toBe(false);
+ });
+
+ it("can be called outside a Solid owner (no lifecycle dependency)", () => {
+ expect(() => {
+ const [, cleanup] = makeVideo(testUrl);
+ cleanup();
+ }).not.toThrow();
+ });
+
+ it("accepts MediaProvider as source", () => {
+ const [player, cleanup] = makeVideo({} as MediaProvider);
+ expect(typeof player.srcObject).toBe("object");
+ cleanup();
+ });
+
+ it("accepts an existing HTMLVideoElement", () => {
+ const el = document.createElement("video");
+ el.src = testUrl;
+ const [player, cleanup] = makeVideo(el);
+ expect(player).toBe(el);
+ cleanup();
+ });
+});
+
+// ── makeVideoPlayer ───────────────────────────────────────────────────────────
+
+describe("makeVideoPlayer", () => {
+ it("returns controls and cleanup tuple", () => {
+ const [controls, cleanup] = makeVideoPlayer(testUrl);
+ expect(controls.player.src).toBe(testUrl);
+ cleanup();
+ });
+
+ it("play and pause work", async () => {
+ const [{ player, play, pause }, cleanup] = makeVideoPlayer(testUrl);
+ expect(player._mock.paused).toBe(true);
+ await play();
+ expect(player._mock.paused).toBe(false);
+ pause();
+ expect(player.paused).toBe(true);
+ cleanup();
+ });
+
+ it("seek updates currentTime", () => {
+ const [{ player, seek }, cleanup] = makeVideoPlayer(testUrl);
+ seek(42);
+ expect(player.currentTime).toBe(42);
+ cleanup();
+ });
+
+ it("setVolume updates volume", () => {
+ const [{ player, setVolume }, cleanup] = makeVideoPlayer(testUrl);
+ setVolume(0.5);
+ expect(player.volume).toBe(0.5);
+ cleanup();
+ });
+
+ it("setMuted toggles muted", () => {
+ const [{ player, setMuted }, cleanup] = makeVideoPlayer(testUrl);
+ setMuted(true);
+ expect(player.muted).toBe(true);
+ setMuted(false);
+ expect(player.muted).toBe(false);
+ cleanup();
+ });
+
+ it("setPlaybackRate changes playback rate", () => {
+ const [{ player, setPlaybackRate }, cleanup] = makeVideoPlayer(testUrl);
+ setPlaybackRate(1.5);
+ expect(player.playbackRate).toBe(1.5);
+ cleanup();
+ });
+
+ it("requestFullscreen enters fullscreen", async () => {
+ const [{ player, requestFullscreen }, cleanup] = makeVideoPlayer(testUrl);
+ await requestFullscreen();
+ expect(document.fullscreenElement).toBe(player);
+ await document.exitFullscreen();
+ cleanup();
+ });
+
+ it("exitFullscreen leaves fullscreen", async () => {
+ const [{ player, requestFullscreen, exitFullscreen }, cleanup] = makeVideoPlayer(testUrl);
+ await requestFullscreen();
+ expect(document.fullscreenElement).toBe(player);
+ await exitFullscreen();
+ expect(document.fullscreenElement).toBeNull();
+ cleanup();
+ });
+
+ it("toggleFullscreen enters then exits fullscreen", async () => {
+ const [{ player, toggleFullscreen }, cleanup] = makeVideoPlayer(testUrl);
+ await toggleFullscreen();
+ expect(document.fullscreenElement).toBe(player);
+ await toggleFullscreen();
+ expect(document.fullscreenElement).toBeNull();
+ cleanup();
+ });
+});
+
+// ── createVideo ───────────────────────────────────────────────────────────────
+
+describe("createVideo", () => {
+ it("returns an object with the expected shape", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(typeof video.playing).toBe("function");
+ expect(typeof video.setPlaying).toBe("function");
+ expect(typeof video.volume).toBe("function");
+ expect(typeof video.setVolume).toBe("function");
+ expect(typeof video.muted).toBe("function");
+ expect(typeof video.setMuted).toBe("function");
+ expect(typeof video.playbackRate).toBe("function");
+ expect(typeof video.setPlaybackRate).toBe("function");
+ expect(typeof video.currentTime).toBe("function");
+ expect(typeof video.seek).toBe("function");
+ expect(typeof video.ended).toBe("function");
+ expect(typeof video.buffered).toBe("function");
+ expect(typeof video.readyState).toBe("function");
+ expect(typeof video.videoWidth).toBe("function");
+ expect(typeof video.videoHeight).toBe("function");
+ expect(typeof video.fullscreen).toBe("function");
+ expect(typeof video.requestFullscreen).toBe("function");
+ expect(typeof video.exitFullscreen).toBe("function");
+ expect(typeof video.toggleFullscreen).toBe("function");
+ expect(typeof video.duration).toBe("function");
+ expect(video.player).toBeInstanceOf(HTMLVideoElement);
+ dispose();
+ }));
+
+ it("initial playing state is false", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.playing()).toBe(false);
+ dispose();
+ }));
+
+ it("setPlaying(true) plays the video", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ video.setPlaying(true);
+ await tick();
+ flush();
+ expect(video.player._mock.paused).toBe(false);
+ expect(video.playing()).toBe(true);
+ dispose();
+ }));
+
+ it("setPlaying(false) pauses the video", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ video.setPlaying(true);
+ await tick();
+ video.setPlaying(false);
+ await tick();
+ expect(video.player.paused).toBe(true);
+ expect(video.playing()).toBe(false);
+ dispose();
+ }));
+
+ it("initial volume is 1", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.volume()).toBe(1);
+ dispose();
+ }));
+
+ it("setVolume updates volume signal via volumechange event", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ video.setVolume(0.4);
+ flush();
+ await tick();
+ expect(video.volume()).toBe(0.4);
+ dispose();
+ }));
+
+ it("initial muted is false", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.muted()).toBe(false);
+ dispose();
+ }));
+
+ it("setMuted(true) updates muted signal via volumechange event", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ video.setMuted(true);
+ flush();
+ await tick();
+ expect(video.muted()).toBe(true);
+ dispose();
+ }));
+
+ it("initial playbackRate is 1", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.playbackRate()).toBe(1);
+ dispose();
+ }));
+
+ it("setPlaybackRate updates signal via ratechange event", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ video.setPlaybackRate(2);
+ flush();
+ await tick();
+ expect(video.playbackRate()).toBe(2);
+ dispose();
+ }));
+
+ it("ended is false initially and true after ended event", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.ended()).toBe(false);
+ video.player.dispatchEvent(new Event("ended"));
+ flush();
+ expect(video.ended()).toBe(true);
+ dispose();
+ }));
+
+ it("ended resets to false on play", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ video.player.dispatchEvent(new Event("ended"));
+ flush();
+ expect(video.ended()).toBe(true);
+ video.setPlaying(true);
+ await tick();
+ flush();
+ expect(video.ended()).toBe(false);
+ dispose();
+ }));
+
+ it("readyState updates when video loads", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.readyState()).toBe(0);
+ video.player._mock._load(video.player);
+ flush();
+ expect(video.readyState()).toBe(4);
+ dispose();
+ }));
+
+ it("videoWidth and videoHeight update after metadata loads", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.videoWidth()).toBe(0);
+ expect(video.videoHeight()).toBe(0);
+ video.player._mock._load(video.player);
+ flush();
+ expect(video.videoWidth()).toBe(1280);
+ expect(video.videoHeight()).toBe(720);
+ dispose();
+ }));
+
+ it("duration throws NotReadyError before load, returns number after", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(() => video.duration(), "should throw before loadeddata").toThrow();
+ video.player._mock._load(video.player);
+ flush();
+ expect(typeof video.duration()).toBe("number");
+ dispose();
+ }));
+
+ it("fullscreen signal reflects fullscreen state", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ expect(video.fullscreen()).toBe(false);
+ await video.requestFullscreen();
+ flush();
+ expect(video.fullscreen()).toBe(true);
+ await video.exitFullscreen();
+ flush();
+ expect(video.fullscreen()).toBe(false);
+ dispose();
+ }));
+
+ it("toggleFullscreen enters and exits fullscreen", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ await video.toggleFullscreen();
+ flush();
+ expect(video.fullscreen()).toBe(true);
+ await video.toggleFullscreen();
+ flush();
+ expect(video.fullscreen()).toBe(false);
+ dispose();
+ }));
+
+ it("src signal change updates player source and seeks to 0", () =>
+ createRoot(async dispose => {
+ const [src, setSrc] = createSignal("track1.mp4", { ownedWrite: true });
+ const video = createVideo(src);
+ expect(video.player.src).toMatch(/track1\.mp4$/);
+ setSrc("track2.mp4");
+ await tick();
+ flush();
+ expect(video.player.src).toMatch(/track2\.mp4$/);
+ expect(video.player.currentTime).toBe(0);
+ dispose();
+ }));
+
+ it("buffered is undefined initially, populated after progress event", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.buffered()).toBeUndefined();
+ video.player.dispatchEvent(new Event("progress"));
+ flush();
+ expect(video.buffered()).toBeDefined();
+ dispose();
+ }));
+
+ it("cleanup via dispose pauses the player", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ dispose();
+ expect(video.player._mock.paused).toBe(true);
+ }));
+});
diff --git a/packages/video/test/server.test.ts b/packages/video/test/server.test.ts
new file mode 100644
index 000000000..0d4da4980
--- /dev/null
+++ b/packages/video/test/server.test.ts
@@ -0,0 +1,54 @@
+import { NotReadyError } from "solid-js";
+import { describe, expect, it } from "vitest";
+import { makeVideo, makeVideoPlayer, createVideo } from "../src/index.js";
+
+describe("API works in SSR", () => {
+ it("makeVideo() - SSR returns stub player and noop cleanup", () => {
+ const [player, cleanup] = makeVideo("https://example.com/clip.mp4");
+ expect(player).toBeDefined();
+ expect(cleanup).toBeInstanceOf(Function);
+ expect(() => cleanup()).not.toThrow();
+ });
+
+ it("makeVideoPlayer() - SSR returns stub controls and noop cleanup", () => {
+ const [controls, cleanup] = makeVideoPlayer("https://example.com/clip.mp4");
+ expect(controls.play).toBeInstanceOf(Function);
+ expect(controls.pause).toBeInstanceOf(Function);
+ expect(controls.seek).toBeInstanceOf(Function);
+ expect(controls.setVolume).toBeInstanceOf(Function);
+ expect(controls.setMuted).toBeInstanceOf(Function);
+ expect(controls.setPlaybackRate).toBeInstanceOf(Function);
+ expect(controls.requestFullscreen).toBeInstanceOf(Function);
+ expect(controls.exitFullscreen).toBeInstanceOf(Function);
+ expect(controls.toggleFullscreen).toBeInstanceOf(Function);
+ expect(() => cleanup()).not.toThrow();
+ });
+
+ it("createVideo() - SSR returns safe stubs with correct initial values", () => {
+ const video = createVideo("https://example.com/clip.mp4");
+ expect(video.playing()).toBe(false);
+ expect(video.volume()).toBe(1);
+ expect(video.muted()).toBe(false);
+ expect(video.playbackRate()).toBe(1);
+ expect(video.currentTime()).toBe(0);
+ expect(video.ended()).toBe(false);
+ expect(video.buffered()).toBeUndefined();
+ expect(video.readyState()).toBe(0);
+ expect(video.videoWidth()).toBe(0);
+ expect(video.videoHeight()).toBe(0);
+ expect(video.fullscreen()).toBe(false);
+ expect(video.setPlaying).toBeInstanceOf(Function);
+ expect(video.setVolume).toBeInstanceOf(Function);
+ expect(video.setMuted).toBeInstanceOf(Function);
+ expect(video.setPlaybackRate).toBeInstanceOf(Function);
+ expect(video.seek).toBeInstanceOf(Function);
+ expect(video.requestFullscreen).toBeInstanceOf(Function);
+ expect(video.exitFullscreen).toBeInstanceOf(Function);
+ expect(video.toggleFullscreen).toBeInstanceOf(Function);
+ });
+
+ it("createVideo() - SSR duration throws NotReadyError (integrates with )", () => {
+ const video = createVideo("https://example.com/clip.mp4");
+ expect(() => video.duration()).toThrow(NotReadyError);
+ });
+});
diff --git a/packages/video/test/setup.ts b/packages/video/test/setup.ts
new file mode 100644
index 000000000..5133f78f7
--- /dev/null
+++ b/packages/video/test/setup.ts
@@ -0,0 +1,176 @@
+declare global {
+ interface HTMLVideoElement {
+ _mock: VideoMock;
+ }
+}
+
+interface VideoMock {
+ paused: boolean;
+ duration: number;
+ volume: number;
+ muted: boolean;
+ playbackRate: number;
+ readyState: number;
+ videoWidth: number;
+ videoHeight: number;
+ _loaded: boolean;
+ _load: (video: HTMLVideoElement) => void;
+ _resetMock: (video: HTMLVideoElement) => void;
+}
+
+// Each HTMLVideoElement instance gets its own mock state lazily on first access.
+// This prevents prototype-level mutations from leaking between tests.
+
+const createMockState = (): VideoMock => ({
+ paused: true,
+ duration: NaN,
+ volume: 1,
+ muted: false,
+ playbackRate: 1,
+ readyState: 0,
+ videoWidth: 0,
+ videoHeight: 0,
+ _loaded: false,
+ _load(video: HTMLVideoElement) {
+ // Update mock values before dispatching events so listeners read correct state.
+ video._mock.duration = 120;
+ video._mock.readyState = 4;
+ video._mock.videoWidth = 1280;
+ video._mock.videoHeight = 720;
+ video._mock._loaded = true;
+ video.dispatchEvent(new Event("loadedmetadata"));
+ video.dispatchEvent(new Event("loadeddata"));
+ video.dispatchEvent(new Event("canplaythrough"));
+ },
+ _resetMock(video: HTMLVideoElement) {
+ const fresh = createMockState();
+ Object.assign(video._mock, fresh);
+ },
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "_mock", {
+ get(this: HTMLVideoElement) {
+ if (!Object.prototype.hasOwnProperty.call(this, "__video_mock__")) {
+ Object.defineProperty(this, "__video_mock__", {
+ value: createMockState(),
+ writable: true,
+ configurable: true,
+ });
+ }
+ return (this as any).__video_mock__;
+ },
+ set(this: HTMLVideoElement, v: VideoMock) {
+ Object.defineProperty(this, "__video_mock__", {
+ value: v,
+ writable: true,
+ configurable: true,
+ });
+ },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "paused", {
+ get(this: HTMLVideoElement) {
+ return this._mock.paused;
+ },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "duration", {
+ get(this: HTMLVideoElement) {
+ return this._mock.duration;
+ },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "volume", {
+ get(this: HTMLVideoElement) {
+ return this._mock.volume;
+ },
+ set(this: HTMLVideoElement, value: number) {
+ this._mock.volume = value;
+ this.dispatchEvent(new Event("volumechange"));
+ },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "muted", {
+ get(this: HTMLVideoElement) {
+ return this._mock.muted;
+ },
+ set(this: HTMLVideoElement, value: boolean) {
+ this._mock.muted = value;
+ this.dispatchEvent(new Event("volumechange"));
+ },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "playbackRate", {
+ get(this: HTMLVideoElement) {
+ return this._mock.playbackRate;
+ },
+ set(this: HTMLVideoElement, value: number) {
+ this._mock.playbackRate = value;
+ this.dispatchEvent(new Event("ratechange"));
+ },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "readyState", {
+ get(this: HTMLVideoElement) {
+ return this._mock.readyState;
+ },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "videoWidth", {
+ get(this: HTMLVideoElement) {
+ return this._mock.videoWidth;
+ },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "videoHeight", {
+ get(this: HTMLVideoElement) {
+ return this._mock.videoHeight;
+ },
+ configurable: true,
+});
+
+global.HTMLVideoElement.prototype.play = async function playMock(this: HTMLVideoElement) {
+ if (!this._mock._loaded) {
+ this._mock._load(this);
+ }
+ this.dispatchEvent(new Event("play"));
+ this._mock.paused = false;
+ this.dispatchEvent(new Event("playing"));
+};
+
+global.HTMLVideoElement.prototype.pause = function pauseMock(this: HTMLVideoElement) {
+ this.dispatchEvent(new Event("pause"));
+ this._mock.paused = true;
+};
+
+// Fullscreen API mock — jsdom does not implement the Fullscreen API
+let _fullscreenElement: Element | null = null;
+
+Object.defineProperty(global.document, "fullscreenElement", {
+ get() {
+ return _fullscreenElement;
+ },
+ configurable: true,
+});
+
+global.HTMLVideoElement.prototype.requestFullscreen = async function requestFullscreenMock(
+ this: HTMLVideoElement,
+) {
+ _fullscreenElement = this;
+ document.dispatchEvent(new Event("fullscreenchange"));
+};
+
+global.document.exitFullscreen = async function exitFullscreenMock() {
+ _fullscreenElement = null;
+ document.dispatchEvent(new Event("fullscreenchange"));
+};
+
+export {};
diff --git a/packages/video/tsconfig.json b/packages/video/tsconfig.json
new file mode 100644
index 000000000..b9b2b6782
--- /dev/null
+++ b/packages/video/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "references": [
+ {
+ "path": "../event-listener"
+ },
+ {
+ "path": "../utils"
+ }
+ ],
+ "include": [
+ "src"
+ ]
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ee1923c5f..4c2a10a3d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1102,6 +1102,22 @@ importers:
specifier: 2.0.0-beta.14
version: 2.0.0-beta.14
+ packages/video:
+ dependencies:
+ '@solid-primitives/event-listener':
+ specifier: workspace:^
+ version: link:../event-listener
+ '@solid-primitives/utils':
+ specifier: workspace:^
+ version: link:../utils
+ devDependencies:
+ '@solidjs/web':
+ specifier: 2.0.0-beta.14
+ version: 2.0.0-beta.14(solid-js@2.0.0-beta.14)
+ solid-js:
+ specifier: 2.0.0-beta.14
+ version: 2.0.0-beta.14
+
packages/virtual:
dependencies:
'@solid-primitives/utils':
From 4a8b7b4d59db060b9534ac6710bbcf6cf24f14b5 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 24 May 2026 12:10:17 -0400
Subject: [PATCH 2/9] Improved composition
---
.changeset/video-initial.md | 16 +-
packages/video/README.md | 156 +++++-----
packages/video/package.json | 3 +-
packages/video/src/createVideo.ts | 110 +++++++
packages/video/src/createVideoPlayer.ts | 139 +++++++++
packages/video/src/index.ts | 376 +-----------------------
packages/video/src/make.ts | 127 ++++++++
packages/video/src/types.ts | 74 +++++
packages/video/test/index.test.ts | 295 ++++++++++++-------
packages/video/test/server.test.ts | 45 ++-
packages/video/test/setup.ts | 69 ++---
11 files changed, 795 insertions(+), 615 deletions(-)
create mode 100644 packages/video/src/createVideo.ts
create mode 100644 packages/video/src/createVideoPlayer.ts
create mode 100644 packages/video/src/make.ts
create mode 100644 packages/video/src/types.ts
diff --git a/.changeset/video-initial.md b/.changeset/video-initial.md
index 51754b91b..1074eac75 100644
--- a/.changeset/video-initial.md
+++ b/.changeset/video-initial.md
@@ -4,16 +4,24 @@
New package: `@solid-primitives/video`
-Layered primitives for managing HTML video playback.
+Layered primitives for managing HTML video playback, built for Solid 2.0 (beta.14).
### `makeVideo`
-Non-reactive base primitive. Creates an `HTMLVideoElement` with optional event handlers and returns a `[player, cleanup]` tuple. No Solid owner required.
+Non-reactive base. Creates an `HTMLVideoElement` with optional event handlers and initial configuration (`autoPlay`, `loop`, `muted`, `preload`). Returns a `[player, cleanup]` tuple. No Solid owner required.
### `makeVideoPlayer`
-Wraps `makeVideo` with playback and fullscreen controls: `play`, `pause`, `seek`, `setVolume`, `setMuted`, `setPlaybackRate`, `requestFullscreen`, `exitFullscreen`, `toggleFullscreen`.
+Wraps `makeVideo` with imperative controls: `play`, `pause`, `seek`, `setVolume`, `setMuted`, `setPlaybackRate`, `setLoop`, and fullscreen (`requestFullscreen`, `exitFullscreen`, `toggleFullscreen`).
### `createVideo`
-Reactive primitive that tracks all media state as signals: `playing`, `currentTime`, `volume`, `muted`, `playbackRate`, `ended`, `buffered`, `readyState`, `videoWidth`, `videoHeight`, `fullscreen`, and `duration`. The `duration` accessor throws `NotReadyError` until metadata loads, integrating naturally with ``. Accepts a static or reactive `VideoSource`.
+Reactive primitive covering essential playback state: `playing`/`setPlaying`, `currentTime`/`seek`, `ended`, `seeking`, `error` (`MediaError | null`), and `duration` (throws `NotReadyError` until metadata loads — integrates with ``). Accepts a static or reactive `VideoSource` and optional `VideoOptions`.
+
+### `createVideoPlayer`
+
+Extends `createVideo` with the full control surface: `volume`/`setVolume`, `muted`/`setMuted`, `playbackRate`/`setPlaybackRate`, `loop`/`setLoop`, `buffered`, `readyState`, `videoWidth`, `videoHeight`, and `fullscreen`/fullscreen controls. Accepts `VideoControlsOptions` which adds `volume` and `playbackRate` initial values to `VideoOptions`.
+
+### Design notes
+
+`createVideo` and `createVideoPlayer` are composable — `createVideoPlayer` calls `createVideo` internally and layers additional `createEventListenerMap` bindings on the same player element. Choose the primitive that matches your needs; the non-reactive `make*` layer remains available when no Solid owner is present.
diff --git a/packages/video/README.md b/packages/video/README.md
index 5b6a170cb..3e7192142 100644
--- a/packages/video/README.md
+++ b/packages/video/README.md
@@ -8,171 +8,171 @@
[](https://www.npmjs.com/package/@solid-primitives/video)
[](https://github.com/solidjs-community/solid-primitives#contribution-process)
-Primitives to manage HTML video playback in the browser. The primitives are layered: `make*` variants are non-reactive base primitives that require no Solid owner, while `createVideo` integrates with Solid's reactive system.
-
-Within an SSR context these primitives perform noops and never interrupt the process.
+Layered primitives for managing HTML video playback. The `make*` variants are non-reactive and require no Solid owner. The `create*` variants integrate with Solid's reactive system — `createVideo` covers essential playback state, and `createVideoPlayer` extends it with the full control surface.
## Installation
```bash
npm install @solid-primitives/video
# or
-yarn add @solid-primitives/video
-# or
pnpm add @solid-primitives/video
```
## How to use it
-### makeVideo
+### `makeVideo`
-A foundational non-reactive primitive that creates a raw `HTMLVideoElement` with optional event handlers. No Solid owner required.
+Creates a raw `HTMLVideoElement` with optional event handlers and initial configuration. No Solid owner required.
```ts
-const [player, cleanup] = makeVideo("clip.mp4");
-// later:
+const [player, cleanup] = makeVideo('clip.mp4', {}, { muted: true, loop: true });
cleanup();
```
-#### Definition
-
```ts
function makeVideo(
src: VideoSource | HTMLVideoElement,
handlers?: VideoEventHandlers,
+ options?: VideoOptions,
): [player: HTMLVideoElement, cleanup: VoidFunction];
```
-### makeVideoPlayer
+### `makeVideoPlayer`
-Wraps `makeVideo` with playback and fullscreen controls. No Solid owner required.
+Wraps `makeVideo` with imperative playback controls. No Solid owner required.
```ts
-const [{ play, pause, seek, setVolume, setMuted, setPlaybackRate, player }, cleanup] =
- makeVideoPlayer("clip.mp4");
+const [{ play, pause, seek, setVolume, setMuted, setPlaybackRate, setLoop }, cleanup] =
+ makeVideoPlayer('clip.mp4');
await play();
seek(30);
setPlaybackRate(1.5);
-await requestFullscreen();
+setLoop(true);
cleanup();
```
-#### Definition
-
```ts
function makeVideoPlayer(
src: VideoSource | HTMLVideoElement,
handlers?: VideoEventHandlers,
+ options?: VideoOptions,
): [controls: VideoControls, cleanup: VoidFunction];
```
-`VideoControls`:
+### `createVideo`
+
+Essential reactive playback state: `playing`, `currentTime`, `ended`, `seeking`, `error`, and an async `duration` that suspends until metadata is loaded.
```ts
-type VideoControls = {
- play: () => Promise;
- pause: VoidFunction;
- seek: (time: number) => void;
- setVolume: (volume: number) => void;
- setMuted: (muted: boolean) => void;
- setPlaybackRate: (rate: number) => void;
- requestFullscreen: () => Promise;
- exitFullscreen: () => Promise;
- toggleFullscreen: () => Promise;
- player: HTMLVideoElement;
-};
+const video = createVideo('clip.mp4');
+// or with a reactive source:
+const video = createVideo(() => selectedUrl());
+
+video.playing() // boolean — true while actively playing
+video.setPlaying(true) // plays
+video.currentTime() // seconds
+video.seek(30)
+video.ended() // boolean
+video.seeking() // boolean — true while scrubbing
+video.error() // MediaError | null
```
-The `seek` function uses `fastSeek` on [supporting browsers](https://caniuse.com/?search=fastseek).
+The `duration` accessor throws `NotReadyError` until video metadata has loaded, integrating with Solid 2.0's `` boundary:
-### createVideo
+```tsx
+
+ {video.duration()}s
+
+```
+
+```ts
+function createVideo(
+ src: VideoSource | Accessor,
+ options?: VideoOptions,
+): VideoReturn;
+```
+
+### `createVideoPlayer`
-A reactive video primitive. Returns a flat object with writable signal accessors for `playing`, `volume`, `muted`, and `playbackRate`; reactive signals for `currentTime`, `ended`, `buffered`, `readyState`, `videoWidth`, `videoHeight`, and `fullscreen`; and an async `duration` that suspends until metadata is loaded — integrating with ``.
+Extends `createVideo` with the full control surface: volume, muted, playback rate, loop, buffering state, and dimensions. Accepts all `VideoOptions` plus `volume` and `playbackRate` initial values.
```ts
-const video = createVideo("clip.mp4");
-// or with a reactive source:
-const video = createVideo(() => selectedUrl());
+const video = createVideoPlayer('clip.mp4', {
+ muted: true,
+ volume: 0.8,
+ playbackRate: 1,
+});
-video.playing() // boolean
-video.setPlaying(true) // plays
+// All fields from createVideo, plus:
video.volume() // 0–1
video.setVolume(0.5)
video.muted() // boolean
video.setMuted(true)
video.playbackRate() // number
video.setPlaybackRate(1.5)
-video.currentTime() // seconds
-video.seek(30)
-video.ended() // boolean
+video.loop() // boolean
+video.setLoop(true)
+video.buffered() // TimeRanges | undefined
video.readyState() // 0–4
video.videoWidth() // intrinsic pixel width
video.videoHeight() // intrinsic pixel height
-video.fullscreen() // boolean
-video.requestFullscreen()
-video.exitFullscreen()
-video.toggleFullscreen()
```
-Attach the `player` to a `` element via `ref`:
+> **Fullscreen** is intentionally omitted — use the dedicated `@solid-primitives/fullscreen` primitive to manage fullscreen state and attach it to `video.player`.
-```tsx
-const video = createVideo("clip.mp4");
-
- { /* video.player === el */ }} src="clip.mp4" />
-// or pass an existing element:
-const video = createVideo(videoEl);
-```
-
-The `duration` accessor throws `NotReadyError` until video metadata has loaded, making it work naturally with Solid 2.0's `` boundary. The pending state resets whenever the source changes.
-
-```tsx
-
- {video.duration()}s
-
+```ts
+function createVideoPlayer(
+ src: VideoSource | Accessor,
+ options?: VideoControlsOptions,
+): VideoControlsReturn;
```
-#### Definition
+## Types
```ts
-function createVideo(src: VideoSource | Accessor): VideoReturn;
-```
+type VideoSource = string | undefined | MediaProvider;
-`VideoReturn`:
+type VideoOptions = {
+ autoPlay?: boolean;
+ loop?: boolean;
+ muted?: boolean;
+ preload?: "" | "none" | "metadata" | "auto";
+};
+
+type VideoControlsOptions = VideoOptions & {
+ volume?: number;
+ playbackRate?: number;
+};
-```ts
type VideoReturn = {
player: HTMLVideoElement;
playing: Accessor;
setPlaying: (v: boolean) => void;
currentTime: Accessor;
seek: (time: number) => void;
+ ended: Accessor;
+ seeking: Accessor;
+ error: Accessor;
+ duration: Accessor; // throws NotReadyError until loaded
+};
+
+type VideoControlsReturn = VideoReturn & {
volume: Accessor;
setVolume: (v: number) => void;
muted: Accessor;
setMuted: (v: boolean) => void;
playbackRate: Accessor;
setPlaybackRate: (rate: number) => void;
- ended: Accessor;
+ loop: Accessor;
+ setLoop: (v: boolean) => void;
buffered: Accessor;
readyState: Accessor;
videoWidth: Accessor;
videoHeight: Accessor;
- fullscreen: Accessor;
- requestFullscreen: () => Promise;
- exitFullscreen: () => Promise;
- toggleFullscreen: () => Promise;
- duration: Accessor;
};
```
-`VideoSource`:
-
-```ts
-type VideoSource = string | undefined | MediaProvider;
-```
-
## Changelog
See [CHANGELOG.md](./CHANGELOG.md)
diff --git a/packages/video/package.json b/packages/video/package.json
index 7ce7bffdf..747e6912c 100644
--- a/packages/video/package.json
+++ b/packages/video/package.json
@@ -19,7 +19,8 @@
"list": [
"makeVideo",
"makeVideoPlayer",
- "createVideo"
+ "createVideo",
+ "createVideoPlayer"
],
"category": "Display & Media"
},
diff --git a/packages/video/src/createVideo.ts b/packages/video/src/createVideo.ts
new file mode 100644
index 000000000..7cbade36e
--- /dev/null
+++ b/packages/video/src/createVideo.ts
@@ -0,0 +1,110 @@
+import { type Accessor, createEffect, createSignal, NotReadyError, onCleanup } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils";
+import { createEventListenerMap } from "@solid-primitives/event-listener";
+import { makeVideo, setVideoSrc } from "./make.js";
+import type { VideoOptions, VideoReturn, VideoSource } from "./types.js";
+
+const NOT_SET: unique symbol = Symbol();
+
+/**
+ * A reactive video primitive with essential playback state.
+ *
+ * Returns `playing`, `currentTime`, `ended`, `seeking`, `error`, and a `duration`
+ * that throws `NotReadyError` until metadata loads (integrates with ``).
+ *
+ * For volume, muted, playback rate, fullscreen, and buffering state use
+ * `createVideoControls`.
+ *
+ * @param src Video URL, MediaProvider, or a reactive accessor returning either
+ * @param options Initial element configuration
+ *
+ * @example
+ * ```ts
+ * const video = createVideo('clip.mp4');
+ * video.playing() // boolean
+ * video.setPlaying(true) // plays
+ * video.seek(30)
+ * video.seeking() // boolean — true while scrubbing
+ * video.error() // MediaError | null
+ *
+ * // duration() throws NotReadyError until metadata loads:
+ * Loading…}>
+ * {video.duration()}s
+ *
+ * ```
+ */
+export const createVideo = (
+ src: VideoSource | Accessor,
+ options?: VideoOptions,
+): VideoReturn => {
+ if (isServer) {
+ return {
+ player: {} as HTMLVideoElement,
+ playing: () => false,
+ setPlaying: noop,
+ currentTime: () => 0,
+ seek: noop,
+ ended: () => false,
+ seeking: () => false,
+ error: () => null,
+ duration: () => {
+ throw new NotReadyError("Video duration not available on the server");
+ },
+ };
+ }
+
+ const [player, cleanup] = makeVideo(access(src), {}, options);
+ onCleanup(cleanup);
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ const seek = player.fastSeek
+ ? (time: number) => player.fastSeek(time)
+ : (time: number) => { player.currentTime = time; };
+
+ const [playing, setPlayingSignal] = createSignal(!player.paused, INTERNAL_OPTIONS);
+ const setPlaying = (v: boolean) => (v ? player.play() : player.pause());
+
+ const [currentTime, setCurrentTime] = createSignal(0, INTERNAL_OPTIONS);
+ const [ended, setEnded] = createSignal(false, INTERNAL_OPTIONS);
+ const [seeking, setSeeking] = createSignal(false, INTERNAL_OPTIONS);
+ const [error, setError] = createSignal(null, INTERNAL_OPTIONS);
+ const [rawDuration, setRawDuration] = createSignal(
+ NOT_SET,
+ INTERNAL_OPTIONS,
+ );
+
+ createEventListenerMap(player, {
+ playing: () => setPlayingSignal(true),
+ pause: () => setPlayingSignal(false),
+ ended: () => {
+ setPlayingSignal(false);
+ setEnded(true);
+ },
+ play: () => setEnded(false),
+ timeupdate: () => setCurrentTime(player.currentTime),
+ seeking: () => setSeeking(true),
+ seeked: () => setSeeking(false),
+ error: () => setError(player.error),
+ loadstart: () => {
+ setRawDuration(NOT_SET);
+ setError(null);
+ },
+ loadeddata: () => setRawDuration(player.duration),
+ });
+
+ const duration = (): number => {
+ const val = rawDuration();
+ if (val === NOT_SET) throw new NotReadyError("Video duration not yet available");
+ return val;
+ };
+
+ if (src instanceof Function) {
+ createEffect(src, (newSrc: VideoSource) => {
+ setVideoSrc(player, newSrc);
+ seek(0);
+ });
+ }
+
+ return { player, playing, setPlaying, currentTime, seek, ended, seeking, error, duration };
+};
diff --git a/packages/video/src/createVideoPlayer.ts b/packages/video/src/createVideoPlayer.ts
new file mode 100644
index 000000000..c0a52e27e
--- /dev/null
+++ b/packages/video/src/createVideoPlayer.ts
@@ -0,0 +1,139 @@
+import { createSignal, NotReadyError } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { INTERNAL_OPTIONS, noop } from "@solid-primitives/utils";
+import { createEventListenerMap } from "@solid-primitives/event-listener";
+import { createVideo } from "./createVideo.js";
+import type { VideoControlsOptions, VideoControlsReturn, VideoSource } from "./types.js";
+import type { Accessor } from "solid-js";
+
+/**
+ * A reactive video primitive with full media controls.
+ *
+ * Extends `createVideo` with `volume`, `muted`, `playbackRate`, `loop`,
+ * `buffered`, `readyState`, `videoWidth`, `videoHeight`, and fullscreen state.
+ *
+ * @param src Video URL, MediaProvider, or a reactive accessor returning either
+ * @param options Initial element configuration including volume and playback rate
+ *
+ * @example
+ * ```ts
+ * const video = createVideoControls('clip.mp4', { muted: true, volume: 0.8 });
+ * video.playing() // boolean
+ * video.volume() // 0–1
+ * video.setVolume(0.5)
+ * video.muted() // boolean
+ * video.setMuted(true)
+ * video.playbackRate() // number
+ * video.setPlaybackRate(1.5)
+ * video.loop() // boolean
+ * video.setLoop(true)
+ * video.fullscreen() // boolean
+ * video.requestFullscreen()
+ * ```
+ */
+export const createVideoPlayer = (
+ src: VideoSource | Accessor,
+ options?: VideoControlsOptions,
+): VideoControlsReturn => {
+ if (isServer) {
+ return {
+ player: {} as HTMLVideoElement,
+ playing: () => false,
+ setPlaying: noop,
+ currentTime: () => 0,
+ seek: noop,
+ ended: () => false,
+ seeking: () => false,
+ error: () => null,
+ duration: () => {
+ throw new NotReadyError("Video duration not available on the server");
+ },
+ volume: () => 1,
+ setVolume: noop,
+ muted: () => false,
+ setMuted: noop,
+ playbackRate: () => 1,
+ setPlaybackRate: noop,
+ loop: () => false,
+ setLoop: noop,
+ buffered: () => undefined,
+ readyState: () => 0,
+ videoWidth: () => 0,
+ videoHeight: () => 0,
+ };
+ }
+
+ const base = createVideo(src, options);
+ const { player } = base;
+
+ // Apply controls-level initial values before reading signal initial state.
+ if (options?.volume !== undefined) player.volume = options.volume;
+ if (options?.playbackRate !== undefined) player.playbackRate = options.playbackRate;
+
+ const [volume, setVolumeSignal] = createSignal(player.volume, INTERNAL_OPTIONS);
+ const setVolume = (v: number) => {
+ player.volume = v;
+ };
+
+ const [muted, setMutedSignal] = createSignal(player.muted, INTERNAL_OPTIONS);
+ const setMuted = (v: boolean) => {
+ player.muted = v;
+ };
+
+ const [playbackRate, setPlaybackRateSignal] = createSignal(player.playbackRate, INTERNAL_OPTIONS);
+ const setPlaybackRate = (v: number) => {
+ player.playbackRate = v;
+ };
+
+ // loop has no DOM event — setLoop must update both DOM and signal directly.
+ const [loop, setLoopSignal] = createSignal(player.loop, INTERNAL_OPTIONS);
+ const setLoop = (v: boolean) => {
+ player.loop = v;
+ setLoopSignal(v);
+ };
+
+ const [buffered, setBuffered] = createSignal(undefined, INTERNAL_OPTIONS);
+ const [readyState, setReadyState] = createSignal(player.readyState, INTERNAL_OPTIONS);
+ const [videoWidth, setVideoWidth] = createSignal(player.videoWidth, INTERNAL_OPTIONS);
+ const [videoHeight, setVideoHeight] = createSignal(player.videoHeight, INTERNAL_OPTIONS);
+
+ const syncReadyState = () => setReadyState(player.readyState);
+ const syncVideoDimensions = () => {
+ setVideoWidth(player.videoWidth);
+ setVideoHeight(player.videoHeight);
+ };
+
+ createEventListenerMap(player, {
+ volumechange: () => {
+ setVolumeSignal(player.volume);
+ setMutedSignal(player.muted);
+ },
+ ratechange: () => setPlaybackRateSignal(player.playbackRate),
+ progress: () => setBuffered(player.buffered),
+ loadedmetadata: () => {
+ syncReadyState();
+ syncVideoDimensions();
+ },
+ loadeddata: syncReadyState,
+ canplay: syncReadyState,
+ canplaythrough: syncReadyState,
+ emptied: syncReadyState,
+ resize: syncVideoDimensions,
+ });
+
+ return {
+ ...base,
+ volume,
+ setVolume,
+ muted,
+ setMuted,
+ playbackRate,
+ setPlaybackRate,
+ loop,
+ setLoop,
+ buffered,
+ readyState,
+ videoWidth,
+ videoHeight,
+ };
+};
diff --git a/packages/video/src/index.ts b/packages/video/src/index.ts
index 4d6695e50..4b49ae1b2 100644
--- a/packages/video/src/index.ts
+++ b/packages/video/src/index.ts
@@ -1,363 +1,13 @@
-import { type Accessor, createEffect, createSignal, NotReadyError, onCleanup } from "solid-js";
-import { isServer } from "@solidjs/web";
-import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils";
-import { createEventListenerMap } from "@solid-primitives/event-listener";
-
-const NOT_SET: unique symbol = Symbol();
-
-export type VideoSource = string | undefined | MediaProvider;
-
-export type VideoEventHandlers = {
- [K in keyof HTMLMediaElementEventMap]?: (event: HTMLMediaElementEventMap[K]) => void;
-};
-
-export type VideoControls = {
- play: () => Promise;
- pause: VoidFunction;
- seek: (time: number) => void;
- setVolume: (volume: number) => void;
- setMuted: (muted: boolean) => void;
- setPlaybackRate: (rate: number) => void;
- requestFullscreen: () => Promise;
- exitFullscreen: () => Promise;
- toggleFullscreen: () => Promise;
- player: HTMLVideoElement;
-};
-
-export type VideoReturn = {
- player: HTMLVideoElement;
- /** `true` while the video is actively playing. */
- playing: Accessor;
- setPlaying: (v: boolean) => void;
- currentTime: Accessor;
- seek: (time: number) => void;
- volume: Accessor;
- setVolume: (v: number) => void;
- muted: Accessor;
- setMuted: (v: boolean) => void;
- playbackRate: Accessor;
- setPlaybackRate: (rate: number) => void;
- /** `true` once playback has reached the end of the media. */
- ended: Accessor;
- /** The current `TimeRanges` of buffered media, or `undefined` before first progress. */
- buffered: Accessor;
- /** `HTMLMediaElement.readyState` — 0 (HAVE_NOTHING) through 4 (HAVE_ENOUGH_DATA). */
- readyState: Accessor;
- videoWidth: Accessor;
- videoHeight: Accessor;
- /** `true` when this player is the active fullscreen element. */
- fullscreen: Accessor;
- requestFullscreen: () => Promise;
- exitFullscreen: () => Promise;
- toggleFullscreen: () => Promise;
- /**
- * Throws `NotReadyError` until video metadata has loaded (integrates with
- * ``). After the first `loadeddata` event returns the duration in
- * seconds reactively. Resets to pending whenever the source changes.
- */
- duration: Accessor;
-};
-
-function setVideoSrc(el: HTMLVideoElement, src: VideoSource): void {
- if (typeof src === "string") {
- el.src = src;
- } else {
- el.srcObject = (src as MediaProvider | null) ?? null;
- }
-}
-
-const unwrapSource = (src: VideoSource | HTMLVideoElement): HTMLVideoElement => {
- if (src instanceof HTMLVideoElement) return src;
- const player = document.createElement("video");
- setVideoSrc(player, src);
- return player;
-};
-
-/**
- * Creates a raw `HTMLVideoElement` with optional event handlers.
- * Non-reactive — no Solid owner required. Returns a cleanup function.
- *
- * @param src Video URL, MediaProvider, or existing HTMLVideoElement
- * @param handlers Event handlers to bind against the player
- * @returns Tuple of `[player, cleanup]`
- *
- * @example
- * ```ts
- * const [player, cleanup] = makeVideo('https://example.com/clip.mp4');
- * ```
- */
-export const makeVideo = (
- src: VideoSource | HTMLVideoElement,
- handlers: VideoEventHandlers = {},
-): [player: HTMLVideoElement, cleanup: VoidFunction] => {
- if (isServer) return [{} as HTMLVideoElement, noop];
-
- const player = unwrapSource(src);
-
- for (const [name, handler] of Object.entries(handlers)) {
- player.addEventListener(name, handler as EventListener);
- }
-
- const cleanup = () => {
- player.pause();
- for (const [name, handler] of Object.entries(handlers)) {
- player.removeEventListener(name, handler as EventListener);
- }
- };
-
- return [player, cleanup];
-};
-
-/**
- * Wraps `makeVideo` with playback and fullscreen controls.
- * Non-reactive — no Solid owner required. Returns a cleanup function.
- *
- * @param src Video URL, MediaProvider, or existing HTMLVideoElement
- * @param handlers Event handlers to bind against the player
- * @returns Tuple of `[controls, cleanup]`
- *
- * @example
- * ```ts
- * const [{ play, pause, seek }, cleanup] = makeVideoPlayer('clip.mp4');
- * ```
- */
-export const makeVideoPlayer = (
- src: VideoSource | HTMLVideoElement,
- handlers: VideoEventHandlers = {},
-): [controls: VideoControls, cleanup: VoidFunction] => {
- if (isServer) {
- return [
- {
- play: async () => noop(),
- pause: noop,
- seek: noop,
- setVolume: noop,
- setMuted: noop,
- setPlaybackRate: noop,
- requestFullscreen: () => Promise.resolve(),
- exitFullscreen: () => Promise.resolve(),
- toggleFullscreen: () => Promise.resolve(),
- player: {} as HTMLVideoElement,
- },
- noop,
- ];
- }
-
- const [player, cleanup] = makeVideo(src, handlers);
-
- const controls: VideoControls = {
- player,
- play: () => player.play(),
- pause: () => player.pause(),
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- seek: player.fastSeek
- ? (time: number) => player.fastSeek(time)
- : (time: number) => {
- player.currentTime = time;
- },
- setVolume: (volume: number) => {
- player.volume = volume;
- },
- setMuted: (muted: boolean) => {
- player.muted = muted;
- },
- setPlaybackRate: (rate: number) => {
- player.playbackRate = rate;
- },
- requestFullscreen: async () => {
- if (!document.fullscreenElement) await player.requestFullscreen();
- },
- exitFullscreen: async () => {
- if (document.fullscreenElement === player) await document.exitFullscreen();
- },
- toggleFullscreen: async () => {
- if (document.fullscreenElement === player) {
- await document.exitFullscreen();
- } else {
- await player.requestFullscreen();
- }
- },
- };
-
- return [controls, cleanup];
-};
-
-/**
- * A reactive video primitive.
- *
- * Returns a flat object with writable derived signals for `playing`, `volume`,
- * `muted`, and `playbackRate`; reactive `currentTime`, `ended`, `buffered`,
- * `readyState`, `videoWidth`, `videoHeight`, and `fullscreen`; and a `duration`
- * that throws `NotReadyError` until metadata loads — integrating with ``.
- *
- * @param src Video URL, MediaProvider, or a reactive accessor returning either
- *
- * @example
- * ```ts
- * const video = createVideo('clip.mp4');
- * // or reactive source:
- * const video = createVideo(() => selectedUrl());
- *
- * video.playing() // boolean
- * video.setPlaying(true) // plays
- * video.volume() // 0–1
- * video.setVolume(0.5)
- * video.muted() // boolean
- * video.setMuted(true)
- * video.playbackRate() // number
- * video.setPlaybackRate(1.5)
- * video.seek(30)
- * video.requestFullscreen()
- *
- * // duration() throws NotReadyError until metadata loads:
- * Loading…}>
- * {video.duration()}s
- *
- * ```
- */
-export const createVideo = (src: VideoSource | Accessor): VideoReturn => {
- if (isServer) {
- return {
- player: {} as HTMLVideoElement,
- playing: () => false,
- setPlaying: noop,
- currentTime: () => 0,
- seek: noop,
- volume: () => 1,
- setVolume: noop,
- muted: () => false,
- setMuted: noop,
- playbackRate: () => 1,
- setPlaybackRate: noop,
- ended: () => false,
- buffered: () => undefined,
- readyState: () => 0,
- videoWidth: () => 0,
- videoHeight: () => 0,
- fullscreen: () => false,
- requestFullscreen: () => Promise.resolve(),
- exitFullscreen: () => Promise.resolve(),
- toggleFullscreen: () => Promise.resolve(),
- duration: () => {
- throw new NotReadyError("Video duration not available on the server");
- },
- };
- }
-
- const player = unwrapSource(access(src));
- const [controls, cleanup] = makeVideoPlayer(player);
- onCleanup(cleanup);
-
- // playing — writable derived signal; DOM events keep it in sync
- const [playing, setPlayingSignal] = createSignal(!player.paused, INTERNAL_OPTIONS);
- const setPlaying = (v: boolean) => (v ? controls.play() : controls.pause());
-
- // volume — writable derived signal; volumechange event keeps it in sync
- const [volume, setVolumeSignal] = createSignal(player.volume, INTERNAL_OPTIONS);
- const setVolume = (v: number) => {
- player.volume = v;
- };
-
- // muted — writable derived signal; volumechange event keeps it in sync
- const [muted, setMutedSignal] = createSignal(player.muted, INTERNAL_OPTIONS);
- const setMuted = (v: boolean) => {
- player.muted = v;
- };
-
- // playbackRate — writable derived signal; ratechange event keeps it in sync
- const [playbackRate, setPlaybackRateSignal] = createSignal(player.playbackRate, INTERNAL_OPTIONS);
- const setPlaybackRate = (v: number) => {
- player.playbackRate = v;
- };
-
- const [currentTime, setCurrentTime] = createSignal(0, INTERNAL_OPTIONS);
- const [ended, setEnded] = createSignal(false, INTERNAL_OPTIONS);
- const [buffered, setBuffered] = createSignal(undefined, INTERNAL_OPTIONS);
- const [readyState, setReadyState] = createSignal(player.readyState, INTERNAL_OPTIONS);
- const [videoWidth, setVideoWidth] = createSignal(player.videoWidth, INTERNAL_OPTIONS);
- const [videoHeight, setVideoHeight] = createSignal(player.videoHeight, INTERNAL_OPTIONS);
- const [fullscreen, setFullscreen] = createSignal(false, INTERNAL_OPTIONS);
-
- // duration — NOT_SET until loadeddata fires; resets to NOT_SET on loadstart (new source)
- const [rawDuration, setRawDuration] = createSignal(
- NOT_SET,
- INTERNAL_OPTIONS,
- );
-
- const syncReadyState = () => setReadyState(player.readyState);
- const syncVideoDimensions = () => {
- setVideoWidth(player.videoWidth);
- setVideoHeight(player.videoHeight);
- };
-
- createEventListenerMap(player, {
- playing: () => setPlayingSignal(true),
- pause: () => setPlayingSignal(false),
- ended: () => {
- setPlayingSignal(false);
- setEnded(true);
- },
- play: () => setEnded(false),
- timeupdate: () => setCurrentTime(player.currentTime),
- volumechange: () => {
- setVolumeSignal(player.volume);
- setMutedSignal(player.muted);
- },
- ratechange: () => setPlaybackRateSignal(player.playbackRate),
- progress: () => setBuffered(player.buffered),
- loadedmetadata: () => {
- syncReadyState();
- syncVideoDimensions();
- },
- loadeddata: () => {
- syncReadyState();
- setRawDuration(player.duration);
- },
- canplay: syncReadyState,
- canplaythrough: syncReadyState,
- emptied: syncReadyState,
- loadstart: () => setRawDuration(NOT_SET),
- resize: syncVideoDimensions,
- });
-
- createEventListenerMap(document, {
- fullscreenchange: () => setFullscreen(document.fullscreenElement === player),
- });
- const duration = (): number => {
- const val = rawDuration();
- if (val === NOT_SET) throw new NotReadyError("Video duration not yet available");
- return val;
- };
-
- // Reactive src — update player source when accessor changes
- if (src instanceof Function) {
- createEffect(src, (newSrc: VideoSource) => {
- setVideoSrc(player, newSrc);
- controls.seek(0);
- });
- }
-
- return {
- player,
- playing,
- setPlaying,
- currentTime,
- seek: controls.seek,
- volume,
- setVolume,
- muted,
- setMuted,
- playbackRate,
- setPlaybackRate,
- ended,
- buffered,
- readyState,
- videoWidth,
- videoHeight,
- fullscreen,
- requestFullscreen: controls.requestFullscreen,
- exitFullscreen: controls.exitFullscreen,
- toggleFullscreen: controls.toggleFullscreen,
- duration,
- };
-};
+export type {
+ VideoSource,
+ VideoEventHandlers,
+ VideoOptions,
+ VideoControlsOptions,
+ VideoControls,
+ VideoReturn,
+ VideoControlsReturn,
+} from "./types.js";
+
+export { makeVideo, makeVideoPlayer } from "./make.js";
+export { createVideo } from "./createVideo.js";
+export { createVideoPlayer } from "./createVideoPlayer.js";
diff --git a/packages/video/src/make.ts b/packages/video/src/make.ts
new file mode 100644
index 000000000..02a845028
--- /dev/null
+++ b/packages/video/src/make.ts
@@ -0,0 +1,127 @@
+import { isServer } from "@solidjs/web";
+import { noop } from "@solid-primitives/utils";
+import type { VideoControls, VideoEventHandlers, VideoOptions, VideoSource } from "./types.js";
+
+export function setVideoSrc(el: HTMLVideoElement, src: VideoSource): void {
+ if (typeof src === "string") {
+ el.src = src;
+ } else {
+ el.srcObject = (src as MediaProvider | null) ?? null;
+ }
+}
+
+function unwrapSource(src: VideoSource | HTMLVideoElement): HTMLVideoElement {
+ if (src instanceof HTMLVideoElement) return src;
+ const player = document.createElement("video");
+ setVideoSrc(player, src);
+ return player;
+}
+
+function applyOptions(player: HTMLVideoElement, options: VideoOptions): void {
+ if (options.autoPlay !== undefined) player.autoplay = options.autoPlay;
+ if (options.loop !== undefined) player.loop = options.loop;
+ if (options.muted !== undefined) player.muted = options.muted;
+ if (options.preload !== undefined) player.preload = options.preload;
+}
+
+/**
+ * Creates a raw `HTMLVideoElement` with optional event handlers and initial options.
+ * Non-reactive — no Solid owner required. Returns a cleanup function.
+ *
+ * @param src Video URL, MediaProvider, or existing HTMLVideoElement
+ * @param handlers Event handlers to bind against the player
+ * @param options Initial element configuration (autoPlay, loop, muted, preload)
+ * @returns Tuple of `[player, cleanup]`
+ *
+ * @example
+ * ```ts
+ * const [player, cleanup] = makeVideo('clip.mp4', {}, { muted: true });
+ * ```
+ */
+export const makeVideo = (
+ src: VideoSource | HTMLVideoElement,
+ handlers: VideoEventHandlers = {},
+ options?: VideoOptions,
+): [player: HTMLVideoElement, cleanup: VoidFunction] => {
+ if (isServer) return [{} as HTMLVideoElement, noop];
+
+ const player = unwrapSource(src);
+ if (options) applyOptions(player, options);
+
+ for (const [name, handler] of Object.entries(handlers)) {
+ player.addEventListener(name, handler as EventListener);
+ }
+
+ const cleanup = () => {
+ player.pause();
+ for (const [name, handler] of Object.entries(handlers)) {
+ player.removeEventListener(name, handler as EventListener);
+ }
+ };
+
+ return [player, cleanup];
+};
+
+/**
+ * Wraps `makeVideo` with playback and fullscreen controls.
+ * Non-reactive — no Solid owner required. Returns a cleanup function.
+ *
+ * @param src Video URL, MediaProvider, or existing HTMLVideoElement
+ * @param handlers Event handlers to bind against the player
+ * @param options Initial element configuration
+ * @returns Tuple of `[controls, cleanup]`
+ *
+ * @example
+ * ```ts
+ * const [{ play, pause, seek }, cleanup] = makeVideoPlayer('clip.mp4');
+ * ```
+ */
+export const makeVideoPlayer = (
+ src: VideoSource | HTMLVideoElement,
+ handlers: VideoEventHandlers = {},
+ options?: VideoOptions,
+): [controls: VideoControls, cleanup: VoidFunction] => {
+ if (isServer) {
+ return [
+ {
+ play: async () => noop(),
+ pause: noop,
+ seek: noop,
+ setVolume: noop,
+ setMuted: noop,
+ setPlaybackRate: noop,
+ setLoop: noop,
+ player: {} as HTMLVideoElement,
+ },
+ noop,
+ ];
+ }
+
+ const [player, cleanup] = makeVideo(src, handlers, options);
+
+ const controls: VideoControls = {
+ player,
+ play: () => player.play(),
+ pause: () => player.pause(),
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ seek: player.fastSeek
+ ? (time: number) => player.fastSeek(time)
+ : (time: number) => {
+ player.currentTime = time;
+ },
+ setVolume: (volume: number) => {
+ player.volume = volume;
+ },
+ setMuted: (muted: boolean) => {
+ player.muted = muted;
+ },
+ setPlaybackRate: (rate: number) => {
+ player.playbackRate = rate;
+ },
+ setLoop: (loop: boolean) => {
+ player.loop = loop;
+ },
+ };
+
+ return [controls, cleanup];
+};
diff --git a/packages/video/src/types.ts b/packages/video/src/types.ts
new file mode 100644
index 000000000..f356320db
--- /dev/null
+++ b/packages/video/src/types.ts
@@ -0,0 +1,74 @@
+import type { Accessor } from "solid-js";
+
+export type VideoSource = string | undefined | MediaProvider;
+
+export type VideoEventHandlers = {
+ [K in keyof HTMLVideoElementEventMap]?: (event: HTMLVideoElementEventMap[K]) => void;
+};
+
+/** Initial configuration applied to the video element on creation. */
+export type VideoOptions = {
+ autoPlay?: boolean;
+ loop?: boolean;
+ muted?: boolean;
+ preload?: "" | "none" | "metadata" | "auto";
+};
+
+/** Extends `VideoOptions` with initial values for controls-level properties. */
+export type VideoControlsOptions = VideoOptions & {
+ /** Initial volume (0–1). Defaults to the browser default (1). */
+ volume?: number;
+ /** Initial playback rate. Defaults to the browser default (1). */
+ playbackRate?: number;
+};
+
+export type VideoControls = {
+ play: () => Promise;
+ pause: VoidFunction;
+ seek: (time: number) => void;
+ setVolume: (volume: number) => void;
+ setMuted: (muted: boolean) => void;
+ setPlaybackRate: (rate: number) => void;
+ setLoop: (loop: boolean) => void;
+ player: HTMLVideoElement;
+};
+
+/** Return type of `createVideo` — essential playback state. */
+export type VideoReturn = {
+ player: HTMLVideoElement;
+ /** `true` while the video is actively playing. */
+ playing: Accessor;
+ setPlaying: (v: boolean) => void;
+ currentTime: Accessor;
+ seek: (time: number) => void;
+ /** `true` once playback has reached the end of the media. */
+ ended: Accessor;
+ /** `true` while the player is seeking to a new position. */
+ seeking: Accessor;
+ /** `MediaError` if the element has encountered a media error, otherwise `null`. */
+ error: Accessor;
+ /**
+ * Throws `NotReadyError` until video metadata has loaded (integrates with
+ * ``). After the first `loadeddata` event returns the duration in
+ * seconds reactively. Resets to pending whenever the source changes.
+ */
+ duration: Accessor;
+};
+
+/** Return type of `createVideoControls` — extends `VideoReturn` with full media controls. */
+export type VideoControlsReturn = VideoReturn & {
+ volume: Accessor;
+ setVolume: (v: number) => void;
+ muted: Accessor;
+ setMuted: (v: boolean) => void;
+ playbackRate: Accessor;
+ setPlaybackRate: (rate: number) => void;
+ loop: Accessor;
+ setLoop: (v: boolean) => void;
+ /** The current `TimeRanges` of buffered media, or `undefined` before first progress. */
+ buffered: Accessor;
+ /** `HTMLMediaElement.readyState` — 0 (HAVE_NOTHING) through 4 (HAVE_ENOUGH_DATA). */
+ readyState: Accessor;
+ videoWidth: Accessor;
+ videoHeight: Accessor;
+};
diff --git a/packages/video/test/index.test.ts b/packages/video/test/index.test.ts
index dd40ede5e..9d3f02e70 100644
--- a/packages/video/test/index.test.ts
+++ b/packages/video/test/index.test.ts
@@ -1,7 +1,7 @@
import "./setup";
import { createRoot, createSignal, flush } from "solid-js";
import { describe, expect, it } from "vitest";
-import { makeVideo, makeVideoPlayer, createVideo } from "../src/index.js";
+import { makeVideo, makeVideoPlayer, createVideo, createVideoPlayer } from "../src/index.js";
const testUrl = "https://example.com/clip.mp4";
@@ -26,7 +26,7 @@ describe("makeVideo", () => {
expect(fired).toBe(false);
});
- it("can be called outside a Solid owner (no lifecycle dependency)", () => {
+ it("can be called outside a Solid owner", () => {
expect(() => {
const [, cleanup] = makeVideo(testUrl);
cleanup();
@@ -46,6 +46,13 @@ describe("makeVideo", () => {
expect(player).toBe(el);
cleanup();
});
+
+ it("applies VideoOptions to the player", () => {
+ const [player, cleanup] = makeVideo(testUrl, {}, { muted: true, loop: true });
+ expect(player.muted).toBe(true);
+ expect(player.loop).toBe(true);
+ cleanup();
+ });
});
// ── makeVideoPlayer ───────────────────────────────────────────────────────────
@@ -90,36 +97,17 @@ describe("makeVideoPlayer", () => {
cleanup();
});
- it("setPlaybackRate changes playback rate", () => {
+ it("setPlaybackRate changes rate", () => {
const [{ player, setPlaybackRate }, cleanup] = makeVideoPlayer(testUrl);
setPlaybackRate(1.5);
expect(player.playbackRate).toBe(1.5);
cleanup();
});
- it("requestFullscreen enters fullscreen", async () => {
- const [{ player, requestFullscreen }, cleanup] = makeVideoPlayer(testUrl);
- await requestFullscreen();
- expect(document.fullscreenElement).toBe(player);
- await document.exitFullscreen();
- cleanup();
- });
-
- it("exitFullscreen leaves fullscreen", async () => {
- const [{ player, requestFullscreen, exitFullscreen }, cleanup] = makeVideoPlayer(testUrl);
- await requestFullscreen();
- expect(document.fullscreenElement).toBe(player);
- await exitFullscreen();
- expect(document.fullscreenElement).toBeNull();
- cleanup();
- });
-
- it("toggleFullscreen enters then exits fullscreen", async () => {
- const [{ player, toggleFullscreen }, cleanup] = makeVideoPlayer(testUrl);
- await toggleFullscreen();
- expect(document.fullscreenElement).toBe(player);
- await toggleFullscreen();
- expect(document.fullscreenElement).toBeNull();
+ it("setLoop changes loop", () => {
+ const [{ player, setLoop }, cleanup] = makeVideoPlayer(testUrl);
+ setLoop(true);
+ expect(player.loop).toBe(true);
cleanup();
});
});
@@ -127,34 +115,22 @@ describe("makeVideoPlayer", () => {
// ── createVideo ───────────────────────────────────────────────────────────────
describe("createVideo", () => {
- it("returns an object with the expected shape", () =>
+ it("returns the expected shape", () =>
createRoot(dispose => {
const video = createVideo(testUrl);
expect(typeof video.playing).toBe("function");
expect(typeof video.setPlaying).toBe("function");
- expect(typeof video.volume).toBe("function");
- expect(typeof video.setVolume).toBe("function");
- expect(typeof video.muted).toBe("function");
- expect(typeof video.setMuted).toBe("function");
- expect(typeof video.playbackRate).toBe("function");
- expect(typeof video.setPlaybackRate).toBe("function");
expect(typeof video.currentTime).toBe("function");
expect(typeof video.seek).toBe("function");
expect(typeof video.ended).toBe("function");
- expect(typeof video.buffered).toBe("function");
- expect(typeof video.readyState).toBe("function");
- expect(typeof video.videoWidth).toBe("function");
- expect(typeof video.videoHeight).toBe("function");
- expect(typeof video.fullscreen).toBe("function");
- expect(typeof video.requestFullscreen).toBe("function");
- expect(typeof video.exitFullscreen).toBe("function");
- expect(typeof video.toggleFullscreen).toBe("function");
+ expect(typeof video.seeking).toBe("function");
+ expect(typeof video.error).toBe("function");
expect(typeof video.duration).toBe("function");
expect(video.player).toBeInstanceOf(HTMLVideoElement);
dispose();
}));
- it("initial playing state is false", () =>
+ it("initial playing is false", () =>
createRoot(dispose => {
const video = createVideo(testUrl);
expect(video.playing()).toBe(false);
@@ -184,16 +160,137 @@ describe("createVideo", () => {
dispose();
}));
- it("initial volume is 1", () =>
+ it("ended is false initially and true after ended event", () =>
createRoot(dispose => {
const video = createVideo(testUrl);
- expect(video.volume()).toBe(1);
+ expect(video.ended()).toBe(false);
+ video.player.dispatchEvent(new Event("ended"));
+ flush();
+ expect(video.ended()).toBe(true);
+ dispose();
+ }));
+
+ it("ended resets to false on play", () =>
+ createRoot(async dispose => {
+ const video = createVideo(testUrl);
+ video.player.dispatchEvent(new Event("ended"));
+ flush();
+ expect(video.ended()).toBe(true);
+ video.setPlaying(true);
+ await tick();
+ flush();
+ expect(video.ended()).toBe(false);
+ dispose();
+ }));
+
+ it("seeking is false initially, true during seek, false after seeked", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.seeking()).toBe(false);
+ video.player.dispatchEvent(new Event("seeking"));
+ flush();
+ expect(video.seeking()).toBe(true);
+ video.player.dispatchEvent(new Event("seeked"));
+ flush();
+ expect(video.seeking()).toBe(false);
+ dispose();
+ }));
+
+ it("error is null initially, set on error event, cleared on loadstart", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(video.error()).toBeNull();
+ const mediaError = { code: 2, message: "MEDIA_ERR_NETWORK" } as MediaError;
+ video.player._mock.error = mediaError;
+ video.player.dispatchEvent(new Event("error"));
+ flush();
+ expect(video.error()).toBe(mediaError);
+ video.player.dispatchEvent(new Event("loadstart"));
+ flush();
+ expect(video.error()).toBeNull();
+ dispose();
+ }));
+
+ it("duration throws before load, returns number after", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl);
+ expect(() => video.duration()).toThrow();
+ video.player._mock._load(video.player);
+ flush();
+ expect(typeof video.duration()).toBe("number");
dispose();
}));
- it("setVolume updates volume signal via volumechange event", () =>
+ it("applies options to the player element", () =>
+ createRoot(dispose => {
+ const video = createVideo(testUrl, { muted: true, loop: true });
+ expect(video.player.muted).toBe(true);
+ expect(video.player.loop).toBe(true);
+ dispose();
+ }));
+
+ it("src accessor change updates player source", () =>
createRoot(async dispose => {
+ const [src, setSrc] = createSignal("track1.mp4", { ownedWrite: true });
+ const video = createVideo(src);
+ expect(video.player.src).toMatch(/track1\.mp4$/);
+ setSrc("track2.mp4");
+ await tick();
+ flush();
+ expect(video.player.src).toMatch(/track2\.mp4$/);
+ dispose();
+ }));
+
+ it("dispose pauses the player", () =>
+ createRoot(dispose => {
const video = createVideo(testUrl);
+ dispose();
+ expect(video.player._mock.paused).toBe(true);
+ }));
+});
+
+// ── createVideoPlayer ───────────────────────────────────────────────────────
+
+describe("createVideoPlayer", () => {
+ it("includes all VideoReturn fields plus controls fields", () =>
+ createRoot(dispose => {
+ const video = createVideoPlayer(testUrl);
+ // base fields
+ expect(typeof video.playing).toBe("function");
+ expect(typeof video.setPlaying).toBe("function");
+ expect(typeof video.currentTime).toBe("function");
+ expect(typeof video.seek).toBe("function");
+ expect(typeof video.ended).toBe("function");
+ expect(typeof video.seeking).toBe("function");
+ expect(typeof video.error).toBe("function");
+ expect(typeof video.duration).toBe("function");
+ // controls fields
+ expect(typeof video.volume).toBe("function");
+ expect(typeof video.setVolume).toBe("function");
+ expect(typeof video.muted).toBe("function");
+ expect(typeof video.setMuted).toBe("function");
+ expect(typeof video.playbackRate).toBe("function");
+ expect(typeof video.setPlaybackRate).toBe("function");
+ expect(typeof video.loop).toBe("function");
+ expect(typeof video.setLoop).toBe("function");
+ expect(typeof video.buffered).toBe("function");
+ expect(typeof video.readyState).toBe("function");
+ expect(typeof video.videoWidth).toBe("function");
+ expect(typeof video.videoHeight).toBe("function");
+ expect(video.player).toBeInstanceOf(HTMLVideoElement);
+ dispose();
+ }));
+
+ it("initial volume is 1", () =>
+ createRoot(dispose => {
+ const video = createVideoPlayer(testUrl);
+ expect(video.volume()).toBe(1);
+ dispose();
+ }));
+
+ it("setVolume updates signal via volumechange event", () =>
+ createRoot(async dispose => {
+ const video = createVideoPlayer(testUrl);
video.setVolume(0.4);
flush();
await tick();
@@ -203,14 +300,14 @@ describe("createVideo", () => {
it("initial muted is false", () =>
createRoot(dispose => {
- const video = createVideo(testUrl);
+ const video = createVideoPlayer(testUrl);
expect(video.muted()).toBe(false);
dispose();
}));
- it("setMuted(true) updates muted signal via volumechange event", () =>
+ it("setMuted updates signal via volumechange event", () =>
createRoot(async dispose => {
- const video = createVideo(testUrl);
+ const video = createVideoPlayer(testUrl);
video.setMuted(true);
flush();
await tick();
@@ -220,14 +317,14 @@ describe("createVideo", () => {
it("initial playbackRate is 1", () =>
createRoot(dispose => {
- const video = createVideo(testUrl);
+ const video = createVideoPlayer(testUrl);
expect(video.playbackRate()).toBe(1);
dispose();
}));
it("setPlaybackRate updates signal via ratechange event", () =>
createRoot(async dispose => {
- const video = createVideo(testUrl);
+ const video = createVideoPlayer(testUrl);
video.setPlaybackRate(2);
flush();
await tick();
@@ -235,32 +332,26 @@ describe("createVideo", () => {
dispose();
}));
- it("ended is false initially and true after ended event", () =>
+ it("initial loop is false", () =>
createRoot(dispose => {
- const video = createVideo(testUrl);
- expect(video.ended()).toBe(false);
- video.player.dispatchEvent(new Event("ended"));
- flush();
- expect(video.ended()).toBe(true);
+ const video = createVideoPlayer(testUrl);
+ expect(video.loop()).toBe(false);
dispose();
}));
- it("ended resets to false on play", () =>
- createRoot(async dispose => {
- const video = createVideo(testUrl);
- video.player.dispatchEvent(new Event("ended"));
- flush();
- expect(video.ended()).toBe(true);
- video.setPlaying(true);
- await tick();
+ it("setLoop updates both player.loop and signal (no DOM event)", () =>
+ createRoot(dispose => {
+ const video = createVideoPlayer(testUrl);
+ video.setLoop(true);
+ expect(video.player.loop).toBe(true); // DOM update is synchronous
flush();
- expect(video.ended()).toBe(false);
+ expect(video.loop()).toBe(true); // signal update needs flush
dispose();
}));
it("readyState updates when video loads", () =>
createRoot(dispose => {
- const video = createVideo(testUrl);
+ const video = createVideoPlayer(testUrl);
expect(video.readyState()).toBe(0);
video.player._mock._load(video.player);
flush();
@@ -270,7 +361,7 @@ describe("createVideo", () => {
it("videoWidth and videoHeight update after metadata loads", () =>
createRoot(dispose => {
- const video = createVideo(testUrl);
+ const video = createVideoPlayer(testUrl);
expect(video.videoWidth()).toBe(0);
expect(video.videoHeight()).toBe(0);
video.player._mock._load(video.player);
@@ -280,67 +371,57 @@ describe("createVideo", () => {
dispose();
}));
- it("duration throws NotReadyError before load, returns number after", () =>
+ it("buffered is undefined initially, defined after progress event", () =>
createRoot(dispose => {
- const video = createVideo(testUrl);
- expect(() => video.duration(), "should throw before loadeddata").toThrow();
- video.player._mock._load(video.player);
+ const video = createVideoPlayer(testUrl);
+ expect(video.buffered()).toBeUndefined();
+ video.player.dispatchEvent(new Event("progress"));
flush();
- expect(typeof video.duration()).toBe("number");
+ expect(video.buffered()).toBeDefined();
dispose();
}));
- it("fullscreen signal reflects fullscreen state", () =>
- createRoot(async dispose => {
- const video = createVideo(testUrl);
- expect(video.fullscreen()).toBe(false);
- await video.requestFullscreen();
- flush();
- expect(video.fullscreen()).toBe(true);
- await video.exitFullscreen();
- flush();
- expect(video.fullscreen()).toBe(false);
+ it("VideoControlsOptions sets initial volume and playbackRate", () =>
+ createRoot(dispose => {
+ const video = createVideoPlayer(testUrl, { volume: 0.3, playbackRate: 1.5 });
+ expect(video.volume()).toBe(0.3);
+ expect(video.playbackRate()).toBe(1.5);
dispose();
}));
- it("toggleFullscreen enters and exits fullscreen", () =>
- createRoot(async dispose => {
- const video = createVideo(testUrl);
- await video.toggleFullscreen();
- flush();
- expect(video.fullscreen()).toBe(true);
- await video.toggleFullscreen();
- flush();
- expect(video.fullscreen()).toBe(false);
+ it("VideoControlsOptions sets initial muted and loop via VideoOptions", () =>
+ createRoot(dispose => {
+ const video = createVideoPlayer(testUrl, { muted: true, loop: true });
+ expect(video.muted()).toBe(true);
+ expect(video.loop()).toBe(true);
dispose();
}));
- it("src signal change updates player source and seeks to 0", () =>
- createRoot(async dispose => {
- const [src, setSrc] = createSignal("track1.mp4", { ownedWrite: true });
- const video = createVideo(src);
- expect(video.player.src).toMatch(/track1\.mp4$/);
- setSrc("track2.mp4");
- await tick();
+ it("inherits seeking state from createVideo", () =>
+ createRoot(dispose => {
+ const video = createVideoPlayer(testUrl);
+ expect(video.seeking()).toBe(false);
+ video.player.dispatchEvent(new Event("seeking"));
flush();
- expect(video.player.src).toMatch(/track2\.mp4$/);
- expect(video.player.currentTime).toBe(0);
+ expect(video.seeking()).toBe(true);
dispose();
}));
- it("buffered is undefined initially, populated after progress event", () =>
+ it("inherits error state from createVideo", () =>
createRoot(dispose => {
- const video = createVideo(testUrl);
- expect(video.buffered()).toBeUndefined();
- video.player.dispatchEvent(new Event("progress"));
+ const video = createVideoPlayer(testUrl);
+ expect(video.error()).toBeNull();
+ const mediaError = { code: 2, message: "MEDIA_ERR_NETWORK" } as MediaError;
+ video.player._mock.error = mediaError;
+ video.player.dispatchEvent(new Event("error"));
flush();
- expect(video.buffered()).toBeDefined();
+ expect(video.error()).toBe(mediaError);
dispose();
}));
- it("cleanup via dispose pauses the player", () =>
+ it("dispose pauses the player", () =>
createRoot(dispose => {
- const video = createVideo(testUrl);
+ const video = createVideoPlayer(testUrl);
dispose();
expect(video.player._mock.paused).toBe(true);
}));
diff --git a/packages/video/test/server.test.ts b/packages/video/test/server.test.ts
index 0d4da4980..7edb09639 100644
--- a/packages/video/test/server.test.ts
+++ b/packages/video/test/server.test.ts
@@ -1,16 +1,16 @@
import { NotReadyError } from "solid-js";
import { describe, expect, it } from "vitest";
-import { makeVideo, makeVideoPlayer, createVideo } from "../src/index.js";
+import { makeVideo, makeVideoPlayer, createVideo, createVideoPlayer } from "../src/index.js";
describe("API works in SSR", () => {
- it("makeVideo() - SSR returns stub player and noop cleanup", () => {
+ it("makeVideo() returns stub player and noop cleanup", () => {
const [player, cleanup] = makeVideo("https://example.com/clip.mp4");
expect(player).toBeDefined();
expect(cleanup).toBeInstanceOf(Function);
expect(() => cleanup()).not.toThrow();
});
- it("makeVideoPlayer() - SSR returns stub controls and noop cleanup", () => {
+ it("makeVideoPlayer() returns stub controls and noop cleanup", () => {
const [controls, cleanup] = makeVideoPlayer("https://example.com/clip.mp4");
expect(controls.play).toBeInstanceOf(Function);
expect(controls.pause).toBeInstanceOf(Function);
@@ -18,37 +18,50 @@ describe("API works in SSR", () => {
expect(controls.setVolume).toBeInstanceOf(Function);
expect(controls.setMuted).toBeInstanceOf(Function);
expect(controls.setPlaybackRate).toBeInstanceOf(Function);
- expect(controls.requestFullscreen).toBeInstanceOf(Function);
- expect(controls.exitFullscreen).toBeInstanceOf(Function);
- expect(controls.toggleFullscreen).toBeInstanceOf(Function);
+ expect(controls.setLoop).toBeInstanceOf(Function);
expect(() => cleanup()).not.toThrow();
});
- it("createVideo() - SSR returns safe stubs with correct initial values", () => {
+ it("createVideo() returns safe stubs with correct initial values", () => {
const video = createVideo("https://example.com/clip.mp4");
expect(video.playing()).toBe(false);
+ expect(video.currentTime()).toBe(0);
+ expect(video.ended()).toBe(false);
+ expect(video.seeking()).toBe(false);
+ expect(video.error()).toBeNull();
+ expect(video.setPlaying).toBeInstanceOf(Function);
+ expect(video.seek).toBeInstanceOf(Function);
+ });
+
+ it("createVideo() duration throws NotReadyError on server", () => {
+ const video = createVideo("https://example.com/clip.mp4");
+ expect(() => video.duration()).toThrow(NotReadyError);
+ });
+
+ it("createVideoPlayer() returns safe stubs with correct initial values", () => {
+ const video = createVideoPlayer("https://example.com/clip.mp4");
+ expect(video.playing()).toBe(false);
+ expect(video.currentTime()).toBe(0);
+ expect(video.ended()).toBe(false);
+ expect(video.seeking()).toBe(false);
+ expect(video.error()).toBeNull();
expect(video.volume()).toBe(1);
expect(video.muted()).toBe(false);
expect(video.playbackRate()).toBe(1);
- expect(video.currentTime()).toBe(0);
- expect(video.ended()).toBe(false);
+ expect(video.loop()).toBe(false);
expect(video.buffered()).toBeUndefined();
expect(video.readyState()).toBe(0);
expect(video.videoWidth()).toBe(0);
expect(video.videoHeight()).toBe(0);
- expect(video.fullscreen()).toBe(false);
expect(video.setPlaying).toBeInstanceOf(Function);
expect(video.setVolume).toBeInstanceOf(Function);
expect(video.setMuted).toBeInstanceOf(Function);
expect(video.setPlaybackRate).toBeInstanceOf(Function);
- expect(video.seek).toBeInstanceOf(Function);
- expect(video.requestFullscreen).toBeInstanceOf(Function);
- expect(video.exitFullscreen).toBeInstanceOf(Function);
- expect(video.toggleFullscreen).toBeInstanceOf(Function);
+ expect(video.setLoop).toBeInstanceOf(Function);
});
- it("createVideo() - SSR duration throws NotReadyError (integrates with )", () => {
- const video = createVideo("https://example.com/clip.mp4");
+ it("createVideoPlayer() duration throws NotReadyError on server", () => {
+ const video = createVideoPlayer("https://example.com/clip.mp4");
expect(() => video.duration()).toThrow(NotReadyError);
});
});
diff --git a/packages/video/test/setup.ts b/packages/video/test/setup.ts
index 5133f78f7..2cf14b770 100644
--- a/packages/video/test/setup.ts
+++ b/packages/video/test/setup.ts
@@ -10,9 +10,11 @@ interface VideoMock {
volume: number;
muted: boolean;
playbackRate: number;
+ loop: boolean;
readyState: number;
videoWidth: number;
videoHeight: number;
+ error: MediaError | null;
_loaded: boolean;
_load: (video: HTMLVideoElement) => void;
_resetMock: (video: HTMLVideoElement) => void;
@@ -27,9 +29,11 @@ const createMockState = (): VideoMock => ({
volume: 1,
muted: false,
playbackRate: 1,
+ loop: false,
readyState: 0,
videoWidth: 0,
videoHeight: 0,
+ error: null,
_loaded: false,
_load(video: HTMLVideoElement) {
// Update mock values before dispatching events so listeners read correct state.
@@ -70,23 +74,17 @@ Object.defineProperty(global.HTMLVideoElement.prototype, "_mock", {
});
Object.defineProperty(global.HTMLVideoElement.prototype, "paused", {
- get(this: HTMLVideoElement) {
- return this._mock.paused;
- },
+ get(this: HTMLVideoElement) { return this._mock.paused; },
configurable: true,
});
Object.defineProperty(global.HTMLVideoElement.prototype, "duration", {
- get(this: HTMLVideoElement) {
- return this._mock.duration;
- },
+ get(this: HTMLVideoElement) { return this._mock.duration; },
configurable: true,
});
Object.defineProperty(global.HTMLVideoElement.prototype, "volume", {
- get(this: HTMLVideoElement) {
- return this._mock.volume;
- },
+ get(this: HTMLVideoElement) { return this._mock.volume; },
set(this: HTMLVideoElement, value: number) {
this._mock.volume = value;
this.dispatchEvent(new Event("volumechange"));
@@ -95,9 +93,7 @@ Object.defineProperty(global.HTMLVideoElement.prototype, "volume", {
});
Object.defineProperty(global.HTMLVideoElement.prototype, "muted", {
- get(this: HTMLVideoElement) {
- return this._mock.muted;
- },
+ get(this: HTMLVideoElement) { return this._mock.muted; },
set(this: HTMLVideoElement, value: boolean) {
this._mock.muted = value;
this.dispatchEvent(new Event("volumechange"));
@@ -106,9 +102,7 @@ Object.defineProperty(global.HTMLVideoElement.prototype, "muted", {
});
Object.defineProperty(global.HTMLVideoElement.prototype, "playbackRate", {
- get(this: HTMLVideoElement) {
- return this._mock.playbackRate;
- },
+ get(this: HTMLVideoElement) { return this._mock.playbackRate; },
set(this: HTMLVideoElement, value: number) {
this._mock.playbackRate = value;
this.dispatchEvent(new Event("ratechange"));
@@ -116,24 +110,29 @@ Object.defineProperty(global.HTMLVideoElement.prototype, "playbackRate", {
configurable: true,
});
+Object.defineProperty(global.HTMLVideoElement.prototype, "loop", {
+ get(this: HTMLVideoElement) { return this._mock.loop; },
+ set(this: HTMLVideoElement, value: boolean) { this._mock.loop = value; },
+ configurable: true,
+});
+
Object.defineProperty(global.HTMLVideoElement.prototype, "readyState", {
- get(this: HTMLVideoElement) {
- return this._mock.readyState;
- },
+ get(this: HTMLVideoElement) { return this._mock.readyState; },
configurable: true,
});
Object.defineProperty(global.HTMLVideoElement.prototype, "videoWidth", {
- get(this: HTMLVideoElement) {
- return this._mock.videoWidth;
- },
+ get(this: HTMLVideoElement) { return this._mock.videoWidth; },
configurable: true,
});
Object.defineProperty(global.HTMLVideoElement.prototype, "videoHeight", {
- get(this: HTMLVideoElement) {
- return this._mock.videoHeight;
- },
+ get(this: HTMLVideoElement) { return this._mock.videoHeight; },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "error", {
+ get(this: HTMLVideoElement) { return this._mock.error; },
configurable: true,
});
@@ -151,26 +150,4 @@ global.HTMLVideoElement.prototype.pause = function pauseMock(this: HTMLVideoElem
this._mock.paused = true;
};
-// Fullscreen API mock — jsdom does not implement the Fullscreen API
-let _fullscreenElement: Element | null = null;
-
-Object.defineProperty(global.document, "fullscreenElement", {
- get() {
- return _fullscreenElement;
- },
- configurable: true,
-});
-
-global.HTMLVideoElement.prototype.requestFullscreen = async function requestFullscreenMock(
- this: HTMLVideoElement,
-) {
- _fullscreenElement = this;
- document.dispatchEvent(new Event("fullscreenchange"));
-};
-
-global.document.exitFullscreen = async function exitFullscreenMock() {
- _fullscreenElement = null;
- document.dispatchEvent(new Event("fullscreenchange"));
-};
-
export {};
From 0e60b65b0c0d88b65ac6b12025efc471623219f2 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 24 May 2026 21:21:05 -0400
Subject: [PATCH 3/9] Correction documentation
---
.changeset/video-initial.md | 4 ++--
packages/video/src/createVideo.ts | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.changeset/video-initial.md b/.changeset/video-initial.md
index 1074eac75..2a7e4f22c 100644
--- a/.changeset/video-initial.md
+++ b/.changeset/video-initial.md
@@ -12,7 +12,7 @@ Non-reactive base. Creates an `HTMLVideoElement` with optional event handlers an
### `makeVideoPlayer`
-Wraps `makeVideo` with imperative controls: `play`, `pause`, `seek`, `setVolume`, `setMuted`, `setPlaybackRate`, `setLoop`, and fullscreen (`requestFullscreen`, `exitFullscreen`, `toggleFullscreen`).
+Wraps `makeVideo` with imperative controls: `play`, `pause`, `seek`, `setVolume`, `setMuted`, `setPlaybackRate`, and `setLoop`.
### `createVideo`
@@ -20,7 +20,7 @@ Reactive primitive covering essential playback state: `playing`/`setPlaying`, `c
### `createVideoPlayer`
-Extends `createVideo` with the full control surface: `volume`/`setVolume`, `muted`/`setMuted`, `playbackRate`/`setPlaybackRate`, `loop`/`setLoop`, `buffered`, `readyState`, `videoWidth`, `videoHeight`, and `fullscreen`/fullscreen controls. Accepts `VideoControlsOptions` which adds `volume` and `playbackRate` initial values to `VideoOptions`.
+Extends `createVideo` with the full control surface: `volume`/`setVolume`, `muted`/`setMuted`, `playbackRate`/`setPlaybackRate`, `loop`/`setLoop`, `buffered`, `readyState`, `videoWidth`, and `videoHeight`. Accepts `VideoControlsOptions` which adds `volume` and `playbackRate` initial values to `VideoOptions`.
### Design notes
diff --git a/packages/video/src/createVideo.ts b/packages/video/src/createVideo.ts
index 7cbade36e..51a50f4fc 100644
--- a/packages/video/src/createVideo.ts
+++ b/packages/video/src/createVideo.ts
@@ -90,7 +90,7 @@ export const createVideo = (
setRawDuration(NOT_SET);
setError(null);
},
- loadeddata: () => setRawDuration(player.duration),
+ loadedmetadata: () => setRawDuration(player.duration),
});
const duration = (): number => {
From 8e1b69e72562bf00313ead79a2517c1474d0bf23 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 24 May 2026 21:22:30 -0400
Subject: [PATCH 4/9] Oops use correct example
---
packages/video/dev/index.tsx | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/packages/video/dev/index.tsx b/packages/video/dev/index.tsx
index 95baff22a..09f525954 100644
--- a/packages/video/dev/index.tsx
+++ b/packages/video/dev/index.tsx
@@ -1,12 +1,12 @@
import { type Component, createSignal } from "solid-js";
-import { createVideo } from "../src/index.js";
+import { createVideoPlayer } from "../src/index.js";
const DEMO_URL =
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
const App: Component = () => {
const [src, setSrc] = createSignal(DEMO_URL);
- const video = createVideo(src);
+ const video = createVideoPlayer(src);
return (
@@ -28,9 +28,7 @@ const App: Component = () => {
video.setMuted(!video.muted())}>
{video.muted() ? "Unmute" : "Mute"}
- video.toggleFullscreen()}>
- {video.fullscreen() ? "Exit Fullscreen" : "Fullscreen"}
-
+
From 60556cbadeff136e7ee4f2a670d65bc069ebee58 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 24 May 2026 21:23:48 -0400
Subject: [PATCH 5/9] Better documentation
---
packages/video/src/createVideo.ts | 4 ++--
packages/video/src/createVideoPlayer.ts | 10 ++++++----
packages/video/src/make.ts | 6 ++++--
packages/video/src/types.ts | 6 +++---
4 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/packages/video/src/createVideo.ts b/packages/video/src/createVideo.ts
index 51a50f4fc..64ebdf1d6 100644
--- a/packages/video/src/createVideo.ts
+++ b/packages/video/src/createVideo.ts
@@ -13,8 +13,8 @@ const NOT_SET: unique symbol = Symbol();
* Returns `playing`, `currentTime`, `ended`, `seeking`, `error`, and a `duration`
* that throws `NotReadyError` until metadata loads (integrates with `
`).
*
- * For volume, muted, playback rate, fullscreen, and buffering state use
- * `createVideoControls`.
+ * For volume, muted, playback rate, buffering, and dimensions use
+ * `createVideoPlayer`.
*
* @param src Video URL, MediaProvider, or a reactive accessor returning either
* @param options Initial element configuration
diff --git a/packages/video/src/createVideoPlayer.ts b/packages/video/src/createVideoPlayer.ts
index c0a52e27e..5778db6ed 100644
--- a/packages/video/src/createVideoPlayer.ts
+++ b/packages/video/src/createVideoPlayer.ts
@@ -10,14 +10,14 @@ import type { Accessor } from "solid-js";
* A reactive video primitive with full media controls.
*
* Extends `createVideo` with `volume`, `muted`, `playbackRate`, `loop`,
- * `buffered`, `readyState`, `videoWidth`, `videoHeight`, and fullscreen state.
+ * `buffered`, `readyState`, `videoWidth`, and `videoHeight`.
*
* @param src Video URL, MediaProvider, or a reactive accessor returning either
* @param options Initial element configuration including volume and playback rate
*
* @example
* ```ts
- * const video = createVideoControls('clip.mp4', { muted: true, volume: 0.8 });
+ * const video = createVideoPlayer('clip.mp4', { muted: true, volume: 0.8 });
* video.playing() // boolean
* video.volume() // 0–1
* video.setVolume(0.5)
@@ -27,8 +27,10 @@ import type { Accessor } from "solid-js";
* video.setPlaybackRate(1.5)
* video.loop() // boolean
* video.setLoop(true)
- * video.fullscreen() // boolean
- * video.requestFullscreen()
+ * video.buffered() // TimeRanges | undefined
+ * video.readyState() // 0–4
+ * video.videoWidth() // number
+ * video.videoHeight() // number
* ```
*/
export const createVideoPlayer = (
diff --git a/packages/video/src/make.ts b/packages/video/src/make.ts
index 02a845028..0d3cb6e74 100644
--- a/packages/video/src/make.ts
+++ b/packages/video/src/make.ts
@@ -63,8 +63,10 @@ export const makeVideo = (
};
/**
- * Wraps `makeVideo` with playback and fullscreen controls.
- * Non-reactive — no Solid owner required. Returns a cleanup function.
+ * Wraps `makeVideo` with playback controls and exposes `player` for external
+ * fullscreen handling. Fullscreen must be implemented by the consumer (e.g. via
+ * `@solid-primitives/fullscreen`). Non-reactive — no Solid owner required.
+ * Returns a cleanup function.
*
* @param src Video URL, MediaProvider, or existing HTMLVideoElement
* @param handlers Event handlers to bind against the player
diff --git a/packages/video/src/types.ts b/packages/video/src/types.ts
index f356320db..10685583f 100644
--- a/packages/video/src/types.ts
+++ b/packages/video/src/types.ts
@@ -48,14 +48,14 @@ export type VideoReturn = {
/** `MediaError` if the element has encountered a media error, otherwise `null`. */
error: Accessor;
/**
- * Throws `NotReadyError` until video metadata has loaded (integrates with
- * ``). After the first `loadeddata` event returns the duration in
+ * Throws `NotReadyError` until metadata has loaded (integrates with
+ * ``). After `loadedmetadata` fires, returns the duration in
* seconds reactively. Resets to pending whenever the source changes.
*/
duration: Accessor;
};
-/** Return type of `createVideoControls` — extends `VideoReturn` with full media controls. */
+/** Return type of `createVideoPlayer` — extends `VideoReturn` with full media controls. */
export type VideoControlsReturn = VideoReturn & {
volume: Accessor;
setVolume: (v: number) => void;
From 8d8e5fdcabba17cc17d9caca0f0565f0094f489f Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 24 May 2026 21:25:39 -0400
Subject: [PATCH 6/9] Moved make file primitives into their related reactive
primitives
---
packages/video/src/createVideo.ts | 63 +++++++++++-
packages/video/src/createVideoPlayer.ts | 77 +++++++++++++-
packages/video/src/index.ts | 5 +-
packages/video/src/make.ts | 129 ------------------------
4 files changed, 138 insertions(+), 136 deletions(-)
delete mode 100644 packages/video/src/make.ts
diff --git a/packages/video/src/createVideo.ts b/packages/video/src/createVideo.ts
index 64ebdf1d6..7ad77fff5 100644
--- a/packages/video/src/createVideo.ts
+++ b/packages/video/src/createVideo.ts
@@ -2,11 +2,70 @@ import { type Accessor, createEffect, createSignal, NotReadyError, onCleanup } f
import { isServer } from "@solidjs/web";
import { access, INTERNAL_OPTIONS, noop } from "@solid-primitives/utils";
import { createEventListenerMap } from "@solid-primitives/event-listener";
-import { makeVideo, setVideoSrc } from "./make.js";
-import type { VideoOptions, VideoReturn, VideoSource } from "./types.js";
+import type { VideoEventHandlers, VideoOptions, VideoReturn, VideoSource } from "./types.js";
const NOT_SET: unique symbol = Symbol();
+export function setVideoSrc(el: HTMLVideoElement, src: VideoSource): void {
+ if (typeof src === "string") {
+ el.src = src;
+ } else {
+ el.srcObject = (src as MediaProvider | null) ?? null;
+ }
+}
+
+function unwrapSource(src: VideoSource | HTMLVideoElement): HTMLVideoElement {
+ if (src instanceof HTMLVideoElement) return src;
+ const player = document.createElement("video");
+ setVideoSrc(player, src);
+ return player;
+}
+
+function applyOptions(player: HTMLVideoElement, options: VideoOptions): void {
+ if (options.autoPlay !== undefined) player.autoplay = options.autoPlay;
+ if (options.loop !== undefined) player.loop = options.loop;
+ if (options.muted !== undefined) player.muted = options.muted;
+ if (options.preload !== undefined) player.preload = options.preload;
+}
+
+/**
+ * Creates a raw `HTMLVideoElement` with optional event handlers and initial options.
+ * Non-reactive — no Solid owner required. Returns a cleanup function.
+ *
+ * @param src Video URL, MediaProvider, or existing HTMLVideoElement
+ * @param handlers Event handlers to bind against the player
+ * @param options Initial element configuration (autoPlay, loop, muted, preload)
+ * @returns Tuple of `[player, cleanup]`
+ *
+ * @example
+ * ```ts
+ * const [player, cleanup] = makeVideo('clip.mp4', {}, { muted: true });
+ * ```
+ */
+export const makeVideo = (
+ src: VideoSource | HTMLVideoElement,
+ handlers: VideoEventHandlers = {},
+ options?: VideoOptions,
+): [player: HTMLVideoElement, cleanup: VoidFunction] => {
+ if (isServer) return [{} as HTMLVideoElement, noop];
+
+ const player = unwrapSource(src);
+ if (options) applyOptions(player, options);
+
+ for (const [name, handler] of Object.entries(handlers)) {
+ player.addEventListener(name, handler as EventListener);
+ }
+
+ const cleanup = () => {
+ player.pause();
+ for (const [name, handler] of Object.entries(handlers)) {
+ player.removeEventListener(name, handler as EventListener);
+ }
+ };
+
+ return [player, cleanup];
+};
+
/**
* A reactive video primitive with essential playback state.
*
diff --git a/packages/video/src/createVideoPlayer.ts b/packages/video/src/createVideoPlayer.ts
index 5778db6ed..394ce7f4b 100644
--- a/packages/video/src/createVideoPlayer.ts
+++ b/packages/video/src/createVideoPlayer.ts
@@ -2,10 +2,83 @@ import { createSignal, NotReadyError } from "solid-js";
import { isServer } from "@solidjs/web";
import { INTERNAL_OPTIONS, noop } from "@solid-primitives/utils";
import { createEventListenerMap } from "@solid-primitives/event-listener";
-import { createVideo } from "./createVideo.js";
-import type { VideoControlsOptions, VideoControlsReturn, VideoSource } from "./types.js";
+import { createVideo, makeVideo } from "./createVideo.js";
+import type {
+ VideoControls,
+ VideoControlsOptions,
+ VideoControlsReturn,
+ VideoEventHandlers,
+ VideoOptions,
+ VideoSource,
+} from "./types.js";
import type { Accessor } from "solid-js";
+/**
+ * Wraps `makeVideo` with playback controls and exposes `player` for external
+ * fullscreen handling. Fullscreen must be implemented by the consumer (e.g. via
+ * `@solid-primitives/fullscreen`). Non-reactive — no Solid owner required.
+ * Returns a cleanup function.
+ *
+ * @param src Video URL, MediaProvider, or existing HTMLVideoElement
+ * @param handlers Event handlers to bind against the player
+ * @param options Initial element configuration
+ * @returns Tuple of `[controls, cleanup]`
+ *
+ * @example
+ * ```ts
+ * const [{ play, pause, seek }, cleanup] = makeVideoPlayer('clip.mp4');
+ * ```
+ */
+export const makeVideoPlayer = (
+ src: VideoSource | HTMLVideoElement,
+ handlers: VideoEventHandlers = {},
+ options?: VideoOptions,
+): [controls: VideoControls, cleanup: VoidFunction] => {
+ if (isServer) {
+ return [
+ {
+ play: async () => noop(),
+ pause: noop,
+ seek: noop,
+ setVolume: noop,
+ setMuted: noop,
+ setPlaybackRate: noop,
+ setLoop: noop,
+ player: {} as HTMLVideoElement,
+ },
+ noop,
+ ];
+ }
+
+ const [player, cleanup] = makeVideo(src, handlers, options);
+
+ const controls: VideoControls = {
+ player,
+ play: () => player.play(),
+ pause: () => player.pause(),
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ seek: player.fastSeek
+ ? (time: number) => player.fastSeek(time)
+ : (time: number) => {
+ player.currentTime = time;
+ },
+ setVolume: (volume: number) => {
+ player.volume = volume;
+ },
+ setMuted: (muted: boolean) => {
+ player.muted = muted;
+ },
+ setPlaybackRate: (rate: number) => {
+ player.playbackRate = rate;
+ },
+ setLoop: (loop: boolean) => {
+ player.loop = loop;
+ },
+ };
+
+ return [controls, cleanup];
+};
+
/**
* A reactive video primitive with full media controls.
*
diff --git a/packages/video/src/index.ts b/packages/video/src/index.ts
index 4b49ae1b2..9ea7e54aa 100644
--- a/packages/video/src/index.ts
+++ b/packages/video/src/index.ts
@@ -8,6 +8,5 @@ export type {
VideoControlsReturn,
} from "./types.js";
-export { makeVideo, makeVideoPlayer } from "./make.js";
-export { createVideo } from "./createVideo.js";
-export { createVideoPlayer } from "./createVideoPlayer.js";
+export { makeVideo, setVideoSrc, createVideo } from "./createVideo.js";
+export { makeVideoPlayer, createVideoPlayer } from "./createVideoPlayer.js";
diff --git a/packages/video/src/make.ts b/packages/video/src/make.ts
deleted file mode 100644
index 0d3cb6e74..000000000
--- a/packages/video/src/make.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { isServer } from "@solidjs/web";
-import { noop } from "@solid-primitives/utils";
-import type { VideoControls, VideoEventHandlers, VideoOptions, VideoSource } from "./types.js";
-
-export function setVideoSrc(el: HTMLVideoElement, src: VideoSource): void {
- if (typeof src === "string") {
- el.src = src;
- } else {
- el.srcObject = (src as MediaProvider | null) ?? null;
- }
-}
-
-function unwrapSource(src: VideoSource | HTMLVideoElement): HTMLVideoElement {
- if (src instanceof HTMLVideoElement) return src;
- const player = document.createElement("video");
- setVideoSrc(player, src);
- return player;
-}
-
-function applyOptions(player: HTMLVideoElement, options: VideoOptions): void {
- if (options.autoPlay !== undefined) player.autoplay = options.autoPlay;
- if (options.loop !== undefined) player.loop = options.loop;
- if (options.muted !== undefined) player.muted = options.muted;
- if (options.preload !== undefined) player.preload = options.preload;
-}
-
-/**
- * Creates a raw `HTMLVideoElement` with optional event handlers and initial options.
- * Non-reactive — no Solid owner required. Returns a cleanup function.
- *
- * @param src Video URL, MediaProvider, or existing HTMLVideoElement
- * @param handlers Event handlers to bind against the player
- * @param options Initial element configuration (autoPlay, loop, muted, preload)
- * @returns Tuple of `[player, cleanup]`
- *
- * @example
- * ```ts
- * const [player, cleanup] = makeVideo('clip.mp4', {}, { muted: true });
- * ```
- */
-export const makeVideo = (
- src: VideoSource | HTMLVideoElement,
- handlers: VideoEventHandlers = {},
- options?: VideoOptions,
-): [player: HTMLVideoElement, cleanup: VoidFunction] => {
- if (isServer) return [{} as HTMLVideoElement, noop];
-
- const player = unwrapSource(src);
- if (options) applyOptions(player, options);
-
- for (const [name, handler] of Object.entries(handlers)) {
- player.addEventListener(name, handler as EventListener);
- }
-
- const cleanup = () => {
- player.pause();
- for (const [name, handler] of Object.entries(handlers)) {
- player.removeEventListener(name, handler as EventListener);
- }
- };
-
- return [player, cleanup];
-};
-
-/**
- * Wraps `makeVideo` with playback controls and exposes `player` for external
- * fullscreen handling. Fullscreen must be implemented by the consumer (e.g. via
- * `@solid-primitives/fullscreen`). Non-reactive — no Solid owner required.
- * Returns a cleanup function.
- *
- * @param src Video URL, MediaProvider, or existing HTMLVideoElement
- * @param handlers Event handlers to bind against the player
- * @param options Initial element configuration
- * @returns Tuple of `[controls, cleanup]`
- *
- * @example
- * ```ts
- * const [{ play, pause, seek }, cleanup] = makeVideoPlayer('clip.mp4');
- * ```
- */
-export const makeVideoPlayer = (
- src: VideoSource | HTMLVideoElement,
- handlers: VideoEventHandlers = {},
- options?: VideoOptions,
-): [controls: VideoControls, cleanup: VoidFunction] => {
- if (isServer) {
- return [
- {
- play: async () => noop(),
- pause: noop,
- seek: noop,
- setVolume: noop,
- setMuted: noop,
- setPlaybackRate: noop,
- setLoop: noop,
- player: {} as HTMLVideoElement,
- },
- noop,
- ];
- }
-
- const [player, cleanup] = makeVideo(src, handlers, options);
-
- const controls: VideoControls = {
- player,
- play: () => player.play(),
- pause: () => player.pause(),
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- seek: player.fastSeek
- ? (time: number) => player.fastSeek(time)
- : (time: number) => {
- player.currentTime = time;
- },
- setVolume: (volume: number) => {
- player.volume = volume;
- },
- setMuted: (muted: boolean) => {
- player.muted = muted;
- },
- setPlaybackRate: (rate: number) => {
- player.playbackRate = rate;
- },
- setLoop: (loop: boolean) => {
- player.loop = loop;
- },
- };
-
- return [controls, cleanup];
-};
From afa505e52aae4092591a1e4c181a45182404f1ae Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 24 May 2026 21:27:22 -0400
Subject: [PATCH 7/9] Add source reset
---
packages/video/src/createVideo.ts | 2 ++
packages/video/test/index.test.ts | 23 ++++++++++++++++++++++-
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/packages/video/src/createVideo.ts b/packages/video/src/createVideo.ts
index 7ad77fff5..db6152972 100644
--- a/packages/video/src/createVideo.ts
+++ b/packages/video/src/createVideo.ts
@@ -8,8 +8,10 @@ const NOT_SET: unique symbol = Symbol();
export function setVideoSrc(el: HTMLVideoElement, src: VideoSource): void {
if (typeof src === "string") {
+ el.srcObject = null;
el.src = src;
} else {
+ el.src = "";
el.srcObject = (src as MediaProvider | null) ?? null;
}
}
diff --git a/packages/video/test/index.test.ts b/packages/video/test/index.test.ts
index 9d3f02e70..fcd555650 100644
--- a/packages/video/test/index.test.ts
+++ b/packages/video/test/index.test.ts
@@ -1,13 +1,34 @@
import "./setup";
import { createRoot, createSignal, flush } from "solid-js";
import { describe, expect, it } from "vitest";
-import { makeVideo, makeVideoPlayer, createVideo, createVideoPlayer } from "../src/index.js";
+import { makeVideo, makeVideoPlayer, createVideo, createVideoPlayer, setVideoSrc } from "../src/index.js";
const testUrl = "https://example.com/clip.mp4";
/** Yield to the microtask queue — used alongside flush() to drain Solid 2.0 effects. */
const tick = () => Promise.resolve();
+// ── setVideoSrc ───────────────────────────────────────────────────────────────
+
+describe("setVideoSrc", () => {
+ it("sets src and nulls srcObject when given a string", () => {
+ const el = document.createElement("video") as HTMLVideoElement;
+ el.srcObject = {} as MediaProvider;
+ setVideoSrc(el, "clip.mp4");
+ expect(el.srcObject).toBeNull();
+ expect(el.src).toMatch(/clip\.mp4$/);
+ });
+
+ it("sets srcObject and clears src when given a MediaProvider", () => {
+ const el = document.createElement("video") as HTMLVideoElement;
+ el.src = "clip.mp4";
+ const provider = {} as MediaProvider;
+ setVideoSrc(el, provider);
+ expect(el.srcObject).toBe(provider);
+ expect(el.src).not.toMatch(/clip\.mp4$/);
+ });
+});
+
// ── makeVideo ─────────────────────────────────────────────────────────────────
describe("makeVideo", () => {
From 0510607d3e2c912ba94249616c4171a71ca33ff8 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Sun, 24 May 2026 21:28:54 -0400
Subject: [PATCH 8/9] Added tests for currentTime, src, srcObject, and buffered
---
packages/video/test/setup.ts | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/packages/video/test/setup.ts b/packages/video/test/setup.ts
index 2cf14b770..3b83e6f55 100644
--- a/packages/video/test/setup.ts
+++ b/packages/video/test/setup.ts
@@ -6,7 +6,11 @@ declare global {
interface VideoMock {
paused: boolean;
+ currentTime: number;
duration: number;
+ src: string;
+ srcObject: MediaProvider | null;
+ buffered: TimeRanges;
volume: number;
muted: boolean;
playbackRate: number;
@@ -25,7 +29,11 @@ interface VideoMock {
const createMockState = (): VideoMock => ({
paused: true,
+ currentTime: 0,
duration: NaN,
+ src: "",
+ srcObject: null,
+ buffered: { length: 0, start: () => 0, end: () => 0 } as unknown as TimeRanges,
volume: 1,
muted: false,
playbackRate: 1,
@@ -136,6 +144,29 @@ Object.defineProperty(global.HTMLVideoElement.prototype, "error", {
configurable: true,
});
+Object.defineProperty(global.HTMLVideoElement.prototype, "currentTime", {
+ get(this: HTMLVideoElement) { return this._mock.currentTime; },
+ set(this: HTMLVideoElement, value: number) { this._mock.currentTime = value; },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "src", {
+ get(this: HTMLVideoElement) { return this._mock.src; },
+ set(this: HTMLVideoElement, value: string) { this._mock.src = value; },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "srcObject", {
+ get(this: HTMLVideoElement) { return this._mock.srcObject; },
+ set(this: HTMLVideoElement, value: MediaProvider | null) { this._mock.srcObject = value; },
+ configurable: true,
+});
+
+Object.defineProperty(global.HTMLVideoElement.prototype, "buffered", {
+ get(this: HTMLVideoElement) { return this._mock.buffered; },
+ configurable: true,
+});
+
global.HTMLVideoElement.prototype.play = async function playMock(this: HTMLVideoElement) {
if (!this._mock._loaded) {
this._mock._load(this);
From 1903a489bc66bc77496e5ba35c18aeb9389c3af5 Mon Sep 17 00:00:00 2001
From: David Di Biase <1168397+davedbase@users.noreply.github.com>
Date: Wed, 27 May 2026 14:22:32 -0400
Subject: [PATCH 9/9] Added video stories
---
.storybook/main.ts | 1 +
assets/video/big_buck_bunny_sall.mp4 | Bin 0 -> 302137 bytes
.../video/stories/createVideo.stories.tsx | 281 ++++++++
.../stories/createVideoPlayer.stories.tsx | 226 ++++++
.../video/stories/makeVideoPlayer.stories.tsx | 107 +++
pnpm-lock.yaml | 682 +++++++++++-------
6 files changed, 1047 insertions(+), 250 deletions(-)
create mode 100644 assets/video/big_buck_bunny_sall.mp4
create mode 100644 packages/video/stories/createVideo.stories.tsx
create mode 100644 packages/video/stories/createVideoPlayer.stories.tsx
create mode 100644 packages/video/stories/makeVideoPlayer.stories.tsx
diff --git a/.storybook/main.ts b/.storybook/main.ts
index 0453258d1..1cb6eb661 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -6,6 +6,7 @@ const config: StorybookConfig = {
staticDirs: [
{ from: "../packages/audio/stories/assets", to: "/audio" },
{ from: "../assets/img", to: "/img" },
+ { from: "../assets/video", to: "/video" },
{ from: "../node_modules/geist/dist/fonts", to: "/geist-fonts" },
],
addons: ["@storybook/addon-docs"],
diff --git a/assets/video/big_buck_bunny_sall.mp4 b/assets/video/big_buck_bunny_sall.mp4
new file mode 100644
index 0000000000000000000000000000000000000000..57f72e53d716ae4751e5d71c8010735aeb018fab
GIT binary patch
literal 302137
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