diff --git a/.changeset/devices-solid2-migration.md b/.changeset/devices-solid2-migration.md new file mode 100644 index 000000000..5e52ca5d3 --- /dev/null +++ b/.changeset/devices-solid2-migration.md @@ -0,0 +1,14 @@ +--- +"@solid-primitives/devices": major +--- + +Migrate to Solid.js v2.0 (beta.13). `createAccelerometer` and `createGyroscope` have been moved to the new `@solid-primitives/sensors` package. + +Breaking changes: +- `solid-js` peer dependency updated to `^2.0.0-beta.13` +- `@solidjs/web` is now a required peer dependency +- `createAccelerometer` removed — use `@solid-primitives/sensors` instead +- `createGyroscope` removed — use `@solid-primitives/sensors` instead +- `createMemo` initialValue arg removed (Solid 2.0 API change) +- `isServer` imported from `@solidjs/web` +- `createStore` imported from `solid-js` (not `solid-js/store`) diff --git a/.changeset/sensors-new-package.md b/.changeset/sensors-new-package.md new file mode 100644 index 000000000..deddd66a8 --- /dev/null +++ b/.changeset/sensors-new-package.md @@ -0,0 +1,10 @@ +--- +"@solid-primitives/sensors": minor +--- + +New package. Provides `makeAccelerometer`, `createAccelerometer`, `makeGyroscope`, and `createGyroscope` following the Solid Primitives `make*`/`create*` convention. + +- `makeAccelerometer(onChange, options?)` — raw event listener, returns cleanup, no Solid lifecycle +- `createAccelerometer(includeGravity?, interval?)` — reactive accessor backed by `devicemotion` events +- `makeGyroscope(onChange, options?)` — raw event listener, returns cleanup, no Solid lifecycle +- `createGyroscope(interval?)` — reactive store `{ alpha, beta, gamma }` backed by `deviceorientation` events diff --git a/packages/devices/README.md b/packages/devices/README.md index e0cba2bdd..d9337bdaa 100644 --- a/packages/devices/README.md +++ b/packages/devices/README.md @@ -8,47 +8,70 @@ [![size](https://img.shields.io/npm/v/@solid-primitives/devices?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/devices) [![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-3.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) -Creates a primitive to get a list of media devices (microphones, speakers, cameras). There are filtered primitives for convenience reasons. +Reactive primitives for enumerating and filtering media input/output devices (microphones, speakers, cameras). + +> **Looking for accelerometer or gyroscope?** Motion and orientation sensor primitives have moved to [`@solid-primitives/sensors`](../sensors/README.md). ## Installation ``` npm install @solid-primitives/devices # or -yarn add @solid-primitives/devices +pnpm add @solid-primitives/devices ``` ## How to use it -### Media Devices +### `createDevices` + +Returns a reactive accessor for the full list of [`MediaDeviceInfo`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo) objects available to the browser. The list updates automatically whenever devices are added or removed. ```ts +import { createDevices } from "@solid-primitives/devices"; + const devices = createDevices(); +// => Accessor + +createEffect(() => console.log(devices())); +``` + +### `createMicrophones` / `createSpeakers` / `createCameras` + +Filtered convenience primitives that each return an accessor for a specific device kind. Each one only re-runs downstream computations when a device of its own kind changes — devices of other kinds changing do not trigger updates. + +```ts +import { createMicrophones, createSpeakers, createCameras } from "@solid-primitives/devices"; const microphones = createMicrophones(); +// => Accessor (kind === "audioinput") + const speakers = createSpeakers(); +// => Accessor (kind === "audiooutput") + const cameras = createCameras(); +// => Accessor (kind === "videoinput") ``` -The filtered primitives are build so that they only triggered if the devices of their own kind changed. - -### Device Motion +## API ```ts -const accelerometer = createAccelerometer(); -const gyroscope = createGyroscope(); +function createDevices(): Accessor; + +function createMicrophones(): Accessor; +function createSpeakers(): Accessor; +function createCameras(): Accessor; ``` +All four primitives: +- Are SSR-safe — return an empty array on the server. +- Require no arguments. +- Subscribe to [`devicechange`](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/devicechange_event) events and clean up automatically via `onCleanup`. + ## Demo You may view a working example here: https://primitives.solidjs.community/playground/devices/ -## Reference - -`createAccelerometer` : [devicemotion event](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicemotion_event) -`createGyroscope` : [deviceorientation event](https://developer.mozilla.org/en-US/docs/Web/API/Window/deviceorientation_event) - ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/devices/package.json b/packages/devices/package.json index ffae54864..8a0a73075 100644 --- a/packages/devices/package.json +++ b/packages/devices/package.json @@ -1,7 +1,7 @@ { "name": "@solid-primitives/devices", - "version": "1.3.1", - "description": "Primitive that enumerates media devices", + "version": "2.0.0", + "description": "Reactive primitives for enumerating and filtering media input/output devices (microphones, speakers, cameras).", "author": "Alex Lohr ", "contributors": [ "Mohan " @@ -19,9 +19,7 @@ "createDevices", "createMicrophones", "createSpeakers", - "createCameras", - "createAccelerometer", - "createGyroscope" + "createCameras" ], "category": "Display & Media" }, @@ -55,10 +53,11 @@ ], "browser": {}, "peerDependencies": { - "solid-js": "^1.6.12" + "solid-js": "^2.0.0-beta.13" }, "typesVersions": {}, "devDependencies": { - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-beta.13", + "solid-js": "2.0.0-beta.13" } } diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index e6e8528ef..f69fffe79 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -1,6 +1,5 @@ -import { createMemo, createSignal, getOwner, onCleanup } from "solid-js"; -import { isServer } from "solid-js/web"; -import { createStore } from "solid-js/store"; +import { createMemo, createSignal, onCleanup } from "solid-js"; +import { isServer } from "@solidjs/web"; /** * Creates a list of all media devices @@ -13,11 +12,11 @@ import { createStore } from "solid-js/store"; */ export const createDevices = () => { if (isServer) { - return () => []; + return () => [] as MediaDeviceInfo[]; } const [devices, setDevices] = createSignal([]); const enumerate = () => { - navigator.mediaDevices.enumerateDevices().then(setDevices); + navigator.mediaDevices.enumerateDevices().then(d => setDevices(d as MediaDeviceInfo[])); }; enumerate(); navigator.mediaDevices.addEventListener("devicechange", enumerate); @@ -39,10 +38,10 @@ const equalDeviceLists = (prev: MediaDeviceInfo[], next: MediaDeviceInfo[]) => */ export const createMicrophones = () => { if (isServer) { - return () => []; + return () => [] as MediaDeviceInfo[]; } const devices = createDevices(); - return createMemo(() => devices().filter(device => device.kind === "audioinput"), [], { + return createMemo(() => devices().filter(device => device.kind === "audioinput"), { name: "microphones", equals: equalDeviceLists, }); @@ -59,10 +58,10 @@ export const createMicrophones = () => { */ export const createSpeakers = () => { if (isServer) { - return () => []; + return () => [] as MediaDeviceInfo[]; } const devices = createDevices(); - return createMemo(() => devices().filter(device => device.kind === "audiooutput"), [], { + return createMemo(() => devices().filter(device => device.kind === "audiooutput"), { name: "speakers", equals: equalDeviceLists, }); @@ -79,74 +78,11 @@ export const createSpeakers = () => { */ export const createCameras = () => { if (isServer) { - return () => []; + return () => [] as MediaDeviceInfo[]; } const devices = createDevices(); - return createMemo(() => devices().filter(device => device.kind === "videoinput"), [], { + return createMemo(() => devices().filter(device => device.kind === "videoinput"), { name: "cameras", equals: equalDeviceLists, }); }; - -/** - * Creates a reactive wrapper to get device acceleration - * @param includeGravity boolean. default value false - * @param interval number as ms. default value 100 - * @returnValue Acceleration: Accessor - */ -export const createAccelerometer = (includeGravity: boolean = false, interval: number = 100) => { - if (isServer) { - return () => ({ - x: 0, - y: 0, - z: 0, - }); - } - const [acceleration, setAcceleration] = createSignal(); - let throttled = false; - - const accelerationEvent = (e: DeviceMotionEvent) => { - if (throttled) return; - throttled = true; - setTimeout(() => { - throttled = false; - }, interval); - - const acceleration = includeGravity ? e.accelerationIncludingGravity : e.acceleration; - setAcceleration(acceleration ? acceleration : undefined); - }; - - addEventListener("devicemotion", accelerationEvent); - getOwner() && onCleanup(() => removeEventListener("devicemotion", accelerationEvent)); - return acceleration; -}; - -/** - * Creates a reactive wrapper to get device orientation - * @param interval number as ms. default value 100 - * @returnValue { alpha: 0, beta: 0, gamma: 0 } - */ -export const createGyroscope = (interval: number = 100) => { - if (isServer) { - return { alpha: 0, beta: 0, gamma: 0 }; - } - const [orientation, setOrientation] = createStore({ alpha: 0, beta: 0, gamma: 0 }); - let throttled = false; - - const orientationEvent = (e: DeviceOrientationEvent) => { - if (throttled) return; - throttled = true; - setTimeout(() => { - throttled = false; - }, interval); - setOrientation({ - alpha: e.alpha ? e.alpha : 0, - beta: e.beta ? e.beta : 0, - gamma: e.gamma ? e.gamma : 0, - }); - }; - - addEventListener("deviceorientation", orientationEvent); - getOwner() && onCleanup(() => removeEventListener("deviceorientation", orientationEvent)); - return orientation; -}; diff --git a/packages/devices/test/index.test.ts b/packages/devices/test/index.test.ts index 487e343e8..f77cffe1a 100644 --- a/packages/devices/test/index.test.ts +++ b/packages/devices/test/index.test.ts @@ -1,14 +1,7 @@ import "./setup"; import { describe, it, expect } from "vitest"; import { createEffect, createRoot } from "solid-js"; -import { - createDevices, - createMicrophones, - createSpeakers, - createCameras, - createAccelerometer, - createGyroscope, -} from "../src/index.js"; +import { createDevices, createMicrophones, createSpeakers, createCameras } from "../src/index.js"; let fakeDeviceCount = 0; const fakeDeviceInfo = (overrides: Partial = {}): MediaDeviceInfo => ({ @@ -37,14 +30,16 @@ describe("devices", () => { ]; setDevices(deviceFakes); const devices = createDevices(); - const expectedDevices = [[], deviceFakes]; - createEffect(() => { - expect(devices()).toEqual(expectedDevices.shift()); - if (expectedDevices.length === 0) { - dispose(); - resolve(); - } - }); + createEffect( + () => devices(), + val => { + if (val.length > 0) { + expect(val).toEqual(deviceFakes); + dispose(); + resolve(); + } + }, + ); }), )); @@ -59,14 +54,16 @@ describe("devices", () => { ]; setDevices(deviceFakes); const microphones = createMicrophones(); - const expectedDevices = [[], [deviceFakes[0]]]; - createEffect(() => { - expect(microphones()).toEqual(expectedDevices.shift()); - if (expectedDevices.length === 0) { - dispose(); - resolve(); - } - }); + createEffect( + () => microphones(), + val => { + if (val.length > 0) { + expect(val).toEqual([deviceFakes[0]]); + dispose(); + resolve(); + } + }, + ); }), )); @@ -81,14 +78,16 @@ describe("devices", () => { ]; setDevices(deviceFakes); const speakers = createSpeakers(); - const expectedDevices = [[], [deviceFakes[1]]]; - createEffect(() => { - expect(speakers()).toEqual(expectedDevices.shift()); - if (expectedDevices.length === 0) { - dispose(); - resolve(); - } - }); + createEffect( + () => speakers(), + val => { + if (val.length > 0) { + expect(val).toEqual([deviceFakes[1]]); + dispose(); + resolve(); + } + }, + ); }), )); @@ -103,14 +102,16 @@ describe("devices", () => { ]; setDevices(deviceFakes); const cameras = createCameras(); - const expectedDevices = [[], [deviceFakes[2]]]; - createEffect(() => { - expect(cameras()).toEqual(expectedDevices.shift()); - if (expectedDevices.length === 0) { - dispose(); - resolve(); - } - }); + createEffect( + () => cameras(), + val => { + if (val.length > 0) { + expect(val).toEqual([deviceFakes[2]]); + dispose(); + resolve(); + } + }, + ); }), )); @@ -125,74 +126,22 @@ describe("devices", () => { ]; setDevices(deviceFakes.slice(0, 1)); const devices = createDevices(); - const expectedDevices = [[], deviceFakes.slice(0, 1), deviceFakes]; - createEffect(() => { - expect(devices()).toEqual(expectedDevices.shift()); - if (expectedDevices.length === 1) { - setDevices(deviceFakes); - (navigator.mediaDevices as any).dispatchFakeEvent(); - // navigator.mediaDevices.dispatchEvent(new Event("devicechange")); - } - if (expectedDevices.length === 0) { - dispose(); - resolve(); - } - }); - }), - )); - - it("reads the accelerometer", () => { - const moveDevice = (acceleration?: { x: number; y: number; z: number }) => - dispatchEvent(new Event("deviceMotion", { acceleration } as any)); - createRoot( - dispose => - new Promise(resolve => { - const acceleration = createAccelerometer(false, 0); - const expectedAcceleration = [ - undefined, - { x: 0, y: 0, z: 0 }, - { x: 1, y: 0, z: 0 }, - { x: 0, y: 1, z: 0 }, - { x: 0, y: 0, z: 1 }, - ]; - moveDevice(expectedAcceleration[1]); - createEffect(() => { - expect(acceleration()).toEqual(expectedAcceleration.shift()); - if (expectedAcceleration.length === 0) { - dispose(); - resolve(); - } else { - moveDevice(expectedAcceleration[0]); - } - }); - }), - ); - }); - - it("reads the gyroscope", () => - createRoot( - dispose => - new Promise(resolve => { - const turnDevice = (orientation?: { alpha: number; beta: number; gamma: number }) => - dispatchEvent(Object.assign(new Event("deviceorientation", {}), orientation as any)); - const orientation = createGyroscope(0); - const expectedOrientations = [ - { alpha: 0, beta: 0, gamma: 0 }, - { alpha: 1, beta: 0, gamma: 1 }, - ]; - createEffect(() => { - expect({ - alpha: orientation.alpha, - beta: orientation.beta, - gamma: orientation.gamma, - }).toEqual(expectedOrientations.shift()); - if (expectedOrientations.length === 0) { - dispose(); - resolve(); - } else { - turnDevice(expectedOrientations[0]); - } - }); + let initialized = false; + createEffect( + () => devices(), + val => { + if (!initialized && val.length > 0) { + initialized = true; + expect(val).toEqual(deviceFakes.slice(0, 1)); + setDevices(deviceFakes); + (navigator.mediaDevices as any).dispatchFakeEvent(); + } else if (initialized && val.length === deviceFakes.length) { + expect(val).toEqual(deviceFakes); + dispose(); + resolve(); + } + }, + ); }), )); }); diff --git a/packages/devices/test/server.test.ts b/packages/devices/test/server.test.ts index 434740dc0..4f46aff6b 100644 --- a/packages/devices/test/server.test.ts +++ b/packages/devices/test/server.test.ts @@ -1,15 +1,7 @@ import { describe, test, expect } from "vitest"; -import { - createAccelerometer, - createCameras, - createDevices, - createGyroscope, - createMicrophones, - createSpeakers, -} from "../src/index.js"; +import { createCameras, createDevices, createMicrophones, createSpeakers } from "../src/index.js"; describe("API doesn't break in SSR", () => { - // check if the API doesn't throw when calling it in SSR test("createDevices() - SSR", () => { expect(createDevices()()).toEqual([]); }); @@ -25,12 +17,4 @@ describe("API doesn't break in SSR", () => { test("createCameras() - SSR", () => { expect(createCameras()()).toEqual([]); }); - - test("createAccelerometer() - SSR", () => { - expect(createAccelerometer()()).toEqual({ x: 0, y: 0, z: 0 }); - }); - - test("createGyroscope() - SSR", () => { - expect(createGyroscope()).toEqual({ alpha: 0, beta: 0, gamma: 0 }); - }); }); diff --git a/packages/sensors/CHANGELOG.md b/packages/sensors/CHANGELOG.md new file mode 100644 index 000000000..96cb6014c --- /dev/null +++ b/packages/sensors/CHANGELOG.md @@ -0,0 +1,7 @@ +# @solid-primitives/sensors + +## 0.1.0 + +### Minor Changes + +- Initial release. Extracted `createAccelerometer` and `createGyroscope` from `@solid-primitives/devices` and added proper `make*` / `create*` split following Solid Primitives naming conventions. diff --git a/packages/sensors/README.md b/packages/sensors/README.md new file mode 100644 index 000000000..e03102470 --- /dev/null +++ b/packages/sensors/README.md @@ -0,0 +1,351 @@ +

+ Solid Primitives Sensors +

+ +# @solid-primitives/sensors + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/sensors?style=for-the-badge)](https://bundlephobia.com/package/@solid-primitives/sensors) +[![size](https://img.shields.io/npm/v/@solid-primitives/sensors?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/sensors) +[![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 device motion, orientation, and hardware sensors using standard browser APIs. + +## Installation + +``` +npm install @solid-primitives/sensors +# or +pnpm add @solid-primitives/sensors +``` + +## Accelerometer + +Uses the [`DeviceMotionEvent`](https://developer.mozilla.org/en-US/docs/Web/API/DeviceMotionEvent) API. + +### `makeAccelerometer` + +Attaches a `devicemotion` event listener and calls `onChange` with the latest acceleration reading, throttled to at most once per `interval` milliseconds. + +```ts +import { makeAccelerometer } from "@solid-primitives/sensors"; + +const cleanup = makeAccelerometer( + acceleration => { + console.log(acceleration?.x, acceleration?.y, acceleration?.z); + }, + { includeGravity: false, interval: 100 }, +); + +// Later, stop listening: +cleanup(); +``` + +**Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `includeGravity` | `boolean` | `false` | When `true`, uses `accelerationIncludingGravity` instead of `acceleration` | +| `interval` | `number` | `100` | Minimum milliseconds between `onChange` calls | + +**Returns:** `VoidFunction` — call to remove the event listener. + +### `createAccelerometer` + +Reactive wrapper around `makeAccelerometer`. Returns a signal accessor that starts as `undefined` and updates to the latest `DeviceMotionEventAcceleration` reading on each throttled event. + +```ts +import { createAccelerometer } from "@solid-primitives/sensors"; + +function MyComponent() { + const acceleration = createAccelerometer(); + // acceleration() is AccelerometerReading | undefined + + return ( +
+ X: {acceleration()?.x ?? 0}, Y: {acceleration()?.y ?? 0}, Z: {acceleration()?.z ?? 0} +
+ ); +} +``` + +**Signature:** + +```ts +function createAccelerometer( + includeGravity?: boolean, // default: false + interval?: number, // default: 100ms +): Accessor; + +type AccelerometerReading = DeviceMotionEventAcceleration | null; +``` + +**SSR:** Returns `() => ({ x: 0, y: 0, z: 0 })` on the server — no event listeners are attached. + +## Gyroscope + +Uses the [`DeviceOrientationEvent`](https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent) API. + +### `makeGyroscope` + +Attaches a `deviceorientation` event listener and calls `onChange` with the latest orientation reading, throttled to at most once per `interval` milliseconds. `null` orientation values (common on some platforms) are coerced to `0`. + +```ts +import { makeGyroscope } from "@solid-primitives/sensors"; + +const cleanup = makeGyroscope( + orientation => { + console.log(orientation.alpha, orientation.beta, orientation.gamma); + }, + { interval: 100 }, +); + +// Later, stop listening: +cleanup(); +``` + +**Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `interval` | `number` | `100` | Minimum milliseconds between `onChange` calls | + +**Returns:** `VoidFunction` — call to remove the event listener. + +### `createGyroscope` + +Reactive wrapper around `makeGyroscope`. Returns an object with reactive `alpha`, `beta`, and `gamma` getters that start at `0` and update on each throttled orientation event. + +```ts +import { createGyroscope } from "@solid-primitives/sensors"; + +function MyComponent() { + const orientation = createGyroscope(); + + return ( +
+ α: {orientation.alpha}°, β: {orientation.beta}°, γ: {orientation.gamma}° +
+ ); +} +``` + +**Signature:** + +```ts +function createGyroscope( + interval?: number, // default: 100ms +): GyroscopeReading; + +type GyroscopeReading = { alpha: number; beta: number; gamma: number }; +``` + +**SSR:** Returns `{ alpha: 0, beta: 0, gamma: 0 }` — a plain non-reactive object. + +## Generic Sensor API + +A factory pair for any [Generic Sensor API](https://developer.mozilla.org/en-US/docs/Web/API/Sensor_APIs) sensor (Chromium-based browsers). Covers `LinearAccelerationSensor`, `GravitySensor`, `AbsoluteOrientationSensor`, `RelativeOrientationSensor`, and others. + +### `makeSensor` + +Sets up any Generic Sensor API sensor and calls `onChange` with the live sensor object on each reading. Returns `null` if the sensor constructor throws (API unsupported or permission denied). + +```ts +import { makeSensor } from "@solid-primitives/sensors"; + +const cleanup = makeSensor( + LinearAccelerationSensor, + sensor => console.log(sensor.x, sensor.y, sensor.z), + { frequency: 60 }, +); + +if (cleanup) { + // Later, stop: + cleanup(); +} +``` + +**Signature:** + +```ts +function makeSensor( + SensorClass: { new(options?: any): T }, + onChange: (sensor: T) => void, + options?: SensorOptions, +): VoidFunction | null; + +type SensorOptions = { frequency?: number }; +``` + +**Returns:** `VoidFunction` (cleanup) or `null` if unsupported. + +### `createSensor` + +Reactive wrapper around `makeSensor`. Returns an accessor that updates on **every reading event** — even if the sensor object reference is the same — because the underlying signal uses `equals: false`. Returns `undefined` until the first reading or if the sensor is unavailable. + +```ts +import { createSensor } from "@solid-primitives/sensors"; + +function MyComponent() { + const sensor = createSensor(LinearAccelerationSensor, { frequency: 60 }); + + return ( + + {s =>
X: {s().x ?? 0}
} +
+ ); +} +``` + +**Signature:** + +```ts +function createSensor( + SensorClass: { new(options?: any): T }, + options?: SensorOptions, +): Accessor; +``` + +**SSR:** Returns `() => undefined`. + +## Compass + +Uses `window.Magnetometer` from the [Generic Sensor API](https://developer.mozilla.org/en-US/docs/Web/API/Magnetometer). Chromium-based browsers only. Reports raw magnetic field strength in microteslas (µT) as `{ x, y, z }` components. + +### `makeCompass` + +```ts +import { makeCompass } from "@solid-primitives/sensors"; + +const cleanup = makeCompass( + ({ x, y, z }) => console.log(`Field: ${x}µT, ${y}µT, ${z}µT`), + { frequency: 10, referenceFrame: "device" }, +); + +if (cleanup) cleanup(); +``` + +**Options:** + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `frequency` | `number` | — | Readings per second | +| `referenceFrame` | `"device" \| "screen"` | `"device"` | Coordinate reference frame | + +**Returns:** `VoidFunction` or `null` if `window.Magnetometer` is unavailable. + +### `createCompass` + +Returns an object with reactive `x`, `y`, `z` getters (in µT), all starting at `0`. + +```ts +import { createCompass } from "@solid-primitives/sensors"; + +function Compass() { + const mag = createCompass({ frequency: 10 }); + const heading = () => Math.atan2(mag.y, mag.x) * (180 / Math.PI); + return
Heading: {heading()}°
; +} +``` + +**Signature:** + +```ts +function createCompass(options?: CompassOptions): CompassReading; + +type CompassOptions = { frequency?: number; referenceFrame?: "device" | "screen" }; +type CompassReading = { x: number; y: number; z: number }; +``` + +**SSR:** Returns `{ x: 0, y: 0, z: 0 }` — a plain non-reactive object. + +## Battery + +Uses the [Battery Status API](https://developer.mozilla.org/en-US/docs/Web/API/Battery_Status_API) (`navigator.getBattery()`). Supported in Chrome/Edge; not available in Firefox or Safari. + +### `makeBattery` + +Subscribes to the Battery API and calls `onChange` immediately with the current reading, then again on every battery change event. Returns a synchronous cleanup function — safe to use with `onCleanup` even though the API initializes asynchronously. + +```ts +import { makeBattery } from "@solid-primitives/sensors"; + +const cleanup = makeBattery(({ level, charging }) => { + console.log(`${Math.round(level * 100)}% ${charging ? "charging" : "discharging"}`); +}); + +// Later: +cleanup(); +``` + +**Signature:** + +```ts +function makeBattery(onChange: (reading: BatteryReading) => void): VoidFunction; + +type BatteryReading = { + charging: boolean; + chargingTime: number; // seconds until full; Infinity if not charging + dischargingTime: number; // seconds until empty; Infinity if charging + level: number; // 0.0–1.0 +}; +``` + +**Returns:** `VoidFunction` — always (no-ops if the API is unavailable). + +### `createBattery` + +Returns a reactive accessor for battery status. Starts as `undefined` until the Battery API resolves. Subscribe to any of the four properties to track specific changes. + +```ts +import { createBattery } from "@solid-primitives/sensors"; + +function BatteryIndicator() { + const battery = createBattery(); + + return ( + Loading battery…}> + {b => ( + + {Math.round(b().level * 100)}%{b().charging ? " ⚡" : ""} + + )} + + ); +} +``` + +**Signature:** + +```ts +function createBattery(): Accessor; +``` + +**SSR:** Returns `() => ({ charging: false, chargingTime: 0, dischargingTime: 0, level: 1 })`. + +## Throttling + +Both `makeAccelerometer` and `makeGyroscope` throttle events using a **leading-edge** strategy: the first event in a burst fires `onChange` immediately; subsequent events within `interval` ms are dropped. The next event after the interval elapses fires again. + +Set `interval: 0` to disable throttling (useful in tests). + +Generic Sensor API primitives (`makeSensor`, `makeCompass`) use the sensor's built-in `frequency` option for rate control — no additional throttling is applied. + +## Types + +```ts +type AccelerometerReading = DeviceMotionEventAcceleration | null; +type GyroscopeReading = { alpha: number; beta: number; gamma: number }; +type SensorOptions = { frequency?: number }; +type CompassOptions = { frequency?: number; referenceFrame?: "device" | "screen" }; +type CompassReading = { x: number; y: number; z: number }; +type BatteryReading = { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; +}; +``` + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/sensors/package.json b/packages/sensors/package.json new file mode 100644 index 000000000..1a77c29fb --- /dev/null +++ b/packages/sensors/package.json @@ -0,0 +1,71 @@ +{ + "name": "@solid-primitives/sensors", + "version": "0.1.0", + "description": "Primitives for device motion and orientation sensors (accelerometer and gyroscope).", + "author": "David Di Biase ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/sensors", + "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": "sensors", + "stage": 0, + "list": [ + "makeAccelerometer", + "createAccelerometer", + "makeGyroscope", + "createGyroscope", + "makeSensor", + "createSensor", + "makeCompass", + "createCompass", + "makeBattery", + "createBattery" + ], + "category": "Display & Media" + }, + "keywords": [ + "accelerometer", + "gyroscope", + "sensors", + "solid", + "primitives" + ], + "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" + }, + "peerDependencies": { + "solid-js": "^2.0.0-beta.13" + }, + "devDependencies": { + "@solidjs/web": "2.0.0-beta.13", + "solid-js": "2.0.0-beta.13" + } +} diff --git a/packages/sensors/src/accelerometer.ts b/packages/sensors/src/accelerometer.ts new file mode 100644 index 000000000..4b8b53a99 --- /dev/null +++ b/packages/sensors/src/accelerometer.ts @@ -0,0 +1,60 @@ +import { createSignal, onCleanup } from "solid-js"; +import type { Accessor, SignalOptions } from "solid-js"; +import { isServer } from "@solidjs/web"; + +const OWNED_WRITE: SignalOptions = { ownedWrite: true }; + +export type AccelerometerReading = DeviceMotionEventAcceleration | null; + +/** + * Sets up a raw `devicemotion` event listener and returns a cleanup function. + * No Solid lifecycle — suitable for use outside a reactive owner. + * + * @param onChange Called on each (throttled) motion event with the acceleration reading + * @param options.includeGravity Use `accelerationIncludingGravity` instead of `acceleration` + * @param options.interval Minimum milliseconds between onChange calls (default 100) + * @returns Cleanup function that removes the event listener + */ +export const makeAccelerometer = ( + onChange: (acceleration: AccelerometerReading) => void, + options: { includeGravity?: boolean; interval?: number } = {}, +): VoidFunction => { + const { includeGravity = false, interval = 100 } = options; + let throttled = false; + const handler = (e: DeviceMotionEvent) => { + if (throttled) return; + throttled = true; + setTimeout(() => { + throttled = false; + }, interval); + onChange(includeGravity ? e.accelerationIncludingGravity : e.acceleration); + }; + addEventListener("devicemotion", handler); + return () => removeEventListener("devicemotion", handler); +}; + +/** + * Creates a reactive accessor for device acceleration data. + * Starts as `undefined` until the first motion event arrives. + * Registers cleanup with `onCleanup` when inside a reactive owner. + * + * @param includeGravity Use `accelerationIncludingGravity` instead of `acceleration` (default false) + * @param interval Minimum milliseconds between updates (default 100) + * @returns Accessor yielding the latest AccelerometerReading, or `undefined` before the first event + */ +export const createAccelerometer = ( + includeGravity = false, + interval = 100, +): Accessor => { + if (isServer) return () => ({ x: 0, y: 0, z: 0 }); + const [acceleration, setAcceleration] = createSignal( + undefined, + OWNED_WRITE as SignalOptions, + ); + const cleanup = makeAccelerometer( + acc => setAcceleration(acc as Exclude), + { includeGravity, interval }, + ); + onCleanup(cleanup); + return acceleration; +}; diff --git a/packages/sensors/src/battery.ts b/packages/sensors/src/battery.ts new file mode 100644 index 000000000..eabebaf8e --- /dev/null +++ b/packages/sensors/src/battery.ts @@ -0,0 +1,81 @@ +import { createSignal, onCleanup } from "solid-js"; +import type { Accessor, SignalOptions } from "solid-js"; +import { isServer } from "@solidjs/web"; + +const OWNED_WRITE: SignalOptions = { ownedWrite: true }; + +export type BatteryReading = { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; +}; + +interface BatteryManager extends EventTarget { + readonly charging: boolean; + readonly chargingTime: number; + readonly dischargingTime: number; + readonly level: number; +} + +/** + * Subscribes to the Battery Status API and calls `onChange` with the current reading + * immediately after the API resolves, then again on every battery change event. + * Returns a synchronous cleanup function safe to pass to `onCleanup`. + * Silently no-ops if `navigator.getBattery` is unavailable. + * + * @param onChange Called with the current BatteryReading on subscribe and on each change + * @returns Synchronous cleanup function that removes all battery event listeners + */ +export const makeBattery = (onChange: (reading: BatteryReading) => void): VoidFunction => { + let disposed = false; + let removeFn: VoidFunction | undefined; + + if (typeof navigator !== "undefined" && "getBattery" in navigator) { + (navigator as any).getBattery().then((battery: BatteryManager) => { + if (disposed) return; + const update = () => + onChange({ + charging: battery.charging, + chargingTime: battery.chargingTime, + dischargingTime: battery.dischargingTime, + level: battery.level, + }); + update(); + const events = [ + "chargingchange", + "chargingtimechange", + "dischargingtimechange", + "levelchange", + ] as const; + events.forEach(e => battery.addEventListener(e, update)); + removeFn = () => events.forEach(e => battery.removeEventListener(e, update)); + }); + } + + return () => { + disposed = true; + removeFn?.(); + }; +}; + +/** + * Creates a reactive accessor for battery status. + * Starts as `undefined` until the Battery Status API resolves (async). + * Returns a static `() => ({ charging: false, chargingTime: 0, dischargingTime: 0, level: 1 })` + * on the server. + * Registers cleanup with `onCleanup` when inside a reactive owner. + * + * @returns Accessor yielding the current BatteryReading, or `undefined` before the API resolves + */ +export const createBattery = (): Accessor => { + if (isServer) + return () => ({ charging: false, chargingTime: 0, dischargingTime: 0, level: 1 }); + const [reading, setReading] = createSignal( + undefined, + OWNED_WRITE as SignalOptions, + ); + const cleanup = makeBattery(r => setReading(r as Exclude)); + onCleanup(cleanup); + return reading; +}; diff --git a/packages/sensors/src/compass.ts b/packages/sensors/src/compass.ts new file mode 100644 index 000000000..b1f9a1d0c --- /dev/null +++ b/packages/sensors/src/compass.ts @@ -0,0 +1,79 @@ +import { createSignal, onCleanup } from "solid-js"; +import type { SignalOptions } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { makeSensor } from "./sensor.js"; +import type { GenericSensor } from "./sensor.js"; + +const OWNED_WRITE: SignalOptions = { ownedWrite: true }; + +/** Options for `makeCompass` / `createCompass`. */ +export type CompassOptions = { + frequency?: number; + referenceFrame?: "device" | "screen"; +}; + +/** Raw magnetometer reading in microteslas (µT), as returned by `makeCompass` / `createCompass`. */ +export type CompassReading = { x: number; y: number; z: number }; + +interface MagnetometerSensor extends GenericSensor { + readonly x: number | null; + readonly y: number | null; + readonly z: number | null; +} + +/** + * Sets up a `Magnetometer` sensor and calls `onChange` with x/y/z readings in microteslas. + * Uses the Generic Sensor API (`window.Magnetometer`). + * Returns `null` if the Magnetometer API is unavailable or throws on construction. + * + * @param onChange Called on each reading with `{ x, y, z }` in microteslas (null coerced to 0) + * @param options Optional `{ frequency, referenceFrame }` passed to the Magnetometer constructor + * @returns Cleanup function that stops the sensor, or `null` if unsupported + */ +export const makeCompass = ( + onChange: (reading: CompassReading) => void, + options?: CompassOptions, +): VoidFunction | null => { + if (!("Magnetometer" in globalThis)) return null; + return makeSensor( + (globalThis as any).Magnetometer, + s => onChange({ x: s.x ?? 0, y: s.y ?? 0, z: s.z ?? 0 }), + options, + ); +}; + +/** + * Creates a reactive object with `x`, `y`, `z` magnetometer readings in microteslas. + * All properties start at `0` and update on each sensor reading. + * Returns a non-reactive `{ x: 0, y: 0, z: 0 }` on the server. + * Registers cleanup with `onCleanup` when inside a reactive owner. + * + * @param options Optional `{ frequency, referenceFrame }` passed to the Magnetometer constructor + * @returns Reactive CompassReading object `{ x, y, z }` + */ +export const createCompass = (options?: CompassOptions): CompassReading => { + if (isServer) return { x: 0, y: 0, z: 0 }; + const [x, setX] = createSignal(0, OWNED_WRITE as SignalOptions); + const [y, setY] = createSignal(0, OWNED_WRITE as SignalOptions); + const [z, setZ] = createSignal(0, OWNED_WRITE as SignalOptions); + const cleanup = makeCompass( + r => { + setX(r.x); + setY(r.y); + setZ(r.z); + }, + options, + ); + if (cleanup) onCleanup(cleanup); + return { + get x() { + return x(); + }, + get y() { + return y(); + }, + get z() { + return z(); + }, + }; +}; diff --git a/packages/sensors/src/gyroscope.ts b/packages/sensors/src/gyroscope.ts new file mode 100644 index 000000000..67333546d --- /dev/null +++ b/packages/sensors/src/gyroscope.ts @@ -0,0 +1,72 @@ +import { createSignal, onCleanup } from "solid-js"; +import type { SignalOptions } from "solid-js"; +import { isServer } from "@solidjs/web"; + +const OWNED_WRITE: SignalOptions = { ownedWrite: true }; + +export type GyroscopeReading = { alpha: number; beta: number; gamma: number }; + +/** + * Sets up a raw `deviceorientation` event listener and returns a cleanup function. + * No Solid lifecycle — suitable for use outside a reactive owner. + * + * @param onChange Called on each (throttled) orientation event with alpha/beta/gamma values + * @param options.interval Minimum milliseconds between onChange calls (default 100) + * @returns Cleanup function that removes the event listener + */ +export const makeGyroscope = ( + onChange: (orientation: GyroscopeReading) => void, + options: { interval?: number } = {}, +): VoidFunction => { + const { interval = 100 } = options; + let throttled = false; + const handler = (e: DeviceOrientationEvent) => { + if (throttled) return; + throttled = true; + setTimeout(() => { + throttled = false; + }, interval); + onChange({ + alpha: e.alpha ?? 0, + beta: e.beta ?? 0, + gamma: e.gamma ?? 0, + }); + }; + addEventListener("deviceorientation", handler); + return () => removeEventListener("deviceorientation", handler); +}; + +/** + * Creates a reactive object tracking device orientation (gyroscope data). + * Returns an object with reactive `alpha`, `beta`, and `gamma` properties. + * Registers cleanup with `onCleanup` when inside a reactive owner. + * + * @param interval Minimum milliseconds between updates (default 100) + * @returns Reactive GyroscopeReading object `{ alpha, beta, gamma }` + */ +export const createGyroscope = (interval = 100): GyroscopeReading => { + if (isServer) return { alpha: 0, beta: 0, gamma: 0 }; + const [alpha, setAlpha] = createSignal(0, OWNED_WRITE as SignalOptions); + const [beta, setBeta] = createSignal(0, OWNED_WRITE as SignalOptions); + const [gamma, setGamma] = createSignal(0, OWNED_WRITE as SignalOptions); + const cleanup = makeGyroscope( + o => { + setAlpha(o.alpha); + setBeta(o.beta); + setGamma(o.gamma); + }, + { interval }, + ); + onCleanup(cleanup); + return { + get alpha() { + return alpha(); + }, + get beta() { + return beta(); + }, + get gamma() { + return gamma(); + }, + }; +}; diff --git a/packages/sensors/src/index.ts b/packages/sensors/src/index.ts new file mode 100644 index 000000000..314956c28 --- /dev/null +++ b/packages/sensors/src/index.ts @@ -0,0 +1,5 @@ +export * from "./accelerometer.js"; +export * from "./gyroscope.js"; +export * from "./sensor.js"; +export * from "./compass.js"; +export * from "./battery.js"; diff --git a/packages/sensors/src/sensor.ts b/packages/sensors/src/sensor.ts new file mode 100644 index 000000000..3f291295c --- /dev/null +++ b/packages/sensors/src/sensor.ts @@ -0,0 +1,75 @@ +import { createSignal, onCleanup } from "solid-js"; +import type { Accessor, SignalOptions } from "solid-js"; +import { isServer } from "@solidjs/web"; + +const OWNED_WRITE: SignalOptions = { ownedWrite: true }; + +/** Options shared by all Generic Sensor API primitives. */ +export type SensorOptions = { frequency?: number }; + +/** + * Minimal Generic Sensor API interface (not in the standard TypeScript DOM lib). + * Extend this to type specific sensors passed to `makeSensor` / `createSensor`. + */ +export interface GenericSensor extends EventTarget { + readonly activated: boolean; + readonly hasReading: boolean; + readonly timestamp: DOMHighResTimeStamp | undefined; + start(): void; + stop(): void; +} + +/** + * Sets up any Generic Sensor API sensor and calls `onChange` on each reading. + * Returns a cleanup function, or `null` if the sensor constructor throws + * (e.g. the API is unsupported or permission was denied). + * + * @param SensorClass Any Generic Sensor API constructor (Magnetometer, LinearAccelerationSensor, etc.) + * @param onChange Called with the live sensor object on each reading event + * @param options Optional `{ frequency }` passed to the sensor constructor + * @returns Cleanup function that stops and removes the sensor, or `null` on failure + */ +export const makeSensor = ( + SensorClass: { new (options?: any): T }, + onChange: (sensor: T) => void, + options?: SensorOptions, +): VoidFunction | null => { + let sensor: T; + try { + sensor = new SensorClass(options); + } catch { + return null; + } + const handleReading = () => onChange(sensor); + sensor.addEventListener("reading", handleReading); + sensor.start(); + return () => { + sensor.removeEventListener("reading", handleReading); + sensor.stop(); + }; +}; + +/** + * Creates a reactive accessor for any Generic Sensor API sensor. + * The accessor re-fires on every reading event (even if the sensor object reference + * is the same) because the underlying signal uses `equals: false`. + * Returns `undefined` until the first reading or if the sensor is unavailable. + * Returns a static `() => undefined` on the server. + * + * @param SensorClass Any Generic Sensor API constructor + * @param options Optional `{ frequency }` passed to the sensor constructor + * @returns Accessor yielding the live sensor object (updated on every reading), or `undefined` + */ +export const createSensor = ( + SensorClass: { new (options?: any): T }, + options?: SensorOptions, +): Accessor => { + if (isServer) return () => undefined; + const [sensor, setSensor] = createSignal(undefined, { + ...(OWNED_WRITE as SignalOptions), + equals: false, + }); + const cleanup = makeSensor(SensorClass, s => setSensor(s as Exclude), options); + if (cleanup) onCleanup(cleanup); + return sensor; +}; diff --git a/packages/sensors/test/index.test.ts b/packages/sensors/test/index.test.ts new file mode 100644 index 000000000..3aa024433 --- /dev/null +++ b/packages/sensors/test/index.test.ts @@ -0,0 +1,675 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createEffect, createRoot } from "solid-js"; +import { + makeAccelerometer, + createAccelerometer, + makeGyroscope, + createGyroscope, + makeSensor, + createSensor, + makeCompass, + createCompass, + makeBattery, + createBattery, +} from "../src/index.js"; + +const dispatchMotion = (acceleration: { x: number; y: number; z: number }) => + dispatchEvent(Object.assign(new Event("devicemotion"), { acceleration })); + +const dispatchOrientation = (orientation: { alpha: number; beta: number; gamma: number }) => + dispatchEvent(Object.assign(new Event("deviceorientation"), orientation)); + +describe("makeAccelerometer", () => { + it("calls onChange with acceleration data", () => + new Promise(resolve => { + const cleanup = makeAccelerometer( + acc => { + expect(acc).toMatchObject({ x: 1, y: 2, z: 3 }); + cleanup(); + resolve(); + }, + { interval: 0 }, + ); + dispatchMotion({ x: 1, y: 2, z: 3 }); + })); + + it("calls onChange multiple times after throttle interval", () => + new Promise((resolve, reject) => { + const readings: Array<{ x: number; y: number; z: number }> = []; + const cleanup = makeAccelerometer( + acc => { + if (acc) readings.push({ x: acc.x ?? 0, y: acc.y ?? 0, z: acc.z ?? 0 }); + if (readings.length === 2) { + expect(readings[0]).toEqual({ x: 1, y: 0, z: 0 }); + expect(readings[1]).toEqual({ x: 0, y: 2, z: 0 }); + cleanup(); + resolve(); + } + }, + { interval: 0 }, + ); + + dispatchMotion({ x: 1, y: 0, z: 0 }); + setTimeout(() => dispatchMotion({ x: 0, y: 2, z: 0 }), 10); + setTimeout(() => reject(new Error("timed out")), 500); + })); + + it("returns a working cleanup function", () => { + const onChange = vi.fn(); + const cleanup = makeAccelerometer(onChange, { interval: 0 }); + dispatchMotion({ x: 1, y: 0, z: 0 }); + expect(onChange).toHaveBeenCalledTimes(1); + cleanup(); + dispatchMotion({ x: 2, y: 0, z: 0 }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("throttles rapid events", () => { + let count = 0; + const cleanup = makeAccelerometer(() => { count++; }, { interval: 100 }); + dispatchMotion({ x: 1, y: 0, z: 0 }); + dispatchMotion({ x: 2, y: 0, z: 0 }); + dispatchMotion({ x: 3, y: 0, z: 0 }); + expect(count).toBe(1); + cleanup(); + }); +}); + +describe("createAccelerometer", () => { + it("starts as undefined and updates on motion events", () => + createRoot( + dispose => + new Promise(resolve => { + const acceleration = createAccelerometer(false, 0); + expect(acceleration()).toBeUndefined(); + + createEffect( + () => acceleration(), + val => { + if (val !== undefined) { + expect(val).toMatchObject({ x: 1, y: 2, z: 3 }); + dispose(); + resolve(); + } + }, + ); + + dispatchMotion({ x: 1, y: 2, z: 3 }); + }), + )); + + it("updates reactively on subsequent events", () => + createRoot( + dispose => + new Promise((resolve, reject) => { + const acceleration = createAccelerometer(false, 0); + let count = 0; + + createEffect( + () => acceleration(), + val => { + if (val === undefined) return; + count++; + if (count === 1) { + expect(val).toMatchObject({ x: 1, y: 0, z: 0 }); + setTimeout(() => dispatchMotion({ x: 0, y: 1, z: 0 }), 10); + } else if (count === 2) { + expect(val).toMatchObject({ x: 0, y: 1, z: 0 }); + dispose(); + resolve(); + } + }, + ); + + dispatchMotion({ x: 1, y: 0, z: 0 }); + setTimeout(() => reject(new Error("timed out")), 500); + }), + )); +}); + +describe("makeGyroscope", () => { + it("calls onChange with orientation data", () => + new Promise(resolve => { + const cleanup = makeGyroscope( + o => { + expect(o).toEqual({ alpha: 10, beta: 20, gamma: 30 }); + cleanup(); + resolve(); + }, + { interval: 0 }, + ); + dispatchOrientation({ alpha: 10, beta: 20, gamma: 30 }); + })); + + it("calls onChange multiple times after throttle interval", () => + new Promise((resolve, reject) => { + const readings: Array<{ alpha: number; beta: number; gamma: number }> = []; + const cleanup = makeGyroscope( + o => { + readings.push(o); + if (readings.length === 2) { + expect(readings[0]).toEqual({ alpha: 10, beta: 20, gamma: 30 }); + expect(readings[1]).toEqual({ alpha: 0, beta: 0, gamma: 0 }); + cleanup(); + resolve(); + } + }, + { interval: 0 }, + ); + + dispatchOrientation({ alpha: 10, beta: 20, gamma: 30 }); + setTimeout(() => dispatchOrientation({ alpha: 0, beta: 0, gamma: 0 }), 10); + setTimeout(() => reject(new Error("timed out")), 500); + })); + + it("returns a working cleanup function", () => { + const onChange = vi.fn(); + const cleanup = makeGyroscope(onChange, { interval: 0 }); + dispatchOrientation({ alpha: 1, beta: 0, gamma: 0 }); + expect(onChange).toHaveBeenCalledTimes(1); + cleanup(); + dispatchOrientation({ alpha: 2, beta: 0, gamma: 0 }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("defaults null orientation values to 0", () => + new Promise(resolve => { + const cleanup = makeGyroscope( + o => { + expect(o).toEqual({ alpha: 0, beta: 0, gamma: 0 }); + cleanup(); + resolve(); + }, + { interval: 0 }, + ); + dispatchEvent(new Event("deviceorientation")); + })); +}); + +describe("createGyroscope", () => { + it("starts with all-zero orientation", () => { + createRoot(dispose => { + const orientation = createGyroscope(0); + expect(orientation.alpha).toBe(0); + expect(orientation.beta).toBe(0); + expect(orientation.gamma).toBe(0); + dispose(); + }); + }); + + it("updates reactively on orientation events", () => + createRoot( + dispose => + new Promise(resolve => { + const orientation = createGyroscope(0); + + createEffect( + () => ({ alpha: orientation.alpha, beta: orientation.beta, gamma: orientation.gamma }), + val => { + if (val.alpha !== 0 || val.beta !== 0 || val.gamma !== 0) { + expect(val).toEqual({ alpha: 10, beta: 20, gamma: 30 }); + dispose(); + resolve(); + } + }, + ); + + dispatchOrientation({ alpha: 10, beta: 20, gamma: 30 }); + }), + )); + + it("updates multiple times reactively", () => + createRoot( + dispose => + new Promise((resolve, reject) => { + const orientation = createGyroscope(0); + let count = 0; + + createEffect( + () => orientation.alpha, + alpha => { + count++; + if (count === 1 && alpha === 5) { + setTimeout(() => dispatchOrientation({ alpha: 10, beta: 0, gamma: 0 }), 10); + } else if (count === 2 && alpha === 10) { + expect(alpha).toBe(10); + dispose(); + resolve(); + } + }, + ); + + dispatchOrientation({ alpha: 5, beta: 0, gamma: 0 }); + setTimeout(() => reject(new Error("timed out")), 500); + }), + )); +}); + +let lastSensorInstance: MockGenericSensor; + +class MockGenericSensor extends EventTarget { + activated = false; + hasReading = false; + timestamp: DOMHighResTimeStamp | undefined = undefined; + value = 0; + + constructor(_opts?: any) { + super(); + lastSensorInstance = this; + } + + start() { + this.activated = true; + } + stop() { + this.activated = false; + } + + fireReading(value: number) { + this.value = value; + this.hasReading = true; + this.dispatchEvent(new Event("reading")); + } +} + +describe("makeSensor", () => { + it("calls onChange on each reading", () => { + const readings: number[] = []; + const cleanup = makeSensor(MockGenericSensor, s => readings.push((s as MockGenericSensor).value)); + lastSensorInstance.fireReading(42); + expect(readings).toEqual([42]); + cleanup!(); + }); + + it("starts the sensor on creation", () => { + const cleanup = makeSensor(MockGenericSensor, () => {}); + expect(lastSensorInstance.activated).toBe(true); + cleanup!(); + }); + + it("returns null when constructor throws", () => { + class ThrowingSensor { + constructor() { + throw new Error("Permission denied"); + } + } + expect(makeSensor(ThrowingSensor as any, () => {})).toBeNull(); + }); + + it("cleanup stops the sensor and removes the reading listener", () => { + const onChange = vi.fn(); + const cleanup = makeSensor(MockGenericSensor, onChange)!; + lastSensorInstance.fireReading(1); + expect(onChange).toHaveBeenCalledTimes(1); + cleanup(); + expect(lastSensorInstance.activated).toBe(false); + lastSensorInstance.fireReading(2); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("fires onChange for every reading", () => { + const onChange = vi.fn(); + const cleanup = makeSensor(MockGenericSensor, onChange)!; + lastSensorInstance.fireReading(1); + lastSensorInstance.fireReading(2); + lastSensorInstance.fireReading(3); + expect(onChange).toHaveBeenCalledTimes(3); + cleanup(); + }); +}); + +describe("createSensor", () => { + it("starts as undefined", () => { + createRoot(dispose => { + const sensor = createSensor(MockGenericSensor); + expect(sensor()).toBeUndefined(); + dispose(); + }); + }); + + it("updates on reading events", () => + createRoot( + dispose => + new Promise(resolve => { + const sensorAcc = createSensor(MockGenericSensor); + + createEffect( + () => sensorAcc(), + s => { + if (s !== undefined) { + expect((s as MockGenericSensor).value).toBe(99); + dispose(); + resolve(); + } + }, + ); + + lastSensorInstance.fireReading(99); + }), + )); + + it("re-fires on every reading even with the same sensor reference", () => + createRoot( + dispose => + new Promise((resolve, reject) => { + const sensorAcc = createSensor(MockGenericSensor); + const values: number[] = []; + + createEffect( + () => sensorAcc(), + s => { + if (s !== undefined) { + values.push((s as MockGenericSensor).value); + if (values.length === 2) { + expect(values).toEqual([10, 20]); + dispose(); + resolve(); + } else { + setTimeout(() => lastSensorInstance.fireReading(20), 10); + } + } + }, + ); + + lastSensorInstance.fireReading(10); + setTimeout(() => reject(new Error("timed out")), 500); + }), + )); +}); + +let lastCompassInstance: MockMagnetometerSensor; + +class MockMagnetometerSensor extends EventTarget { + x: number | null = null; + y: number | null = null; + z: number | null = null; + activated = false; + hasReading = false; + timestamp: DOMHighResTimeStamp | undefined = undefined; + + constructor(_opts?: any) { + super(); + lastCompassInstance = this; + } + + start() { + this.activated = true; + } + stop() { + this.activated = false; + } + + fireReading(x: number | null, y: number | null, z: number | null) { + this.x = x; + this.y = y; + this.z = z; + this.hasReading = true; + this.dispatchEvent(new Event("reading")); + } +} + +describe("makeCompass", () => { + beforeEach(() => { + (globalThis as any).Magnetometer = MockMagnetometerSensor; + }); + afterEach(() => { + delete (globalThis as any).Magnetometer; + }); + + it("returns null when Magnetometer is not in globalThis", () => { + delete (globalThis as any).Magnetometer; + expect(makeCompass(() => {})).toBeNull(); + }); + + it("calls onChange with x/y/z reading", () => { + const readings: Array<{ x: number; y: number; z: number }> = []; + const cleanup = makeCompass(r => readings.push(r))!; + lastCompassInstance.fireReading(1.5, 2.5, 3.5); + expect(readings).toEqual([{ x: 1.5, y: 2.5, z: 3.5 }]); + cleanup(); + }); + + it("coerces null sensor values to 0", () => { + const readings: Array<{ x: number; y: number; z: number }> = []; + const cleanup = makeCompass(r => readings.push(r))!; + lastCompassInstance.fireReading(null, null, null); + expect(readings).toEqual([{ x: 0, y: 0, z: 0 }]); + cleanup(); + }); + + it("cleanup stops listening", () => { + const onChange = vi.fn(); + const cleanup = makeCompass(onChange)!; + lastCompassInstance.fireReading(1, 2, 3); + expect(onChange).toHaveBeenCalledTimes(1); + cleanup(); + lastCompassInstance.fireReading(4, 5, 6); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("passes options to the sensor constructor", () => { + const spy = vi.spyOn(MockMagnetometerSensor.prototype, "start"); + const cleanup = makeCompass(() => {}, { frequency: 60, referenceFrame: "screen" })!; + expect(spy).toHaveBeenCalled(); + cleanup(); + spy.mockRestore(); + }); +}); + +describe("createCompass", () => { + beforeEach(() => { + (globalThis as any).Magnetometer = MockMagnetometerSensor; + }); + afterEach(() => { + delete (globalThis as any).Magnetometer; + }); + + it("starts with all-zero values", () => { + createRoot(dispose => { + const compass = createCompass(); + expect(compass.x).toBe(0); + expect(compass.y).toBe(0); + expect(compass.z).toBe(0); + dispose(); + }); + }); + + it("updates reactively on readings", () => + createRoot( + dispose => + new Promise(resolve => { + const compass = createCompass(); + + createEffect( + () => ({ x: compass.x, y: compass.y, z: compass.z }), + val => { + if (val.x !== 0 || val.y !== 0 || val.z !== 0) { + expect(val).toEqual({ x: 10, y: 20, z: 30 }); + dispose(); + resolve(); + } + }, + ); + + lastCompassInstance.fireReading(10, 20, 30); + }), + )); + + it("coerces null sensor values to 0 reactively", () => + createRoot( + dispose => + new Promise((resolve, reject) => { + const compass = createCompass(); + let count = 0; + + createEffect( + () => compass.x, + x => { + count++; + if (count === 1) { + expect(x).toBe(5); + setTimeout(() => lastCompassInstance.fireReading(null, null, null), 10); + } else if (count === 2) { + expect(x).toBe(0); + dispose(); + resolve(); + } + }, + ); + + lastCompassInstance.fireReading(5, 0, 0); + setTimeout(() => reject(new Error("timed out")), 500); + }), + )); +}); + +type MockBatteryManager = EventTarget & { + charging: boolean; + chargingTime: number; + dischargingTime: number; + level: number; +}; + +const makeMockBattery = ( + overrides: Partial> = {}, +): MockBatteryManager => + Object.assign(new EventTarget(), { + charging: true, + chargingTime: 0, + dischargingTime: Infinity, + level: 0.8, + ...overrides, + }) as MockBatteryManager; + +describe("makeBattery", () => { + let mockBattery: MockBatteryManager; + + beforeEach(() => { + mockBattery = makeMockBattery(); + (navigator as any).getBattery = vi.fn(() => Promise.resolve(mockBattery)); + }); + + afterEach(() => { + delete (navigator as any).getBattery; + }); + + it("calls onChange with the initial battery reading", async () => { + const readings: any[] = []; + const cleanup = makeBattery(r => readings.push(r)); + await Promise.resolve(); + expect(readings).toHaveLength(1); + expect(readings[0]).toEqual({ + charging: true, + chargingTime: 0, + dischargingTime: Infinity, + level: 0.8, + }); + cleanup(); + }); + + it("calls onChange when a battery event fires", async () => { + const readings: any[] = []; + const cleanup = makeBattery(r => readings.push(r)); + await Promise.resolve(); + const initialCount = readings.length; + mockBattery.level = 0.5; + mockBattery.dispatchEvent(new Event("levelchange")); + expect(readings.length).toBe(initialCount + 1); + expect(readings[readings.length - 1].level).toBe(0.5); + cleanup(); + }); + + it("cleanup removes all battery event listeners", async () => { + const onChange = vi.fn(); + const cleanup = makeBattery(onChange); + await Promise.resolve(); + const callCount = onChange.mock.calls.length; + cleanup(); + mockBattery.dispatchEvent(new Event("levelchange")); + mockBattery.dispatchEvent(new Event("chargingchange")); + expect(onChange).toHaveBeenCalledTimes(callCount); + }); + + it("no-ops when getBattery is not available", () => { + delete (navigator as any).getBattery; + const onChange = vi.fn(); + const cleanup = makeBattery(onChange); + expect(onChange).not.toHaveBeenCalled(); + expect(() => cleanup()).not.toThrow(); + }); + + it("cleanup before battery promise resolves does not attach listeners", async () => { + const onChange = vi.fn(); + const cleanup = makeBattery(onChange); + cleanup(); + await Promise.resolve(); + expect(onChange).not.toHaveBeenCalled(); + mockBattery.dispatchEvent(new Event("levelchange")); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe("createBattery", () => { + let mockBattery: MockBatteryManager; + + beforeEach(() => { + mockBattery = makeMockBattery(); + (navigator as any).getBattery = vi.fn(() => Promise.resolve(mockBattery)); + }); + + afterEach(() => { + delete (navigator as any).getBattery; + }); + + it("starts as undefined", () => { + createRoot(dispose => { + const battery = createBattery(); + expect(battery()).toBeUndefined(); + dispose(); + }); + }); + + it("updates after the battery API resolves", async () => { + await createRoot(async dispose => { + const battery = createBattery(); + await Promise.resolve(); // let getBattery().then() run and call setReading() + await Promise.resolve(); // let Solid's auto-batch flush apply the signal write + expect(battery()).toEqual({ + charging: true, + chargingTime: 0, + dischargingTime: Infinity, + level: 0.8, + }); + dispose(); + }); + }); + + it("updates reactively on battery events", () => + createRoot( + dispose => + new Promise(async (resolve, reject) => { + const battery = createBattery(); + let count = 0; + + createEffect( + () => battery(), + val => { + if (val === undefined) return; + count++; + if (count === 1) { + expect(val.level).toBe(0.8); + mockBattery.level = 0.5; + mockBattery.dispatchEvent(new Event("levelchange")); + } else if (count === 2) { + expect(val.level).toBe(0.5); + dispose(); + resolve(); + } + }, + ); + + await Promise.resolve(); + setTimeout(() => reject(new Error("timed out")), 500); + }), + )); +}); diff --git a/packages/sensors/test/server.test.ts b/packages/sensors/test/server.test.ts new file mode 100644 index 000000000..5e3556299 --- /dev/null +++ b/packages/sensors/test/server.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from "vitest"; +import { + createAccelerometer, + createGyroscope, + createSensor, + createCompass, + createBattery, +} from "../src/index.js"; + +describe("API doesn't break in SSR", () => { + test("createAccelerometer() - SSR", () => { + expect(createAccelerometer()()).toEqual({ x: 0, y: 0, z: 0 }); + }); + + test("createGyroscope() - SSR", () => { + expect(createGyroscope()).toEqual({ alpha: 0, beta: 0, gamma: 0 }); + }); + + test("createSensor() - SSR returns undefined accessor", () => { + class FakeSensor extends EventTarget { + activated = false; + hasReading = false; + timestamp = undefined; + start() {} + stop() {} + } + expect(createSensor(FakeSensor)()).toBeUndefined(); + }); + + test("createCompass() - SSR", () => { + expect(createCompass()).toEqual({ x: 0, y: 0, z: 0 }); + }); + + test("createBattery() - SSR returns default reading", () => { + expect(createBattery()()).toEqual({ + charging: false, + chargingTime: 0, + dischargingTime: 0, + level: 1, + }); + }); +}); diff --git a/packages/sensors/tsconfig.json b/packages/sensors/tsconfig.json new file mode 100644 index 000000000..38c71ce71 --- /dev/null +++ b/packages/sensors/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce8026663..3a2d5805a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,9 +284,12 @@ importers: packages/devices: devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13(solid-js@2.0.0-beta.13) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13 packages/event-bus: dependencies: @@ -894,6 +897,15 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/sensors: + devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13(solid-js@2.0.0-beta.13) + solid-js: + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13 + packages/set: dependencies: '@solid-primitives/trigger':