feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
246
ts/features/feature.playback.ts
Normal file
246
ts/features/feature.playback.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Playback Feature
|
||||
* Provides media playback control capability
|
||||
*/
|
||||
|
||||
import { Feature, type TDeviceReference } from './feature.abstract.js';
|
||||
import type {
|
||||
TPlaybackProtocol,
|
||||
TPlaybackState,
|
||||
ITrackInfo,
|
||||
IPlaybackStatus,
|
||||
IPlaybackFeatureInfo,
|
||||
IFeatureOptions,
|
||||
} from '../interfaces/feature.interfaces.js';
|
||||
|
||||
/**
|
||||
* Options for creating a PlaybackFeature
|
||||
*/
|
||||
export interface IPlaybackFeatureOptions extends IFeatureOptions {
|
||||
protocol: TPlaybackProtocol;
|
||||
supportsQueue?: boolean;
|
||||
supportsSeek?: boolean;
|
||||
/** Protocol-specific client instance (Sonos, AirPlay, etc.) */
|
||||
protocolClient?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback Feature - provides media playback control
|
||||
*
|
||||
* Abstract feature that can be backed by different protocols (Sonos, AirPlay, Chromecast, DLNA).
|
||||
* Concrete implementations should extend this class for protocol-specific behavior.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const playback = device.getFeature<PlaybackFeature>('playback');
|
||||
* if (playback) {
|
||||
* await playback.play('spotify:track:123');
|
||||
* const status = await playback.getPlaybackStatus();
|
||||
* console.log(`Playing: ${status.track?.title}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class PlaybackFeature extends Feature {
|
||||
public readonly type = 'playback' as const;
|
||||
public readonly protocol: TPlaybackProtocol;
|
||||
|
||||
// Capabilities
|
||||
public supportsQueue: boolean = true;
|
||||
public supportsSeek: boolean = true;
|
||||
|
||||
// Current state
|
||||
protected _playbackState: TPlaybackState = 'stopped';
|
||||
protected _currentTrack: ITrackInfo | null = null;
|
||||
protected _position: number = 0;
|
||||
protected _duration: number = 0;
|
||||
|
||||
// Protocol client (set by subclass or passed in options)
|
||||
protected protocolClient: unknown = null;
|
||||
|
||||
constructor(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
options: IPlaybackFeatureOptions
|
||||
) {
|
||||
super(device, port, options);
|
||||
this.protocol = options.protocol;
|
||||
if (options.supportsQueue !== undefined) this.supportsQueue = options.supportsQueue;
|
||||
if (options.supportsSeek !== undefined) this.supportsSeek = options.supportsSeek;
|
||||
if (options.protocolClient) this.protocolClient = options.protocolClient;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Properties
|
||||
// ============================================================================
|
||||
|
||||
public get playbackState(): TPlaybackState {
|
||||
return this._playbackState;
|
||||
}
|
||||
|
||||
public get currentTrack(): ITrackInfo | null {
|
||||
return this._currentTrack;
|
||||
}
|
||||
|
||||
public get position(): number {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
public get duration(): number {
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
public get isPlaying(): boolean {
|
||||
return this._playbackState === 'playing';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Connection (to be overridden by protocol-specific subclasses)
|
||||
// ============================================================================
|
||||
|
||||
protected async doConnect(): Promise<void> {
|
||||
// Base implementation - protocol-specific subclasses should override
|
||||
// and establish their protocol connection
|
||||
}
|
||||
|
||||
protected async doDisconnect(): Promise<void> {
|
||||
// Base implementation - protocol-specific subclasses should override
|
||||
this.protocolClient = null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Playback Control
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start playback
|
||||
* @param uri Optional URI to play. If not provided, resumes current playback.
|
||||
*/
|
||||
public async play(uri?: string): Promise<void> {
|
||||
this.emit('playback:play', { uri });
|
||||
// Protocol-specific implementation should be provided by subclass
|
||||
this._playbackState = 'playing';
|
||||
this.emit('playback:state:changed', this._playbackState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
this.emit('playback:pause');
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:state:changed', this._playbackState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.emit('playback:stop');
|
||||
this._playbackState = 'stopped';
|
||||
this._position = 0;
|
||||
this.emit('playback:state:changed', this._playbackState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip to next track
|
||||
*/
|
||||
public async next(): Promise<void> {
|
||||
this.emit('playback:next');
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous track
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
this.emit('playback:previous');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
* @param seconds Position in seconds
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.supportsSeek) {
|
||||
throw new Error('Seek not supported');
|
||||
}
|
||||
this._position = seconds;
|
||||
this.emit('playback:seek', seconds);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Status
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current track info
|
||||
*/
|
||||
public async getCurrentTrack(): Promise<ITrackInfo | null> {
|
||||
return this._currentTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playback status
|
||||
*/
|
||||
public async getPlaybackStatus(): Promise<IPlaybackStatus> {
|
||||
return {
|
||||
state: this._playbackState,
|
||||
position: this._position,
|
||||
duration: this._duration,
|
||||
track: this._currentTrack ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update track info (called by protocol-specific implementations)
|
||||
*/
|
||||
protected updateTrack(track: ITrackInfo | null): void {
|
||||
const oldTrack = this._currentTrack;
|
||||
this._currentTrack = track;
|
||||
if (track?.title !== oldTrack?.title || track?.uri !== oldTrack?.uri) {
|
||||
this.emit('playback:track:changed', track);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update position (called by protocol-specific implementations)
|
||||
*/
|
||||
protected updatePosition(position: number, duration: number): void {
|
||||
this._position = position;
|
||||
this._duration = duration;
|
||||
this.emit('playback:position:changed', { position, duration });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization
|
||||
// ============================================================================
|
||||
|
||||
public getFeatureInfo(): IPlaybackFeatureInfo {
|
||||
return {
|
||||
...this.getBaseFeatureInfo(),
|
||||
type: 'playback',
|
||||
protocol: this.protocol,
|
||||
supportsQueue: this.supportsQueue,
|
||||
supportsSeek: this.supportsSeek,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create from discovery metadata
|
||||
*/
|
||||
public static fromDiscovery(
|
||||
device: TDeviceReference,
|
||||
port: number,
|
||||
protocol: TPlaybackProtocol,
|
||||
metadata: Record<string, unknown>
|
||||
): PlaybackFeature {
|
||||
return new PlaybackFeature(device, port, {
|
||||
protocol,
|
||||
supportsQueue: metadata.supportsQueue as boolean ?? true,
|
||||
supportsSeek: metadata.supportsSeek as boolean ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user