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'; /** * AirPlay features bitmask */ export const AIRPLAY_FEATURES = { Video: 1 << 0, Photo: 1 << 1, VideoFairPlay: 1 << 2, VideoVolumeControl: 1 << 3, VideoHTTPLiveStreams: 1 << 4, Slideshow: 1 << 5, Screen: 1 << 7, ScreenRotate: 1 << 8, Audio: 1 << 9, AudioRedundant: 1 << 11, FPSAPv2pt5_AES_GCM: 1 << 12, PhotoCaching: 1 << 13, Authentication4: 1 << 14, MetadataFeatures: 1 << 15, AudioFormats: 1 << 16, Authentication1: 1 << 17, }; /** * AirPlay device info */ export interface IAirPlaySpeakerInfo extends ISpeakerInfo { protocol: 'airplay'; features: number; supportsVideo: boolean; supportsAudio: boolean; supportsScreen: boolean; deviceId?: string; } /** * AirPlay playback info */ export interface IAirPlayPlaybackInfo { duration: number; position: number; rate: number; readyToPlay: boolean; playbackBufferEmpty: boolean; playbackBufferFull: boolean; playbackLikelyToKeepUp: boolean; } /** * AirPlay Speaker device * Basic implementation for AirPlay-compatible devices */ export class AirPlaySpeaker extends Speaker { private _features: number = 0; private _deviceId?: string; private _supportsVideo: boolean = false; private _supportsAudio: boolean = true; private _supportsScreen: boolean = false; private _currentUri?: string; private _currentPosition: number = 0; private _currentDuration: number = 0; private _isPlaying: boolean = false; constructor( info: IDeviceInfo, options?: { roomName?: string; modelName?: string; features?: number; deviceId?: string; }, retryOptions?: IRetryOptions ) { super(info, 'airplay', options, retryOptions); this._features = options?.features || 0; this._deviceId = options?.deviceId; // Parse features if (this._features) { this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video); this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio); this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen); } } // Getters public get features(): number { return this._features; } public get deviceId(): string | undefined { return this._deviceId; } public get supportsVideo(): boolean { return this._supportsVideo; } public get supportsAudio(): boolean { return this._supportsAudio; } public get supportsScreen(): boolean { return this._supportsScreen; } /** * Connect to AirPlay device * AirPlay 2 devices (HomePods) may not respond to /server-info, * so we consider them connected even if we can't get device info. */ protected async doConnect(): Promise { // Try /server-info endpoint (works for older AirPlay devices) const url = `http://${this.address}:${this.port}/server-info`; try { const response = await fetch(url, { signal: AbortSignal.timeout(3000), }); if (response.ok) { // Parse server info (plist format) const text = await response.text(); // Extract features if available const featuresMatch = text.match(/features<\/key>\s*(\d+)<\/integer>/); if (featuresMatch) { this._features = parseInt(featuresMatch[1]); this._supportsVideo = !!(this._features & AIRPLAY_FEATURES.Video); this._supportsAudio = !!(this._features & AIRPLAY_FEATURES.Audio); this._supportsScreen = !!(this._features & AIRPLAY_FEATURES.Screen); } // Extract device ID const deviceIdMatch = text.match(/deviceid<\/key>\s*([^<]+)<\/string>/); if (deviceIdMatch) { this._deviceId = deviceIdMatch[1]; } // Extract model const modelMatch = text.match(/model<\/key>\s*([^<]+)<\/string>/); if (modelMatch) { this._modelName = modelMatch[1]; this.model = modelMatch[1]; } return; } // Non-OK response - might be AirPlay 2, continue below } catch { // /server-info failed, might be AirPlay 2 device } // For AirPlay 2 devices (HomePods), /server-info doesn't work // Try a simple port check - if the port responds, consider it connected // HomePods will respond to proper AirPlay 2 protocol even if HTTP endpoints fail // We'll assume it's an AirPlay 2 audio device this._supportsAudio = true; this._supportsVideo = false; this._supportsScreen = false; } /** * Disconnect */ protected async doDisconnect(): Promise { try { await this.stop(); } catch { // Ignore stop errors } } /** * Refresh status */ public async refreshStatus(): Promise { try { const info = await this.getAirPlayPlaybackInfo(); this._isPlaying = info.rate > 0; this._currentPosition = info.position; this._currentDuration = info.duration; this._playbackState = this._isPlaying ? 'playing' : 'paused'; } catch { this._playbackState = 'stopped'; } this.emit('status:updated', this.getSpeakerInfo()); } // ============================================================================ // Playback Control // ============================================================================ /** * Play media URL */ public async play(uri?: string): Promise { if (uri) { this._currentUri = uri; const body = `Content-Location: ${uri}\nStart-Position: 0\n`; const response = await fetch(`http://${this.address}:${this.port}/play`, { method: 'POST', headers: { 'Content-Type': 'text/parameters', }, body, signal: AbortSignal.timeout(10000), }); if (!response.ok) { throw new Error(`Play failed: ${response.status}`); } } else { // Resume playback await this.setRate(1); } this._isPlaying = true; this._playbackState = 'playing'; this.emit('playback:started'); } /** * Pause playback */ public async pause(): Promise { await this.setRate(0); this._isPlaying = false; this._playbackState = 'paused'; this.emit('playback:paused'); } /** * Stop playback */ public async stop(): Promise { const response = await fetch(`http://${this.address}:${this.port}/stop`, { method: 'POST', signal: AbortSignal.timeout(5000), }); if (!response.ok) { throw new Error(`Stop failed: ${response.status}`); } this._isPlaying = false; this._playbackState = 'stopped'; this._currentUri = undefined; this.emit('playback:stopped'); } /** * Next track (not supported on basic AirPlay) */ public async next(): Promise { throw new Error('Next track not supported on AirPlay'); } /** * Previous track (not supported on basic AirPlay) */ public async previous(): Promise { throw new Error('Previous track not supported on AirPlay'); } /** * Seek to position */ public async seek(seconds: number): Promise { const body = `position: ${seconds}\n`; const response = await fetch(`http://${this.address}:${this.port}/scrub`, { method: 'POST', headers: { 'Content-Type': 'text/parameters', }, body, signal: AbortSignal.timeout(5000), }); if (!response.ok) { throw new Error(`Seek failed: ${response.status}`); } this._currentPosition = seconds; this.emit('playback:seeked', { position: seconds }); } // ============================================================================ // Volume Control (limited support) // ============================================================================ /** * Get volume (not always supported) */ public async getVolume(): Promise { // AirPlay volume control varies by device return this._volume; } /** * Set volume (not always supported) */ public async setVolume(level: number): Promise { const clamped = Math.max(0, Math.min(100, level)); try { const body = `volume: ${clamped / 100}\n`; const response = await fetch(`http://${this.address}:${this.port}/volume`, { method: 'POST', headers: { 'Content-Type': 'text/parameters', }, body, signal: AbortSignal.timeout(5000), }); if (response.ok) { this._volume = clamped; this.emit('volume:changed', { volume: clamped }); } } catch { // Volume control may not be supported throw new Error('Volume control not supported on this device'); } } /** * Get mute state (not always supported) */ public async getMute(): Promise { return this._muted; } /** * Set mute state (not always supported) */ public async setMute(muted: boolean): Promise { // Mute by setting volume to 0 if (muted) { await this.setVolume(0); } else { await this.setVolume(this._volume || 50); } this._muted = muted; this.emit('mute:changed', { muted }); } // ============================================================================ // Track Information // ============================================================================ /** * Get current track */ public async getCurrentTrack(): Promise { if (!this._currentUri) { return null; } return { title: this._currentUri.split('/').pop() || 'Unknown', duration: this._currentDuration, position: this._currentPosition, uri: this._currentUri, }; } /** * 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, }; } // ============================================================================ // AirPlay-specific Methods // ============================================================================ /** * Set playback rate */ private async setRate(rate: number): Promise { const body = `value: ${rate}\n`; const response = await fetch(`http://${this.address}:${this.port}/rate`, { method: 'POST', headers: { 'Content-Type': 'text/parameters', }, body, signal: AbortSignal.timeout(5000), }); if (!response.ok) { throw new Error(`Set rate failed: ${response.status}`); } } /** * Get AirPlay playback info */ public async getAirPlayPlaybackInfo(): Promise { const response = await fetch(`http://${this.address}:${this.port}/playback-info`, { signal: AbortSignal.timeout(5000), }); if (!response.ok) { throw new Error(`Get playback info failed: ${response.status}`); } const text = await response.text(); // Parse plist response const extractReal = (key: string): number => { const match = text.match(new RegExp(`${key}\\s*([\\d.]+)`)); return match ? parseFloat(match[1]) : 0; }; const extractBool = (key: string): boolean => { const match = text.match(new RegExp(`${key}\\s*<(true|false)/>`)); return match?.[1] === 'true'; }; return { duration: extractReal('duration'), position: extractReal('position'), rate: extractReal('rate'), readyToPlay: extractBool('readyToPlay'), playbackBufferEmpty: extractBool('playbackBufferEmpty'), playbackBufferFull: extractBool('playbackBufferFull'), playbackLikelyToKeepUp: extractBool('playbackLikelyToKeepUp'), }; } /** * Get scrub position */ public async getScrubPosition(): Promise<{ position: number; duration: number }> { const response = await fetch(`http://${this.address}:${this.port}/scrub`, { signal: AbortSignal.timeout(5000), }); if (!response.ok) { throw new Error(`Get scrub position failed: ${response.status}`); } const text = await response.text(); const durationMatch = text.match(/duration:\s*([\d.]+)/); const positionMatch = text.match(/position:\s*([\d.]+)/); return { duration: durationMatch ? parseFloat(durationMatch[1]) : 0, position: positionMatch ? parseFloat(positionMatch[1]) : 0, }; } // ============================================================================ // Device Info // ============================================================================ /** * Get speaker info */ public getSpeakerInfo(): IAirPlaySpeakerInfo { return { id: this.id, name: this.name, type: 'speaker', address: this.address, port: this.port, status: this.status, protocol: 'airplay', roomName: this._roomName, modelName: this._modelName, features: this._features, supportsVideo: this._supportsVideo, supportsAudio: this._supportsAudio, supportsScreen: this._supportsScreen, deviceId: this._deviceId, }; } /** * Create from mDNS discovery */ public static fromDiscovery( data: { id: string; name: string; address: string; port?: number; roomName?: string; modelName?: string; features?: number; deviceId?: string; }, retryOptions?: IRetryOptions ): AirPlaySpeaker { const info: IDeviceInfo = { id: data.id, name: data.name, type: 'speaker', address: data.address, port: data.port ?? 7000, status: 'unknown', }; return new AirPlaySpeaker( info, { roomName: data.roomName, modelName: data.modelName, features: data.features, deviceId: data.deviceId, }, retryOptions ); } /** * Probe for AirPlay device */ public static async probe(address: string, port: number = 7000, timeout: number = 3000): Promise { try { const response = await fetch(`http://${address}:${port}/server-info`, { signal: AbortSignal.timeout(timeout), }); return response.ok; } catch { return false; } } }