Files
devicemanager/ts/speaker/speaker.classes.airplay.ts

549 lines
14 KiB
TypeScript
Raw Normal View History

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<void> {
// 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(/<key>features<\/key>\s*<integer>(\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(/<key>deviceid<\/key>\s*<string>([^<]+)<\/string>/);
if (deviceIdMatch) {
this._deviceId = deviceIdMatch[1];
}
// Extract model
const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/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<void> {
try {
await this.stop();
} catch {
// Ignore stop errors
}
}
/**
* Refresh status
*/
public async refreshStatus(): Promise<void> {
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<void> {
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<void> {
await this.setRate(0);
this._isPlaying = false;
this._playbackState = 'paused';
this.emit('playback:paused');
}
/**
* Stop playback
*/
public async stop(): Promise<void> {
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<void> {
throw new Error('Next track not supported on AirPlay');
}
/**
* Previous track (not supported on basic AirPlay)
*/
public async previous(): Promise<void> {
throw new Error('Previous track not supported on AirPlay');
}
/**
* Seek to position
*/
public async seek(seconds: number): Promise<void> {
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<number> {
// AirPlay volume control varies by device
return this._volume;
}
/**
* Set volume (not always supported)
*/
public async setVolume(level: number): Promise<void> {
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<boolean> {
return this._muted;
}
/**
* Set mute state (not always supported)
*/
public async setMute(muted: boolean): Promise<void> {
// 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<ITrackInfo | null> {
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<IPlaybackStatus> {
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<void> {
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<IAirPlayPlaybackInfo> {
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>${key}</key>\\s*<real>([\\d.]+)</real>`));
return match ? parseFloat(match[1]) : 0;
};
const extractBool = (key: string): boolean => {
const match = text.match(new RegExp(`<key>${key}</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<boolean> {
try {
const response = await fetch(`http://${address}:${port}/server-info`, {
signal: AbortSignal.timeout(timeout),
});
return response.ok;
} catch {
return false;
}
}
}