mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-11 10:09:59 +00:00
Cast API overhaul (#173)
Re-write the shim<->bridge messaging interface and session creation/update handling for better accuracy.
This commit is contained in:
260
app/src/bridge/components/cast/Session.ts
Normal file
260
app/src/bridge/components/cast/Session.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
"use strict";
|
||||
|
||||
import { Channel, Client } 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type OnSessionCreatedCallback = (sessionId: string) => void;
|
||||
|
||||
export default class Session extends CastClient {
|
||||
// Assigned by the receiver once the session is established
|
||||
public sessionId?: string;
|
||||
|
||||
// Platform messaging
|
||||
private receiverChannel?: Channel;
|
||||
private receiverRequestId = 0;
|
||||
|
||||
// Receiver app messaging
|
||||
private transportId?: string;
|
||||
private transportConnection?: Channel;
|
||||
private transportHeartbeat?: Channel;
|
||||
|
||||
// Channels created by `sendCastSessionMessage` messages
|
||||
private namespaceChannelMap = new Map<string, Channel>();
|
||||
|
||||
/**
|
||||
* Request ID used to correlate the launch request with the
|
||||
* RECEIVER_STATUS message associated with session creation.
|
||||
*/
|
||||
private launchRequestId?: number;
|
||||
|
||||
private onSessionCreated?: OnSessionCreatedCallback;
|
||||
|
||||
|
||||
private establishAppConnection(transportId: string) {
|
||||
this.transportConnection = this.createChannel(
|
||||
NS_CONNECTION, this.sourceId, transportId);
|
||||
this.transportHeartbeat = this.createChannel(
|
||||
NS_HEARTBEAT, this.sourceId, transportId);
|
||||
|
||||
this.transportConnection.send({ type: "CONNECT" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming receiver messages.
|
||||
*/
|
||||
private onReceiverMessage = (message: ReceiverMessage) => {
|
||||
switch (message.type) {
|
||||
case "RECEIVER_STATUS": {
|
||||
const { status } = message;
|
||||
const application = status.applications?.find(
|
||||
app => app.appId === this.appId);
|
||||
|
||||
/**
|
||||
* If application isn't set, still waiting on the launch
|
||||
* request response.
|
||||
*/
|
||||
if (!this.sessionId) {
|
||||
// Launch message response only
|
||||
if (message.requestId !== this.launchRequestId) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (application) {
|
||||
this.sessionId = application.sessionId;
|
||||
this.transportId = application.transportId;
|
||||
|
||||
this.establishAppConnection(this.transportId);
|
||||
this.onSessionCreated?.(this.sessionId);
|
||||
|
||||
const { friendlyName } = this.receiverDevice;
|
||||
|
||||
sendMessage({
|
||||
subject: "shim:castSessionCreated"
|
||||
, data: {
|
||||
sessionId: this.sessionId
|
||||
, statusText: application.statusText
|
||||
, namespaces: application.namespaces
|
||||
, volume: status.volume
|
||||
, appId: application.appId
|
||||
, displayName: application.displayName
|
||||
, receiverFriendlyName: friendlyName
|
||||
, transportId: this.sessionId
|
||||
|
||||
// TODO: Fix this
|
||||
, senderApps: []
|
||||
, appImages: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle session stop
|
||||
if (!application) {
|
||||
this.client.close();
|
||||
break;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
subject: "shim:castSessionUpdated"
|
||||
, data: {
|
||||
sessionId: this.sessionId
|
||||
, statusText: application.statusText
|
||||
, namespaces: application.namespaces
|
||||
, volume: message.status.volume
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "LAUNCH_ERROR": {
|
||||
console.error(`err: LAUNCH_ERROR, ${message.reason}`);
|
||||
this.client.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(namespace: string, message: unknown) {
|
||||
let channel = this.namespaceChannelMap.get(namespace);
|
||||
if (!channel) {
|
||||
channel = this.createChannel(
|
||||
namespace, this.sourceId, this.transportId);
|
||||
|
||||
channel.on("message", messageData => {
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageData = JSON.stringify(messageData);
|
||||
|
||||
sendMessage({
|
||||
subject: "shim:receivedCastSessionMessage"
|
||||
, data: {
|
||||
sessionId: this.sessionId
|
||||
, namespace
|
||||
, messageData
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.namespaceChannelMap.set(namespace, channel);
|
||||
}
|
||||
|
||||
channel.send(message);
|
||||
}
|
||||
|
||||
sendReceiverMessage(message: DistributiveOmit<SenderMessage, "requestId">) {
|
||||
if (!this.receiverChannel) {
|
||||
this.receiverChannel = this.createChannel(NS_RECEIVER);
|
||||
this.receiverChannel.on("message", this.onReceiverMessage);
|
||||
}
|
||||
|
||||
const requestId = this.receiverRequestId++;
|
||||
this.receiverChannel?.send({ ...message, requestId });
|
||||
|
||||
return requestId;
|
||||
}
|
||||
|
||||
constructor(public appId: string
|
||||
, public receiverDevice: ReceiverDevice) {
|
||||
|
||||
super();
|
||||
|
||||
this.client.on("close", () => {
|
||||
if (this.sessionId) {
|
||||
sendMessage({
|
||||
subject: "shim:castSessionStopped"
|
||||
, data: { sessionId: this.sessionId }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
this.launchRequestId = this.sendReceiverMessage({
|
||||
type: "LAUNCH"
|
||||
, appId: this.appId
|
||||
});
|
||||
}
|
||||
}
|
||||
114
app/src/bridge/components/cast/index.ts
Normal file
114
app/src/bridge/components/cast/index.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
"use strict";
|
||||
|
||||
import { sendMessage } from "../../lib/nativeMessaging";
|
||||
import { Message } from "../../messaging";
|
||||
|
||||
import Session from "./Session";
|
||||
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
export function handleCastMessage(message: Message) {
|
||||
switch (message.subject) {
|
||||
case "bridge:createCastSession": {
|
||||
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);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:sendCastReceiverMessage": {
|
||||
const { sessionId, messageData, messageId } = message.data;
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
sendMessage({
|
||||
subject: "shim:impl_sendCastMessage"
|
||||
, data: {
|
||||
error: "Session does not exist"
|
||||
, sessionId, messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
session.sendReceiverMessage(messageData);
|
||||
} catch (err) {
|
||||
sendMessage({
|
||||
subject: "shim:impl_sendCastMessage"
|
||||
, data: {
|
||||
error: `Failed to send message (${err})`
|
||||
, sessionId, messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Success
|
||||
sendMessage({
|
||||
subject: "shim:impl_sendCastMessage"
|
||||
, data: { sessionId, messageId }
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:sendCastSessionMessage": {
|
||||
const { namespace, sessionId, messageId } = message.data;
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
sendMessage({
|
||||
subject: "shim:impl_sendCastMessage"
|
||||
, data: {
|
||||
error: "Session does not exist"
|
||||
, sessionId, messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle string messages
|
||||
let { messageData } = message.data;
|
||||
if (typeof messageData === "string") {
|
||||
messageData = JSON.parse(messageData);
|
||||
}
|
||||
|
||||
session.sendMessage(namespace, messageData);
|
||||
} catch (err) {
|
||||
sendMessage({
|
||||
subject: "shim:impl_sendCastMessage"
|
||||
, data: {
|
||||
error: `Failed to send message (${err})`
|
||||
, sessionId, messageId
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Success
|
||||
sendMessage({
|
||||
subject: "shim:impl_sendCastMessage"
|
||||
, data: { sessionId, messageId }
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:stopCastApp": {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,29 @@
|
||||
|
||||
export interface Image {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
height: Nullable<number>;
|
||||
width: Nullable<number>;
|
||||
}
|
||||
|
||||
enum Capability {
|
||||
VIDEO_OUT = "video_out"
|
||||
, AUDIO_OUT = "audio_out"
|
||||
, VIDEO_IN = "video_in"
|
||||
, AUDIO_IN = "audio_in"
|
||||
, MULTIZONE_GROUP = "multizone_group"
|
||||
}
|
||||
|
||||
enum ReceiverType {
|
||||
CAST = "cast"
|
||||
, DIAL = "dial"
|
||||
, HANGOUT = "hangout"
|
||||
, CUSTOM = "custom"
|
||||
}
|
||||
|
||||
export interface SenderApplication {
|
||||
packageId: Nullable<string>;
|
||||
platform: string;
|
||||
url: Nullable<string>;
|
||||
}
|
||||
|
||||
enum VolumeControlType {
|
||||
@@ -257,13 +278,13 @@ interface QueueItem {
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
|
||||
export interface MediaStatus {
|
||||
mediaSessionId: number;
|
||||
media?: MediaInformation;
|
||||
playbackRate: number;
|
||||
playerState: PlayerState;
|
||||
idleReason?: IdleReason;
|
||||
items?: QueueItem[]
|
||||
currentTime: number;
|
||||
supportedMediaCommands: number;
|
||||
repeatMode: RepeatMode;
|
||||
@@ -271,25 +292,41 @@ export interface MediaStatus {
|
||||
customData: unknown;
|
||||
}
|
||||
|
||||
interface ReceiverDisplayStatus {
|
||||
showStop: Nullable<boolean>;
|
||||
statusText: string;
|
||||
appImages: Image[];
|
||||
}
|
||||
|
||||
export interface Receiver {
|
||||
displayStatus: Nullable<ReceiverDisplayStatus>;
|
||||
isActiveInput: Nullable<boolean>;
|
||||
receiverType: ReceiverType;
|
||||
label: string;
|
||||
friendlyName: string;
|
||||
capabilities: Capability[];
|
||||
volume: Nullable<Volume>;
|
||||
}
|
||||
|
||||
export interface ReceiverApplication {
|
||||
appId: string
|
||||
, appType: string
|
||||
, displayName: string
|
||||
, iconUrl: string
|
||||
, isIdleScreen: boolean
|
||||
, launchedFromCloud: boolean
|
||||
, namespaces: Array<{ name: string }>
|
||||
, sessionId: string
|
||||
, statusText: string
|
||||
, transportId: string
|
||||
, universalAppId: string
|
||||
appId: string;
|
||||
appType: string;
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
isIdleScreen: boolean;
|
||||
launchedFromCloud: boolean;
|
||||
namespaces: Array<{ name: string }>;
|
||||
sessionId: string;
|
||||
statusText: string;
|
||||
transportId: string;
|
||||
universalAppId: string;
|
||||
}
|
||||
|
||||
export interface ReceiverStatus {
|
||||
applications?: ReceiverApplication[]
|
||||
, isActiveInput?: boolean
|
||||
, isStandBy?: boolean
|
||||
, volume: Volume
|
||||
applications?: ReceiverApplication[];
|
||||
isActiveInput?: boolean;
|
||||
isStandBy?: boolean;
|
||||
volume: Volume;
|
||||
}
|
||||
|
||||
|
||||
@@ -306,13 +343,12 @@ export type SenderMessage =
|
||||
| ReqBase & { type: "SET_VOLUME", volume: Volume };
|
||||
|
||||
export type ReceiverMessage =
|
||||
ReqBase & {
|
||||
type: "RECEIVER_STATUS"
|
||||
, status: ReceiverStatus
|
||||
};
|
||||
ReqBase & { type: "RECEIVER_STATUS", status: ReceiverStatus }
|
||||
| ReqBase & { type: "LAUNCH_ERROR", reason: string }
|
||||
|
||||
|
||||
interface MediaReqBase extends ReqBase {
|
||||
mediaSessionId: number;
|
||||
customData?: unknown;
|
||||
}
|
||||
|
||||
@@ -324,11 +360,16 @@ export type SenderMediaMessage =
|
||||
| MediaReqBase & { type: "STOP" }
|
||||
| MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Volume }
|
||||
| MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number }
|
||||
| MediaReqBase & {
|
||||
| ReqBase & {
|
||||
type: "LOAD"
|
||||
, media: MediaInformation
|
||||
, activeTrackIds: Nullable<number[]>
|
||||
, atvCredentials?: string
|
||||
, atvCredentialsType?: string
|
||||
, autoplay: Nullable<boolean>
|
||||
, currentTime: Nullable<number>
|
||||
, customData?: unknown
|
||||
, media: MediaInformation
|
||||
, sessionId: Nullable<string>
|
||||
}
|
||||
| MediaReqBase & {
|
||||
type: "SEEK"
|
||||
@@ -366,7 +407,7 @@ export type SenderMediaMessage =
|
||||
type: "QUEUE_UPDATE"
|
||||
, jump: Nullable<number>
|
||||
, currentItemId: Nullable<number>
|
||||
, sessionId: Nullable<number>
|
||||
, sessionId: Nullable<string>
|
||||
}
|
||||
// QueueRemoveItemsRequest
|
||||
| MediaReqBase & {
|
||||
@@ -1,78 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import castv2 from "castv2";
|
||||
|
||||
import { ReceiverMediaMessage } from "./types";
|
||||
|
||||
import { Message } from "../../messaging";
|
||||
import { sendMessage } from "../../lib/nativeMessaging";
|
||||
|
||||
import Session from "./Session";
|
||||
|
||||
|
||||
const NS_MEDIA = "urn:x-cast:com.google.cast.media";
|
||||
|
||||
|
||||
export default class Media {
|
||||
private channel: castv2.Channel;
|
||||
|
||||
constructor(
|
||||
private referenceId: string
|
||||
, private session: Session) {
|
||||
|
||||
// Ensure channel exists
|
||||
this.session.createChannel(NS_MEDIA);
|
||||
|
||||
const channel = this.session.channelMap.get(NS_MEDIA);
|
||||
if (!channel) {
|
||||
throw new Error("Media message cannel not found");
|
||||
}
|
||||
|
||||
this.channel = channel;
|
||||
this.channel.on("message", this.onMediaMessage);
|
||||
}
|
||||
|
||||
private onMediaMessage = (message: ReceiverMediaMessage) => {
|
||||
switch (message.type) {
|
||||
case "MEDIA_STATUS": {
|
||||
// TODO: Fix for multiple media statuses
|
||||
const status = message.status[0];
|
||||
|
||||
this.sendMessage({
|
||||
subject: "shim:media/updateStatus"
|
||||
, data: { status }
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public messageHandler(message: Message) {
|
||||
switch (message.subject) {
|
||||
case "bridge:media/sendMediaMessage": {
|
||||
let error = false;
|
||||
try {
|
||||
this.channel.send(message.data.message);
|
||||
} catch (err) {
|
||||
error = true;
|
||||
}
|
||||
|
||||
this.sendMessage({
|
||||
subject: "shim:media/sendMediaMessageResponse"
|
||||
, data: {
|
||||
messageId: message.data.messageId
|
||||
, error
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessage(message: Message) {
|
||||
(message.data as any)._id = this.referenceId;
|
||||
sendMessage(message);
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import { Channel, Client } from "castv2";
|
||||
|
||||
import { Message } from "../../messaging";
|
||||
import { sendMessage } from "../../lib/nativeMessaging";
|
||||
|
||||
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;
|
||||
|
||||
|
||||
export default class Session {
|
||||
private isSessionCreated = false;
|
||||
|
||||
private client: Client;
|
||||
private clientId = `client-${Math.floor(Math.random() * 10e5)}`;
|
||||
private transportId?: string;
|
||||
|
||||
public channelMap = new Map<string, Channel>();
|
||||
|
||||
private platformConnection?: Channel;
|
||||
private platformHeartbeat?: Channel;
|
||||
private platformReceiver?: Channel;
|
||||
private platformHeartbeatIntervalId?: NodeJS.Timeout;
|
||||
|
||||
private transportConnection?: Channel;
|
||||
private transportHeartbeat?: Channel;
|
||||
|
||||
private app?: ReceiverApplication;
|
||||
|
||||
constructor(
|
||||
public host: string
|
||||
, public port: number
|
||||
, private appId: string
|
||||
, private referenceId: string) {
|
||||
|
||||
const client = new Client();
|
||||
|
||||
client.on("error", err => {
|
||||
console.error(`castv2 error: ${err}`);
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
// TODO: Don't send new data
|
||||
if (this.platformHeartbeatIntervalId) {
|
||||
clearInterval(this.platformHeartbeatIntervalId);
|
||||
}
|
||||
});
|
||||
|
||||
client.connect({ host, port }, this.onConnect.bind(this));
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public createChannel(namespace: string) {
|
||||
if (!this.channelMap.has(namespace)) {
|
||||
this.channelMap.set(namespace, this.client.createChannel(
|
||||
this.clientId!, this.transportId!
|
||||
, namespace, "JSON"));
|
||||
}
|
||||
}
|
||||
|
||||
private establishSession(app: ReceiverApplication) {
|
||||
this.transportId = app.transportId;
|
||||
|
||||
// Mesage channel to app
|
||||
this.transportConnection = this.client.createChannel(
|
||||
this.clientId, this.transportId, NS_CONNECTION, "JSON");
|
||||
this.transportHeartbeat = this.client.createChannel(
|
||||
this.clientId, this.transportId, NS_HEARTBEAT, "JSON");
|
||||
|
||||
this.transportConnection.send({
|
||||
type: "CONNECT"
|
||||
});
|
||||
}
|
||||
|
||||
private onConnect() {
|
||||
const sourceId = "sender-0";
|
||||
const destinationId = "receiver-0";
|
||||
|
||||
this.platformConnection = this.client.createChannel(
|
||||
sourceId, destinationId, NS_CONNECTION, "JSON");
|
||||
this.platformHeartbeat = this.client.createChannel(
|
||||
sourceId, destinationId, NS_HEARTBEAT, "JSON");
|
||||
this.platformReceiver = this.client.createChannel(
|
||||
sourceId, destinationId, NS_RECEIVER, "JSON");
|
||||
|
||||
this.platformConnection.send({ type: "CONNECT" });
|
||||
this.platformHeartbeat.send({ type: "PING" });
|
||||
|
||||
this.platformHeartbeatIntervalId = setInterval(() => {
|
||||
this.platformHeartbeat?.send({ type: "PING" });
|
||||
|
||||
if (this.transportHeartbeat) {
|
||||
this.transportHeartbeat.send({ type: "PING" });
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
|
||||
this.platformReceiver.send({
|
||||
type: "LAUNCH"
|
||||
, appId: this.appId
|
||||
, requestId: 0
|
||||
});
|
||||
|
||||
this.platformReceiver.on("message", (message: ReceiverMessage) => {
|
||||
switch (message.type) {
|
||||
case "RECEIVER_STATUS": {
|
||||
const { status } = message;
|
||||
|
||||
if (status.applications) {
|
||||
// TODO: Fix for multiple applications?
|
||||
const app = status.applications[0];
|
||||
|
||||
if (app.appId !== this.appId) {
|
||||
this.sendMessage({
|
||||
subject: "shim:session/stopped"
|
||||
});
|
||||
|
||||
this.client.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isSessionCreated) {
|
||||
this.isSessionCreated = true;
|
||||
this.establishSession(app);
|
||||
}
|
||||
}
|
||||
|
||||
this.sendMessage({
|
||||
subject: "shim:session/updateStatus"
|
||||
, data: { status: message.status }
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.error(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public messageHandler(message: Message) {
|
||||
switch (message.subject) {
|
||||
case "bridge:session/close": {
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:session/impl_addMessageListener": {
|
||||
this._impl_addMessageListener(message.data.namespace);
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:session/impl_sendMessage": {
|
||||
this._impl_sendMessage(
|
||||
message.data.namespace
|
||||
, message.data.message
|
||||
, message.data.messageId);
|
||||
break;
|
||||
}
|
||||
case "bridge:session/impl_sendReceiverMessage": {
|
||||
const { message: receiverMessage
|
||||
, messageId: receiverMessageId } = message.data;
|
||||
|
||||
this.impl_sendReceiverMessage(
|
||||
receiverMessage, receiverMessageId);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.platformConnection?.send({ type: "CLOSE" });
|
||||
this.transportConnection?.send({ type: "CLOSE" });
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.platformConnection?.send({ type: "STOP" });
|
||||
}
|
||||
|
||||
private sendMessage(message: Message) {
|
||||
(message.data as any)._id = this.referenceId;
|
||||
sendMessage(message);
|
||||
}
|
||||
|
||||
private _impl_addMessageListener(namespace: string) {
|
||||
// TODO: Limit to one listener per namespace
|
||||
this.createChannel(namespace);
|
||||
this.channelMap.get(namespace)?.on("message", (message: any) => {
|
||||
this.sendMessage({
|
||||
subject: "shim:session/impl_addMessageListener"
|
||||
, data: {
|
||||
namespace
|
||||
, message: JSON.stringify(message)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _impl_sendMessage(
|
||||
namespace: string
|
||||
, message: object | string
|
||||
, messageId: string) {
|
||||
|
||||
let wasError = false;
|
||||
|
||||
try {
|
||||
// Decode string messages
|
||||
if (typeof message === "string") {
|
||||
message = JSON.parse(message);
|
||||
}
|
||||
|
||||
this.createChannel(namespace);
|
||||
this.channelMap.get(namespace)?.send(message);
|
||||
} catch (err) {
|
||||
wasError = true;
|
||||
}
|
||||
|
||||
this.sendMessage({
|
||||
subject: "shim:session/impl_sendMessage"
|
||||
, data: { messageId, wasError }
|
||||
});
|
||||
}
|
||||
|
||||
private impl_sendReceiverMessage(
|
||||
message: SenderMessage
|
||||
, messageId: string) {
|
||||
|
||||
let wasError = false;
|
||||
try {
|
||||
this.platformReceiver?.send(message);
|
||||
} catch (err) {
|
||||
wasError = true;
|
||||
}
|
||||
|
||||
// Handle stop message
|
||||
if (message.type === "STOP") {
|
||||
this.client.close();
|
||||
}
|
||||
|
||||
this.sendMessage({
|
||||
subject: "shim:session/impl_sendReceiverMessage"
|
||||
, data: { messageId, wasError }
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import castv2 from "castv2";
|
||||
|
||||
import Session, { NS_CONNECTION, NS_RECEIVER } from "./Session";
|
||||
import Media from "./Media";
|
||||
import { ReceiverDevice } from "../../types";
|
||||
|
||||
|
||||
// Existing counterpart Media/Session objects
|
||||
const existingSessions: Map<string, Session> = new Map();
|
||||
const existingMedia: Map<string, Media> = new Map();
|
||||
|
||||
export function handleSessionMessage(message: any) {
|
||||
if (!message.data._id) {
|
||||
console.error("Session message missing _id");
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = message.data._id;
|
||||
|
||||
if (existingSessions.has(sessionId)) {
|
||||
// Forward message to instance message handler
|
||||
existingSessions.get(sessionId)?.messageHandler(message);
|
||||
} else {
|
||||
if (message.subject === "bridge:session/initialize") {
|
||||
existingSessions.set(sessionId, new Session(
|
||||
message.data.address
|
||||
, message.data.port
|
||||
, message.data.appId
|
||||
, sessionId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleMediaMessage(message: any) {
|
||||
if (!message.data._id) {
|
||||
console.error("Media message missing _id");
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaId = message.data._id;
|
||||
|
||||
if (existingMedia.has(mediaId)) {
|
||||
// Forward message to instance message handler
|
||||
existingMedia.get(mediaId)?.messageHandler(message);
|
||||
} else {
|
||||
if (message.subject === "bridge:media/initialize") {
|
||||
// Get Session object media belongs to
|
||||
const parentSession = existingSessions.get(
|
||||
message.data._internalSessionId);
|
||||
|
||||
if (parentSession) {
|
||||
// Create Media
|
||||
existingMedia.set(mediaId, new Media(
|
||||
mediaId
|
||||
, parentSession));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stopReceiverApp(host: string, port: number) {
|
||||
const client = new castv2.Client();
|
||||
|
||||
client.connect({ host, port }, () => {
|
||||
const sourceId = "sender-0";
|
||||
const destinationId = "receiver-0";
|
||||
|
||||
const clientConnection = client.createChannel(
|
||||
sourceId, destinationId, NS_CONNECTION, "JSON");
|
||||
const clientReceiver = client.createChannel(
|
||||
sourceId, destinationId, NS_RECEIVER, "JSON");
|
||||
|
||||
clientConnection.send({ type: "CONNECT" });
|
||||
clientReceiver.send({ type: "STOP", requestId: 1 });
|
||||
});
|
||||
|
||||
client.on("error", err => {
|
||||
console.error(`castv2 error (stopReceiverApp): ${err}`);
|
||||
});
|
||||
}
|
||||
@@ -8,10 +8,9 @@ import mdns from "mdns";
|
||||
|
||||
import { sendMessage } from "../lib/nativeMessaging";
|
||||
|
||||
import { ReceiverStatus } from "./chromecast/types";
|
||||
import { NS_CONNECTION
|
||||
, NS_HEARTBEAT
|
||||
, NS_RECEIVER } from "./chromecast/Session";
|
||||
import { ReceiverStatus } from "./cast/types";
|
||||
import { NS_CONNECTION, NS_HEARTBEAT, NS_RECEIVER }
|
||||
from "./cast/Session";
|
||||
|
||||
|
||||
interface CastTxtRecord {
|
||||
@@ -152,8 +151,10 @@ export function stopDiscovery() {
|
||||
* Closes status listener connection.
|
||||
*/
|
||||
public deregister(): void {
|
||||
if (this.clientReceiver) {
|
||||
this.clientReceiver.send({ type: "CLOSE" });
|
||||
try {
|
||||
this.clientReceiver?.send({ type: "CLOSE" });
|
||||
} catch (err) {
|
||||
// Supress
|
||||
}
|
||||
|
||||
this.client.close();
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { decodeTransform, encodeTransform } from "./lib/nativeMessaging";
|
||||
import { Message } from "./messaging";
|
||||
|
||||
import { handleSessionMessage, handleMediaMessage, stopReceiverApp }
|
||||
from "./components/chromecast";
|
||||
import { handleCastMessage } from "./components/cast";
|
||||
import { startDiscovery, stopDiscovery } from "./components/discovery";
|
||||
import { startMediaServer, stopMediaServer } from "./components/mediaServer";
|
||||
import { startReceiverSelector, stopReceiverSelector }
|
||||
@@ -28,16 +27,6 @@ process.on("SIGTERM", () => {
|
||||
* for managing existing ones.
|
||||
*/
|
||||
decodeTransform.on("data", (message: Message) => {
|
||||
if (message.subject.startsWith("bridge:session/")) {
|
||||
handleSessionMessage(message);
|
||||
return;
|
||||
}
|
||||
if (message.subject.startsWith("bridge:media/")) {
|
||||
handleMediaMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
switch (message.subject) {
|
||||
case "bridge:getInfo":
|
||||
case "bridge:/getInfo": {
|
||||
@@ -50,12 +39,6 @@ decodeTransform.on("data", (message: Message) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case "bridge:stopReceiverApp": {
|
||||
const { receiverDevice } = message.data;
|
||||
stopReceiverApp(receiverDevice.host, receiverDevice.port);
|
||||
break;
|
||||
}
|
||||
|
||||
// Receiver selector
|
||||
case "bridge:openReceiverSelector": {
|
||||
startReceiverSelector(message.data); break;
|
||||
@@ -74,5 +57,9 @@ decodeTransform.on("data", (message: Message) => {
|
||||
stopMediaServer();
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
handleCastMessage(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,84 +1,70 @@
|
||||
"use strict";
|
||||
|
||||
import { MediaStatus, ReceiverStatus, ReceiverApplication, SenderMessage }
|
||||
from "./components/chromecast/types";
|
||||
import { Image
|
||||
, ReceiverStatus
|
||||
, SenderApplication
|
||||
, SenderMessage
|
||||
, Volume } from "./components/cast/types";
|
||||
|
||||
import { ReceiverDevice
|
||||
, ReceiverSelectionCast
|
||||
, ReceiverSelectionStop } from "./types";
|
||||
|
||||
|
||||
interface CastSessionUpdated {
|
||||
sessionId: string
|
||||
, statusText: string
|
||||
, namespaces: Array<{ name: string }>
|
||||
, volume: Volume
|
||||
}
|
||||
|
||||
interface CastSessionCreated extends CastSessionUpdated {
|
||||
appId: string
|
||||
, appImages: Image[]
|
||||
, displayName: string
|
||||
, receiverFriendlyName: string
|
||||
, senderApps: SenderApplication[]
|
||||
, transportId: string
|
||||
}
|
||||
|
||||
type MessageDefinitions = {
|
||||
// Session messages
|
||||
"shim:session/connected": { application: ReceiverApplication }
|
||||
, "shim:session/updateStatus": { status: ReceiverStatus }
|
||||
, "shim:session/stopped": {}
|
||||
, "shim:session/impl_addMessageListener": {
|
||||
namespace: string
|
||||
, message: string
|
||||
}
|
||||
, "shim:session/impl_sendMessage": {
|
||||
messageId: string
|
||||
, wasError: boolean
|
||||
}
|
||||
, "shim:session/impl_sendReceiverMessage": {
|
||||
messageId: string
|
||||
, wasError: boolean
|
||||
}
|
||||
|
||||
// Bridge session messages
|
||||
, "bridge:session/initialize": {
|
||||
address: string
|
||||
, port: number
|
||||
, appId: string
|
||||
, sessionId: string
|
||||
, _id: string
|
||||
}
|
||||
, "bridge:session/close": {}
|
||||
, "bridge:session/impl_leave": {
|
||||
id: string
|
||||
, _id: string
|
||||
}
|
||||
, "bridge:session/impl_sendMessage": {
|
||||
namespace: string
|
||||
, message: any
|
||||
, messageId: string
|
||||
, _id: string
|
||||
}
|
||||
, "bridge:session/impl_sendReceiverMessage": {
|
||||
message: SenderMessage
|
||||
, messageId: string
|
||||
, _id: string
|
||||
}
|
||||
, "bridge:session/impl_addMessageListener": {
|
||||
namespace: string;
|
||||
_id: string;
|
||||
}
|
||||
|
||||
// Media messages
|
||||
, "shim:media/updateStatus": {
|
||||
status: MediaStatus
|
||||
}
|
||||
, "shim:media/sendMediaMessageResponse": {
|
||||
messageId: string
|
||||
, error: boolean
|
||||
}
|
||||
|
||||
// Bridge media messages
|
||||
, "bridge:media/initialize": {
|
||||
"shim:castSessionCreated": CastSessionCreated
|
||||
, "shim:castSessionUpdated": CastSessionUpdated
|
||||
, "shim:castSessionStopped": {
|
||||
sessionId: string
|
||||
, mediaSessionId: number
|
||||
, _internalSessionId: string
|
||||
, _id: string
|
||||
}
|
||||
|
||||
, "bridge:media/sendMediaMessage": {
|
||||
message: any
|
||||
, "shim:receivedCastSessionMessage": {
|
||||
sessionId: string
|
||||
, namespace: string
|
||||
, messageData: string
|
||||
}
|
||||
|
||||
, "shim:impl_sendCastMessage": {
|
||||
sessionId: string
|
||||
, messageId: string
|
||||
, _id: string
|
||||
, error?: string
|
||||
}
|
||||
|
||||
// Bridge messages
|
||||
, "bridge:createCastSession": {
|
||||
appId: string
|
||||
, receiverDevice: ReceiverDevice
|
||||
}
|
||||
, "bridge:sendCastReceiverMessage": {
|
||||
sessionId: string
|
||||
, messageData: SenderMessage
|
||||
, messageId: string
|
||||
}
|
||||
, "bridge:sendCastSessionMessage": {
|
||||
sessionId: string
|
||||
, namespace: string
|
||||
, messageData: object | string
|
||||
, messageId: string
|
||||
}
|
||||
|
||||
, "bridge:stopCastApp": { receiverDevice: ReceiverDevice }
|
||||
|
||||
// Bridge messages
|
||||
, "main:receiverSelector/selected": ReceiverSelectionCast
|
||||
, "main:receiverSelector/stopped": ReceiverSelectionStop
|
||||
, "main:receiverSelector/cancelled": {}
|
||||
@@ -98,9 +84,6 @@ type MessageDefinitions = {
|
||||
, "bridge:openReceiverSelector": string
|
||||
, "bridge:closeReceiverSelector": {}
|
||||
|
||||
, "bridge:stopReceiverApp": { receiverDevice: ReceiverDevice }
|
||||
|
||||
|
||||
, "bridge:startMediaServer": {
|
||||
filePath: string
|
||||
, port: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
import { ReceiverStatus } from "./components/chromecast/types";
|
||||
import { ReceiverStatus } from "./components/cast/types";
|
||||
|
||||
|
||||
export enum ReceiverSelectorMediaType {
|
||||
|
||||
5
app/src/global.d.ts
vendored
5
app/src/global.d.ts
vendored
@@ -1 +1,6 @@
|
||||
declare type Nullable<T> = T | null;
|
||||
|
||||
declare type DistributiveOmit<T, K extends keyof any> =
|
||||
T extends any
|
||||
? Omit<T, K>
|
||||
: never;
|
||||
|
||||
Reference in New Issue
Block a user