feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
548
ts/speaker/speaker.classes.airplay.ts
Normal file
548
ts/speaker/speaker.classes.airplay.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user