257 lines
6.8 KiB
TypeScript
257 lines
6.8 KiB
TypeScript
/**
|
|
* Volume Feature
|
|
* Provides volume control capability
|
|
*/
|
|
|
|
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
|
import type {
|
|
IVolumeFeatureInfo,
|
|
IFeatureOptions,
|
|
} from '../interfaces/feature.interfaces.js';
|
|
|
|
/**
|
|
* Options for creating a VolumeFeature
|
|
*/
|
|
export interface IVolumeFeatureOptions extends IFeatureOptions {
|
|
/** Volume control protocol (usually same as device protocol) */
|
|
volumeProtocol?: string;
|
|
/** Minimum volume level */
|
|
minVolume?: number;
|
|
/** Maximum volume level */
|
|
maxVolume?: number;
|
|
/** Volume step increment */
|
|
volumeStep?: number;
|
|
/** Whether mute is supported */
|
|
supportsMute?: boolean;
|
|
/** Protocol-specific volume controller */
|
|
volumeController?: IVolumeController;
|
|
}
|
|
|
|
/**
|
|
* Interface for protocol-specific volume control
|
|
*/
|
|
export interface IVolumeController {
|
|
getVolume(): Promise<number>;
|
|
setVolume(level: number): Promise<void>;
|
|
getMute(): Promise<boolean>;
|
|
setMute(muted: boolean): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Volume Feature - provides volume control capability
|
|
*
|
|
* Separated from PlaybackFeature because some devices have volume control
|
|
* without playback capability (e.g., amplifiers, HDMI matrix switches).
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const volume = device.getFeature<VolumeFeature>('volume');
|
|
* if (volume) {
|
|
* const current = await volume.getVolume();
|
|
* await volume.setVolume(current + 10);
|
|
* await volume.toggleMute();
|
|
* }
|
|
* ```
|
|
*/
|
|
export class VolumeFeature extends Feature {
|
|
public readonly type = 'volume' as const;
|
|
public readonly protocol: string;
|
|
|
|
// Capabilities
|
|
public readonly minVolume: number;
|
|
public readonly maxVolume: number;
|
|
public readonly volumeStep: number;
|
|
public readonly supportsMute: boolean;
|
|
|
|
// Current state
|
|
protected _volume: number = 0;
|
|
protected _muted: boolean = false;
|
|
|
|
// Volume controller (protocol-specific)
|
|
protected volumeController: IVolumeController | null = null;
|
|
|
|
constructor(
|
|
device: TDeviceReference,
|
|
port: number,
|
|
options?: IVolumeFeatureOptions
|
|
) {
|
|
super(device, port, options);
|
|
this.protocol = options?.volumeProtocol ?? 'generic';
|
|
this.minVolume = options?.minVolume ?? 0;
|
|
this.maxVolume = options?.maxVolume ?? 100;
|
|
this.volumeStep = options?.volumeStep ?? 5;
|
|
this.supportsMute = options?.supportsMute ?? true;
|
|
|
|
if (options?.volumeController) {
|
|
this.volumeController = options.volumeController;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Properties
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get current volume level (cached)
|
|
*/
|
|
public get volume(): number {
|
|
return this._volume;
|
|
}
|
|
|
|
/**
|
|
* Get mute state (cached)
|
|
*/
|
|
public get muted(): boolean {
|
|
return this._muted;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Connection
|
|
// ============================================================================
|
|
|
|
protected async doConnect(): Promise<void> {
|
|
// Fetch initial state if we have a controller
|
|
if (this.volumeController) {
|
|
try {
|
|
this._volume = await this.volumeController.getVolume();
|
|
this._muted = await this.volumeController.getMute();
|
|
} catch {
|
|
// Ignore errors fetching initial state
|
|
}
|
|
}
|
|
}
|
|
|
|
protected async doDisconnect(): Promise<void> {
|
|
// Nothing to disconnect for volume control
|
|
}
|
|
|
|
// ============================================================================
|
|
// Volume Control
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get current volume level
|
|
*/
|
|
public async getVolume(): Promise<number> {
|
|
if (this.volumeController) {
|
|
this._volume = await this.volumeController.getVolume();
|
|
}
|
|
return this._volume;
|
|
}
|
|
|
|
/**
|
|
* Set volume level
|
|
* @param level Volume level (clamped to min/max)
|
|
*/
|
|
public async setVolume(level: number): Promise<void> {
|
|
const clampedLevel = Math.max(this.minVolume, Math.min(this.maxVolume, level));
|
|
|
|
if (this.volumeController) {
|
|
await this.volumeController.setVolume(clampedLevel);
|
|
}
|
|
|
|
const oldVolume = this._volume;
|
|
this._volume = clampedLevel;
|
|
|
|
if (oldVolume !== clampedLevel) {
|
|
this.emit('volume:changed', { oldVolume, newVolume: clampedLevel });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increase volume by step
|
|
*/
|
|
public async volumeUp(step?: number): Promise<number> {
|
|
const increment = step ?? this.volumeStep;
|
|
const newVolume = Math.min(this.maxVolume, this._volume + increment);
|
|
await this.setVolume(newVolume);
|
|
return this._volume;
|
|
}
|
|
|
|
/**
|
|
* Decrease volume by step
|
|
*/
|
|
public async volumeDown(step?: number): Promise<number> {
|
|
const decrement = step ?? this.volumeStep;
|
|
const newVolume = Math.max(this.minVolume, this._volume - decrement);
|
|
await this.setVolume(newVolume);
|
|
return this._volume;
|
|
}
|
|
|
|
/**
|
|
* Get mute state
|
|
*/
|
|
public async getMute(): Promise<boolean> {
|
|
if (this.volumeController && this.supportsMute) {
|
|
this._muted = await this.volumeController.getMute();
|
|
}
|
|
return this._muted;
|
|
}
|
|
|
|
/**
|
|
* Set mute state
|
|
*/
|
|
public async setMute(muted: boolean): Promise<void> {
|
|
if (!this.supportsMute) {
|
|
throw new Error('Mute not supported');
|
|
}
|
|
|
|
if (this.volumeController) {
|
|
await this.volumeController.setMute(muted);
|
|
}
|
|
|
|
const oldMuted = this._muted;
|
|
this._muted = muted;
|
|
|
|
if (oldMuted !== muted) {
|
|
this.emit('mute:changed', { oldMuted, newMuted: muted });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle mute state
|
|
*/
|
|
public async toggleMute(): Promise<boolean> {
|
|
const currentMuted = await this.getMute();
|
|
await this.setMute(!currentMuted);
|
|
return this._muted;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Serialization
|
|
// ============================================================================
|
|
|
|
public getFeatureInfo(): IVolumeFeatureInfo {
|
|
return {
|
|
...this.getBaseFeatureInfo(),
|
|
type: 'volume',
|
|
minVolume: this.minVolume,
|
|
maxVolume: this.maxVolume,
|
|
volumeStep: this.volumeStep,
|
|
supportsMute: this.supportsMute,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Static Factory
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Create from discovery metadata
|
|
*/
|
|
public static fromDiscovery(
|
|
device: TDeviceReference,
|
|
port: number,
|
|
protocol: string,
|
|
metadata: Record<string, unknown>
|
|
): VolumeFeature {
|
|
return new VolumeFeature(device, port, {
|
|
volumeProtocol: protocol,
|
|
minVolume: metadata.minVolume as number ?? 0,
|
|
maxVolume: metadata.maxVolume as number ?? 100,
|
|
volumeStep: metadata.volumeStep as number ?? 5,
|
|
supportsMute: metadata.supportsMute as boolean ?? true,
|
|
});
|
|
}
|
|
}
|