/** * 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('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 { // Base implementation - protocol-specific subclasses should override // and establish their protocol connection } protected async doDisconnect(): Promise { // 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 { 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 { this.emit('playback:pause'); this._playbackState = 'paused'; this.emit('playback:state:changed', this._playbackState); } /** * Stop playback */ public async stop(): Promise { 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 { this.emit('playback:next'); } /** * Go to previous track */ public async previous(): Promise { this.emit('playback:previous'); } /** * Seek to position * @param seconds Position in seconds */ public async seek(seconds: number): Promise { 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 { return this._currentTrack; } /** * Get playback status */ public async getPlaybackStatus(): Promise { 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 ): PlaybackFeature { return new PlaybackFeature(device, port, { protocol, supportsQueue: metadata.supportsQueue as boolean ?? true, supportsSeek: metadata.supportsSeek as boolean ?? true, }); } }