549 lines
14 KiB
TypeScript
549 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|