feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
725
ts/speaker/speaker.classes.chromecast.ts
Normal file
725
ts/speaker/speaker.classes.chromecast.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
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<typeof plugins.castv2Client.Client> | 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<void> {
|
||||
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<void> {
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
this.player = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh status
|
||||
*/
|
||||
public async refreshStatus(): Promise<void> {
|
||||
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<InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.launch(plugins.castv2Client.DefaultMediaReceiver, (err: Error | null, player: InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>) => {
|
||||
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<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const player = await this.getMediaPlayer() as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>;
|
||||
|
||||
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<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).pause((err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this._playbackState = 'paused';
|
||||
this.emit('playback:paused');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).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<void> {
|
||||
throw new Error('Next track not supported on basic Chromecast');
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous track (not supported)
|
||||
*/
|
||||
public async previous(): Promise<void> {
|
||||
throw new Error('Previous track not supported on basic Chromecast');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to position
|
||||
*/
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
if (!this.player) {
|
||||
throw new Error('No active media session');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).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<number> {
|
||||
await this.refreshStatus();
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume
|
||||
*/
|
||||
public async setVolume(level: number): Promise<void> {
|
||||
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<boolean> {
|
||||
await this.refreshStatus();
|
||||
return this._muted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
*/
|
||||
public async setMute(muted: boolean): Promise<void> {
|
||||
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<ITrackInfo | null> {
|
||||
if (!this.player) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>).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<IPlaybackStatus> {
|
||||
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<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.launch({ id: appId } as Parameters<typeof plugins.castv2Client.Client.prototype.launch>[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<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client!.stop(this.player as InstanceType<typeof plugins.castv2Client.DefaultMediaReceiver>, (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<boolean> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user