import type { ConnectionState, MidiControllerPlugin, } from "@suaveplan/audio-controllers"; import { captureNextLearn } from "@suaveplan/audio-controllers"; import { createContext, useContext, useSyncExternalStore } from "react"; import { REACT_AUDIO_CODES, ReactAudioError, } from "../../error-codes/error-codes.js"; /** * A `MidiControllerRegistry` is any read-only lookup keyed by * `controllerId` returning the live `MidiControllerPlugin` instance. * * In practice an application wires the registry once at the top of its * React tree via `` * and the Phase-3 surface (`useMidiController` and * ``) reads from it. */ export type MidiControllerRegistry = ReadonlyMap; /** * React context distributing the `MidiControllerRegistry` to the * Phase-3 MIDI surface. Defaults to an empty registry so consumers * outside the provider see "controller not found" instead of "null * context" (which would couple to the `` lifecycle). */ export const MidiControllerRegistryContext = createContext< MidiControllerRegistry | undefined >(undefined); MidiControllerRegistryContext.displayName = "SuaveplanMidiControllerRegistry"; /** * Discriminated learn target — every concrete plugin parameter that * `useMidiController(...).learn(target)` will bind to. Keeping the * shape parallel to `bindParameter`'s call-site makes wiring trivial. */ export interface LearnTarget { readonly pluginId: string; readonly paramId: string; } export interface UseMidiControllerResult { /** The resolved controller. `undefined` if the id is unknown. */ readonly controller: MidiControllerPlugin | undefined; /** * Current FSM connection state, mirrored from the controller's * `state` event. Returns `'disconnected'` when the controller is * unknown so consumers can render a graceful empty state. */ readonly state: ConnectionState; /** * Enter learn mode and resolve with the captured `(channel, cc)` * pair. Rejects with `REACT_AUDIO.MIDI_LEARN_FAILED` if no * controller is bound. */ learn(target: LearnTarget): Promise<{ readonly target: LearnTarget; readonly channel: number; readonly controller: number; }>; } const EMPTY_UNSUBSCRIBE = (): void => undefined; /** * React hook subscribing to a single controller's connection-state * event surface via `useSyncExternalStore`. Returns the live * controller, its current state, and a `learn(target)` method that * delegates to `captureNextLearn` from `@suaveplan/audio-controllers`. * * The lookup runs against the nearest * ``; an explicit `registry` argument * overrides the context value (handy for tests). */ export function useMidiController( controllerId: string, registryOverride?: MidiControllerRegistry ): UseMidiControllerResult { const ctxRegistry = useContext(MidiControllerRegistryContext); const registry = registryOverride ?? ctxRegistry; const controller = registry?.get(controllerId); const subscribe = (notify: () => void): (() => void) => { if (!controller) return EMPTY_UNSUBSCRIBE; return controller.on("state", notify); }; const getSnapshot = (): ConnectionState => controller ? controller.state : "disconnected"; const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const learn = async ( target: LearnTarget ): Promise<{ readonly target: LearnTarget; readonly channel: number; readonly controller: number; }> => { if (!controller) { throw new ReactAudioError( REACT_AUDIO_CODES.MIDI_LEARN_FAILED, "react-audio.midi-learn-no-controller", { context: { controllerId, target } } ); } try { const captured = await captureNextLearn(controller); return { target, channel: captured.channel, controller: captured.controller, }; } catch (cause) { throw new ReactAudioError( REACT_AUDIO_CODES.MIDI_LEARN_FAILED, "react-audio.midi-learn-capture-failed", { context: { controllerId, target }, cause: cause instanceof Error ? cause : undefined, } ); } }; return { controller, state, learn }; }