diff --git a/openspec/changes/baseline-media-capture/.openspec.yaml b/openspec/changes/baseline-media-capture/.openspec.yaml new file mode 100644 index 0000000..9e5b8a1 --- /dev/null +++ b/openspec/changes/baseline-media-capture/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-23 diff --git a/openspec/changes/baseline-media-capture/design.md b/openspec/changes/baseline-media-capture/design.md new file mode 100644 index 0000000..1deaaa4 --- /dev/null +++ b/openspec/changes/baseline-media-capture/design.md @@ -0,0 +1,105 @@ +## Context + +`@bengreenier/react-user-media` already implements media capture in `packages/react-user-media`: + +- `useMedia` (`src/hooks/use-media.ts`) — React hook over `getUserMedia` / `getDisplayMedia` +- `closeMedia` (`src/close-media.ts`) — stop all tracks on a stream +- `getSupportedConstraints` (`src/index.ts`) — thin, null-safe wrapper around `mediaDevices.getSupportedConstraints` + +This design records the **as-built** architecture so future OpenSpec deltas can reason about lifecycle and API contracts. No new implementation is proposed. + +Covered tests live in `use-media.spec.tsx` and `close-media.spec.ts` (Vitest + Testing Library, browser-oriented mocks). + +## Goals / Non-Goals + +**Goals:** + +- Document how media acquisition, discriminated state, request generations, and cleanup work today +- Make the public surface and lifecycle guarantees explicit for baselining +- Align design language with existing tests and TypeScript types + +**Non-Goals:** + +- Redesigning the hook API or state machine +- Adding workers, WebCodecs, device enumeration, or MediaRecorder into this capability +- Changing cleanup semantics or introducing new dependencies +- Migrating runtime behavior; this change is documentation-only + +## Decisions + +### 1. Single hook with a type discriminant + +**Choice:** One `useMedia(type)` entry point where `type` is `"user" | "display"`, rather than separate hooks. + +**Rationale (as-built):** Shared loading/error/ready machinery and identical cleanup rules; TypeScript conditional types (`inferMediaDef`) specialize `request` argument and return state per type. + +**Alternatives considered:** Separate `useUserMedia` / `useDisplayMedia` hooks — not used; would duplicate generation and teardown logic. + +### 2. Discriminated boolean flags + cast return + +**Choice:** Internal state is three React state fields (`media`, `error`, `isLoading`) plus derived `isReady` / `isError`. The return value is typed as a discriminated union but assembled as one object and cast (`as inferMediaDef["stateType"]`). + +**Rationale:** Avoids runtime branching cost while preserving a strong consumer-facing type. Flags remain mutually exclusive by construction of setters. + +**Alternatives considered:** Explicit tagged union in runtime state (`status: "idle" | ...`) — not used in current code. + +### 3. Request generations for async correctness + +**Choice:** A mutable `requestGeneration` ref increments on every `request`, `stop`, `type` change, and unmount. Success/error/finalize handlers ignore mismatched generations; successful-but-stale streams are passed to `closeMedia`. + +**Rationale:** Media promises outlive React renders; without generations, late resolutions would leak tracks or overwrite newer media (covered by stale / unmount / type-change tests). + +**Alternatives considered:** AbortController on constraints APIs — not universally available / not used here; generation counter is sufficient for ignore-and-close. + +### 4. Eager close of prior stream on re-request + +**Choice:** At the start of `request`, call `closeMedia(mediaRef.current)` and clear ref/state media before invoking the browser API. + +**Rationale:** Prevents overlapping live tracks when the consumer re-requests; matches “stops the previous user media stream when re-requesting” test. + +### 5. Type change resets like stop (without requiring consumer action) + +**Choice:** An effect compares `type` to `typeRef`; on change, bump generation, close held media, and clear to idle. + +**Rationale:** Switching `"user"` ↔ `"display"` must not keep the wrong stream or apply a late user promise to a display hook instance. + +### 6. Unmount teardown closes tracks only + +**Choice:** Cleanup effect bumps generation and `closeMedia`s the ref; it does not set React state (component is gone). + +**Rationale:** Avoids setState-after-unmount while still ending tracks and invalidating in-flight handlers. + +### 7. Capability checks before promise + +**Choice:** If the matching capture API is missing, set a descriptive `Error` and skip `setIsLoading(true)` / promise path. + +**Rationale:** Fail closed in non-secure or incomplete environments without throwing from `request`. + +### 8. Shared `closeMedia` helper + +**Choice:** Centralize `media?.getTracks().forEach(track => track.stop())`. + +**Rationale:** One place for teardown used by the hook; undefined-safe for idle paths. + +### 9. Null-safe `getSupportedConstraints` + +**Choice:** Optional chaining through `globalThis.navigator?.mediaDevices?.getSupportedConstraints?.()` with `?? {}`. + +**Rationale:** Safe to call during SSR or degraded environments; returns empty constraint map rather than throwing. + +## Risks / Trade-offs + +- **[Risk] Cast return can drift from true discriminant invariants** → Mitigation: keep tests asserting exclusive idle/loading/ready/error presentations; prefer not widening setters. +- **[Risk] Generation counter is process-local and silent** → Mitigation: always `closeMedia` on stale success so leaks are prevented even when state updates are skipped. +- **[Risk] No AbortSignal** → Mitigation: superseded requests may still complete in the browser; library closes unused streams on resolve. Consumers should still prefer `stop` / unmount for intentional teardown. +- **[Risk] Baseline docs can drift from code** → Mitigation: verification tasks audit scenarios against `use-media.spec.tsx` / `close-media.spec.ts` before treating archive as source of truth. +- **[Trade-off] Single object + cast vs runtime tagged state** → Favor typing ergonomics and less boilerplate; correctness relies on disciplined updates and tests. + +## Migration Plan + +Not applicable for runtime. After archive/sync, main specs under `openspec/specs/media-capture/` SHOULD mirror this baseline so later changes use MODIFIED/ADDED deltas. No rollback beyond deleting or revising these planning artifacts. + +## Open Questions + +- Whether to promote this capability into `openspec/specs/` immediately after validation (optional; listed as a verification task, not required for apply-ready planning). +- Whether future deltas should add an explicit runtime `status` discriminant (out of scope for this baseline). diff --git a/openspec/changes/baseline-media-capture/proposal.md b/openspec/changes/baseline-media-capture/proposal.md new file mode 100644 index 0000000..d344f57 --- /dev/null +++ b/openspec/changes/baseline-media-capture/proposal.md @@ -0,0 +1,36 @@ +## Why + +The library already ships `useMedia`, `closeMedia`, and `getSupportedConstraints` for browser media capture, but there is no OpenSpec baseline for that behavior. Documenting it as-built gives future deltas a stable contract for lifecycle, cleanup, and API surface without changing runtime behavior. + +## What Changes + +- Add an OpenSpec capability `media-capture` that records existing requirements for: + - `useMedia("user" | "display")` → `getUserMedia` / `getDisplayMedia` + - Discriminated idle → loading → ready | error state, plus `request` / `stop` + - Request generations, stream close on re-request / type change / unmount + - `closeMedia` and `getSupportedConstraints` +- No application code, API, or test behavior changes. + +## Non-goals + +- Implementing or refactoring hooks, helpers, or examples +- Changing public API signatures or state shape +- Adding workers, WebCodecs, or MediaRecorder coverage +- Expanding browser support beyond what already exists +- Syncing into `openspec/specs/` as part of this change (optional follow-up) + +## Capabilities + +### New Capabilities + +- `media-capture`: React hook and helpers for obtaining, managing, and releasing user/display `MediaStream`s, including lifecycle correctness and constraint discovery. + +### Modified Capabilities + +- (none) + +## Impact + +- Planning only: artifacts under `openspec/changes/baseline-media-capture/` +- Documents existing code in `packages/react-user-media` (`use-media.ts`, `close-media.ts`, `index.ts`) and tests (`use-media.spec.tsx`, `close-media.spec.ts`) +- No package publish or dependency impact diff --git a/openspec/changes/baseline-media-capture/specs/media-capture/spec.md b/openspec/changes/baseline-media-capture/specs/media-capture/spec.md new file mode 100644 index 0000000..b12930e --- /dev/null +++ b/openspec/changes/baseline-media-capture/specs/media-capture/spec.md @@ -0,0 +1,115 @@ +## ADDED Requirements + +### Requirement: User and display media acquisition +`useMedia` MUST accept a `type` of `"user"` or `"display"`. When `type` is `"user"`, `request` MUST obtain media via `navigator.mediaDevices.getUserMedia`. When `type` is `"display"`, `request` MUST obtain media via `navigator.mediaDevices.getDisplayMedia`. Optional request arguments MUST be forwarded to the corresponding browser API (`MediaStreamConstraints` for user, `DisplayMediaStreamOptions` for display). + +#### Scenario: User media request uses getUserMedia +- **WHEN** a consumer calls `useMedia("user")` and invokes `request` with constraints +- **THEN** the hook MUST call `navigator.mediaDevices.getUserMedia` with those constraints +- **AND** on success enter the ready state with the returned `MediaStream` + +#### Scenario: Display media request uses getDisplayMedia +- **WHEN** a consumer calls `useMedia("display")` and invokes `request` with options (for example `{ video: true }`) +- **THEN** the hook MUST call `navigator.mediaDevices.getDisplayMedia` with those options +- **AND** on success enter the ready state with the returned `MediaStream` + +### Requirement: Discriminated media state +`useMedia` MUST expose a discriminated state shape with mutually exclusive idle, loading, ready, and error modes, plus `request` and `stop` actions on every mode. + +- Idle: `isLoading: false`, `isError: false`, `isReady: false`, `error: null`, `media: undefined` +- Loading: `isLoading: true`, `isError: false`, `isReady: false`, `error: null`, `media: undefined` +- Ready: `isLoading: false`, `isError: false`, `isReady: true`, `error: null`, `media: MediaStream` +- Error: `isLoading: false`, `isError: true`, `isReady: false`, `error: Error`, `media: undefined` + +#### Scenario: Initial state is idle +- **WHEN** `useMedia` mounts before any `request` +- **THEN** the returned state MUST be idle (`isReady`, `isLoading`, and `isError` all false; `media` undefined; `error` null) + +#### Scenario: Request transitions through loading to ready +- **WHEN** `request` is invoked and the browser media promise resolves with a stream +- **THEN** state MUST first reflect loading while the request is in flight +- **AND** then reflect ready with that stream as `media` + +#### Scenario: Request failure yields error state +- **WHEN** `getUserMedia` rejects or throws synchronously +- **THEN** the hook MUST NOT throw to the caller +- **AND** state MUST become error with a non-null `Error` and undefined `media` + +#### Scenario: Missing getUserMedia capability yields error +- **WHEN** `type` is `"user"` and `navigator.mediaDevices.getUserMedia` is unavailable +- **THEN** invoking `request` MUST set error state with a message indicating getUserMedia is not available +- **AND** MUST NOT throw + +#### Scenario: Missing getDisplayMedia capability yields error +- **WHEN** `type` is `"display"` and `navigator.mediaDevices.getDisplayMedia` is unavailable +- **THEN** invoking `request` MUST set error state with a message indicating getDisplayMedia is not available +- **AND** MUST NOT throw + +### Requirement: Stop returns to idle and releases media +`stop` MUST increment the request generation, stop tracks on the current stream via `closeMedia`, clear `media` and `error`, clear loading, and return the hook to idle. + +#### Scenario: Stop clears ready user media +- **WHEN** the hook is ready with an active stream and the consumer calls `stop` +- **THEN** every track on that stream MUST be stopped +- **AND** state MUST return to idle with `media` undefined + +### Requirement: Re-request closes the previous stream +When `request` is invoked while a prior stream is held, the hook MUST close the prior stream before starting the new acquisition. + +#### Scenario: Re-request stops previous user media stream +- **WHEN** the hook is ready with stream A and the consumer calls `request` again +- **THEN** tracks on stream A MUST be stopped before stream B becomes ready +- **AND** ready state MUST expose stream B + +### Requirement: Stale in-flight requests must not leak or overwrite newer state +The hook MUST track request generations so that outcomes from superseded requests do not replace newer media or loading/error state. Streams that resolve after being superseded MUST be closed. + +#### Scenario: Stale promise after stop and re-request does not replace newer media +- **WHEN** an in-flight user media request is stopped, a newer request is started, the newer request resolves first, and then the older request resolves +- **THEN** ready state MUST keep the newer stream +- **AND** the older stream's tracks MUST be stopped +- **AND** the newer stream's tracks MUST remain live + +#### Scenario: Stream resolving after unmount is closed +- **WHEN** `request` is in flight, the component unmounts, and the media promise later resolves +- **THEN** the resolved stream's tracks MUST be stopped +- **AND** the unmounted component MUST NOT retain the stream as active media + +#### Scenario: In-flight user media ignored after type changes to display +- **WHEN** `useMedia("user")` has an in-flight request and the `type` prop changes to `"display"` +- **THEN** state MUST reset to idle without that user stream +- **AND** when the stale user media promise later resolves, its tracks MUST be stopped +- **AND** state MUST remain idle (or otherwise not adopt that user stream) + +### Requirement: Unmount and type-change cleanup +Changing `type` or unmounting MUST bump the request generation and close any currently held stream so tracks are not left live. + +#### Scenario: Unmount cleanup stops ready user media tracks +- **WHEN** the hook is ready with an active stream and the component unmounts +- **THEN** every track on that stream MUST be stopped + +#### Scenario: Type change closes held media and resets state +- **WHEN** `type` changes while the hook holds media or has an in-flight request +- **THEN** the hook MUST close any held stream, clear error/loading/media, and return to idle for the new type + +### Requirement: closeMedia stops all tracks +`closeMedia` MUST accept `MediaStream | undefined`. When given a stream, it MUST call `stop()` on every track from `getTracks()`. When given `undefined`, it MUST return without throwing. + +#### Scenario: closeMedia stops every track on the stream +- **WHEN** `closeMedia` is called with a stream that has multiple tracks +- **THEN** each track's `stop` MUST be invoked once + +#### Scenario: closeMedia accepts undefined without throwing +- **WHEN** `closeMedia(undefined)` is called +- **THEN** it MUST NOT throw + +### Requirement: getSupportedConstraints safe default +`getSupportedConstraints` MUST return `navigator.mediaDevices.getSupportedConstraints()` when available, otherwise an empty object. It MUST NOT throw when `navigator` or `mediaDevices` is missing. + +#### Scenario: Missing mediaDevices returns empty object +- **WHEN** `navigator.mediaDevices` is undefined +- **THEN** `getSupportedConstraints()` MUST return `{}` + +#### Scenario: Missing navigator returns empty object +- **WHEN** `globalThis.navigator` is unavailable +- **THEN** `getSupportedConstraints()` MUST return `{}` diff --git a/openspec/changes/baseline-media-capture/tasks.md b/openspec/changes/baseline-media-capture/tasks.md new file mode 100644 index 0000000..ed891a0 --- /dev/null +++ b/openspec/changes/baseline-media-capture/tasks.md @@ -0,0 +1,22 @@ +## 1. Spec–test coverage audit + +- [x] 1.1 Map each `media-capture` scenario in `specs/media-capture/spec.md` to a test in `use-media.spec.tsx` or `close-media.spec.ts` (note any scenario without a direct test) +- [x] 1.2 Confirm user vs display acquisition scenarios match `getUserMedia` / `getDisplayMedia` spies and args +- [x] 1.3 Confirm state transition scenarios (idle, loading→ready, error, stop→idle) match existing assertions +- [x] 1.4 Confirm lifecycle scenarios (re-request close, stale after stop/re-request, unmount ready, unmount in-flight, type change) match generation/cleanup tests +- [x] 1.5 Confirm `closeMedia` and `getSupportedConstraints` scenarios match `close-media.spec.ts` + +## 2. Gap documentation + +- [x] 2.1 Record uncovered scenarios (if any) as follow-up test candidates without changing production code in this baseline — gaps: (a) no dedicated initial-idle assert before `request`; (b) no explicit happy-path loading→ready sequence assert; (c) no `getUserMedia` `toHaveBeenCalledWith(constraints)` assert (display has args assert); (d) no async `getUserMedia` rejection path (sync throw only); (e) no missing-`getDisplayMedia` capability test; (f) type-change covers in-flight only, not ready-held media +- [x] 2.2 Confirm proposal Non-goals still hold (no API/behavior changes implied by tasks) + +## 3. Optional main-spec sync + +- [x] 3.1 Decide whether to sync `media-capture` into `openspec/specs/` via archive/sync workflow (deferred — keep as change delta) +- [x] 3.2 If syncing, run the project’s OpenSpec sync/archive path so main specs mirror this baseline unchanged (deferred — keep as change delta) + +## 4. Validation + +- [x] 4.1 Run `OPENSPEC_TELEMETRY=0 npx --yes @fission-ai/openspec validate baseline-media-capture --json` and fix artifact issues until valid +- [x] 4.2 Run package tests for media capture (`use-media.spec.tsx`, `close-media.spec.ts`) to confirm baseline still matches green tests diff --git a/openspec/changes/baseline-media-devices/.openspec.yaml b/openspec/changes/baseline-media-devices/.openspec.yaml new file mode 100644 index 0000000..9e5b8a1 --- /dev/null +++ b/openspec/changes/baseline-media-devices/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-23 diff --git a/openspec/changes/baseline-media-devices/design.md b/openspec/changes/baseline-media-devices/design.md new file mode 100644 index 0000000..482e8af --- /dev/null +++ b/openspec/changes/baseline-media-devices/design.md @@ -0,0 +1,89 @@ +## Context + +`@bengreenier/react-user-media` already implements device enumeration as React hooks over `navigator.mediaDevices.enumerateDevices`. This design records the as-built architecture for the `media-devices` capability so future OpenSpec deltas can change behavior against a known baseline. No runtime changes are introduced by this change. + +Primary sources: + +- `packages/react-user-media/src/hooks/use-media-devices.ts` — `useMediaDevices`, `MediaDeviceState`, `UseMediaDeviceOptions` +- `packages/react-user-media/src/hooks/use-media-devices-ext.ts` — kind-filtered wrappers +- `packages/react-user-media/src/hooks/use-media-devices.spec.tsx` — behavioral contract +- Public re-exports via `packages/react-user-media/src/hooks/index.ts` and package entry + +## Goals / Non-Goals + +**Goals:** + +- Document the request-driven state machine (idle / loading / ready / error) +- Capture filtering, `devicechange` subscription, and stale-request invalidation as designed +- Describe how extension hooks compose filters without owning separate enumeration logic + +**Non-Goals:** + +- Changing hook APIs, defaults, or implementation +- Specifying capture (`useMedia`), tracks, players, or MediaRecorder +- Adding permission prompts, device selection UI, or label-enrichment strategies beyond what the browser returns from `enumerateDevices` + +## Decisions + +### 1. Explicit `request()`; no mount-time enumeration + +**Choice:** Start idle; enumeration only when `request()` runs (or when `devicechange` fires and monitoring is enabled). + +**Rationale:** Enumeration can be gated by secure context and permission state; auto-calling on mount would surprise callers and complicate SSR / unavailable `mediaDevices` cases. Tests assert idle-on-mount and no throw when `mediaDevices` is missing until `request()`. + +**Alternatives considered:** Auto-enumerate on mount (rejected for control and testability). + +### 2. Discriminated-style flags with shared shape + +**Choice:** Expose `isLoading` / `isError` / `isReady`, `error`, `devices`, and `request` as a TypeScript union (`MediaDeviceState`) while returning a single object cast from a shallow shape. + +**Rationale:** Matches library convention for other media hooks; consumers can narrow on flags. Runtime avoids exhaustive branch checks for cost. + +**Alternatives considered:** Separate return tuples or status enum string (not used elsewhere in this package). + +### 3. Filter applied at settle time via ref + +**Choice:** Store `filter` in a ref updated each render; apply `devices.filter(filterRef.current)` when the promise succeeds. Default filter is `() => true`. + +**Rationale:** `request` is stable (`useCallback` with `[]`); using a ref keeps the latest filter without recreating `request` or ignoring mid-flight option changes. Filter throws are caught and surfaced as error state so loading cannot stick. + +**Alternatives considered:** Close over filter in `request` (would freeze filter at call time; tests require latest filter). + +### 4. `deviceChangedEvent` defaults to true + +**Choice:** Subscribe to `devicechange` when enabled and call `request()`; tear down the listener on option change / unmount. Default `true`. + +**Rationale:** Device lists go stale when hardware is plugged/unplugged; opt-out exists for tests and callers who manage refresh themselves. + +**Alternatives considered:** Default false (would require every consumer to opt in for live updates). + +### 5. Request generation counter for concurrency and unmount + +**Choice:** Increment a generation on each `request()` and on unmount teardown; ignore success/error callbacks whose generation no longer matches. + +**Rationale:** Overlapping enumerate calls and post-unmount resolutions must not clobber newer UI state or update unmounted trees. + +**Alternatives considered:** AbortController (not available on `enumerateDevices` itself); ignore only on unmount (insufficient for overlapping requests). + +### 6. Kind filters as thin wrappers + +**Choice:** Extension hooks call `useMediaDevices` with a composed filter: kind predicate AND optional caller `filter`. + +**Rationale:** Single enumeration implementation; kind helpers stay small and share lifecycle / options behavior. Audio/video use `kind.startsWith(...)`; input/output use exact `kind` equality. + +**Alternatives considered:** Separate hooks with duplicated enumerate logic (rejected). + +## Risks / Trade-offs + +- **[Risk] Labels empty without prior permission** → Mitigation: documented browser behavior; out of scope for this baseline to fabricate labels. +- **[Risk] `devicechange` only in secure contexts** → Mitigation: listener gated on `addEventListener`; missing API simply skips monitoring. +- **[Risk] Filter side effects on every settle** → Mitigation: filter is expected to be pure; throws become error state. +- **[Trade-off] State returned via cast, not runtime narrowing** → Callers rely on TypeScript; incorrect flag combinations are not runtime-validated. + +## Migration Plan + +Not applicable — documentation-only baseline. Optional later step: archive/sync into `openspec/specs/media-devices/` after review. + +## Open Questions + +- None for as-built documentation. Future deltas may decide whether to auto-request, expose permission-aware label refresh, or unify state helpers across hooks. diff --git a/openspec/changes/baseline-media-devices/proposal.md b/openspec/changes/baseline-media-devices/proposal.md new file mode 100644 index 0000000..595e428 --- /dev/null +++ b/openspec/changes/baseline-media-devices/proposal.md @@ -0,0 +1,36 @@ +## Why + +The library already ships `useMediaDevices` and kind-filtered extensions for `enumerateDevices`, but there is no OpenSpec baseline for that behavior. Documenting it as-built gives future deltas a stable contract for request lifecycle, filtering, and `devicechange` handling without changing runtime behavior. + +## What Changes + +- Add an OpenSpec capability `media-devices` that records existing requirements for: + - `useMediaDevices` with idle → loading → ready | error state, `devices`, and `request()` + - Options `filter` and `deviceChangedEvent` (default `true`); no auto-request on mount + - Kind-filtered extensions: `useMediaAudioDevices`, `useMediaAudioInputDevices`, `useMediaAudioOutputDevices`, `useMediaVideoDevices` + - Request generations, filter exceptions, and unmount invalidation +- No application code, API, or test behavior changes. + +## Non-goals + +- Implementing or refactoring hooks, extensions, or examples +- Changing public API signatures or state shape +- Covering `useMedia`, MediaRecorder, players, or track helpers +- Expanding browser support beyond what already exists +- Syncing into `openspec/specs/` as part of this change (optional follow-up) + +## Capabilities + +### New Capabilities + +- `media-devices`: React hooks for enumerating media devices via `navigator.mediaDevices.enumerateDevices`, including optional filtering, `devicechange` re-request, and kind-scoped convenience hooks. + +### Modified Capabilities + +- (none) + +## Impact + +- Planning only: artifacts under `openspec/changes/baseline-media-devices/` +- Documents existing code in `packages/react-user-media` (`use-media-devices.ts`, `use-media-devices-ext.ts`, package exports) and tests (`use-media-devices.spec.tsx`) +- No package publish or dependency impact diff --git a/openspec/changes/baseline-media-devices/specs/media-devices/spec.md b/openspec/changes/baseline-media-devices/specs/media-devices/spec.md new file mode 100644 index 0000000..a489f17 --- /dev/null +++ b/openspec/changes/baseline-media-devices/specs/media-devices/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: Explicit request lifecycle with idle default +`useMediaDevices` MUST start in an idle state and MUST NOT call `enumerateDevices` on mount. Callers MUST obtain devices only by invoking `request()`. + +#### Scenario: Idle before any request +- **WHEN** a component mounts `useMediaDevices` without calling `request()` +- **THEN** the hook returns `isLoading: false`, `isError: false`, `isReady: false`, `error: null`, and `devices: undefined` + +#### Scenario: Mount is safe when mediaDevices is missing +- **WHEN** `navigator.mediaDevices` is unavailable and the hook mounts with default options +- **THEN** rendering MUST NOT throw and the hook MUST remain idle + +### Requirement: Enumerate devices on request +Calling `request()` MUST transition through loading and then ready or error based on `navigator.mediaDevices.enumerateDevices`. + +#### Scenario: Successful enumeration +- **WHEN** the caller invokes `request()` and `enumerateDevices` resolves with one or more `MediaDeviceInfo` values +- **THEN** the hook MUST set `isReady: true`, `isLoading: false`, `isError: false`, `error: null`, and `devices` to the (filtered) result list + +#### Scenario: Loading clears prior devices +- **WHEN** the caller invokes `request()` while `enumerateDevices` is available +- **THEN** the hook MUST set `isLoading: true`, clear `devices` to `undefined`, and clear `error` to `null` before the promise settles + +#### Scenario: Enumerate failure +- **WHEN** `enumerateDevices` rejects with an error +- **THEN** the hook MUST set `isError: true`, `isLoading: false`, `isReady: false`, `devices: undefined`, and `error` to that error (or a wrapped `Error`) + +#### Scenario: enumerateDevices unavailable +- **WHEN** the caller invokes `request()` and `navigator.mediaDevices` or `enumerateDevices` is unavailable +- **THEN** the hook MUST set `isError: true`, `isLoading: false`, `isReady: false`, `devices: undefined`, and `error` with message `enumerateDevices is not available. Are you in a secure context?` + +### Requirement: Optional device filter +`UseMediaDeviceOptions.filter` MUST limit which devices appear in the ready `devices` array. The default filter MUST include all devices. The filter in effect when a pending request resolves MUST be applied (latest filter via ref). + +#### Scenario: Filter throws +- **WHEN** `enumerateDevices` resolves and the configured `filter` throws +- **THEN** the hook MUST set `isError: true`, `isLoading: false`, `devices: undefined`, and surface the thrown error (not remain loading) + +#### Scenario: Latest filter applies to pending request +- **WHEN** `request()` is in flight and the caller changes `filter` before the promise resolves +- **THEN** the ready `devices` list MUST reflect the latest filter, not the filter from request start + +### Requirement: devicechange monitoring +When `deviceChangedEvent` is `true` (the default), the hook MUST listen for `navigator.mediaDevices` `devicechange` and re-invoke `request()`. When `deviceChangedEvent` is `false`, the hook MUST NOT re-request on `devicechange`. + +#### Scenario: Re-request on devicechange +- **WHEN** `deviceChangedEvent` is `true` and a `devicechange` event fires after a successful request +- **THEN** the hook MUST call `enumerateDevices` again and update `devices` with the new result + +#### Scenario: Opt out of devicechange +- **WHEN** `deviceChangedEvent` is `false` and a `devicechange` event fires +- **THEN** the hook MUST NOT call `enumerateDevices` again solely because of that event + +### Requirement: Ignore stale async results +Concurrent or superseded `enumerateDevices` outcomes MUST NOT overwrite newer state. Unmount MUST invalidate in-flight requests. + +#### Scenario: Newer request wins over stale success +- **WHEN** two `request()` calls overlap and the older promise resolves after the newer one has already produced ready state +- **THEN** the hook MUST keep the newer ready result and MUST NOT replace it with the stale device list + +#### Scenario: Newer request wins over stale rejection +- **WHEN** two `request()` calls overlap, the newer request succeeds, and the older promise later rejects +- **THEN** the hook MUST remain ready with the newer devices and MUST NOT transition to error from the stale rejection + +#### Scenario: Results after unmount are ignored +- **WHEN** the component unmounts while `enumerateDevices` is pending and the promise later resolves +- **THEN** the hook MUST NOT apply that result to React state (no post-unmount update from that generation) + +### Requirement: Kind-filtered extension hooks +The package MUST export convenience hooks that wrap `useMediaDevices` with kind filters, still accepting `UseMediaDeviceOptions` and composing with a caller `filter` when provided. + +#### Scenario: Audio and video kind prefixes +- **WHEN** `useMediaAudioDevices` and `useMediaVideoDevices` request devices from a mixed list +- **THEN** audio MUST include devices whose `kind` starts with `audio`, and video MUST include devices whose `kind` starts with `video` + +#### Scenario: Audio input and output exact kinds +- **WHEN** `useMediaAudioInputDevices` and `useMediaAudioOutputDevices` request devices from a mixed list +- **THEN** input MUST include only `kind === "audioinput"` and output MUST include only `kind === "audiooutput"` diff --git a/openspec/changes/baseline-media-devices/tasks.md b/openspec/changes/baseline-media-devices/tasks.md new file mode 100644 index 0000000..54c6e80 --- /dev/null +++ b/openspec/changes/baseline-media-devices/tasks.md @@ -0,0 +1,16 @@ +## 1. Spec coverage verification + +- [x] 1.1 Confirm `useMediaDevices` idle-on-mount and no auto-request match scenarios in `specs/media-devices/spec.md` against `use-media-devices.ts` / `use-media-devices.spec.tsx` +- [x] 1.2 Confirm loading → ready | error, secure-context error message, and filter (including throw + latest-filter) scenarios match existing tests +- [x] 1.3 Confirm `deviceChangedEvent` default/opt-out and stale/unmount generation scenarios match existing tests +- [x] 1.4 Confirm extension hooks (`useMediaAudioDevices`, `useMediaAudioInputDevices`, `useMediaAudioOutputDevices`, `useMediaVideoDevices`) kind filters match spec scenarios + +## 2. Regression checks + +- [x] 2.1 Run package tests for media devices: `pnpm --filter @bengreenier/react-user-media test -- use-media-devices` +- [x] 2.2 Confirm public exports of hooks and types remain available from package entry (`hooks/index.ts` / `src/index.ts`) with no source edits required + +## 3. OpenSpec hygiene + +- [x] 3.1 Run `OPENSPEC_TELEMETRY=0 npx --yes @fission-ai/openspec validate --change baseline-media-devices` and resolve any reported issues +- [x] 3.2 Optionally archive/sync this capability into `openspec/specs/media-devices/` in a follow-up (out of scope for apply of this baseline) (deferred — keep as change delta) diff --git a/openspec/changes/baseline-media-players/.openspec.yaml b/openspec/changes/baseline-media-players/.openspec.yaml new file mode 100644 index 0000000..9e5b8a1 --- /dev/null +++ b/openspec/changes/baseline-media-players/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-23 diff --git a/openspec/changes/baseline-media-players/design.md b/openspec/changes/baseline-media-players/design.md new file mode 100644 index 0000000..210de31 --- /dev/null +++ b/openspec/changes/baseline-media-players/design.md @@ -0,0 +1,77 @@ +## Context + +This change baselines existing `VideoPlayer` and `AudioPlayer` components in `packages/react-user-media`. Both are thin `forwardRef` wrappers around native `