Cast API overhaul (#173)

Re-write the shim<->bridge messaging interface and session creation/update handling for better accuracy.
This commit is contained in:
Matt Hensman
2021-05-03 14:37:54 +01:00
committed by GitHub
parent ccac662e74
commit 101c25e26d
25 changed files with 1079 additions and 1346 deletions

View 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
});
}
}

View 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;
}
}
}

View File

@@ -0,0 +1,437 @@
"use strict";
export interface Image {
url: string;
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 {
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;
items?: QueueItem[]
currentTime: number;
supportedMediaCommands: number;
repeatMode: RepeatMode;
volume: Volume
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;
}
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 }
| ReqBase & { type: "LAUNCH_ERROR", reason: string }
interface MediaReqBase extends ReqBase {
mediaSessionId: number;
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 }
| ReqBase & {
type: "LOAD"
, activeTrackIds: Nullable<number[]>
, atvCredentials?: string
, atvCredentialsType?: string
, autoplay: Nullable<boolean>
, currentTime: Nullable<number>
, customData?: unknown
, media: MediaInformation
, sessionId: Nullable<string>
}
| 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<string>
}
// 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" };