Misc fixes/improvements from WIP branches

This commit is contained in:
hensm
2022-03-15 06:11:25 +00:00
parent 7ab541643a
commit cef8f3a261
13 changed files with 559 additions and 457 deletions

View File

@@ -1,76 +1,13 @@
"use strict";
import { Channel, Client } from "castv2";
import { Channel } from "castv2";
import { sendMessage } from "../../lib/nativeMessaging";
import { ReceiverDevice } from "../../types";
import { ReceiverApplication, ReceiverMessage, SenderMessage } from "./types";
export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
const HEARTBEAT_INTERVAL = 5000;
class CastClient {
protected client = new Client();
protected connectionChannel?: Channel;
protected heartbeatChannel?: Channel;
protected heartbeatIntervalId?: NodeJS.Timeout;
constructor(
protected sourceId = "sender-0",
protected destinationId = "receiver-0"
) {}
/**
* Create a channel on the client connection with a given
* namespace.
*/
createChannel(
namespace: string,
sourceId = this.sourceId,
destinationId = this.destinationId
) {
return this.client.createChannel(
sourceId,
destinationId,
namespace,
"JSON"
);
}
connect(host: string, port: number, onHeartbeat?: () => void) {
return new Promise<void>((resolve, reject) => {
// Handle errors
this.client.on("error", reject);
this.client.on("close", () => {
if (this.heartbeatChannel && this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
}
});
this.client.connect({ host, port }, () => {
this.connectionChannel = this.createChannel(NS_CONNECTION);
this.heartbeatChannel = this.createChannel(NS_HEARTBEAT);
this.connectionChannel.send({ type: "CONNECT" });
this.heartbeatChannel.send({ type: "PING" });
this.heartbeatIntervalId = setInterval(() => {
this.heartbeatChannel?.send({ type: "PING" });
if (onHeartbeat) {
onHeartbeat();
}
}, HEARTBEAT_INTERVAL);
resolve();
});
});
}
}
import CastClient, { NS_CONNECTION, NS_HEARTBEAT, NS_RECEIVER } from "./client";
type OnSessionCreatedCallback = (sessionId: string) => void;
@@ -96,8 +33,6 @@ export default class Session extends CastClient {
*/
private launchRequestId?: number;
private onSessionCreated?: OnSessionCreatedCallback;
private establishAppConnection(transportId: string) {
this.transportConnection = this.createChannel(
NS_CONNECTION,
@@ -236,7 +171,11 @@ export default class Session extends CastClient {
return requestId;
}
constructor(public appId: string, public receiverDevice: ReceiverDevice) {
constructor(
private appId: string,
private receiverDevice: ReceiverDevice,
private onSessionCreated?: OnSessionCreatedCallback
) {
super();
this.client.on("close", () => {
@@ -247,27 +186,19 @@ export default class Session extends CastClient {
});
}
});
}
async connect(
host: string,
port: number,
onSessionCreated?: OnSessionCreatedCallback
) {
if (onSessionCreated) {
this.onSessionCreated = onSessionCreated;
}
await super.connect(host, port, () => {
// Include transport heartbeat with platform heartbeat
if (this.transportHeartbeat) {
this.transportHeartbeat.send({ type: "PING" });
super.connect(receiverDevice.host, {
onHeartbeat: () => {
// Include transport heartbeat with platform heartbeat
if (this.transportHeartbeat) {
this.transportHeartbeat.send({ type: "PING" });
}
}
});
this.launchRequestId = this.sendReceiverMessage({
type: "LAUNCH",
appId: this.appId
}).then(() => {
this.launchRequestId = this.sendReceiverMessage({
type: "LAUNCH",
appId: this.appId
});
});
}
}

View File

@@ -0,0 +1,90 @@
"use strict";
import { Channel, Client } from "castv2";
export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
const DEFAULT_PORT = 8009;
const HEARTBEAT_INTERVAL_MS = 5000;
interface CastClientConnectOptions {
port?: number;
onHeartbeat?: () => void;
}
export default class CastClient {
protected client = new Client();
protected connectionChannel?: Channel;
protected heartbeatChannel?: Channel;
protected heartbeatIntervalId?: NodeJS.Timeout;
constructor(
protected sourceId = "sender-0",
protected destinationId = "receiver-0"
) {}
/**
* Create a channel on the client connection with a given
* namespace.
*/
protected createChannel(
namespace: string,
sourceId = this.sourceId,
destinationId = this.destinationId
) {
return this.client.createChannel(
sourceId,
destinationId,
namespace,
"JSON"
);
}
/**
* Connects to a cast receiver at a given host, returning a
* promise that resolves once the client is connected.
*/
connect(host: string, options?: CastClientConnectOptions) {
return new Promise<void>((resolve, reject) => {
// Handle errors
this.client.on("error", reject);
this.client.on("close", () => {
if (this.heartbeatChannel && this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
}
});
const connectOpts = {
host,
port: options?.port ?? DEFAULT_PORT
};
this.client.connect(connectOpts, () => {
this.connectionChannel = this.createChannel(NS_CONNECTION);
this.heartbeatChannel = this.createChannel(NS_HEARTBEAT);
this.connectionChannel.send({ type: "CONNECT" });
this.heartbeatChannel.send({ type: "PING" });
this.heartbeatIntervalId = setInterval(() => {
this.heartbeatChannel?.send({ type: "PING" });
options?.onHeartbeat?.();
}, HEARTBEAT_INTERVAL_MS);
resolve();
});
});
}
disconnect() {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
}
this.connectionChannel?.send({ type: "CLOSE" });
this.client.close();
}
}

View File

@@ -5,7 +5,9 @@ import castv2 from "castv2";
import { sendMessage } from "../../lib/nativeMessaging";
import { Message } from "../../messaging";
import Session, { NS_CONNECTION, NS_RECEIVER } from "./Session";
import Session from "./Session";
import { NS_CONNECTION, NS_RECEIVER } from "./client";
const sessions = new Map<string, Session>();
@@ -15,14 +17,9 @@ export function handleCastMessage(message: Message) {
const { appId, receiverDevice } = message.data;
// Connect and store with returned ID
const session = new Session(appId, receiverDevice);
session.connect(
receiverDevice.host,
receiverDevice.port,
sessionId => {
sessions.set(sessionId, session);
}
);
const session = new Session(appId, receiverDevice, sessionId => {
sessions.set(sessionId, session);
});
break;
}

View File

@@ -0,0 +1,117 @@
"use strict";
import CastClient, { NS_RECEIVER } from "./client";
import {
MediaStatus,
ReceiverMessage,
ReceiverMediaMessage,
ReceiverStatus,
SenderMediaMessage
} from "./types";
const NS_MEDIA = "urn:x-cast:com.google.cast.media";
interface CastRemoteOptions {
onApplicationFound?: () => void;
onApplicationClose?: () => void;
onReceiverStatusUpdate?: (status: ReceiverStatus) => void;
onMediaStatusUpdate?: (status?: MediaStatus) => void;
}
/**
* castv2 client for receiver tracking.
*/
export default class Remote extends CastClient {
private transportClient?: RemoteTransport;
constructor(private host: string, private options?: CastRemoteOptions) {
super();
this.connect(host).then(() => {
// Request receiver status
const receiverChannel = this.createChannel(NS_RECEIVER);
receiverChannel.on("message", message => {
this.onReceiverMessage(message);
});
receiverChannel.send({ type: "GET_STATUS", requestId: 1 });
});
}
disconnect() {
super.disconnect();
this.transportClient?.disconnect();
}
/**
* Handle `NS_RECEIVER` messages from the receiver device.
* On initial connection, a `GET_STATUS` message is sent that
* results in a `RECEIVER_STATUS` response. If an application
* is running, get the transport ID and make a connection to
* fetch media status updates.
*/
private onReceiverMessage(message: ReceiverMessage) {
if (message.type !== "RECEIVER_STATUS") {
return;
}
const application = message.status.applications?.[0];
if (!application || application.isIdleScreen) {
// Handle app close
if (this.transportClient) {
this.transportClient = undefined;
this.options?.onApplicationClose?.();
}
}
// Update status before possible transport init
this.options?.onReceiverStatusUpdate?.(message.status);
// Handle app creation/discovery
if (application && !this.transportClient) {
this.transportClient = new RemoteTransport(
application.transportId,
message => this.onMediaMessage(message)
);
this.transportClient.connect(this.host).then(() => {
this.transportClient?.sendMediaMessage({
type: "GET_STATUS"
});
});
this.options?.onApplicationFound?.();
}
}
/**
* Handle `NS_MEDIA` messages from the receiver application.
* On initial connection. a `GET_STATUS` message is sent that
* results in a `MEDIA_STATUS` response.
*/
private onMediaMessage(message: ReceiverMediaMessage) {
if (message.type !== "MEDIA_STATUS") {
return;
}
this.options?.onMediaStatusUpdate?.(message.status[0]);
}
}
/**
* castv2 client for receiver application tracking.
*/
class RemoteTransport extends CastClient {
private mediaChannel = this.createChannel(NS_MEDIA);
constructor(
transportId: string,
onMediaMessage: (message: ReceiverMediaMessage) => void
) {
super(undefined, transportId);
this.mediaChannel.on("message", message => onMediaMessage(message));
}
sendMediaMessage(message: SenderMediaMessage) {
this.mediaChannel.send(message);
}
}

View File

@@ -353,9 +353,19 @@ interface MediaReqBase extends ReqBase {
export type SenderMediaMessage =
| (MediaReqBase & { type: "PLAY" })
| (MediaReqBase & { type: "PAUSE" })
| (MediaReqBase & { type: "MEDIA_GET_STATUS" })
| {
type: "MEDIA_GET_STATUS";
mediaSessionId?: number;
customData?: unknown;
}
| {
type: "GET_STATUS";
mediaSessionId?: number;
customData?: unknown;
}
| (MediaReqBase & { type: "STOP" })
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
| (MediaReqBase & { type: "SET_VOLUME"; volume: Volume })
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
| (ReqBase & {
type: "LOAD";