Files
devicemanager/ts/features/feature.playback.ts

247 lines
6.9 KiB
TypeScript

/**
* 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,
});
}
}