import * as plugins from '../plugins.js'; import { Speaker, type ITrackInfo, type IPlaybackStatus, type TPlaybackState, type ISpeakerInfo } from './speaker.classes.speaker.js'; import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js'; /** * Chromecast device types */ export type TChromecastType = 'audio' | 'video' | 'group'; /** * Chromecast application IDs */ export const CHROMECAST_APPS = { DEFAULT_MEDIA_RECEIVER: 'CC1AD845', BACKDROP: 'E8C28D3C', YOUTUBE: '233637DE', NETFLIX: 'CA5E8412', PLEX: '9AC194DC', }; /** * Chromecast device info */ export interface IChromecastSpeakerInfo extends ISpeakerInfo { protocol: 'chromecast'; friendlyName: string; deviceType: TChromecastType; capabilities: string[]; currentAppId?: string; currentAppName?: string; } /** * Chromecast media metadata */ export interface IChromecastMediaMetadata { metadataType?: number; title?: string; subtitle?: string; artist?: string; albumName?: string; albumArtist?: string; trackNumber?: number; discNumber?: number; images?: { url: string; width?: number; height?: number }[]; releaseDate?: string; studio?: string; seriesTitle?: string; season?: number; episode?: number; } /** * Chromecast media status */ export interface IChromecastMediaStatus { mediaSessionId: number; playbackRate: number; playerState: 'IDLE' | 'PLAYING' | 'PAUSED' | 'BUFFERING'; currentTime: number; idleReason?: 'CANCELLED' | 'INTERRUPTED' | 'FINISHED' | 'ERROR'; media?: { contentId: string; contentType: string; duration: number; metadata?: IChromecastMediaMetadata; }; volume: { level: number; muted: boolean; }; } /** * Chromecast Speaker device */ export class ChromecastSpeaker extends Speaker { private client: InstanceType | null = null; private player: unknown = null; private _friendlyName: string = ''; private _deviceType: TChromecastType = 'audio'; private _capabilities: string[] = []; private _currentAppId?: string; private _currentAppName?: string; private _mediaSessionId?: number; constructor( info: IDeviceInfo, options?: { roomName?: string; modelName?: string; friendlyName?: string; deviceType?: TChromecastType; capabilities?: string[]; }, retryOptions?: IRetryOptions ) { super(info, 'chromecast', options, retryOptions); this._friendlyName = options?.friendlyName || info.name; this._deviceType = options?.deviceType || 'audio'; this._capabilities = options?.capabilities || []; } // Getters public get friendlyName(): string { return this._friendlyName; } public get deviceType(): TChromecastType { return this._deviceType; } public get capabilities(): string[] { return this._capabilities; } public get currentAppId(): string | undefined { return this._currentAppId; } public get currentAppName(): string | undefined { return this._currentAppName; } /** * Connect to Chromecast */ protected async doConnect(): Promise { return new Promise((resolve, reject) => { this.client = new plugins.castv2Client.Client(); const timeout = setTimeout(() => { if (this.client) { this.client.close(); this.client = null; } reject(new Error('Connection timeout')); }, 10000); this.client.on('error', (err: Error) => { clearTimeout(timeout); if (this.client) { this.client.close(); this.client = null; } reject(err); }); this.client.connect(this.address, () => { clearTimeout(timeout); // Get receiver status this.client!.getStatus((err: Error | null, status: { applications?: Array<{ appId: string; displayName: string }> }) => { if (err) { reject(err); return; } if (status && status.applications && status.applications.length > 0) { const app = status.applications[0]; this._currentAppId = app.appId; this._currentAppName = app.displayName; } resolve(); }); }); }); } /** * Disconnect */ protected async doDisconnect(): Promise { if (this.client) { this.client.close(); this.client = null; } this.player = null; } /** * Refresh status */ public async refreshStatus(): Promise { if (!this.client) { throw new Error('Not connected'); } return new Promise((resolve) => { this.client!.getStatus((err: Error | null, status: { applications?: Array<{ appId: string; displayName: string }>; volume?: { level: number; muted: boolean }; }) => { if (!err && status) { if (status.applications && status.applications.length > 0) { const app = status.applications[0]; this._currentAppId = app.appId; this._currentAppName = app.displayName; } if (status.volume) { this._volume = Math.round(status.volume.level * 100); this._muted = status.volume.muted; } } this.emit('status:updated', this.getSpeakerInfo()); resolve(); }); }); } /** * Launch media receiver and get player */ private async getMediaPlayer(): Promise> { if (!this.client) { throw new Error('Not connected'); } return new Promise((resolve, reject) => { this.client!.launch(plugins.castv2Client.DefaultMediaReceiver, (err: Error | null, player: InstanceType) => { if (err) { reject(err); return; } this.player = player; player.on('status', (status: IChromecastMediaStatus) => { this.handleMediaStatus(status); }); resolve(player); }); }); } /** * Handle media status update */ private handleMediaStatus(status: IChromecastMediaStatus): void { if (!status) return; this._mediaSessionId = status.mediaSessionId; // Update playback state switch (status.playerState) { case 'PLAYING': this._playbackState = 'playing'; break; case 'PAUSED': this._playbackState = 'paused'; break; case 'BUFFERING': this._playbackState = 'transitioning'; break; case 'IDLE': default: this._playbackState = 'stopped'; break; } // Update volume if (status.volume) { this._volume = Math.round(status.volume.level * 100); this._muted = status.volume.muted; } this.emit('playback:status', status); } // ============================================================================ // Playback Control // ============================================================================ /** * Play media URL */ public async play(uri?: string): Promise { if (!this.client) { throw new Error('Not connected'); } const player = await this.getMediaPlayer() as InstanceType; if (uri) { // Determine content type const contentType = this.guessContentType(uri); const media = { contentId: uri, contentType, streamType: 'BUFFERED' as const, metadata: { type: 0, metadataType: 0, title: uri.split('/').pop() || 'Media', }, }; return new Promise((resolve, reject) => { player.load(media, { autoplay: true }, (err: Error | null) => { if (err) { reject(err); return; } this._playbackState = 'playing'; this.emit('playback:started'); resolve(); }); }); } else { // Resume playback return new Promise((resolve, reject) => { player.play((err: Error | null) => { if (err) { reject(err); return; } this._playbackState = 'playing'; this.emit('playback:started'); resolve(); }); }); } } /** * Pause playback */ public async pause(): Promise { if (!this.player) { throw new Error('No active media session'); } return new Promise((resolve, reject) => { (this.player as InstanceType).pause((err: Error | null) => { if (err) { reject(err); return; } this._playbackState = 'paused'; this.emit('playback:paused'); resolve(); }); }); } /** * Stop playback */ public async stop(): Promise { if (!this.player) { throw new Error('No active media session'); } return new Promise((resolve, reject) => { (this.player as InstanceType).stop((err: Error | null) => { if (err) { reject(err); return; } this._playbackState = 'stopped'; this.emit('playback:stopped'); resolve(); }); }); } /** * Next track (not supported) */ public async next(): Promise { throw new Error('Next track not supported on basic Chromecast'); } /** * Previous track (not supported) */ public async previous(): Promise { throw new Error('Previous track not supported on basic Chromecast'); } /** * Seek to position */ public async seek(seconds: number): Promise { if (!this.player) { throw new Error('No active media session'); } return new Promise((resolve, reject) => { (this.player as InstanceType).seek(seconds, (err: Error | null) => { if (err) { reject(err); return; } this.emit('playback:seeked', { position: seconds }); resolve(); }); }); } // ============================================================================ // Volume Control // ============================================================================ /** * Get volume */ public async getVolume(): Promise { await this.refreshStatus(); return this._volume; } /** * Set volume */ public async setVolume(level: number): Promise { if (!this.client) { throw new Error('Not connected'); } const clamped = Math.max(0, Math.min(100, level)); return new Promise((resolve, reject) => { this.client!.setVolume({ level: clamped / 100 }, (err: Error | null) => { if (err) { reject(err); return; } this._volume = clamped; this.emit('volume:changed', { volume: clamped }); resolve(); }); }); } /** * Get mute state */ public async getMute(): Promise { await this.refreshStatus(); return this._muted; } /** * Set mute state */ public async setMute(muted: boolean): Promise { if (!this.client) { throw new Error('Not connected'); } return new Promise((resolve, reject) => { this.client!.setVolume({ muted }, (err: Error | null) => { if (err) { reject(err); return; } this._muted = muted; this.emit('mute:changed', { muted }); resolve(); }); }); } // ============================================================================ // Track Information // ============================================================================ /** * Get current track */ public async getCurrentTrack(): Promise { if (!this.player) { return null; } return new Promise((resolve) => { (this.player as InstanceType).getStatus((err: Error | null, status: IChromecastMediaStatus) => { if (err || !status || !status.media) { resolve(null); return; } const media = status.media; const metadata = media.metadata; resolve({ title: metadata?.title || 'Unknown', artist: metadata?.artist, album: metadata?.albumName, duration: media.duration || 0, position: status.currentTime || 0, albumArtUri: metadata?.images?.[0]?.url, uri: media.contentId, }); }); }); } /** * Get playback status */ public async getPlaybackStatus(): Promise { await this.refreshStatus(); return { state: this._playbackState, volume: this._volume, muted: this._muted, track: await this.getCurrentTrack() || undefined, }; } // ============================================================================ // Chromecast-specific Methods // ============================================================================ /** * Launch an application */ public async launchApp(appId: string): Promise { if (!this.client) { throw new Error('Not connected'); } return new Promise((resolve, reject) => { this.client!.launch({ id: appId } as Parameters[0], (err: Error | null) => { if (err) { reject(err); return; } this._currentAppId = appId; this.emit('app:launched', { appId }); resolve(); }); }); } /** * Stop current application */ public async stopApp(): Promise { if (!this.client) { throw new Error('Not connected'); } return new Promise((resolve, reject) => { this.client!.stop(this.player as InstanceType, (err: Error | null) => { if (err) { reject(err); return; } this._currentAppId = undefined; this._currentAppName = undefined; this.player = null; this.emit('app:stopped'); resolve(); }); }); } /** * Get receiver status */ public async getReceiverStatus(): Promise<{ applications?: Array<{ appId: string; displayName: string }>; volume: { level: number; muted: boolean }; }> { if (!this.client) { throw new Error('Not connected'); } return new Promise((resolve, reject) => { this.client!.getStatus((err: Error | null, status: { applications?: Array<{ appId: string; displayName: string }>; volume: { level: number; muted: boolean }; }) => { if (err) { reject(err); return; } resolve(status); }); }); } /** * Guess content type from URL */ private guessContentType(url: string): string { const ext = url.split('.').pop()?.toLowerCase(); switch (ext) { case 'mp3': return 'audio/mpeg'; case 'mp4': case 'm4v': return 'video/mp4'; case 'webm': return 'video/webm'; case 'mkv': return 'video/x-matroska'; case 'ogg': return 'audio/ogg'; case 'flac': return 'audio/flac'; case 'wav': return 'audio/wav'; case 'm3u8': return 'application/x-mpegURL'; case 'mpd': return 'application/dash+xml'; default: return 'video/mp4'; } } // ============================================================================ // Device Info // ============================================================================ /** * Get speaker info */ public getSpeakerInfo(): IChromecastSpeakerInfo { return { id: this.id, name: this.name, type: 'speaker', address: this.address, port: this.port, status: this.status, protocol: 'chromecast', roomName: this._roomName, modelName: this._modelName, friendlyName: this._friendlyName, deviceType: this._deviceType, capabilities: this._capabilities, currentAppId: this._currentAppId, currentAppName: this._currentAppName, }; } /** * Create from mDNS discovery */ public static fromDiscovery( data: { id: string; name: string; address: string; port?: number; roomName?: string; modelName?: string; friendlyName?: string; deviceType?: TChromecastType; capabilities?: string[]; }, retryOptions?: IRetryOptions ): ChromecastSpeaker { const info: IDeviceInfo = { id: data.id, name: data.name, type: 'speaker', address: data.address, port: data.port ?? 8009, status: 'unknown', }; return new ChromecastSpeaker( info, { roomName: data.roomName, modelName: data.modelName, friendlyName: data.friendlyName, deviceType: data.deviceType, capabilities: data.capabilities, }, retryOptions ); } /** * Probe for Chromecast device */ public static async probe(address: string, port: number = 8009, timeout: number = 5000): Promise { return new Promise((resolve) => { const client = new plugins.castv2Client.Client(); const timer = setTimeout(() => { client.close(); resolve(false); }, timeout); client.on('error', () => { clearTimeout(timer); client.close(); resolve(false); }); client.connect(address, () => { clearTimeout(timer); client.close(); resolve(true); }); }); } }