726 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|