Files
devicemanager/ts/speaker/speaker.classes.chromecast.ts

726 lines
17 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';
/**
* 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);
});
});
}
}