247 lines
6.9 KiB
TypeScript
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,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|