From 21acc8ad73800fa60b3d1cbc218478132bd9f4db Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 23 May 2026 11:38:10 -0400 Subject: [PATCH 1/3] Migrate to Solid 2 beta 14 --- .changeset/mediastream-solid2-migration.md | 19 ++ packages/mediastream/CHANGELOG.md | 16 ++ packages/mediastream/README.md | 154 +++++++++++ packages/mediastream/dev/index.tsx | 61 +++++ packages/mediastream/package.json | 72 +++++ packages/mediastream/src/index.ts | 296 +++++++++++++++++++++ packages/mediastream/test/index.test.ts | 106 ++++++++ packages/mediastream/test/server.test.ts | 25 ++ packages/mediastream/test/setup.ts | 64 +++++ packages/mediastream/tsconfig.json | 16 ++ pnpm-lock.yaml | 13 + 11 files changed, 842 insertions(+) create mode 100644 .changeset/mediastream-solid2-migration.md create mode 100644 packages/mediastream/CHANGELOG.md create mode 100644 packages/mediastream/README.md create mode 100644 packages/mediastream/dev/index.tsx create mode 100644 packages/mediastream/package.json create mode 100644 packages/mediastream/src/index.ts create mode 100644 packages/mediastream/test/index.test.ts create mode 100644 packages/mediastream/test/server.test.ts create mode 100644 packages/mediastream/test/setup.ts create mode 100644 packages/mediastream/tsconfig.json diff --git a/.changeset/mediastream-solid2-migration.md b/.changeset/mediastream-solid2-migration.md new file mode 100644 index 000000000..b48217d4f --- /dev/null +++ b/.changeset/mediastream-solid2-migration.md @@ -0,0 +1,19 @@ +--- +"@solid-primitives/mediastream": major +--- + +Initial release of `@solid-primitives/mediastream`, replacing `@solid-primitives/stream` for Solid.js v2. + +## Breaking Changes + +**Peer dependency**: `solid-js@^2.0.0-beta.14` and `@solidjs/web@^2.0.0-beta.14` are now required. + +### `@solid-primitives/mediastream` + +- `createStream` and `createScreen` now return `[Accessor, { stop, mute }]` instead of `[Resource, ResourceActions & { stop, mute }]`. The `mutate` and `refetch` controls are removed — source reactivity drives re-acquisition automatically. +- `createAmplitudeStream` second element is now `{ stream, stop }` (no `mutate` / `refetch`). +- `ResourceActions` type export removed; no longer depends on `createResource`. +- Loading state: use `` from `@solidjs/web` to handle the pending state. +- Error state: use `` from `@solidjs/web` to handle stream acquisition errors. +- `isServer` imported internally from `@solidjs/web` (not `solid-js/web`). +- All `createEffect` calls use the Solid 2.0 split compute/apply form. diff --git a/packages/mediastream/CHANGELOG.md b/packages/mediastream/CHANGELOG.md new file mode 100644 index 000000000..91ca6b9d9 --- /dev/null +++ b/packages/mediastream/CHANGELOG.md @@ -0,0 +1,16 @@ +# @solid-primitives/mediastream + +## 0.1.0 + +### Major Changes + +Initial release as `@solid-primitives/mediastream`, replacing `@solid-primitives/stream` with full Solid.js v2 compatibility. + +- Migrated from `createResource` to reactive signals + split `createEffect` for async stream acquisition +- `isServer` imported from `@solidjs/web` +- `createStream` / `createScreen`: return type is now `[Accessor, { stop, mute }]` — `mutate` and `refetch` removed +- `createAmplitudeStream`: second element simplified to `{ stream, stop }` +- `createAmplitudeFromStream`: split `createEffect` for stream-to-analyser binding +- Loading state handled via `` from `@solidjs/web`; error state via `` +- Added `getDisplayMedia` mock support in test setup +- Race conditions between concurrent getUserMedia calls handled with `active` flag diff --git a/packages/mediastream/README.md b/packages/mediastream/README.md new file mode 100644 index 000000000..47c4d1dd1 --- /dev/null +++ b/packages/mediastream/README.md @@ -0,0 +1,154 @@ +

+ Solid Primitives Mediastream +

+ +# @solid-primitives/mediastream + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/mediastream?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/mediastream) +[![size](https://img.shields.io/npm/v/@solid-primitives/mediastream?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/mediastream) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +Reactive primitives for working with [MediaStream](https://developer.mozilla.org/en-US/docs/Web/API/MediaStream) — microphones, cameras, and screen capture. + +## Installation + +```bash +npm install @solid-primitives/mediastream +# or +pnpm add @solid-primitives/mediastream +``` + +## Primitives + +### `createStream` + +Creates a reactive accessor for a `MediaStream` from a camera or microphone. + +```ts +const [stream, { mute, stop }] = createStream(streamSource); +``` + +**Parameters** + +- `streamSource` — `MediaDeviceInfo | MediaStreamConstraints | Accessor<...> | FalsyValue` + +**Returns** + +- `stream()` — `Accessor` — the current stream (undefined while loading or stopped) +- `mute(muted?)` — mutes the stream; pass `false` to unmute +- `stop()` — stops the stream immediately + +The stream stops automatically when the reactive owner is disposed. Wrap in `` to handle the loading state: + +```tsx +import { Loading } from "@solidjs/web"; + +const [stream] = createStream({ video: true }); + +Requesting camera...

}> +
+``` + +### `createScreen` + +Creates a reactive accessor for a display capture stream (screen, window, or browser tab). + +```ts +const [stream, { mute, stop }] = createScreen(screenSource); +``` + +Same interface as `createStream` but uses `getDisplayMedia` instead of `getUserMedia`. + +```tsx +const [stream] = createScreen({ video: true }); + +Requesting screen capture...

}> +
+``` + +### `createAmplitudeStream` + +Creates a reactive signal with the RMS amplitude (0–100) from a microphone device. + +```ts +const [amplitude, { stream, stop }] = createAmplitudeStream(streamSource?); +``` + +**Parameters** + +- `streamSource?` — same as `createStream` (optional) + +**Returns** + +- `amplitude()` — `Accessor` — value between 0 and 100 +- `stream` — `Accessor` — the underlying stream +- `stop()` — stops the amplitude measurement and underlying stream + +```tsx +const [audioConstraints, setAudioConstraints] = createSignal(); +const [level] = createAmplitudeStream(audioConstraints); + + setAudioConstraints({ audio: true })}>Start} +> + + +``` + +### `createAmplitudeFromStream` + +Creates an amplitude signal from an existing stream accessor. + +```ts +const [amplitude, stop] = createAmplitudeFromStream(stream); +``` + +**Parameters** + +- `stream` — `MaybeAccessor` + +**Returns** + +- `amplitude()` — `Accessor` — value between 0 and 100 +- `stop()` — stops the amplitude measurement + +### `createMediaPermissionRequest` + +Requests media permissions from the user by briefly opening then immediately stopping a stream. + +```ts +createMediaPermissionRequest(source?); +``` + +**Parameters** + +- `source?` — `'audio' | 'video' | MediaStreamConstraints` — defaults to both audio and video + +Returns a `Promise` that resolves once the permission prompt is handled. + +```ts +// Request both microphone and camera permissions +await createMediaPermissionRequest(); + +// Request only microphone permission +await createMediaPermissionRequest('audio'); +``` + +Use `createPermission` from `@solid-primitives/permission` to reactively observe the resulting permission state. + +## Breaking Changes from `@solid-primitives/stream` + +This package replaces `@solid-primitives/stream` with full Solid.js v2 compatibility. + +- **`createStream` / `createScreen`**: return type changed from `[Resource, ResourceActions & { stop, mute }]` to `[Accessor, { stop, mute }]`. The `mutate` and `refetch` controls are removed; reactivity is driven by the source accessor directly. +- **`createAmplitudeStream`**: return type simplified — second element is now `{ stream, stop }` (no `mutate` / `refetch`). +- **`isServer`**: now imported from `@solidjs/web` internally. +- **Async loading state**: use `` from `@solidjs/web` instead of inspecting `stream.loading`. +- **Error handling**: wrap in `` from `@solidjs/web` instead of inspecting `stream.error`. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/mediastream/dev/index.tsx b/packages/mediastream/dev/index.tsx new file mode 100644 index 000000000..9f604d1d8 --- /dev/null +++ b/packages/mediastream/dev/index.tsx @@ -0,0 +1,61 @@ +import type { Component } from "solid-js"; +import { createSignal, Show } from "solid-js"; +import type { JSX } from "@solidjs/web"; + +import { + createStream, + createAmplitudeStream, + createMediaPermissionRequest, + createScreen, +} from "../src/index.js"; + +declare module "@solidjs/web" { + namespace JSX { + interface ExplicitProperties { + srcObject?: MediaStream; + } + } +} + +export type E = JSX.Element; + +const App: Component = () => { + createMediaPermissionRequest(); + const [video] = createStream({ video: true }); + const [audioConstraints, setAudioConstraints] = createSignal(); + const [level] = createAmplitudeStream(audioConstraints); + const [screenConstraints, setScreenConstraints] = createSignal(); + const [screen] = createScreen(screenConstraints); + return ( +
+
+

TestVideo

+
+
+ ); +}; + +export default App; diff --git a/packages/mediastream/package.json b/packages/mediastream/package.json new file mode 100644 index 000000000..e9d01ac6b --- /dev/null +++ b/packages/mediastream/package.json @@ -0,0 +1,72 @@ +{ + "name": "@solid-primitives/mediastream", + "version": "0.1.0", + "description": "Primitives to work with media streams from microphones, cameras, and the screen", + "author": "Alex Lohr ", + "contributors": [ + "David Di Biase " + ], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/mediastream", + "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": "mediastream", + "stage": 0, + "list": [ + "createStream", + "createAmplitudeStream", + "createAmplitudeFromStream", + "createMediaPermissionRequest", + "createScreen" + ], + "category": "Browser APIs" + }, + "files": [ + "dist" + ], + "private": false, + "sideEffects": false, + "type": "module", + "module": "./dist/index.js", + "browser": {}, + "types": "./dist/index.d.ts", + "exports": { + "import": { + "@solid-primitives/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "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" + }, + "keywords": [ + "solid", + "mediastream", + "media", + "stream", + "primitives" + ], + "dependencies": { + "@solid-primitives/utils": "workspace:^" + }, + "peerDependencies": { + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" + }, + "typesVersions": {}, + "devDependencies": { + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" + } +} diff --git a/packages/mediastream/src/index.ts b/packages/mediastream/src/index.ts new file mode 100644 index 000000000..454a34eb3 --- /dev/null +++ b/packages/mediastream/src/index.ts @@ -0,0 +1,296 @@ +import { + type Accessor, + createEffect, + createMemo, + createSignal, + onCleanup, + untrack, +} from "solid-js"; +import { isServer } from "@solidjs/web"; +import { + access, + noop, + INTERNAL_OPTIONS, + type FalsyValue, + type MaybeAccessor, +} from "@solid-primitives/utils"; + +const constraintsFromDevice = ( + device?: MediaDeviceInfo | MediaStreamConstraints, +): MediaStreamConstraints | undefined => { + return device && "deviceId" in device + ? { + [device.kind === "videoinput" ? "video" : "audio"]: { + deviceId: { exact: device.deviceId }, + }, + } + : device; +}; + +const stopStream = (stream: MediaStream | undefined) => + stream?.getTracks().forEach(track => track.stop()); + +const muteStream = (stream: MediaStream | undefined, muted?: boolean) => + stream?.getTracks().forEach(track => { + track.enabled = muted === false; + }); + +export type StreamSourceDescription = + | MediaDeviceInfo + | MediaStreamConstraints + | Accessor + | FalsyValue; + +export type StreamControls = { + /** stop the stream */ + stop: () => void; + /** if called with false, unmute, otherwise mute the stream */ + mute: (muted?: boolean) => void; +}; + +export type StreamReturn = [stream: Accessor, controls: StreamControls]; + +/** + * Creates a reactive wrapper to get media streams from devices. + * ```typescript + * const [stream, { mute, stop }] = createStream(streamSource); + * ``` + * @param streamSource MediaDeviceInfo | MediaStreamConstraints | FalsyValue | Accessor<...> + * @returns `stream()` — accessor to the current MediaStream (undefined while loading or stopped) + * @method `mute` mutes the stream, or unmutes when called with `false` + * @method `stop` stops the stream immediately + * + * Wrap in `` to handle the async pending state. The stream stops automatically on cleanup. + */ +export const createStream = (streamSource: StreamSourceDescription): StreamReturn => { + if (isServer) { + return [() => undefined, { stop: noop, mute: noop }]; + } + + const [stream, setStream] = createSignal(undefined, INTERNAL_OPTIONS); + + const constraints = createMemo(() => + constraintsFromDevice(access(streamSource) || undefined), + ); + + createEffect( + () => constraints(), + c => { + let active = true; + stopStream(untrack(stream)); + + if (c) { + navigator.mediaDevices.getUserMedia(c).then(s => { + if (active) setStream(s); + else stopStream(s); + }); + } else { + setStream(undefined); + } + + return () => { + active = false; + }; + }, + ); + + onCleanup(() => stopStream(untrack(stream))); + + return [ + stream, + { + mute: (muted?: boolean) => muteStream(untrack(stream), muted), + stop: () => stopStream(untrack(stream)), + }, + ]; +}; + +/** + * Creates a reactive signal with the RMS amplitude (0–100) from a microphone stream. + * ```typescript + * const [amplitude, stop] = createAmplitudeFromStream(stream); + * ``` + * @param stream MaybeAccessor + * @returns `amplitude()` — number between 0 and 100 + * @returns `stop()` — stop the amplitude measurement + * + * The amplitude measurement stops automatically on cleanup. + */ +export const createAmplitudeFromStream = ( + stream: MaybeAccessor, +): [amplitude: Accessor, stop: () => void] => { + if (isServer) { + return [() => 0, noop]; + } + + const [amplitude, setAmplitude] = createSignal(0, INTERNAL_OPTIONS); + const ctx = new AudioContext(); + const analyser = ctx.createAnalyser(); + Object.assign(analyser, { + fftSize: 128, + minDecibels: -60, + maxDecibels: -10, + smoothingTimeConstant: 0.8, + }); + + let source: MediaStreamAudioSourceNode | undefined; + + createEffect( + () => access(stream), + currentStream => { + if (currentStream !== undefined) { + ctx.resume(); + source?.disconnect(); + source = ctx.createMediaStreamSource(currentStream); + source.connect(analyser); + } + }, + ); + + const buffer = new Uint8Array(analyser.frequencyBinCount); + const read = () => { + analyser.getByteFrequencyData(buffer); + const rootMeanSquare = + Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length) << 2; + setAmplitude(rootMeanSquare > 100 ? 100 : rootMeanSquare); + }; + + let rafId: number; + const loop = () => { + rafId = requestAnimationFrame(loop); + read(); + }; + loop(); + + const stop = () => { + source?.disconnect(); + if (ctx.state !== "closed") { + ctx.close(); + } + }; + + onCleanup(() => cancelAnimationFrame(rafId)); + onCleanup(stop); + + return [amplitude, stop]; +}; + +/** + * Creates a reactive signal with the RMS amplitude (0–100) from a microphone device. + * ```typescript + * const [amplitude, { stream, stop }] = createAmplitudeStream(streamSource); + * ``` + * @param streamSource MediaDeviceInfo | MediaStreamConstraints | FalsyValue | Accessor<...> + * @returns `amplitude()` — number between 0 and 100 + * @property `stream` — accessor to the underlying MediaStream + * @method `stop` — stop the stream and amplitude measurement + * + * The stream stops automatically on cleanup. + */ +export const createAmplitudeStream = ( + streamSource?: StreamSourceDescription, +): [ + amplitude: Accessor, + controls: { + stream: Accessor; + stop: () => void; + }, +] => { + const [stream, streamControls] = createStream(streamSource); + const [amplitude, amplitudeStop] = createAmplitudeFromStream(stream); + + const teardown = () => { + amplitudeStop(); + streamControls.stop(); + }; + onCleanup(teardown); + + return [amplitude, { stream, stop: teardown }]; +}; + +declare global { + interface DisplayMediaStreamConstraints { + audio?: boolean | MediaTrackConstraints; + video?: boolean | MediaTrackConstraints; + } +} + +/** + * Creates a reactive wrapper to capture a display media stream (screen/window/tab). + * ```typescript + * const [stream, { mute, stop }] = createScreen(screenSource); + * ``` + * @param screenSource DisplayMediaStreamConstraints | undefined | Accessor<...> + * @returns `stream()` — accessor to the current display MediaStream (undefined while loading or stopped) + * @method `mute` mutes the stream, or unmutes when called with `false` + * @method `stop` stops the stream immediately + * + * Wrap in `` to handle the async pending state. The stream stops automatically on cleanup. + */ +export const createScreen = ( + screenSource: MaybeAccessor, +): StreamReturn => { + if (isServer) { + return [() => undefined, { stop: noop, mute: noop }]; + } + + const [stream, setStream] = createSignal(undefined, INTERNAL_OPTIONS); + + createEffect( + () => access(screenSource), + constraints => { + let active = true; + stopStream(untrack(stream)); + + if (constraints) { + navigator.mediaDevices.getDisplayMedia(constraints).then(s => { + if (active) setStream(s); + else stopStream(s); + }); + } else { + setStream(undefined); + } + + return () => { + active = false; + }; + }, + ); + + onCleanup(() => stopStream(untrack(stream))); + + return [ + stream, + { + mute: (muted?: boolean) => muteStream(untrack(stream), muted), + stop: () => stopStream(untrack(stream)), + }, + ]; +}; + +/** + * Requests media permissions from the user by opening and immediately stopping a stream. + * ```typescript + * createMediaPermissionRequest('audio'); + * ``` + * @param source MediaStreamConstraints | 'audio' | 'video' | undefined + * + * If no source is given, both microphone and camera permissions will be requested. + * Read the resulting permissions with `createPermission` from `@solid-primitives/permission`. + */ +export const createMediaPermissionRequest = ( + source?: MediaStreamConstraints | "audio" | "video", +): Promise => { + if (isServer) { + return Promise.resolve(); + } + return navigator.mediaDevices + .getUserMedia( + source + ? typeof source === "string" + ? { [source]: true } + : source + : { audio: true, video: true }, + ) + .then(stream => stopStream(stream)); +}; diff --git a/packages/mediastream/test/index.test.ts b/packages/mediastream/test/index.test.ts new file mode 100644 index 000000000..838457f07 --- /dev/null +++ b/packages/mediastream/test/index.test.ts @@ -0,0 +1,106 @@ +import "./setup"; +import { afterAll, describe, expect, it } from "vitest"; +import { createEffect, createRoot } from "solid-js"; +import { + createStream, + createAmplitudeStream, + createMediaPermissionRequest, + createScreen, +} from "../src/index.js"; + +describe("createStream", () => { + it("gets a stream", () => + new Promise(resolve => + createRoot(dispose => { + const [stream] = createStream({ video: true }); + createEffect( + () => stream(), + value => { + if (value === (window as any).__mockstream__) { + dispose(); + resolve(); + } + }, + ); + }), + )); + + it("returns undefined before stream is acquired", () => + createRoot(dispose => { + const [stream] = createStream({ video: true }); + expect(stream()).toBe(undefined); + dispose(); + })); +}); + +describe("createAmplitudeStream", () => { + it("gets an amplitude > 0", () => + new Promise(resolve => + createRoot(dispose => { + const mockDevice: MediaDeviceInfo = { + deviceId: "mock-device-id", + groupId: "mock-group-id", + label: "mock-device-label", + kind: "audioinput", + toJSON: function () { + return JSON.stringify(this); + }, + }; + const [amplitude] = createAmplitudeStream(mockDevice); + createEffect( + () => amplitude(), + value => { + if (value > 0) { + dispose(); + resolve(); + } + }, + ); + }), + )); +}); + +describe("createScreen", () => { + it("gets a screen stream", () => + new Promise(resolve => + createRoot(dispose => { + const [stream] = createScreen({ video: true }); + createEffect( + () => stream(), + value => { + if (value === (window as any).__mockscreen__) { + dispose(); + resolve(); + } + }, + ); + }), + )); +}); + +describe("createMediaPermissionRequest", () => { + const allConstraints: (MediaStreamConstraints | undefined)[] = []; + const originalGetUserMedia = navigator.mediaDevices.getUserMedia; + navigator.mediaDevices.getUserMedia = constraints => { + allConstraints.push(constraints); + return originalGetUserMedia(constraints); + }; + afterAll(() => { + navigator.mediaDevices.getUserMedia = originalGetUserMedia; + }); + + it("requests both audio and video by default", () => { + createMediaPermissionRequest(); + expect(allConstraints.at(-1)).toEqual({ audio: true, video: true }); + }); + + it("requests only audio", () => { + createMediaPermissionRequest("audio"); + expect(allConstraints.at(-1)).toEqual({ audio: true }); + }); + + it("requests only video", () => { + createMediaPermissionRequest("video"); + expect(allConstraints.at(-1)).toEqual({ video: true }); + }); +}); diff --git a/packages/mediastream/test/server.test.ts b/packages/mediastream/test/server.test.ts new file mode 100644 index 000000000..c496e0ad6 --- /dev/null +++ b/packages/mediastream/test/server.test.ts @@ -0,0 +1,25 @@ +import { describe, test, expect } from "vitest"; +import { + createStream, + createAmplitudeStream, + createMediaPermissionRequest, + createScreen, +} from "../src/index.js"; + +describe("API doesn't break in SSR", () => { + test("createStream() - SSR", () => { + expect(createStream({ audio: true })[0]()).toBe(undefined); + }); + + test("createAmplitudeStream() - SSR", () => { + expect(createAmplitudeStream({ audio: true })[0]()).toBe(0); + }); + + test("createMediaPermissionRequest() - SSR", () => { + expect(createMediaPermissionRequest()).toBeInstanceOf(Promise); + }); + + test("createScreen() - SSR", () => { + expect(createScreen({ audio: true })[0]()).toBe(undefined); + }); +}); diff --git a/packages/mediastream/test/setup.ts b/packages/mediastream/test/setup.ts new file mode 100644 index 000000000..7b9554dbf --- /dev/null +++ b/packages/mediastream/test/setup.ts @@ -0,0 +1,64 @@ +class AudioContextMock { + public state: AudioContextState = "running"; + + constructor(_options?: AudioContextOptions) {} + + resume() { + return Promise.resolve(); + } + + createAnalyser() { + return Object.assign( + { + frequencyBinCount: 128, + getByteFrequencyData: (array: Uint8Array) => { + array.set([...array].map(() => (Math.random() * 255) | 0)); + }, + } as AnalyserNode, + { + connect: () => null, + disconnect: () => null, + }, + ); + } + + createMediaStreamSource(mediaStream: MediaStream) { + return Object.assign( + { + mediaStream, + } as MediaStreamAudioSourceNode, + { connect: () => null, disconnect: () => null }, + ); + } + + close() { + if (this.state === "closed") { + return Promise.reject(new Error("Closing an already closed AudioContext")); + } + this.state = "closed"; + return Promise.resolve(); + } +} +(window as any).AudioContext ??= AudioContextMock; +(globalThis as any).AudioContext ??= AudioContextMock; + +(window as any).__mockstream__ = Object.assign(new EventTarget(), { + getTracks: () => [], + getVideoTracks: () => [], + getAudioTracks: () => [], +}); + +(window as any).__mockscreen__ = Object.assign(new EventTarget(), { + getTracks: () => [], + getVideoTracks: () => [], + getAudioTracks: () => [], +}); + +(navigator as any).mediaDevices = { + getUserMedia: (_constraints: MediaStreamConstraints) => { + return Promise.resolve((window as any).__mockstream__ as MediaStream); + }, + getDisplayMedia: (_constraints: DisplayMediaStreamConstraints) => { + return Promise.resolve((window as any).__mockscreen__ as MediaStream); + }, +}; diff --git a/packages/mediastream/tsconfig.json b/packages/mediastream/tsconfig.json new file mode 100644 index 000000000..dc1970e16 --- /dev/null +++ b/packages/mediastream/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { + "path": "../utils" + } + ], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee1923c5f..6d3ad08fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -638,6 +638,19 @@ importers: specifier: 2.0.0-beta.14 version: 2.0.0-beta.14 + packages/mediastream: + dependencies: + '@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/memo: dependencies: '@solid-primitives/utils': From 1b05f757adaf8cff741965d10a447a7ca9859c09 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 23 May 2026 11:39:53 -0400 Subject: [PATCH 2/3] Minor adjustment --- packages/mediastream/README.md | 8 +++++++- packages/mediastream/package.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/mediastream/README.md b/packages/mediastream/README.md index 47c4d1dd1..18b30d25f 100644 --- a/packages/mediastream/README.md +++ b/packages/mediastream/README.md @@ -58,7 +58,11 @@ Creates a reactive accessor for a display capture stream (screen, window, or bro const [stream, { mute, stop }] = createScreen(screenSource); ``` -Same interface as `createStream` but uses `getDisplayMedia` instead of `getUserMedia`. +**Parameters** + +- `screenSource` — `DisplayMediaStreamConstraints | undefined | Accessor` + +Same controls as `createStream` but uses `getDisplayMedia` instead of `getUserMedia`. The stream stops automatically when the reactive owner is disposed. ```tsx const [stream] = createScreen({ video: true }); @@ -115,6 +119,8 @@ const [amplitude, stop] = createAmplitudeFromStream(stream); - `amplitude()` — `Accessor` — value between 0 and 100 - `stop()` — stops the amplitude measurement +The measurement stops automatically when the reactive owner is disposed. + ### `createMediaPermissionRequest` Requests media permissions from the user by briefly opening then immediately stopping a stream. diff --git a/packages/mediastream/package.json b/packages/mediastream/package.json index e9d01ac6b..579528bbc 100644 --- a/packages/mediastream/package.json +++ b/packages/mediastream/package.json @@ -4,7 +4,7 @@ "description": "Primitives to work with media streams from microphones, cameras, and the screen", "author": "Alex Lohr ", "contributors": [ - "David Di Biase " + "David Di Biase " ], "license": "MIT", "homepage": "https://primitives.solidjs.community/package/mediastream", From 875792c37b4b57bd5976e1a5980bac2c41913452 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 23 May 2026 11:58:48 -0400 Subject: [PATCH 3/3] Applied CR suggestions --- packages/mediastream/README.md | 2 +- packages/mediastream/src/index.ts | 14 +++++++++++--- packages/mediastream/test/setup.ts | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/mediastream/README.md b/packages/mediastream/README.md index 18b30d25f..aa9f3ebdf 100644 --- a/packages/mediastream/README.md +++ b/packages/mediastream/README.md @@ -133,7 +133,7 @@ createMediaPermissionRequest(source?); - `source?` — `'audio' | 'video' | MediaStreamConstraints` — defaults to both audio and video -Returns a `Promise` that resolves once the permission prompt is handled. +Returns a `Promise` that resolves when permission is granted, and rejects if the user denies permission or `navigator.mediaDevices.getUserMedia` fails for any other reason — forwarding the rejection directly from `getUserMedia`. ```ts // Request both microphone and camera permissions diff --git a/packages/mediastream/src/index.ts b/packages/mediastream/src/index.ts index 454a34eb3..53f41a67c 100644 --- a/packages/mediastream/src/index.ts +++ b/packages/mediastream/src/index.ts @@ -80,9 +80,13 @@ export const createStream = (streamSource: StreamSourceDescription): StreamRetur stopStream(untrack(stream)); if (c) { + setStream(undefined); navigator.mediaDevices.getUserMedia(c).then(s => { if (active) setStream(s); else stopStream(s); + }).catch((err: unknown) => { + if (active) setStream(undefined); + console.error(err); }); } else { setStream(undefined); @@ -100,7 +104,7 @@ export const createStream = (streamSource: StreamSourceDescription): StreamRetur stream, { mute: (muted?: boolean) => muteStream(untrack(stream), muted), - stop: () => stopStream(untrack(stream)), + stop: () => { stopStream(untrack(stream)); setStream(undefined); }, }, ]; }; @@ -163,13 +167,13 @@ export const createAmplitudeFromStream = ( loop(); const stop = () => { + cancelAnimationFrame(rafId); source?.disconnect(); if (ctx.state !== "closed") { ctx.close(); } }; - onCleanup(() => cancelAnimationFrame(rafId)); onCleanup(stop); return [amplitude, stop]; @@ -243,9 +247,13 @@ export const createScreen = ( stopStream(untrack(stream)); if (constraints) { + setStream(undefined); navigator.mediaDevices.getDisplayMedia(constraints).then(s => { if (active) setStream(s); else stopStream(s); + }).catch((err: unknown) => { + if (active) setStream(undefined); + console.error(err); }); } else { setStream(undefined); @@ -263,7 +271,7 @@ export const createScreen = ( stream, { mute: (muted?: boolean) => muteStream(untrack(stream), muted), - stop: () => stopStream(untrack(stream)), + stop: () => { stopStream(untrack(stream)); setStream(undefined); }, }, ]; }; diff --git a/packages/mediastream/test/setup.ts b/packages/mediastream/test/setup.ts index 7b9554dbf..6d8506dd6 100644 --- a/packages/mediastream/test/setup.ts +++ b/packages/mediastream/test/setup.ts @@ -12,7 +12,7 @@ class AudioContextMock { { frequencyBinCount: 128, getByteFrequencyData: (array: Uint8Array) => { - array.set([...array].map(() => (Math.random() * 255) | 0)); + array.fill(128); }, } as AnalyserNode, {