Improve session/media bridge messaging

This commit is contained in:
hensm
2021-04-30 10:37:21 +01:00
parent 119d6c806a
commit 9ebbedcd84
17 changed files with 1065 additions and 720 deletions

View File

@@ -2,27 +2,16 @@
import castv2 from "castv2";
import Session from "./Session";
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 interface UpdateMessageData {
_volumeLevel?: number;
_volumeMuted?: boolean;
_lastCurrentTime: number;
currentTime: number;
customData?: any;
playbackRate: number;
playerState: string;
repeatMode?: string;
media?: any;
mediaSessionId?: number;
}
export default class Media {
private channel: castv2.Channel;
@@ -31,40 +20,32 @@ export default class Media {
private referenceId: string
, private session: Session) {
// Ensure channel exists
this.session.createChannel(NS_MEDIA);
this.channel = this.session.channelMap.get(NS_MEDIA)!;
this.channel.on("message", (data: any) => {
if (data && data.type === "MEDIA_STATUS"
&& data.status && data.status.length > 0) {
const channel = this.session.channelMap.get(NS_MEDIA);
if (!channel) {
throw new Error("Media message cannel not found");
}
const status = data.status[0];
this.channel = channel;
this.channel.on("message", this.onMediaMessage);
}
const messageData: UpdateMessageData = {
_lastCurrentTime: Date.now() / 1000
private onMediaMessage = (message: ReceiverMediaMessage) => {
switch (message.type) {
case "MEDIA_STATUS": {
// TODO: Fix for multiple media statuses
const status = message.status[0];
, currentTime: status.currentTime
, customData: status.customData
, playbackRate: status.playbackRate
, playerState: status.playerState
, repeatMode: status.repeatMode
};
this.sendMessage({
subject: "shim:media/updateStatus"
, data: { status }
});
if (status.volume) {
messageData._volumeLevel = status.volume.level;
messageData._volumeMuted = status.volume.muted;
}
if (status.media) {
messageData.media = status.media;
}
if (status.mediaSessionId) {
messageData.mediaSessionId = status.mediaSessionId;
}
this.sendMessage("shim:media/update", messageData);
break;
}
});
}
}
public messageHandler(message: Message) {
@@ -77,9 +58,12 @@ export default class Media {
error = true;
}
this.sendMessage("shim:media/sendMediaMessageResponse", {
messageId: message.data.messageId
, error
this.sendMessage({
subject: "shim:media/sendMediaMessageResponse"
, data: {
messageId: message.data.messageId
, error
}
});
break;
@@ -87,11 +71,8 @@ export default class Media {
}
}
private sendMessage(subject: string, data: any) {
data._id = this.referenceId;
(sendMessage as any)({
subject
, data
});
private sendMessage(message: Message) {
(message.data as any)._id = this.referenceId;
sendMessage(message);
}
}

View File

@@ -5,32 +5,39 @@ 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 {
public channelMap = new Map<string, Channel>();
private isSessionCreated = false;
private client: Client;
private clientConnection?: Channel;
private clientHeartbeat?: Channel;
private clientReceiver?: Channel;
private clientHeartbeatIntervalId?: NodeJS.Timeout;
private isSessionCreated = false;
private clientId?: string;
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 app: any;
private transportHeartbeat?: Channel;
private app?: ReceiverApplication;
constructor(
public host: string
, public port: number
, private appId: string
, private sessionId: string
, private referenceId: string) {
const client = new Client();
@@ -38,135 +45,18 @@ export default class Session {
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;
}
private onConnect() {
let transportHeartbeat: Channel;
const sourceId = "sender-0";
const destinationId = "receiver-0";
this.clientConnection = this.client.createChannel(
sourceId, destinationId, NS_CONNECTION, "JSON");
this.clientHeartbeat = this.client.createChannel(
sourceId, destinationId, NS_HEARTBEAT, "JSON");
this.clientReceiver = this.client.createChannel(
sourceId, destinationId, NS_RECEIVER, "JSON");
this.clientConnection.send({ type: "CONNECT" });
this.clientHeartbeat.send({ type: "PING" });
this.clientHeartbeatIntervalId = setInterval(() => {
if (transportHeartbeat) {
transportHeartbeat.send({ type: "PING" });
}
this.clientHeartbeat?.send({ type: "PING" });
}, 5000);
this.clientReceiver.send({
type: "LAUNCH"
, appId: this.appId
, requestId: 1
});
this.clientReceiver.on("message", (message: any) => {
if (message.type === "RECEIVER_STATUS") {
this.sendMessage("shim:session/updateStatus", message.status);
if (message.status.applications) {
const receiverApp = message.status.applications[0];
const receiverAppId = receiverApp.appId;
this.app = receiverApp;
if (receiverAppId !== this.appId) {
// Close session
this.sendMessage("shim:session/stopped");
this.client.close();
clearInterval(this.clientHeartbeatIntervalId!);
return;
}
if (!this.isSessionCreated) {
this.isSessionCreated = true;
this.transportId = this.app.transportId;
this.clientId =
`client-${Math.floor(Math.random() * 10e5)}`;
this.transportConnection = this.client.createChannel(
this.clientId, this.transportId!
, NS_CONNECTION, "JSON");
transportHeartbeat = this.client.createChannel(
this.clientId, this.transportId!
, NS_HEARTBEAT, "JSON");
this.transportConnection.send({ type: "CONNECT" });
this.sessionId = this.app.sessionId;
this.sendMessage("shim:session/connected", {
sessionId: this.app.sessionId
, namespaces: this.app.namespaces
, displayName: this.app.displayName
, statusText: this.app.displayName
});
}
}
}
});
}
public messageHandler(message: Message) {
switch (message.subject) {
case "bridge:session/close":
this.close();
break;
case "bridge:session/sendReceiverMessage": {
let wasError = false;
try {
this.clientReceiver?.send(message.data.message);
} catch (err) {
wasError = true;
}
if (message.data.message.type === "STOP") {
if (this.clientHeartbeatIntervalId) {
clearInterval(this.clientHeartbeatIntervalId);
}
this.client.close();
}
this.sendMessage("shim:session/sendReceiverMessageResponse", {
messageId: message.data.messageId
, wasError
});
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;
}
}
public createChannel(namespace: string) {
if (!this.channelMap.has(namespace)) {
this.channelMap.set(namespace, this.client.createChannel(
@@ -175,40 +65,152 @@ export default class Session {
}
}
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.clientConnection?.send({ type: "CLOSE" });
this.platformConnection?.send({ type: "CLOSE" });
this.transportConnection?.send({ type: "CLOSE" });
}
public stop() {
this.clientConnection?.send({ type: "STOP" });
this.platformConnection?.send({ type: "STOP" });
}
private sendMessage(subject: string, data: any = {}) {
data._id = this.referenceId;
sendMessage({
// @ts-ignore
subject
, data
});
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", (data: any) => {
this.sendMessage("shim:session/impl_addMessageListener", {
namespace
, data: JSON.stringify(data)
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: {} | string
, message: object | string
, messageId: string) {
let error = false;
let wasError = false;
try {
// Decode string messages
@@ -219,12 +221,34 @@ export default class Session {
this.createChannel(namespace);
this.channelMap.get(namespace)?.send(message);
} catch (err) {
error = true;
wasError = true;
}
this.sendMessage("shim:session/impl_sendMessage", {
messageId
, error
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 }
});
}

View File

@@ -28,7 +28,6 @@ export function handleSessionMessage(message: any) {
message.data.address
, message.data.port
, message.data.appId
, message.data.sessionId
, sessionId));
}
}

View File

@@ -0,0 +1,396 @@
"use strict";
export interface Image {
url: string;
height?: number;
width?: number;
}
enum VolumeControlType {
ATTENUATION = "attenuation"
, FIXED = "fixed"
, MASTER = "master"
}
export interface Volume {
controlType?: VolumeControlType;
stepInterval?: number;
level: Nullable<number>;
muted: Nullable<boolean>;
}
// Media
enum IdleReason {
CANCELLED = "CANCELLED"
, INTERRUPTED = "INTERRUPTED"
, FINISHED = "FINISHED"
, ERROR = "ERROR"
}
enum HlsSegmentFormat {
AAC = "aac"
, AC3 = "ac3"
, MP3 = "mp3"
, TS = "ts"
, TS_AAC = "ts_aac"
, E_AC3 = "e_ac3"
, FMP4 = "fmp4"
}
export enum HlsVideoSegmentFormat {
MPEG2_TS = "mpeg2_ts"
, FMP4 = "fmp4"
}
enum MetadataType {
GENERIC
, MOVIE
, TV_SHOW
, MUSIC_TRACK
, PHOTO
, AUDIOBOOK_CHAPTER
}
enum PlayerState {
IDLE = "IDLE"
, PLAYING = "PLAYING"
, PAUSED = "PAUSED"
, BUFFERING = "BUFFERING"
}
enum RepeatMode {
OFF = "REPEAT_OFF"
, ALL = "REPEAT_ALL"
, SINGLE = "REPEAT_SINGLE"
, ALL_AND_SHUFFLE = "REPEAT_ALL_AND_SHUFFLE"
}
enum ResumeState {
PLAYBACK_START = "PLAYBACK_START"
, PLAYBACK_PAUSE = "PLAYBACK_PAUSE"
}
enum StreamType {
BUFFERED = "BUFFERED"
, LIVE = "LIVE"
, OTHER = "OTHER"
}
enum TrackType {
TEXT = "TEXT"
, AUDIO = "AUDIO"
, VIDEO = "VIDEO"
}
export enum UserAction {
LIKE = "LIKE"
, DISLIKE = "DISLIKE"
, FOLLOW = "FOLLOW"
, UNFOLLOW = "UNFOLLOW"
}
interface Break {
breakClipIds: string[];
duration?: number;
id: string;
isEmbedded?: boolean;
isWatched: boolean;
position: number;
}
interface BreakClip {
clickThroughUrl?: string;
contentId?: string;
contentType?: string;
contentUrl?: string;
customData?: {};
duration?: number;
id: string;
hlsSegmentFormat?: HlsSegmentFormat;
posterUrl?: string;
title?: string;
vastAdsRequest?: VastAdsRequest;
whenSkippable?: number;
}
interface TextTrackStyle {
backgroundColor: Nullable<string>;
customData: any;
edgeColor: Nullable<string>;
edgeType: Nullable<string>;
fontFamily: Nullable<string>;
fontGenericFamily: Nullable<string>;
fontScale: Nullable<number>;
fontStyle: Nullable<string>;
foregroundColor: Nullable<string>;
windowColor: Nullable<string>;
windowRoundedCornerRadius: Nullable<number>;
windowType: Nullable<string>;
}
interface Track {
customData: any;
language: Nullable<string>;
name: Nullable<string>;
subtype: Nullable<string>;
trackContentId: Nullable<string>;
trackContentType: Nullable<string>;
trackId: string;
type: TrackType;
}
interface UserActionState {
customData: any;
userAction: UserAction;
}
interface VastAdsRequest {
adsResponse?: string;
adTagUrl?: string;
}
type Metadata =
GenericMediaMetadata
| MovieMediaMetadata
| MusicTrackMediaMetadata
| PhotoMediaMetadata
| TvShowMediaMetadata;
interface MediaInformation {
atvEntity?: string;
breakClips?: BreakClip[];
breaks?: Break[];
contentId: string;
contentType: string;
contentUrl?: string;
customData: any;
duration: Nullable<number>;
entity?: string;
hlsSegmentFormat?: HlsSegmentFormat;
hlsVideoSegmentFormat?: HlsVideoSegmentFormat;
metadata: Nullable<Metadata>;
startAbsoluteTime?: number;
streamType: StreamType;
textTrackStyle: Nullable<TextTrackStyle>;
tracks: Nullable<Track[]>;
userActionStates?: UserActionState[];
vmapAdsRequest?: VastAdsRequest;
}
interface GenericMediaMetadata {
images?: Image[];
metadataType: number;
releaseDate?: string;
releaseYear?: number;
subtitle?: string;
title?: string;
type: MetadataType.GENERIC;
}
interface MovieMediaMetadata {
images?: Image[];
metadataType: number;
releaseDate?: string;
releaseYear?: number;
studio?: string;
subtitle?: string;
title?: string;
type: MetadataType.MOVIE;
}
interface TvShowMediaMetadata {
episode?: number;
episodeNumber?: number;
episodeTitle?: string;
images?: Image[];
metadataType: number;
originalAirdate?: string;
releaseYear?: number;
season?: number;
seasonNumber?: number;
seriesTitle?: string;
title?: string;
type: MetadataType.TV_SHOW;
}
interface MusicTrackMediaMetadata {
albumArtist?: string;
albumName?: string;
artist?: string;
artistName?: string;
composer?: string;
discNumber?: number;
images?: Image[];
metadataType: number;
releaseDate?: string;
releaseYear?: number;
songName?: string;
title?: string;
trackNumber?: number;
type: MetadataType.MUSIC_TRACK;
}
interface PhotoMediaMetadata {
artist?: string;
creationDateTime?: string;
height?: number;
images?: Image[];
latitude?: number;
location?: string;
longitude?: number;
metadataType: number;
title?: string;
type: MetadataType.PHOTO;
width?: number;
}
interface QueueItem {
activeTrackIds: Nullable<number[]>;
autoplay: boolean;
customData: any;
itemId: Nullable<number>;
media: MediaInformation;
playbackDuration: Nullable<number>;
preloadTime: number;
startTime: number;
}
export interface MediaStatus {
mediaSessionId: number;
media?: MediaInformation;
playbackRate: number;
playerState: PlayerState;
idleReason?: IdleReason;
currentTime: number;
supportedMediaCommands: number;
repeatMode: RepeatMode;
volume: Volume
customData: unknown;
}
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
}
export interface ReceiverStatus {
applications?: ReceiverApplication[]
, isActiveInput?: boolean
, isStandBy?: boolean
, volume: Volume
}
interface ReqBase {
requestId: number;
}
// NS: urn:x-cast:com.google.cast.receiver
export type SenderMessage =
ReqBase & { type: "LAUNCH", appId: string }
| ReqBase & { type: "STOP", sessionId: string }
| ReqBase & { type: "GET_STATUS" }
| ReqBase & { type: "GET_APP_AVAILABILITY", appId: string[] }
| ReqBase & { type: "SET_VOLUME", volume: Volume };
export type ReceiverMessage =
ReqBase & {
type: "RECEIVER_STATUS"
, status: ReceiverStatus
};
interface MediaReqBase extends ReqBase {
customData?: unknown;
}
// NS: urn:x-cast:com.google.cast.media
export type SenderMediaMessage =
| MediaReqBase & { type: "PLAY" }
| MediaReqBase & { type: "PAUSE" }
| MediaReqBase & { type: "MEDIA_GET_STATUS" }
| MediaReqBase & { type: "STOP" }
| MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Volume }
| MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number }
| MediaReqBase & {
type: "LOAD"
, media: MediaInformation
, autoplay: Nullable<boolean>
, currentTime: Nullable<number>
}
| MediaReqBase & {
type: "SEEK"
, resumeState: Nullable<ResumeState>
, currentTime: Nullable<number>
}
| MediaReqBase & {
type: "EDIT_TRACKS_INFO"
, activeTrackIds: Nullable<number[]>
, textTrackStyle: Nullable<string>
}
// QueueLoadRequest
| MediaReqBase & {
type: "QUEUE_LOAD"
, items: QueueItem[]
, startIndex: number
, repeatMode: string
, sessionId: Nullable<string>
}
// QueueInsertItemsRequest
| MediaReqBase & {
type: "QUEUE_INSERT"
, items: QueueItem[]
, insertBefore: Nullable<number>
, sessionId: Nullable<string>
}
// QueueUpdateItemsRequest
| MediaReqBase & {
type: "QUEUE_UPDATE"
, items: QueueItem[]
, sessionId: Nullable<string>
}
// QueueJumpRequest
| MediaReqBase & {
type: "QUEUE_UPDATE"
, jump: Nullable<number>
, currentItemId: Nullable<number>
, sessionId: Nullable<number>
}
// QueueRemoveItemsRequest
| MediaReqBase & {
type: "QUEUE_REMOVE"
, itemIds: number[]
, sessionId: Nullable<string>
}
// QueueReorderItemsRequest
| MediaReqBase & {
type: "QUEUE_REORDER"
, itemIds: number[]
, insertBefore: Nullable<number>
, sessionId: Nullable<string>
}
// QueueSetPropertiesRequest
| MediaReqBase & {
type: "QUEUE_UPDATE"
, repeatMode: Nullable<string>
, sessionId: Nullable<string>
};
export type ReceiverMediaMessage =
MediaReqBase & { type: "MEDIA_STATUS", status: MediaStatus[] }
| MediaReqBase & { type: "INVALID_PLAYER_STATE" }
| MediaReqBase & { type: "LOAD_FAILED" }
| MediaReqBase & { type: "LOAD_CANCELLED" }
| MediaReqBase & { type: "INVALID_REQUEST" };

View File

@@ -3,13 +3,12 @@
import { EventEmitter } from "events";
import { Channel, Client } from "castv2";
import mdns from "mdns";
import { sendMessage } from "../lib/nativeMessaging";
import { ReceiverStatus } from "../types";
import { Message } from "../messaging";
import { ReceiverStatus } from "./chromecast/types";
import { NS_CONNECTION
, NS_HEARTBEAT
, NS_RECEIVER } from "./chromecast/Session";