Files
devicemanager/ts/dlna/dlna.classes.renderer.ts

528 lines
13 KiB
TypeScript
Raw Normal View History

import * as plugins from '../plugins.js';
import { Device } from '../abstract/device.abstract.js';
import {
UpnpSoapClient,
UPNP_SERVICE_TYPES,
type TDlnaTransportState,
type IDlnaTransportInfo,
type IDlnaPositionInfo,
type IDlnaMediaInfo,
} from './dlna.classes.upnp.js';
import type { IDeviceInfo, IRetryOptions } from '../interfaces/index.js';
import type { ISsdpDevice, ISsdpService } from '../discovery/discovery.classes.ssdp.js';
/**
* DLNA Renderer device info
*/
export interface IDlnaRendererInfo extends IDeviceInfo {
type: 'dlna-renderer';
friendlyName: string;
modelName: string;
modelNumber?: string;
manufacturer: string;
udn: string;
iconUrl?: string;
supportsVolume: boolean;
supportsSeek: boolean;
}
/**
* Playback state
*/
export interface IDlnaPlaybackState {
state: TDlnaTransportState;
volume: number;
muted: boolean;
currentUri: string;
currentTrack: {
title: string;
artist?: string;
album?: string;
duration: number;
position: number;
albumArtUri?: string;
};
}
/**
* DLNA Media Renderer device
* Represents a device that can play media (TV, speaker, etc.)
*/
export class DlnaRenderer extends Device {
private soapClient: UpnpSoapClient | null = null;
private avTransportUrl: string = '';
private renderingControlUrl: string = '';
private baseUrl: string = '';
private _friendlyName: string;
private _modelName: string = '';
private _modelNumber?: string;
private _udn: string = '';
private _iconUrl?: string;
private _supportsVolume: boolean = true;
private _supportsSeek: boolean = true;
private _currentState: TDlnaTransportState = 'STOPPED';
private _currentVolume: number = 0;
private _currentMuted: boolean = false;
constructor(
info: IDeviceInfo,
options: {
friendlyName: string;
baseUrl: string;
avTransportUrl?: string;
renderingControlUrl?: string;
modelName?: string;
modelNumber?: string;
udn?: string;
iconUrl?: string;
},
retryOptions?: IRetryOptions
) {
super(info, retryOptions);
this._friendlyName = options.friendlyName;
this.baseUrl = options.baseUrl;
this.avTransportUrl = options.avTransportUrl || '/AVTransport/control';
this.renderingControlUrl = options.renderingControlUrl || '/RenderingControl/control';
this._modelName = options.modelName || '';
this._modelNumber = options.modelNumber;
this._udn = options.udn || '';
this._iconUrl = options.iconUrl;
}
// Getters
public get friendlyName(): string {
return this._friendlyName;
}
public get modelName(): string {
return this._modelName;
}
public get modelNumber(): string | undefined {
return this._modelNumber;
}
public get udn(): string {
return this._udn;
}
public get iconUrl(): string | undefined {
return this._iconUrl;
}
public get supportsVolume(): boolean {
return this._supportsVolume;
}
public get supportsSeek(): boolean {
return this._supportsSeek;
}
public get currentState(): TDlnaTransportState {
return this._currentState;
}
public get currentVolume(): number {
return this._currentVolume;
}
public get currentMuted(): boolean {
return this._currentMuted;
}
/**
* Connect to renderer
*/
protected async doConnect(): Promise<void> {
this.soapClient = new UpnpSoapClient(this.baseUrl);
// Test connection by getting transport info
try {
await this.getTransportInfo();
} catch (error) {
this.soapClient = null;
throw error;
}
// Try to get volume (may not be supported)
try {
this._currentVolume = await this.getVolume();
this._supportsVolume = true;
} catch {
this._supportsVolume = false;
}
}
/**
* Disconnect
*/
protected async doDisconnect(): Promise<void> {
this.soapClient = null;
}
/**
* Refresh status
*/
public async refreshStatus(): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
const [transport, volume, muted] = await Promise.all([
this.getTransportInfo(),
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
this._supportsVolume ? this.getMute() : Promise.resolve(false),
]);
this._currentState = transport.state;
this._currentVolume = volume;
this._currentMuted = muted;
this.emit('status:updated', this.getDeviceInfo());
}
// ============================================================================
// Playback Control
// ============================================================================
/**
* Set media URI to play
*/
public async setAVTransportURI(uri: string, metadata?: string): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
const meta = metadata || this.soapClient.generateDidlMetadata('Media', uri);
await this.soapClient.setAVTransportURI(this.avTransportUrl, uri, meta);
this.emit('media:loaded', { uri });
}
/**
* Play current media
*/
public async play(uri?: string, metadata?: string): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
if (uri) {
await this.setAVTransportURI(uri, metadata);
}
await this.soapClient.play(this.avTransportUrl);
this._currentState = 'PLAYING';
this.emit('playback:started');
}
/**
* Pause playback
*/
public async pause(): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
await this.soapClient.pause(this.avTransportUrl);
this._currentState = 'PAUSED_PLAYBACK';
this.emit('playback:paused');
}
/**
* Stop playback
*/
public async stop(): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
await this.soapClient.stop(this.avTransportUrl);
this._currentState = 'STOPPED';
this.emit('playback:stopped');
}
/**
* Seek to position
*/
public async seek(seconds: number): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
const target = this.soapClient.secondsToDuration(seconds);
await this.soapClient.seek(this.avTransportUrl, target, 'REL_TIME');
this.emit('playback:seeked', { position: seconds });
}
/**
* Next track
*/
public async next(): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
await this.soapClient.next(this.avTransportUrl);
this.emit('playback:next');
}
/**
* Previous track
*/
public async previous(): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
await this.soapClient.previous(this.avTransportUrl);
this.emit('playback:previous');
}
// ============================================================================
// Volume Control
// ============================================================================
/**
* Get volume level
*/
public async getVolume(): Promise<number> {
if (!this.soapClient) {
throw new Error('Not connected');
}
return this.soapClient.getVolume(this.renderingControlUrl);
}
/**
* Set volume level
*/
public async setVolume(level: number): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
await this.soapClient.setVolume(this.renderingControlUrl, level);
this._currentVolume = level;
this.emit('volume:changed', { volume: level });
}
/**
* Get mute state
*/
public async getMute(): Promise<boolean> {
if (!this.soapClient) {
throw new Error('Not connected');
}
return this.soapClient.getMute(this.renderingControlUrl);
}
/**
* Set mute state
*/
public async setMute(muted: boolean): Promise<void> {
if (!this.soapClient) {
throw new Error('Not connected');
}
await this.soapClient.setMute(this.renderingControlUrl, muted);
this._currentMuted = muted;
this.emit('mute:changed', { muted });
}
/**
* Toggle mute
*/
public async toggleMute(): Promise<boolean> {
const newMuted = !this._currentMuted;
await this.setMute(newMuted);
return newMuted;
}
// ============================================================================
// Status Information
// ============================================================================
/**
* Get transport info
*/
public async getTransportInfo(): Promise<IDlnaTransportInfo> {
if (!this.soapClient) {
throw new Error('Not connected');
}
return this.soapClient.getTransportInfo(this.avTransportUrl);
}
/**
* Get position info
*/
public async getPositionInfo(): Promise<IDlnaPositionInfo> {
if (!this.soapClient) {
throw new Error('Not connected');
}
return this.soapClient.getPositionInfo(this.avTransportUrl);
}
/**
* Get media info
*/
public async getMediaInfo(): Promise<IDlnaMediaInfo> {
if (!this.soapClient) {
throw new Error('Not connected');
}
return this.soapClient.getMediaInfo(this.avTransportUrl);
}
/**
* Get full playback state
*/
public async getPlaybackState(): Promise<IDlnaPlaybackState> {
if (!this.soapClient) {
throw new Error('Not connected');
}
const [transport, position, media, volume, muted] = await Promise.all([
this.getTransportInfo(),
this.getPositionInfo(),
this.getMediaInfo(),
this._supportsVolume ? this.getVolume() : Promise.resolve(0),
this._supportsVolume ? this.getMute() : Promise.resolve(false),
]);
// Parse metadata for track info
const trackMeta = this.parseTrackMetadata(position.trackMetadata);
return {
state: transport.state,
volume,
muted,
currentUri: media.currentUri,
currentTrack: {
title: trackMeta.title || 'Unknown',
artist: trackMeta.artist,
album: trackMeta.album,
duration: this.soapClient.durationToSeconds(position.trackDuration),
position: this.soapClient.durationToSeconds(position.relativeTime),
albumArtUri: trackMeta.albumArtUri,
},
};
}
/**
* Parse track metadata from DIDL-Lite
*/
private parseTrackMetadata(metadata: string): {
title?: string;
artist?: string;
album?: string;
albumArtUri?: string;
} {
if (!metadata) return {};
const extractTag = (xml: string, tag: string): string | undefined => {
const regex = new RegExp(`<(?:[^:]*:)?${tag}[^>]*>([^<]*)<\/(?:[^:]*:)?${tag}>`, 'i');
const match = xml.match(regex);
return match ? match[1].trim() : undefined;
};
return {
title: extractTag(metadata, 'title'),
artist: extractTag(metadata, 'creator') || extractTag(metadata, 'artist'),
album: extractTag(metadata, 'album'),
albumArtUri: extractTag(metadata, 'albumArtURI'),
};
}
/**
* Get device info
*/
public getDeviceInfo(): IDlnaRendererInfo {
return {
id: this.id,
name: this.name,
type: 'dlna-renderer',
address: this.address,
port: this.port,
status: this.status,
friendlyName: this._friendlyName,
modelName: this._modelName,
modelNumber: this._modelNumber,
manufacturer: this.manufacturer || '',
udn: this._udn,
iconUrl: this._iconUrl,
supportsVolume: this._supportsVolume,
supportsSeek: this._supportsSeek,
};
}
/**
* Create from SSDP discovery
*/
public static fromSsdpDevice(
ssdpDevice: ISsdpDevice,
retryOptions?: IRetryOptions
): DlnaRenderer | null {
if (!ssdpDevice.description) {
return null;
}
const desc = ssdpDevice.description;
// Find AVTransport and RenderingControl URLs
const avTransport = desc.services.find((s) =>
s.serviceType.includes('AVTransport')
);
const renderingControl = desc.services.find((s) =>
s.serviceType.includes('RenderingControl')
);
if (!avTransport) {
return null; // Not a media renderer
}
// Build base URL
const baseUrl = new URL(ssdpDevice.location);
const baseUrlStr = `${baseUrl.protocol}//${baseUrl.host}`;
// Get icon URL
let iconUrl: string | undefined;
if (desc.icons && desc.icons.length > 0) {
const bestIcon = desc.icons.sort((a, b) => b.width - a.width)[0];
iconUrl = bestIcon.url.startsWith('http')
? bestIcon.url
: `${baseUrlStr}${bestIcon.url}`;
}
const info: IDeviceInfo = {
id: `dlna-renderer:${desc.UDN}`,
name: desc.friendlyName,
type: 'dlna-renderer',
address: ssdpDevice.address,
port: ssdpDevice.port,
status: 'unknown',
manufacturer: desc.manufacturer,
model: desc.modelName,
};
return new DlnaRenderer(
info,
{
friendlyName: desc.friendlyName,
baseUrl: baseUrlStr,
avTransportUrl: avTransport.controlURL,
renderingControlUrl: renderingControl?.controlURL,
modelName: desc.modelName,
modelNumber: desc.modelNumber,
udn: desc.UDN,
iconUrl,
},
retryOptions
);
}
}