tiny-server-essentials
Version:
A good utility toolkit to unify Express v5 and Socket.IO v4 into a seamless development experience with modular helpers, server wrappers, and WebSocket tools.
1,082 lines (1,081 loc) • 45.4 kB
JavaScript
import { EventEmitter } from 'events';
import { TinyPromiseQueue } from 'tiny-essentials';
import VolumeMeter from './VolumeMeter.mjs';
import TinyMediaReceiver from './TinyMediaReceiver.mjs';
/** @typedef {'mic'|'cam'|'screen'} StreamTypes */
/** @typedef {'Mic'|'Cam'|'Screen'} StreamEventTypes */
/** @typedef {import('./TinyMediaReceiver.mjs').ReceiverTags} ReceiverTags */
/**
* @typedef {Object} StreamConfig
* @property {string} mimeType - A valid MIME type for MediaRecorder.
* @property {number} timeslice - Interval in milliseconds for emitting data chunks.
* @property {string|null} audioCodec - Audio codec that can be used
* @property {string|null} videoCodec - Video codec that can be used
*/
/**
* @typedef {Object} MandatoryConstraints
* @property {'screen'|'window'|'application'|'desktop'} [chromeMediaSource]
* Capture source for Chrome/Electron.
* @property {string} [chromeMediaSourceId]
* Specific ID of the capture source (usually obtained via desktopCapturer).
* @property {number} [maxWidth]
* Maximum width of the capture.
* @property {number} [maxHeight]
* Maximum height of the capture.
* @property {number} [maxFrameRate]
* Maximum frame rate.
* @property {number} [minWidth]
* Minimum width of the capture.
* @property {number} [minHeight]
* Minimum height of the capture.
* @property {number} [minFrameRate]
* Minimum frame rate.
* @property {boolean} [googLeakyBucket]
* Experimental setting used in some Chromium versions.
* @property {boolean} [googTemporalLayeredScreencast]
* Experimental setting for temporal layers in screen capture.
*/
/**
* @typedef {Object} AdvancedScreenVideoConstraints
* @property {'screen'|'window'|'application'|'browser'|'monitor'} [mediaSource]
* Capture source, useful in Electron and Firefox.
* @property {string} [chromeMediaSource]
* Used in older versions of Chrome/Electron. Usually 'desktop'.
* @property {string} [chromeMediaSourceId]
* Used in Electron to select a specific screen or window.
* @property {number} [frameRate]
* Desired capture frame rate (e.g., 30 or 60).
* @property {number} [width]
* Ideal capture width.
* @property {number} [height]
* Ideal capture height.
* @property {MandatoryConstraints} [mandatory]
* Allows configuring advanced fields in Electron/Chrome (like `chromeMediaSource`).
*/
/**
* @typedef {Object} ScreenShareConstraints
* @property {boolean|MediaTrackConstraints} [audio]
* @property {boolean|AdvancedScreenVideoConstraints|MediaTrackConstraints} video
* Constraints for screen capture, can be simple (true) or detailed.
*/
/**
* Manages media streams (microphone, camera, screen) with flexible device constraints,
* socket emission support, and audio volume metering.
*
* This class:
* - Allows starting and managing media input streams (mic, cam, screen).
* - Supports custom constraints and device targeting via deviceId or full constraint objects.
* - Emits media streams to a socket or handler using predefined labels (mic, cam, screen, etc.).
* - Provides audio volume metering for microphone and screen when audio is available.
* - Supports Electron-specific constraints when applicable.
*
* Events emitted:
* - `"Mic"`: Audio stream from microphone.
* - `"MicMeter"`: Volume level from microphone.
* - `"Cam"`: Video stream from webcam.
* - `"Screen"`: Video stream from screen capture.
* - `"ScreenMeter"`: Volume level from screen audio.
*
* Internally uses:
* - `navigator.mediaDevices.getUserMedia` for mic/cam.
* - `navigator.mediaDevices.getDisplayMedia` for screen sharing.
* - Optional support for `chromeMediaSource` and related properties in Electron environments.
*
* @class
* @beta
*/
export class TinyStreamManager {
#loadingDevices = false;
#queue = new TinyPromiseQueue();
#firstLoad = false;
/** @type {Map<string, TinyMediaReceiver>} */
#streams = new Map();
/**
* Important instance used to make event emitter.
* @type {EventEmitter}
*/
#events = new EventEmitter();
/**
* Important instance used to make system event emitter.
* @type {EventEmitter}
*/
#sysEvents = new EventEmitter();
#sysEventsUsed = false;
/**
* Event labels used internally and externally for stream control and monitoring.
* These events are emitted or listened to over socket or internal dispatch.
* @readonly
*/
Events = {
/**
* Event name emitted when the instance is destroyed.
* This constant can be used to subscribe to the destruction event of the instance.
* @type {'Destroyed'}
*/
Destroyed: 'Destroyed',
/**
* Emitted when a media data receiver (e.g., WebSocket, PeerConnection, etc.) has been removed.
* This may happen when a connection is closed or explicitly terminated.
* @type {'ReceiverDeleted'}
*/
ReceiverDeleted: 'ReceiverDeleted',
/**
* Emitted when a new media data receiver has been added to the stream.
* Useful for dynamic systems where receivers can join at runtime.
* @type {'ReceiverAdded'}
*/
ReceiverAdded: 'ReceiverAdded',
/**
* Event emitted to request starting the webcam stream.
* @type {'StartCam'}
*/
StartCam: 'StartCam',
/**
* Event emitted to request starting the microphone stream.
* @type {'StartMic'}
*/
StartMic: 'StartMic',
/**
* Event emitted to request starting the screen sharing stream.
* @type {'StartScreen'}
*/
StartScreen: 'StartScreen',
/**
* Event emitted to request stopping the webcam stream.
* @type {'StopCam'}
*/
StopCam: 'StopCam',
/**
* Event emitted to request stopping the microphone stream.
* @type {'StopMic'}
*/
StopMic: 'StopMic',
/**
* Event emitted to request stopping the screen sharing stream.
* @type {'StopScreen'}
*/
StopScreen: 'StopScreen',
/**
* Event emitted when the webcam stream is transmitted.
* @type {'Cam'}
*/
Cam: 'Cam',
/**
* Event emitted when the microphone stream is transmitted.
* @type {'Mic'}
*/
Mic: 'Mic',
/**
* Event emitted when the screen sharing stream is transmitted.
* @type {'Screen'}
*/
Screen: 'Screen',
/**
* Event emitted periodically with screen audio volume data.
* @type {'ScreenMeter'}
*/
ScreenMeter: 'ScreenMeter',
/**
* Event emitted periodically with microphone audio volume data.
* @type {'MicMeter'}
*/
MicMeter: 'MicMeter',
};
/**
* Checks whether a given event name is defined in the Events map.
*
* This method verifies if the provided string matches one of the predefined
* event labels (e.g., "Mic", "Cam", "Screen", "MicMeter", "ScreenMeter").
*
* @param {string} name - The name of the event to check.
* @returns {boolean} Returns `true` if the event exists in the Events map, otherwise `false`.
*/
existsEvent(name) {
// @ts-ignore
if (typeof this.Events[name] === 'string')
return true;
return false;
}
/**
* Emits an event with optional arguments to all system emit.
* @param {string | symbol} event - The name of the event to emit.
* @param {...any} args - Arguments passed to event listeners.
*/
#emit(event, ...args) {
this.#events.emit(event, ...args);
if (this.#sysEventsUsed)
this.#sysEvents.emit(event, ...args);
}
/**
* Provides access to a secure internal EventEmitter for subclass use only.
*
* This method exposes a dedicated EventEmitter instance intended specifically for subclasses
* that extend the main class. It prevents subclasses from accidentally or intentionally using
* the primary class's public event system (`emit`), which could lead to unpredictable behavior
* or interference in the base class's event flow.
*
* For security and consistency, this method is designed to be accessed only once.
* Multiple accesses are blocked to avoid leaks or misuse of the internal event bus.
*
* @returns {EventEmitter} A special internal EventEmitter instance for subclass use.
* @throws {Error} If the method is called more than once.
*/
getSysEvents() {
if (this.#sysEventsUsed)
throw new Error('Access denied: getSysEvents() can only be called once. ' +
'This restriction ensures subclass event isolation and prevents accidental interference ' +
'with the main class event emitter.');
this.#sysEventsUsed = true;
return this.#sysEvents;
}
/**
* @typedef {(...args: any[]) => void} ListenerCallback
* A generic callback function used for event listeners.
*/
/**
* Sets the maximum number of listeners for the internal event emitter.
*
* @param {number} max - The maximum number of listeners allowed.
*/
setMaxListeners(max) {
this.#events.setMaxListeners(max);
}
/**
* Emits an event with optional arguments.
* @param {string | symbol} event - The name of the event to emit.
* @param {...any} args - Arguments passed to event listeners.
* @returns {boolean} `true` if the event had listeners, `false` otherwise.
*/
emit(event, ...args) {
return this.#events.emit(event, ...args);
}
/**
* Registers a listener for the specified event.
* @param {string | symbol} event - The name of the event to listen for.
* @param {ListenerCallback} listener - The callback function to invoke.
* @returns {this} The current class instance (for chaining).
*/
on(event, listener) {
this.#events.on(event, listener);
return this;
}
/**
* Registers a one-time listener for the specified event.
* @param {string | symbol} event - The name of the event to listen for once.
* @param {ListenerCallback} listener - The callback function to invoke.
* @returns {this} The current class instance (for chaining).
*/
once(event, listener) {
this.#events.once(event, listener);
return this;
}
/**
* Removes a listener from the specified event.
* @param {string | symbol} event - The name of the event.
* @param {ListenerCallback} listener - The listener to remove.
* @returns {this} The current class instance (for chaining).
*/
off(event, listener) {
this.#events.off(event, listener);
return this;
}
/**
* Alias for `on`.
* @param {string | symbol} event - The name of the event.
* @param {ListenerCallback} listener - The callback to register.
* @returns {this} The current class instance (for chaining).
*/
addListener(event, listener) {
this.#events.addListener(event, listener);
return this;
}
/**
* Alias for `off`.
* @param {string | symbol} event - The name of the event.
* @param {ListenerCallback} listener - The listener to remove.
* @returns {this} The current class instance (for chaining).
*/
removeListener(event, listener) {
this.#events.removeListener(event, listener);
return this;
}
/**
* Removes all listeners for a specific event, or all events if no event is specified.
* @param {string | symbol} [event] - The name of the event. If omitted, all listeners from all events will be removed.
* @returns {this} The current class instance (for chaining).
*/
removeAllListeners(event) {
this.#events.removeAllListeners(event);
return this;
}
/**
* Returns the number of times the given `listener` is registered for the specified `event`.
* If no `listener` is passed, returns how many listeners are registered for the `event`.
* @param {string | symbol} eventName - The name of the event.
* @param {Function} [listener] - Optional listener function to count.
* @returns {number} Number of matching listeners.
*/
listenerCount(eventName, listener) {
return this.#events.listenerCount(eventName, listener);
}
/**
* Adds a listener function to the **beginning** of the listeners array for the specified event.
* The listener is called every time the event is emitted.
* @param {string | symbol} eventName - The event name.
* @param {ListenerCallback} listener - The callback function.
* @returns {this} The current class instance (for chaining).
*/
prependListener(eventName, listener) {
this.#events.prependListener(eventName, listener);
return this;
}
/**
* Adds a **one-time** listener function to the **beginning** of the listeners array.
* The next time the event is triggered, this listener is removed and then invoked.
* @param {string | symbol} eventName - The event name.
* @param {ListenerCallback} listener - The callback function.
* @returns {this} The current class instance (for chaining).
*/
prependOnceListener(eventName, listener) {
this.#events.prependOnceListener(eventName, listener);
return this;
}
/**
* Returns an array of event names for which listeners are currently registered.
* @returns {(string | symbol)[]} Array of event names.
*/
eventNames() {
return this.#events.eventNames();
}
/**
* Gets the current maximum number of listeners allowed for any single event.
* @returns {number} The max listener count.
*/
getMaxListeners() {
return this.#events.getMaxListeners();
}
/**
* Returns a copy of the listeners array for the specified event.
* @param {string | symbol} eventName - The event name.
* @returns {Function[]} An array of listener functions.
*/
listeners(eventName) {
return this.#events.listeners(eventName);
}
/**
* Returns a copy of the internal listeners array for the specified event,
* including wrapper functions like those used by `.once()`.
* @param {string | symbol} eventName - The event name.
* @returns {Function[]} An array of raw listener functions.
*/
rawListeners(eventName) {
return this.#events.rawListeners(eventName);
}
/**
* @type {{
* audio: MediaDeviceInfo[];
* speaker: MediaDeviceInfo[];
* video: MediaDeviceInfo[];
* }}
*/
#devices = {
audio: [],
speaker: [],
video: [],
};
/** @type {VolumeMeter|null} */
micMeter = null;
/** @type {VolumeMeter|null} */
screenMeter = null;
/** @type {MediaStream|null} */
micStream = null;
/** @type {MediaStream|null} */
camStream = null;
/** @type {MediaStream|null} */
screenStream = null;
/** @type {Map<string, MediaRecorder>} */
#recorders = new Map();
/**
* Interval ID returned by setInterval, used to clear the interval later.
* @type {NodeJS.Timeout | null}
*/
#monitorIntervalId = null;
/**
* Starts the interval that monitors volume meters for mic and screen.
*
* This function sets up a `setInterval` that continuously reads audio volume
* from active meters (if any), calculates a percentage level, and emits
* events like `'micMeter'` and `'screenMeter'` with detailed volume info.
*
* It will not create a new interval if one is already running, or if no meters are active.
*/
#startMonitorInterval() {
if (!this.#monitorIntervalId && (this.hasMicMeter() || this.hasScreenMeter())) {
this.#monitorIntervalId = setInterval(() => {
/**
* @param {string} eventName
* @param {VolumeMeter} audio
*/
const emitData = (eventName, audio) => {
let perc = audio.volume * 1000;
perc = perc < 100 ? (perc > 0 ? perc : 0) : 100;
this.#emit(eventName, { audio, perc, vol: audio.volume });
};
if (this.hasMicMeter())
emitData(this.Events.MicMeter, this.getMicMeter());
if (this.hasScreenMeter())
emitData(this.Events.ScreenMeter, this.getScreenMeter());
}, 1);
}
}
/**
* Stops the interval responsible for monitoring volume meters.
*
* This function clears the active monitor interval if it exists
* and no mic or screen meters are currently active.
*/
#stopMonitorInterval() {
if (this.#monitorIntervalId && !this.hasMicMeter() && !this.hasScreenMeter()) {
clearInterval(this.#monitorIntervalId);
this.#monitorIntervalId = null;
}
}
/**
* Configuration used for microphone streaming.
*
* @type {StreamConfig}
*/
#micConfig = {
mimeType: 'audio/webm',
audioCodec: 'opus',
videoCodec: null,
timeslice: 100,
};
/**
* Configuration used for camera streaming.
*
* @type {StreamConfig}
*/
#camConfig = {
mimeType: 'video/webm',
audioCodec: null,
videoCodec: 'vp9',
timeslice: 100,
};
/**
* Configuration used for screen streaming.
*
* Framerate (FPS) Timeslice
* 60 FPS ~16.6 ms
* 30 FPS ~33.3 ms
* 15 FPS ~66.6 ms
* 10 FPS ~100 ms
*
* @type {StreamConfig}
*/
#screenConfig = {
mimeType: 'video/webm',
audioCodec: 'opus',
videoCodec: 'vp9',
timeslice: 500,
};
/**
* Updates the configuration for a specific media source.
*
* @param {StreamTypes} target - The config to update.
* @param {{ mimeType?: string, timeslice?: number }} updates - The new configuration values.
*
* @throws {Error} If the target is invalid.
* @throws {Error} If the mimeType is invalid or unsupported.
* @throws {Error} If the timeslice is not a positive finite number.
*/
updateMediaConfig(target, updates = {}) {
if (!['mic', 'cam', 'screen'].includes(target))
throw new Error(`Invalid config target: "${target}"`);
const currentConfig = {
mic: this.#micConfig,
cam: this.#camConfig,
screen: this.#screenConfig,
}[target];
const updated = { ...currentConfig };
if ('mimeType' in updates) {
const mime = updates.mimeType;
if (typeof mime !== 'string' || !MediaRecorder.isTypeSupported(mime))
throw new Error(`Invalid or unsupported MIME type: "${mime}"`);
updated.mimeType = mime;
}
if ('timeslice' in updates) {
const slice = updates.timeslice;
if (typeof slice !== 'number' || !Number.isFinite(slice) || slice <= 0)
throw new Error(`Invalid timeslice: must be a positive number (got ${slice})`);
updated.timeslice = slice;
}
switch (target) {
case 'mic':
this.#micConfig = updated;
break;
case 'cam':
this.#camConfig = updated;
break;
case 'screen':
this.#screenConfig = updated;
break;
}
}
/**
* Returns the current microphone stream if it is a valid MediaStream.
*
* @returns {MediaStream} The active microphone stream.
* @throws {Error} If the microphone stream is not a valid MediaStream.
*/
getMicStream() {
if (!(this.micStream instanceof MediaStream))
throw new Error('Microphone stream is not a valid MediaStream');
return this.micStream;
}
/**
* Returns the current webcam stream if it is a valid MediaStream.
*
* @returns {MediaStream} The active webcam stream.
* @throws {Error} If the webcam stream is not a valid MediaStream.
*/
getCamStream() {
if (!(this.camStream instanceof MediaStream))
throw new Error('Webcam stream is not a valid MediaStream');
return this.camStream;
}
/**
* Returns the current screen sharing stream if it is a valid MediaStream.
*
* @returns {MediaStream} The active screen sharing stream.
* @throws {Error} If the screen stream is not a valid MediaStream.
*/
getScreenStream() {
if (!(this.screenStream instanceof MediaStream))
throw new Error('Screen stream is not a valid MediaStream');
return this.screenStream;
}
/**
* Returns the current microphone volume meter if it is a valid VolumeMeter instance.
*
* @returns {VolumeMeter} The active microphone volume meter.
* @throws {Error} If the microphone meter is not a valid VolumeMeter instance.
*/
getMicMeter() {
if (!(this.micMeter instanceof VolumeMeter))
throw new Error('Microphone meter is not a valid VolumeMeter instance.');
return this.micMeter;
}
/**
* Returns the current screen volume meter if it is a valid VolumeMeter instance.
*
* @returns {VolumeMeter} The active screen volume meter.
* @throws {Error} If the screen meter is not a valid VolumeMeter instance.
*/
getScreenMeter() {
if (!(this.screenMeter instanceof VolumeMeter))
throw new Error('Screen meter is not a valid VolumeMeter instance.');
return this.screenMeter;
}
/**
* Checks if the microphone volume meter exists and is a valid VolumeMeter instance.
*
* @returns {boolean} True if micMeter exists and is valid, false otherwise.
*/
hasMicMeter() {
return this.micMeter instanceof VolumeMeter;
}
/**
* Checks if the screen volume meter exists and is a valid VolumeMeter instance.
*
* @returns {boolean} True if screenMeter exists and is valid, false otherwise.
*/
hasScreenMeter() {
return this.screenMeter instanceof VolumeMeter;
}
/**
* Creates an instance of the media device manager.
*
* This constructor initializes the internal socket reference and sets up the default
* structure for tracking media devices and active media streams. It also automatically
* triggers a device list update on instantiation.
*/
constructor() {
this.updateDeviceList();
}
/**
* Updates the internal list of available media devices.
*
* This method queries the user's system for media input and output devices using
* `navigator.mediaDevices.enumerateDevices()` and categorizes them into three groups:
* video inputs, audio inputs, and audio outputs (speakers). The result is stored in the
* `this.devices` object and also returned for immediate use.
*
* If the device list cannot be retrieved, it falls back to setting all categories as empty arrays.
*
* @returns {Promise<{ video: MediaDeviceInfo[], audio: MediaDeviceInfo[], speaker: MediaDeviceInfo[] }>}
* A promise resolving to an object containing categorized media devices.
*/
async updateDeviceList() {
if (!this.#loadingDevices) {
this.#loadingDevices = true;
await this.#queue.enqueue(async () => {
const devicesResult = await navigator.mediaDevices?.enumerateDevices?.();
if (devicesResult) {
const video = [];
const audio = [];
const speaker = [];
for (const device of devicesResult) {
switch (device.kind) {
case 'videoinput':
video.push(device);
break;
case 'audioinput':
audio.push(device);
break;
case 'audiooutput':
speaker.push(device);
break;
}
}
this.#devices.audio = audio;
this.#devices.speaker = speaker;
this.#devices.video = video;
this.#firstLoad = true;
}
else {
this.#devices.audio = [];
this.#devices.speaker = [];
this.#devices.video = [];
}
});
this.#loadingDevices = false;
}
else
await this.#queue.enqueuePoint(async () => { });
return {
video: this.#devices.video,
audio: this.#devices.audio,
speaker: this.#devices.speaker,
};
}
/**
* Retrieves a list of media devices filtered by the specified kind.
*
* This method strictly enforces the kind to be one of: 'audio', 'video', or 'speaker'.
* It throws an error if the input is not a string, not one of the allowed kinds,
* or if no devices are available for the given kind.
*
* @param {'audio' | 'video' | 'speaker'} kind - The type of device to retrieve.
* @returns {MediaDeviceInfo[]} An array of media devices matching the specified kind.
* @throws {Error} If the input is not a string.
* @throws {RangeError} If the input is not one of the accepted device kinds.
* @throws {Error} If no devices are found for the given kind.
*/
getDevicesByKind(kind) {
if (!this.#firstLoad)
throw new Error('Cannot retrieve devices: the manager has not been initialized.');
if (typeof kind !== 'string')
throw new Error('Parameter "kind" must be a string');
if (!['audio', 'video', 'speaker'].includes(kind))
throw new RangeError('Parameter "kind" must be one of: "audio", "video", "speaker"');
const devices = this.#devices[kind];
if (!devices)
throw new Error(`No devices found for kind: "${kind}"`);
return devices;
}
/**
* Returns an array containing all available audio, video, and speaker devices.
*
* This method throws an error if the manager is not yet initialized.
*
* @returns {MediaDeviceInfo[]} An array of all available media devices.
* @throws {Error} If the manager is not yet initialized.
*/
getAllDevices() {
if (!this.#firstLoad)
throw new Error('Cannot retrieve devices: the manager has not been initialized.');
return [...this.#devices.speaker, ...this.#devices.audio, ...this.#devices.video];
}
/**
* Stops an active MediaRecorder associated with a specific socket label.
*
* This method stops the recording process for the given label if a recorder exists,
* and removes the recorder reference from the internal map to free up resources.
*
* Use this to manually stop sending a stream over the socket.
*
* @param {StreamEventTypes} label - The socket event label associated with the recorder (e.g., 'mic', 'cam', 'screen').
*/
#stopSocketStream(label) {
const recorder = this.#recorders.get(label);
if (recorder) {
recorder.stop();
this.#recorders.delete(label);
}
}
/**
* Internal helper to detect when media tracks end, and stop the corresponding socket stream.
*
* @param {MediaStream} stream - The media stream being monitored.
* @param {StreamEventTypes} label - The socket label to stop when the stream ends.
*/
#stopDeviceDetector(stream, label) {
if (!(stream instanceof MediaStream))
return;
stream.getTracks().forEach((track) => {
track.addEventListener('ended', () => {
this.#emit(`Stop${label}`);
this.#stopSocketStream(label);
this.#stopMonitorInterval();
}, { once: true });
});
}
/**
* Starts capturing audio from the microphone with flexible constraints.
*
* If a deviceId is provided, it targets a specific microphone. You can also pass in
* a full MediaTrackConstraints object to fine-tune behavior (e.g., noise suppression, echo cancellation).
*
* This method:
* - Emits the audio stream over the socket under the label `"mic"`.
* - Starts volume monitoring and emits microphone volume under the label `"micMeter"`.
*
* @param {string|MediaTrackConstraints|null} options - Either a deviceId string, a full constraints object, or null for defaults.
* @param {boolean} hearVoice - `true` = Hear your voice.
* @returns {Promise<MediaStream>} A promise resolving to the active audio stream.
* @throws {Error} If the deviceId is invalid or if no audio track is found in the stream.
*/
async startMic(options = null, hearVoice = false) {
let constraints;
if (typeof options === 'string') {
const valid = this.#devices.audio.some((d) => d.deviceId === options);
if (!valid)
throw new Error(`Invalid microphone deviceId: ${options}`);
constraints = { audio: { deviceId: { exact: options } } };
}
else if (typeof options === 'object' && options !== null) {
// @ts-ignore
const id = options.deviceId?.exact;
if (id && !this.#devices.audio.some((d) => d.deviceId === id))
throw new Error(`Invalid microphone deviceId: ${id}`);
constraints = { audio: options };
}
else {
constraints = { audio: true };
}
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const hasAudio = stream.getAudioTracks().length > 0;
if (!hasAudio)
throw new Error('The microphone stream does not contain any audio track.');
this.micStream = stream;
this.micMeter = new VolumeMeter();
this.micMeter.connectToSource(stream, hearVoice);
this.#startMonitorInterval();
this.#emit(this.Events.StartMic);
this.#sendStreamOverSocket(stream, { audio: true, video: false }, this.Events.Mic, this.#micConfig);
return stream;
}
/**
* Starts capturing video from the webcam with flexible constraints.
*
* Accepts a deviceId string for a specific webcam, or a full MediaTrackConstraints
* object to customize video input (resolution, frameRate, etc.).
*
* This method:
* - Emits the video stream over the socket under the label `"cam"`.
*
* @param {string|MediaTrackConstraints|null} options - Either a deviceId string, a full constraints object, or null for defaults.
* @returns {Promise<MediaStream>} A promise resolving to the active video stream.
* @throws {Error} If the deviceId is invalid.
*/
async startCam(options = null) {
let constraints;
if (typeof options === 'string') {
const valid = this.#devices.video.some((d) => d.deviceId === options);
if (!valid)
throw new Error(`Invalid webcam deviceId: ${options}`);
constraints = { video: { deviceId: { exact: options } } };
}
else if (typeof options === 'object' && options !== null) {
// @ts-ignore
const id = options.deviceId?.exact;
if (id && !this.#devices.video.some((d) => d.deviceId === id))
throw new Error(`Invalid webcam deviceId: ${id}`);
constraints = { video: options };
}
else {
constraints = { video: true };
}
const stream = await navigator.mediaDevices.getUserMedia(constraints);
this.camStream = stream;
this.#emit(this.Events.StartCam);
this.#sendStreamOverSocket(stream, { audio: false, video: true }, this.Events.Cam, this.#camConfig);
return stream;
}
/**
* Starts screen sharing with customizable audio and video constraints.
*
* You can pass a boolean to enable or disable audio,
* or an object to define custom `audio` and `video` constraints.
*
* This method:
* - Emits the screen stream over the socket under the label `"screen"`.
* - If audio is present, starts volume monitoring and emits under the label `"screenMeter"`.
*
* @param {boolean|ScreenShareConstraints} options - `true` = enable audio, `false` = no audio, or an object with audio/video constraints.
* @param {boolean} hearScreen - `true` = Hear your screen audio (**Your ear will be destroyed!!!**).
* @returns {Promise<MediaStream>} A promise resolving to the active screen capture stream.
* @throws {Error} If the options are invalid.
*/
async startScreen(options = true, hearScreen = false) {
let constraints;
if (typeof options === 'boolean') {
constraints = {
video: true,
audio: options,
};
}
else if (typeof options === 'object' && options !== null) {
constraints = {
video: options.video ?? true,
audio: options.audio ?? false,
};
}
else
throw new Error('Invalid screen share options.');
const stream = await navigator.mediaDevices.getDisplayMedia(constraints);
this.screenStream = stream;
const hasAudio = stream.getAudioTracks().length > 0;
if (hasAudio) {
this.screenMeter = new VolumeMeter();
this.screenMeter.connectToSource(stream, hearScreen);
}
else
this.screenMeter = null;
this.#startMonitorInterval();
this.#emit(this.Events.StartScreen);
this.#sendStreamOverSocket(stream, { audio: hasAudio, video: true }, this.Events.Screen, this.#screenConfig);
return stream;
}
/**
* Sends a media stream over the socket using a MediaRecorder.
*
* This method starts recording the provided MediaStream using the specified MIME type and interval,
* and emits the resulting data chunks through the socket under the provided label.
*
* It will not start a new recorder if one is already active for the given label.
*
* @param {MediaStream} stream - The media stream to send. Must be a valid MediaStream instance.
* @param {{ audio: boolean; video: boolean; }} allowCodecs - The codecs that are allowed to use.
* @param {StreamEventTypes} label - The socket channel label used to identify the stream (e.g., 'mic', 'cam', 'screen').
* @param {StreamConfig} options - Settings for recording.
*
* @throws {Error} If the stream is not a valid MediaStream.
* @throws {Error} If a recorder is already active for the given label.
* @throws {Error} If the MIME type is not supported by MediaRecorder.
* @throws {Error} If timeslice is not a positive number.
* @throws {DOMException} If the MediaRecorder cannot be created with the given stream and options.
*/
#sendStreamOverSocket(stream, allowCodecs = { audio: true, video: true }, label, options) {
if (typeof label !== 'string')
throw new Error('Parameter "label" must be a valid string.');
if (!(stream instanceof MediaStream))
throw new Error('Parameter "stream" must be a valid MediaStream instance.');
if (this.#recorders.has(label))
throw new Error(`A recorder for "${label}" is already running.`);
const mime = `${options.mimeType};codecs=${allowCodecs.video ? options.videoCodec : ''}${allowCodecs.video && allowCodecs.audio ? `,` : ''}${allowCodecs.audio ? options.audioCodec : ''}`;
const timeslice = options.timeslice ?? 100;
if (typeof mime !== 'string' || !MediaRecorder.isTypeSupported(mime))
throw new Error(`Unsupported or invalid MIME type: "${mime}"`);
if (typeof timeslice !== 'number' || !Number.isFinite(timeslice) || timeslice <= 0)
throw new Error(`Invalid timeslice: must be a positive number (got ${timeslice})`);
const recorder = new MediaRecorder(stream, { mimeType: mime });
recorder.ondataavailable = (e) => {
if (e.data.size > 0)
this.#emit(label, { streamData: e.data, mime });
};
recorder.start(timeslice);
this.#recorders.set(label, recorder);
this.#stopDeviceDetector(stream, label);
}
/**
* Stops the microphone stream if active.
*
* Stops all tracks from the current microphone stream and clears its reference.
* Throws an error if the microphone stream is not set or invalid.
*
* @throws {Error} If the microphone stream is not active or invalid.
*/
stopMic() {
if (!(this.micStream instanceof MediaStream))
throw new Error('No active microphone stream to stop.');
this.micStream.getTracks().forEach((t) => t.stop());
this.micStream = null;
this.micMeter = null;
}
/**
* Stops the webcam stream if active.
*
* Stops all tracks from the current webcam stream and clears its reference.
* Throws an error if the webcam stream is not set or invalid.
*
* @throws {Error} If the webcam stream is not active or invalid.
*/
stopCam() {
if (!(this.camStream instanceof MediaStream))
throw new Error('No active webcam stream to stop.');
this.camStream.getTracks().forEach((t) => t.stop());
this.camStream = null;
}
/**
* Stops the screen sharing stream if active.
*
* Stops all tracks from the current screen stream and clears its reference.
* Throws an error if the screen stream is not set or invalid.
*
* @throws {Error} If the screen sharing stream is not active or invalid.
*/
stopScreen() {
if (!(this.screenStream instanceof MediaStream))
throw new Error('No active screen sharing stream to stop.');
this.screenStream.getTracks().forEach((t) => t.stop());
this.screenStream = null;
this.screenMeter = null;
}
/**
* Stops all active media streams (microphone, webcam, and screen share).
*
* This method calls the individual stop methods to ensure each stream is safely terminated.
* Use this when you want to stop all media input/output at once.
*/
stopAll() {
this.stopMic();
this.stopCam();
this.stopScreen();
}
/**
* Generates a unique media identifier string based on the given parameters.
*
* @param {string} userId - The ID of the user.
* @param {string} type - The type/category of the media.
* @param {string} mime - The MIME type of the media.
* @param {ReceiverTags} element - The name of the media element.
* @returns {string} A concatenated string uniquely identifying the media.
*/
getMediaId(userId, type, mime, element) {
if (typeof userId !== 'string')
throw new Error('userId must be a string.');
if (typeof type !== 'string')
throw new Error('type must be a string.');
if (typeof mime !== 'string')
throw new Error('mime must be a string.');
// @ts-ignore
if (!(element instanceof HTMLMediaElement) && typeof element !== 'string')
throw new Error("element must be either 'string' or 'HTMLMediaElement'.");
if (typeof element === 'string' && !['video', 'audio'].includes(element))
throw new Error("element must be either 'audio' or 'video'.");
if (!['mic', 'cam', 'screen'].includes(type))
throw new Error(`Invalid config type: "${type}"`);
// @ts-ignore
return `${userId}:${type}:${mime}:${typeof element === 'string' ? element : element.tagName}`;
}
/**
* Checks if a media receiver exists for the given stream parameters.
*
* @param {string} userId - The user id to attach the stream.
* @param {string} type - The stream type, e.g., 'mic'.
* @param {string} mime - The mime type, e.g., 'audio/webm;codecs=opus'.
* @param {ReceiverTags} element - The tag name needs to be `audio` or `video` to attach the stream.
* @returns {boolean}
*/
hasReceiver(userId, type, mime, element) {
const id = this.getMediaId(userId, type, mime, element);
return this.#streams.has(id);
}
/**
* Deletes a media receiver.
*
* @param {string} userId - The user id to attach the stream.
* @param {string} type - The stream type, e.g., 'mic'.
* @param {string} mime - The mime type, e.g., 'audio/webm;codecs=opus'.
* @param {ReceiverTags} element - The tag name needs to be `audio` or `video` to attach the stream.
*/
deleteReceiver(userId, type, mime, element) {
const id = this.getMediaId(userId, type, mime, element);
if (!this.#streams.has(id))
throw new Error('');
const receiver = this.#streams.get(id);
receiver?.destroy();
this.#streams.delete(id);
this.#emit(this.Events.ReceiverDeleted, { userId, type, mime, element }, receiver);
}
/**
* Gets a media player for continuous streaming from received chunks.
*
* @param {string} userId - The user id to attach the stream.
* @param {string} type - The stream type, e.g., 'mic'.
* @param {string} mime - The mime type, e.g., 'audio/webm;codecs=opus'.
* @param {ReceiverTags} element - The tag name needs to be `audio` or `video` to attach the stream.
* @returns {TinyMediaReceiver} - If the instance has not yet been created, it will be created automatically.
* @throws {Error} If no media receiver exists for the given parameters.
*/
getReceiver(userId, type, mime, element) {
const id = this.getMediaId(userId, type, mime, element);
const oldReceiver = this.#streams.get(id);
if (oldReceiver)
return oldReceiver;
throw new Error(`No media receiver found for ID "${id}"`);
}
/**
* Initializes a media player for continuous streaming from received chunks.
*
* @param {string} userId - The user id to attach the stream.
* @param {StreamTypes} type - The stream type, e.g., 'mic'.
* @param {string} mimeType - The mime type, e.g., 'audio/webm;codecs=opus'.
* @param {ReceiverTags} element - The tag name needs to be `audio` or `video` to attach the stream.
* @param {Object} [options={}]
* @param {number} [options.maxBufferBack=10] - Maximum buffer (in seconds) back to keep in the buffer behind the current time.
* @param {number} [options.cleanupTime] - Interval time in milliseconds to perform buffer cleanup. Must be a positive number.
* @param {number} [options.bufferTolerance] - Tolerance value (in seconds) used when comparing buffer ranges. Must be a positive number.
* @returns {TinyMediaReceiver} - If the instance has not yet been created, it will be created automatically.
*/
initReceiver(userId, type, mimeType, element, { maxBufferBack = 10, cleanupTime = 100, bufferTolerance = 0.1 } = {}) {
const id = this.getMediaId(userId, type, mimeType, element);
const oldReceiver = this.#streams.get(id);
if (oldReceiver)
return oldReceiver;
const receiver = new TinyMediaReceiver({
element,
mimeType,
maxBufferBack,
cleanupTime,
bufferTolerance,
});
this.#streams.set(id, receiver);
this.#emit(this.Events.ReceiverAdded, { userId, type, mimeType, element }, receiver);
return receiver;
}
/**
* Destroys the instance by terminating all active streams, stopping processes, and removing all event listeners.
*
* This method performs a full cleanup of the instance. It first iterates through all entries in `#streams` and calls
* their `destroy()` methods to properly dispose of any underlying resources (e.g., sockets, file handles, etc.). After that,
* it clears the `#streams` map entirely. It also calls `stopAll()` to terminate any ongoing operations or processes,
* and finally removes all listeners from both `#events` and `#sysEvents` to avoid memory leaks or unintended side effects.
*
* Call this method when the instance is no longer needed or before disposing of it.
*
* @returns {void}
*/
destroy() {
this.#streams.forEach((value) => {
value.destroy();
});
this.#streams.clear();
this.stopAll();
this.#emit(this.Events.Destroyed);
this.#events.removeAllListeners();
this.#sysEvents.removeAllListeners();
}
}