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

@@ -55,5 +55,8 @@
, "@typescript-eslint/no-unused-vars": "off" , "@typescript-eslint/no-unused-vars": "off"
, "@typescript-eslint/ban-types": "off" , "@typescript-eslint/ban-types": "off"
, "@typescript-eslint/ban-ts-comment": "off" , "@typescript-eslint/ban-ts-comment": "off"
, "@typescript-eslint/no-this-alias": [ "error", {
"allowedNames": [ "this_" ]
}]
} }
} }

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

@@ -2,8 +2,29 @@
export interface Image { export interface Image {
url: string; url: string;
height?: number; height: Nullable<number>;
width?: 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 { enum VolumeControlType {
@@ -257,13 +278,13 @@ interface QueueItem {
startTime: number; startTime: number;
} }
export interface MediaStatus { export interface MediaStatus {
mediaSessionId: number; mediaSessionId: number;
media?: MediaInformation; media?: MediaInformation;
playbackRate: number; playbackRate: number;
playerState: PlayerState; playerState: PlayerState;
idleReason?: IdleReason; idleReason?: IdleReason;
items?: QueueItem[]
currentTime: number; currentTime: number;
supportedMediaCommands: number; supportedMediaCommands: number;
repeatMode: RepeatMode; repeatMode: RepeatMode;
@@ -271,25 +292,41 @@ export interface MediaStatus {
customData: unknown; 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 { export interface ReceiverApplication {
appId: string appId: string;
, appType: string appType: string;
, displayName: string displayName: string;
, iconUrl: string iconUrl: string;
, isIdleScreen: boolean isIdleScreen: boolean;
, launchedFromCloud: boolean launchedFromCloud: boolean;
, namespaces: Array<{ name: string }> namespaces: Array<{ name: string }>;
, sessionId: string sessionId: string;
, statusText: string statusText: string;
, transportId: string transportId: string;
, universalAppId: string universalAppId: string;
} }
export interface ReceiverStatus { export interface ReceiverStatus {
applications?: ReceiverApplication[] applications?: ReceiverApplication[];
, isActiveInput?: boolean isActiveInput?: boolean;
, isStandBy?: boolean isStandBy?: boolean;
, volume: Volume volume: Volume;
} }
@@ -306,13 +343,12 @@ export type SenderMessage =
| ReqBase & { type: "SET_VOLUME", volume: Volume }; | ReqBase & { type: "SET_VOLUME", volume: Volume };
export type ReceiverMessage = export type ReceiverMessage =
ReqBase & { ReqBase & { type: "RECEIVER_STATUS", status: ReceiverStatus }
type: "RECEIVER_STATUS" | ReqBase & { type: "LAUNCH_ERROR", reason: string }
, status: ReceiverStatus
};
interface MediaReqBase extends ReqBase { interface MediaReqBase extends ReqBase {
mediaSessionId: number;
customData?: unknown; customData?: unknown;
} }
@@ -324,11 +360,16 @@ export type SenderMediaMessage =
| MediaReqBase & { type: "STOP" } | MediaReqBase & { type: "STOP" }
| MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Volume } | MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Volume }
| MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number } | MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number }
| MediaReqBase & { | ReqBase & {
type: "LOAD" type: "LOAD"
, media: MediaInformation , activeTrackIds: Nullable<number[]>
, atvCredentials?: string
, atvCredentialsType?: string
, autoplay: Nullable<boolean> , autoplay: Nullable<boolean>
, currentTime: Nullable<number> , currentTime: Nullable<number>
, customData?: unknown
, media: MediaInformation
, sessionId: Nullable<string>
} }
| MediaReqBase & { | MediaReqBase & {
type: "SEEK" type: "SEEK"
@@ -366,7 +407,7 @@ export type SenderMediaMessage =
type: "QUEUE_UPDATE" type: "QUEUE_UPDATE"
, jump: Nullable<number> , jump: Nullable<number>
, currentItemId: Nullable<number> , currentItemId: Nullable<number>
, sessionId: Nullable<number> , sessionId: Nullable<string>
} }
// QueueRemoveItemsRequest // QueueRemoveItemsRequest
| MediaReqBase & { | MediaReqBase & {

View File

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

View File

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

View File

@@ -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}`);
});
}

View File

@@ -8,10 +8,9 @@ import mdns from "mdns";
import { sendMessage } from "../lib/nativeMessaging"; import { sendMessage } from "../lib/nativeMessaging";
import { ReceiverStatus } from "./chromecast/types"; import { ReceiverStatus } from "./cast/types";
import { NS_CONNECTION import { NS_CONNECTION, NS_HEARTBEAT, NS_RECEIVER }
, NS_HEARTBEAT from "./cast/Session";
, NS_RECEIVER } from "./chromecast/Session";
interface CastTxtRecord { interface CastTxtRecord {
@@ -152,8 +151,10 @@ export function stopDiscovery() {
* Closes status listener connection. * Closes status listener connection.
*/ */
public deregister(): void { public deregister(): void {
if (this.clientReceiver) { try {
this.clientReceiver.send({ type: "CLOSE" }); this.clientReceiver?.send({ type: "CLOSE" });
} catch (err) {
// Supress
} }
this.client.close(); this.client.close();

View File

@@ -3,8 +3,7 @@
import { decodeTransform, encodeTransform } from "./lib/nativeMessaging"; import { decodeTransform, encodeTransform } from "./lib/nativeMessaging";
import { Message } from "./messaging"; import { Message } from "./messaging";
import { handleSessionMessage, handleMediaMessage, stopReceiverApp } import { handleCastMessage } from "./components/cast";
from "./components/chromecast";
import { startDiscovery, stopDiscovery } from "./components/discovery"; import { startDiscovery, stopDiscovery } from "./components/discovery";
import { startMediaServer, stopMediaServer } from "./components/mediaServer"; import { startMediaServer, stopMediaServer } from "./components/mediaServer";
import { startReceiverSelector, stopReceiverSelector } import { startReceiverSelector, stopReceiverSelector }
@@ -28,16 +27,6 @@ process.on("SIGTERM", () => {
* for managing existing ones. * for managing existing ones.
*/ */
decodeTransform.on("data", (message: Message) => { 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) { switch (message.subject) {
case "bridge:getInfo": case "bridge:getInfo":
case "bridge:/getInfo": { case "bridge:/getInfo": {
@@ -50,12 +39,6 @@ decodeTransform.on("data", (message: Message) => {
break; break;
} }
case "bridge:stopReceiverApp": {
const { receiverDevice } = message.data;
stopReceiverApp(receiverDevice.host, receiverDevice.port);
break;
}
// Receiver selector // Receiver selector
case "bridge:openReceiverSelector": { case "bridge:openReceiverSelector": {
startReceiverSelector(message.data); break; startReceiverSelector(message.data); break;
@@ -74,5 +57,9 @@ decodeTransform.on("data", (message: Message) => {
stopMediaServer(); stopMediaServer();
break; break;
} }
default: {
handleCastMessage(message);
}
} }
}); });

View File

@@ -1,83 +1,69 @@
"use strict"; "use strict";
import { MediaStatus, ReceiverStatus, ReceiverApplication, SenderMessage } import { Image
from "./components/chromecast/types"; , ReceiverStatus
, SenderApplication
, SenderMessage
, Volume } from "./components/cast/types";
import { ReceiverDevice import { ReceiverDevice
, ReceiverSelectionCast , ReceiverSelectionCast
, ReceiverSelectionStop } from "./types"; , ReceiverSelectionStop } from "./types";
type MessageDefinitions = { interface CastSessionUpdated {
// Session messages sessionId: string
"shim:session/connected": { application: ReceiverApplication } , statusText: string
, "shim:session/updateStatus": { status: ReceiverStatus } , namespaces: Array<{ name: string }>
, "shim:session/stopped": {} , volume: Volume
, "shim:session/impl_addMessageListener": { }
namespace: string
, message: string interface CastSessionCreated extends CastSessionUpdated {
} appId: string
, "shim:session/impl_sendMessage": { , appImages: Image[]
messageId: string , displayName: string
, wasError: boolean , receiverFriendlyName: string
} , senderApps: SenderApplication[]
, "shim:session/impl_sendReceiverMessage": { , transportId: string
messageId: string }
, wasError: boolean
} type MessageDefinitions = {
"shim:castSessionCreated": CastSessionCreated
// Bridge session messages , "shim:castSessionUpdated": CastSessionUpdated
, "bridge:session/initialize": { , "shim:castSessionStopped": {
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": {
sessionId: string sessionId: string
, mediaSessionId: number
, _internalSessionId: string
, _id: string
} }
, "bridge:media/sendMediaMessage": { , "shim:receivedCastSessionMessage": {
message: any sessionId: string
, messageId: string , namespace: string
, _id: string , messageData: string
} }
, "shim:impl_sendCastMessage": {
sessionId: string
, messageId: string
, error?: string
}
, "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 // Bridge messages
, "main:receiverSelector/selected": ReceiverSelectionCast , "main:receiverSelector/selected": ReceiverSelectionCast
, "main:receiverSelector/stopped": ReceiverSelectionStop , "main:receiverSelector/stopped": ReceiverSelectionStop
@@ -98,9 +84,6 @@ type MessageDefinitions = {
, "bridge:openReceiverSelector": string , "bridge:openReceiverSelector": string
, "bridge:closeReceiverSelector": {} , "bridge:closeReceiverSelector": {}
, "bridge:stopReceiverApp": { receiverDevice: ReceiverDevice }
, "bridge:startMediaServer": { , "bridge:startMediaServer": {
filePath: string filePath: string
, port: number , port: number

View File

@@ -1,6 +1,6 @@
"use strict"; "use strict";
import { ReceiverStatus } from "./components/chromecast/types"; import { ReceiverStatus } from "./components/cast/types";
export enum ReceiverSelectorMediaType { export enum ReceiverSelectorMediaType {

5
app/src/global.d.ts vendored
View File

@@ -1 +1,6 @@
declare type Nullable<T> = T | null; declare type Nullable<T> = T | null;
declare type DistributiveOmit<T, K extends keyof any> =
T extends any
? Omit<T, K>
: never;

View File

@@ -41,7 +41,7 @@ export default new class ShimManager {
for (const shim of this.activeShims) { for (const shim of this.activeShims) {
shim.contentPort.postMessage({ shim.contentPort.postMessage({
subject: "shim:serviceUp" subject: "shim:serviceUp"
, data: { id: ev.detail.receiverDevice.id } , data: { receiverDevice: ev.detail.receiverDevice }
}); });
} }
}); });
@@ -50,7 +50,7 @@ export default new class ShimManager {
for (const shim of this.activeShims) { for (const shim of this.activeShims) {
shim.contentPort.postMessage({ shim.contentPort.postMessage({
subject: "shim:serviceDown" subject: "shim:serviceDown"
, data: { id: ev.detail.receiverDeviceId } , data: { receiverDeviceId: ev.detail.receiverDeviceId }
}); });
} }
}); });
@@ -173,7 +173,7 @@ export default new class ShimManager {
for (const receiverDevice of receiverDevices.getDevices()) { for (const receiverDevice of receiverDevices.getDevices()) {
shim.contentPort.postMessage({ shim.contentPort.postMessage({
subject: "shim:serviceUp" subject: "shim:serviceUp"
, data: { id: receiverDevice.id } , data: { receiverDevice }
}); });
} }

View File

@@ -76,7 +76,7 @@ export default new class extends TypedEventTarget<EventMap> {
const receiverDevice = this.receiverDevices.get(receiverDeviceId); const receiverDevice = this.receiverDevices.get(receiverDeviceId);
if (receiverDevice) { if (receiverDevice) {
this.bridgePort.postMessage({ this.bridgePort.postMessage({
subject: "bridge:stopReceiverApp" subject: "bridge:stopCastApp"
, data: { receiverDevice } , data: { receiverDevice }
}); });
} }

7
ext/src/global.d.ts vendored
View File

@@ -3,8 +3,15 @@ declare const MIRRORING_APP_ID: string;
declare const APPLICATION_NAME: string; declare const APPLICATION_NAME: string;
declare const APPLICATION_VERSION: string; declare const APPLICATION_VERSION: string;
declare type Nullable<T> = T | null; declare type Nullable<T> = T | null;
declare type DistributiveOmit<T, K extends keyof any> =
T extends any
? Omit<T, K>
: never;
declare interface Object { declare interface Object {
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
wrappedJSObject: Object; wrappedJSObject: Object;

View File

@@ -5,19 +5,18 @@ import Messenger from "./lib/Messenger";
import { TypedPort } from "./lib/TypedPort"; import { TypedPort } from "./lib/TypedPort";
import { BridgeInfo } from "./lib/bridge"; import { BridgeInfo } from "./lib/bridge";
import { ReceiverDevice } from "./types";
import { ReceiverSelectorMediaType } from "./background/receiverSelector"; import { ReceiverSelectorMediaType } from "./background/receiverSelector";
import { ReceiverSelection import { ReceiverSelection
, ReceiverSelectionCast , ReceiverSelectionCast
, ReceiverSelectionStop } , ReceiverSelectionStop }
from "./background/receiverSelector/ReceiverSelector"; from "./background/receiverSelector/ReceiverSelector";
import { Volume } from "./shim/cast/dataClasses"; import { CastSessionCreated
import { MediaStatus , CastSessionUpdated
, SenderMessage , ReceiverStatus
, ReceiverApplication , SenderMessage } from "./shim/cast/types";
, ReceiverStatus } from "./shim/cast/types";
import { ReceiverDevice } from "./types";
/** /**
@@ -60,8 +59,8 @@ type ExtMessageDefinitions = {
, "main:sessionCreated": {} , "main:sessionCreated": {}
, "shim:initialized": BridgeInfo , "shim:initialized": BridgeInfo
, "shim:serviceUp": { id: ReceiverDevice["id"] } , "shim:serviceUp": { receiverDevice: ReceiverDevice }
, "shim:serviceDown": { id: ReceiverDevice["id"] } , "shim:serviceDown": { receiverDeviceId: ReceiverDevice["id"] }
, "shim:launchApp": { receiver: ReceiverDevice } , "shim:launchApp": { receiver: ReceiverDevice }
} }
@@ -72,74 +71,42 @@ type ExtMessageDefinitions = {
* app/bridge/messaging.ts > MessagesBase * app/bridge/messaging.ts > MessagesBase
*/ */
type AppMessageDefinitions = { type AppMessageDefinitions = {
// Session messages "shim:castSessionCreated": CastSessionCreated
"shim:session/stopped": {} , "shim:castSessionUpdated": CastSessionUpdated
, "shim:session/connected": { application: ReceiverApplication } , "shim:castSessionStopped": {
, "shim:session/updateStatus": { status: ReceiverStatus }
, "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": {
sessionId: string 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 , messageId: string
, _id: string , error?: string
} }
, "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 // Bridge messages
, "main:receiverSelector/selected": ReceiverSelectionCast , "main:receiverSelector/selected": ReceiverSelectionCast
, "main:receiverSelector/stopped": ReceiverSelectionStop , "main:receiverSelector/stopped": ReceiverSelectionStop
@@ -160,9 +127,6 @@ type AppMessageDefinitions = {
, "bridge:openReceiverSelector": string , "bridge:openReceiverSelector": string
, "bridge:closeReceiverSelector": {} , "bridge:closeReceiverSelector": {}
, "bridge:stopReceiverApp": { receiverDevice: ReceiverDevice }
, "bridge:startMediaServer": { , "bridge:startMediaServer": {
filePath: string filePath: string
, port: number , port: number

View File

@@ -64,10 +64,10 @@ function getSession(opts: InitOptions): Promise<cast.Session> {
function receiverListener(availability: string) { function receiverListener(availability: string) {
if (availability === cast.ReceiverAvailability.AVAILABLE) { if (availability === cast.ReceiverAvailability.AVAILABLE) {
if (opts.receiver) { if (opts.receiver) {
cast._requestSession( /*cast._requestSession(
opts.receiver opts.receiver
, onRequestSessionSuccess , onRequestSessionSuccess
, onRequestSessionError); , onRequestSessionError);*/
} else { } else {
cast.requestSession( cast.requestSession(
onRequestSessionSuccess onRequestSessionSuccess

View File

@@ -162,10 +162,10 @@ function receiverListener(availability: string) {
if (availability === cast.ReceiverAvailability.AVAILABLE) { if (availability === cast.ReceiverAvailability.AVAILABLE) {
wasSessionRequested = true; wasSessionRequested = true;
cast._requestSession( /*cast._requestSession(
selectedReceiver selectedReceiver
, onRequestSessionSuccess , onRequestSessionSuccess
, onRequestSessionError); , onRequestSessionError);*/
} }
} }

View File

@@ -4,8 +4,7 @@ import { v4 as uuid } from "uuid";
import logger from "../../lib/logger"; import logger from "../../lib/logger";
import { onMessage import { sendMessageResponse } from "../eventMessageChannel";
, sendMessageResponse } from "../eventMessageChannel";
import { ErrorCallback import { ErrorCallback
, LoadSuccessCallback , LoadSuccessCallback
@@ -14,167 +13,157 @@ import { ErrorCallback
, SuccessCallback , SuccessCallback
, UpdateListener } from "../types"; , UpdateListener } from "../types";
import { SenderMediaMessage, SenderMessage } from "./types"; import { MediaStatus
, ReceiverMediaMessage
, SenderMediaMessage
, SenderMessage } from "./types";
import { Error as _Error import { Image, Receiver, SenderApplication } from "./dataClasses";
, Image, Receiver import { SessionStatus } from "./enums";
, SenderApplication, Volume } from "./dataClasses"; import { Media, LoadRequest, QueueLoadRequest, QueueItem } from "./media";
import { ErrorCode, SessionStatus } from "./enums";
import { Media
, LoadRequest
, QueueLoadRequest } from "./media";
type SenderMessageData<T = SenderMessage> = const NS_MEDIA = "urn:x-cast:com.google.cast.media";
T extends any
? Omit<T, "requestId">
: never; /**
* Takes a media object and a media status object and merges
* the status with the existing media object, updating it with
* new properties.
*/
function updateMedia(media: Media, status: MediaStatus) {
if (status.currentTime) {
media._lastUpdateTime = Date.now();
}
// Copy props
for (const prop in status) {
if (prop !== "items" && status.hasOwnProperty(prop)) {
(media as any)[prop] = (status as any)[prop];
}
}
// Update queue state
if (status.items) {
const newItems: QueueItem[] = [];
for (const newItem of status.items) {
if (!newItem.media) {
// Existing queue item with the same ID
const existingItem = media.items?.find(
item => item.itemId === newItem.itemId);
/**
* Use existing queue item's media info if available
* otherwise, if the current queue item, use the main
* media item.
*/
if (existingItem?.media) {
newItem.media = existingItem.media;
} else if (media.media
&& newItem.itemId === media.currentItemId) {
newItem.media = media.media;
}
}
}
media.items = newItems;
}
}
type SessionSuccessCallback = (session: Session) => void;
export default class Session { export default class Session {
#id = uuid(); #id = uuid();
#isConnected = false; #isConnected = false;
#successCallback?: SessionSuccessCallback;
#messageListeners = new Map<string, Set<MessageListener>>(); #loadMediaSuccessCallback?: (media: Media) => void;
#updateListeners = new Set<UpdateListener>(); #loadMediaErrorCallback?: ErrorCallback;
#loadMediaRequest?: LoadRequest;
#sendMessageCallbacks = _messageListeners = new Map<string, Set<MessageListener>>();
_updateListeners = new Set<UpdateListener>();
_sendMessageCallbacks =
new Map<string, [ SuccessCallback?, ErrorCallback? ]>(); new Map<string, [ SuccessCallback?, ErrorCallback? ]>();
#sendReceiverMessageCallbacks =
new Map<string, (wasError: boolean) => void>();
#listener = onMessage(message => { /**
// Filter other session messages *
if ((message as any).data._id !== this.#id) { */
return; #mediaMessageListener = (namespace: string, messageString: string) => {
if (namespace !== NS_MEDIA) return;
const message: ReceiverMediaMessage = JSON.parse(messageString);
switch (message.type) {
case "MEDIA_STATUS": {
// Update media
for (const mediaStatus of message.status) {
let media = this.media.find(
media => media.mediaSessionId ===
mediaStatus.mediaSessionId);
console.info(media);
// Handle Media creation
if (!media) {
media = new Media(
this.sessionId
, mediaStatus.mediaSessionId
, this.#sendMediaMessage);
this.media.push(media);
this.#loadMediaSuccessCallback?.(media);
} }
switch (message.subject) { updateMedia(media, mediaStatus);
case "shim:session/stopped": {
// Disconnect from extension messages
this.#listener.disconnect();
this.status = SessionStatus.STOPPED; for (const listener of media._updateListeners) {
for (const listener of this.#updateListeners) {
listener(false);
}
break;
}
case "shim:session/updateStatus": {
const { status } = message.data;
// First status message indicates session creation
if (!this.#isConnected && status.applications) {
this.#isConnected = true;
this.status = SessionStatus.CONNECTED;
// Update app props
const app = status.applications[0];
this.sessionId = app.sessionId;
this.namespaces = app.namespaces;
this.displayName = app.displayName;
this.statusText = app.statusText;
if (this.#successCallback) {
this.#successCallback(this);
}
return;
}
this.receiver.volume = status.volume;
for (const listener of this.#updateListeners) {
listener(true); listener(true);
} }
break; break;
} }
case "shim:session/impl_addMessageListener": {
const { namespace, message: newMessage } = message.data;
const messageListeners = this.#messageListeners.get(namespace);
if (messageListeners) {
for (const listener of messageListeners) {
listener(namespace, newMessage);
} }
} }
break;
} }
case "shim:session/impl_sendMessage": {
const { messageId, wasError } = message.data;
const [ successCallback, errorCallback ] =
this.#sendMessageCallbacks.get(messageId) ?? [];
if (wasError && errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
} else if (successCallback) {
successCallback();
}
this.#sendMessageCallbacks.delete(messageId);
break;
}
case "shim:session/impl_sendReceiverMessage": {
const { messageId, wasError } = message.data;
const callback =
this.#sendReceiverMessageCallbacks.get(messageId);
if (callback) {
callback(wasError);
}
break;
}
}
});
/** /**
* Sends a message to the bridge that is forwarded to the * Sends a media message to the app receiver.
* receiver device. Promise resolves once the message is sent * urn:x-cast:com.google.cast.media
* or an error occurs.
*/ */
#sendReceiverMessage = (message: SenderMessageData) => { #sendMediaMessage = (message: DistributiveOmit<
const messageId = uuid(); SenderMediaMessage, "requestId">) => {
sendMessageResponse({
subject: "bridge:session/impl_sendReceiverMessage"
, data: {
message: { requestId: 0, ...message }
, messageId
, _id: this.#id
}
});
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
this.#sendReceiverMessageCallbacks.set(messageId this.sendMessage(
, (wasError: boolean) => { "urn:x-cast:com.google.cast.media"
, { ...message, requestId: 0 }
, resolve, reject);
if (wasError) {
reject(new _Error(ErrorCode.SESSION_ERROR));
return;
}
resolve();
});
}); });
} }
private _sendMediaMessage(message: SenderMediaMessage) { #sendReceiverMessage = (message: DistributiveOmit<
this.sendMessage("urn:x-cast:com.google.cast.media", message); SenderMessage, "requestId">) => {
return new Promise<void>((resolve, reject) => {
const messageId = uuid();
sendMessageResponse({
subject: "bridge:sendCastReceiverMessage"
, data: {
sessionId: this.sessionId
, messageData: message as SenderMessage
, messageId
} }
});
this._sendMessageCallbacks.set(
messageId, [ resolve, reject ]);
});
}
media: Media[] = []; media: Media[] = [];
namespaces: Array<{ name: string }> = []; namespaces: Array<{ name: string }> = [];
@@ -187,51 +176,37 @@ export default class Session {
, public appId: string , public appId: string
, public displayName: string , public displayName: string
, public appImages: Image[] , public appImages: Image[]
, public receiver: Receiver , public receiver: Receiver) {
, _successCallback: SessionSuccessCallback) {
this.#successCallback = _successCallback;
this.transportId = sessionId || ""; this.transportId = sessionId || "";
if (receiver) { this.addMessageListener(NS_MEDIA, this.#mediaMessageListener);
sendMessageResponse({
subject: "bridge:session/initialize"
, data: {
address: (receiver as any)._address
, port: (receiver as any)._port
, appId
, sessionId
, _id: this.#id
}
});
}
} }
addMediaListener(_mediaListener: MediaListener) { addMediaListener(_mediaListener: MediaListener) {
logger.info("STUB :: Session#addMediaListener"); logger.info("STUB :: Session#addMediaListener");
} }
removeMediaListener(_mediaListener: MediaListener): void {
addMessageListener(namespace: string logger.info("STUB :: Session#removeMediaListener");
, listener: MessageListener) {
if (!this.#messageListeners.has(namespace)) {
this.#messageListeners.set(namespace, new Set());
} }
this.#messageListeners.get(namespace)?.add(listener); addMessageListener(namespace: string, listener: MessageListener) {
if (!this._messageListeners.has(namespace)) {
sendMessageResponse({ this._messageListeners.set(namespace, new Set());
subject: "bridge:session/impl_addMessageListener"
, data: {
namespace
, _id: this.#id
} }
});
this._messageListeners.get(namespace)?.add(listener);
}
removeMessageListener(namespace: string, listener: MessageListener): void {
this._messageListeners.get(namespace)?.delete(listener);
} }
addUpdateListener(listener: UpdateListener) { addUpdateListener(listener: UpdateListener) {
this.#updateListeners.add(listener); this._updateListeners.add(listener);
}
removeUpdateListener(listener: UpdateListener): void {
this._updateListeners.delete(listener);
} }
leave(_successCallback?: SuccessCallback leave(_successCallback?: SuccessCallback
@@ -244,48 +219,13 @@ export default class Session {
, successCallback?: LoadSuccessCallback , successCallback?: LoadSuccessCallback
, errorCallback?: ErrorCallback): void { , errorCallback?: ErrorCallback): void {
this._sendMediaMessage(loadRequest); this.#loadMediaSuccessCallback = successCallback;
this.#loadMediaErrorCallback = errorCallback;
this.#loadMediaRequest = loadRequest;
loadRequest.sessionId = this.sessionId;
let hasResponded = false; this.#sendMediaMessage(loadRequest)
.catch(errorCallback);
this.addMessageListener(
"urn:x-cast:com.google.cast.media"
, (_namespace, data) => {
if (hasResponded) {
return;
}
const message = JSON.parse(data);
if (message.status && message.status.length > 0) {
const sessionId = this.#id;
if (!sessionId) {
return;
}
hasResponded = true;
const media = new Media(
this.sessionId
, message.status[0].mediaSessionId
, sessionId);
media.media = loadRequest.media;
this.media = [ media ];
media.play();
if (successCallback) {
successCallback(media);
}
} else {
if (errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
}
}
});
} }
queueLoad(_queueLoadRequest: QueueLoadRequest queueLoad(_queueLoadRequest: QueueLoadRequest
@@ -295,34 +235,24 @@ export default class Session {
logger.info("STUB :: Session#queueLoad"); logger.info("STUB :: Session#queueLoad");
} }
removeMediaListener(_mediaListener: MediaListener): void {
logger.info("STUB :: Session#removeMediaListener");
}
removeMessageListener(namespace: string, listener: MessageListener): void {
this.#messageListeners.get(namespace)?.delete(listener);
}
removeUpdateListener(_namespace: string, listener: UpdateListener): void {
this.#updateListeners.delete(listener);
}
sendMessage(namespace: string sendMessage(namespace: string
, message: {} | string , message: object | string
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback): void { , errorCallback?: ErrorCallback): void {
const messageId = uuid(); const messageId = uuid();
sendMessageResponse({ sendMessageResponse({
subject: "bridge:session/impl_sendMessage" subject: "bridge:sendCastSessionMessage"
, data: { , data: {
namespace sessionId: this.sessionId
, message , namespace
, messageData: message
, messageId , messageId
, _id: this.#id
} }
}); });
this.#sendMessageCallbacks.set(messageId, [ this._sendMessageCallbacks.set(messageId, [
successCallback successCallback
, errorCallback , errorCallback
]); ]);

View File

@@ -55,7 +55,7 @@ export class Image {
export class Receiver { export class Receiver {
displayStatus: Nullable<ReceiverDisplayStatus> = null; displayStatus: Nullable<ReceiverDisplayStatus> = null;
isActiveInput: Nullable<boolean> = null; isActiveInput: Nullable<boolean> = null;
receiverType: string = ReceiverType.CAST; receiverType = ReceiverType.CAST;
constructor( constructor(
public label: string public label: string

View File

@@ -3,56 +3,19 @@
import logger from "../../lib/logger"; import logger from "../../lib/logger";
import { ReceiverDevice } from "../../types"; import { ReceiverDevice } from "../../types";
import { onMessage, sendMessageResponse } from "../eventMessageChannel"; import { onMessage, sendMessageResponse } from "../eventMessageChannel";
import { AutoJoinPolicy, Capability, DefaultActionPolicy, DialAppState
, ErrorCode, ReceiverAction, ReceiverAvailability, ReceiverType
, SenderPlatform, SessionStatus, VolumeControlType } from "./enums";
import { ApiConfig, CredentialsData, DialRequest, Error as Error_, Image
, Receiver, ReceiverDisplayStatus, SenderApplication, SessionRequest
, Timeout, Volume } from "./dataClasses";
import Session from "./Session"; import Session from "./Session";
import { ApiConfig
, CredentialsData
, DialRequest
, Error as Error_
, Image as Image_
, Receiver as Receiver
, ReceiverDisplayStatus
, SenderApplication
, SessionRequest
, Timeout
, Volume } from "./dataClasses";
import { AutoJoinPolicy
, Capability
, DefaultActionPolicy
, DialAppState
, ErrorCode
, ReceiverAction
, ReceiverAvailability
, ReceiverType
, SenderPlatform
, SessionStatus
, VolumeControlType } from "./enums";
export * as media from "./media";
export {
// Enums
AutoJoinPolicy, Capability, DefaultActionPolicy, DialAppState, ErrorCode
, ReceiverAction, ReceiverAvailability, ReceiverType, SenderPlatform
, SessionStatus, VolumeControlType
// Classes
, ApiConfig, CredentialsData, DialRequest, ReceiverDisplayStatus
, SenderApplication, Session, SessionRequest, Timeout, Volume
, Error_ as Error
, Image_ as Image
, Receiver as Receiver
};
export let isAvailable = false;
export const timeout = new Timeout();
export const VERSION = [ 1, 2 ];
type ReceiverActionListener = ( type ReceiverActionListener = (
receiver: Receiver receiver: Receiver
@@ -64,41 +27,48 @@ type SuccessCallback = () => void;
type ErrorCallback = (err: Error_) => void; type ErrorCallback = (err: Error_) => void;
let apiConfig: ApiConfig; let apiConfig: Nullable<ApiConfig>;
let sessionRequest: Nullable<SessionRequest>;
const receiverList: Array<{ id: string }> = []; let requestSessionSuccessCallback: Nullable<
const sessionList: Session[] = []; RequestSessionSuccessCallback>;
let requestSessionErrorCallback: Nullable<ErrorCallback>;
const receiverActionListeners = new Set<ReceiverActionListener>(); const receiverActionListeners = new Set<ReceiverActionListener>();
let sessionRequestInProgress = false; const receiverDevices = new Map<string, ReceiverDevice>();
let sessionSuccessCallback: RequestSessionSuccessCallback; const sessions = new Map<string, Session>();
let sessionErrorCallback: ErrorCallback;
export function addReceiverActionListener( export { AutoJoinPolicy, Capability, DefaultActionPolicy, DialAppState
listener: ReceiverActionListener): void { , ErrorCode, ReceiverAction, ReceiverAvailability, ReceiverType
, SenderPlatform, SessionStatus, VolumeControlType };
receiverActionListeners.add(listener); export { ApiConfig, CredentialsData, DialRequest, Error_ as Error, Image
} , Receiver, ReceiverDisplayStatus, SenderApplication, SessionRequest
, Timeout, Volume, Session };
export function initialize( export const VERSION = [ 1, 2 ];
newApiConfig: ApiConfig export let isAvailable = false;
export const timeout = new Timeout();
// chrome.cast.media namespace
export * as media from "./media";
export function initialize(newApiConfig: ApiConfig
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback): void { , errorCallback?: ErrorCallback) {
logger.info("cast.initialize"); logger.info("cast.initialize");
// Already initialized // Already initialized
if (apiConfig) { if (apiConfig) {
if (errorCallback) { errorCallback?.(new Error_(ErrorCode.INVALID_PARAMETER));
errorCallback(new Error_(ErrorCode.INVALID_PARAMETER));
}
return; return;
} }
apiConfig = newApiConfig; apiConfig = newApiConfig;
sendMessageResponse({ sendMessageResponse({
@@ -106,127 +76,61 @@ export function initialize(
, data: { appId: apiConfig.sessionRequest.appId } , data: { appId: apiConfig.sessionRequest.appId }
}); });
if (successCallback) { successCallback?.();
successCallback();
}
apiConfig.receiverListener(receiverList.length apiConfig.receiverListener(receiverDevices.size
? ReceiverAvailability.AVAILABLE ? ReceiverAvailability.AVAILABLE
: ReceiverAvailability.UNAVAILABLE); : ReceiverAvailability.UNAVAILABLE);
} }
export function logMessage(message: string): void { export function requestSession(successCallback: RequestSessionSuccessCallback
// eslint-disable-next-line no-console
console.log("CAST MSG:", message);
}
export function precache(_data: string): void {
logger.info("STUB :: cast.precache");
}
export function removeReceiverActionListener(
listener: ReceiverActionListener): void {
receiverActionListeners.delete(listener);
}
export function requestSession(
successCallback: RequestSessionSuccessCallback
, errorCallback: ErrorCallback , errorCallback: ErrorCallback
, _sessionRequest: SessionRequest = apiConfig.sessionRequest): void { , newSessionRequest?: SessionRequest) {
logger.info("cast.requestSession"); logger.info("cast.requestSession");
// Called before initialization // Not yet initialized
if (!apiConfig) { if (!apiConfig) {
if (errorCallback) { errorCallback?.(new Error_(ErrorCode.API_NOT_INITIALIZED));
errorCallback(new Error_(ErrorCode.API_NOT_INITIALIZED));
}
return; return;
} }
// Already requesting session // Already requesting session
if (sessionRequestInProgress) { if (sessionRequest) {
if (errorCallback) { errorCallback?.(new Error_(
errorCallback(new Error_(ErrorCode.INVALID_PARAMETER ErrorCode.INVALID_PARAMETER
, "Session request already in progress.")); , "Session request already in progress."));
}
return; return;
} }
// No available receivers // No receivers available
if (!receiverList.length) { if (!receiverDevices.size) {
if (errorCallback) { errorCallback?.(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
errorCallback(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
}
return; return;
} }
sessionRequestInProgress = true; /**
* Store session request for use in return message from
* receiver selection.
*/
sessionRequest = newSessionRequest ?? apiConfig.sessionRequest;
sessionSuccessCallback = successCallback; requestSessionSuccessCallback = successCallback;
sessionErrorCallback = errorCallback; requestSessionErrorCallback = errorCallback;
// Open destination chooser // Open receiver selector UI
sendMessageResponse({ sendMessageResponse({
subject: "main:selectReceiver" subject: "main:selectReceiver"
}); });
} }
export function _requestSession(
receiver: ReceiverDevice
, successCallback?: RequestSessionSuccessCallback
, errorCallback?: ErrorCallback): void {
logger.info("cast._requestSession");
if (!apiConfig) {
if (errorCallback) {
errorCallback(new Error_(ErrorCode.API_NOT_INITIALIZED));
}
return;
}
if (sessionRequestInProgress) {
if (errorCallback) {
errorCallback(new Error_(ErrorCode.INVALID_PARAMETER
, "Session request already in progress."));
}
return;
}
if (!receiverList.length) {
if (errorCallback) {
errorCallback(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
}
return;
}
sessionRequestInProgress = true;
createSession(receiver).then(session => {
sessionRequestInProgress = false;
if (successCallback) {
successCallback(session);
}
});
}
export function requestSessionById(_sessionId: string): void { export function requestSessionById(_sessionId: string): void {
logger.info("STUB :: cast.requestSessionById"); logger.info("STUB :: cast.requestSessionById");
} }
export function setCustomReceivers( export function setCustomReceivers(_receivers: Receiver[]
_receivers: Receiver[]
, _successCallback?: SuccessCallback , _successCallback?: SuccessCallback
, _errorCallback?: ErrorCallback): void { , _errorCallback?: ErrorCallback): void {
logger.info("STUB :: cast.setCustomReceivers"); logger.info("STUB :: cast.setCustomReceivers");
} }
@@ -239,55 +143,26 @@ export function setReceiverDisplayStatus(_sessionId: string): void {
} }
export function unescape(escaped: string): string { export function unescape(escaped: string): string {
return decodeURI(escaped); return window.decodeURI(escaped);
}
export function addReceiverActionListener(listener: ReceiverActionListener) {
receiverActionListeners.add(listener);
}
export function removeReceiverActionListener(listener: ReceiverActionListener) {
receiverActionListeners.delete(listener);
}
export function logMessage(message: string) {
logger.info("cast.logMessage", message);
}
export function precache(_data: string) {
logger.info("STUB :: cast.precache");
} }
function createSession(receiver: ReceiverDevice): Promise<Session> { onMessage(message => {
const selectedReceiver = new Receiver(
receiver.id
, receiver.friendlyName);
(selectedReceiver as any)._address = receiver.host;
(selectedReceiver as any)._port = receiver.port;
async function createSessionObject(): Promise<Session> {
return new Promise((resolve, _reject) => {
const session = new Session(
sessionList.length.toString() // sessionId
, apiConfig.sessionRequest.appId // appId
, receiver.friendlyName // displayName
, [] // appImages
, selectedReceiver // receiver
, session => {
sendMessageResponse({
subject: "main:sessionCreated"
});
resolve(session);
});
});
}
// If an existing session is active, stop it and start new one
// TODO: Fix whatever broken behaviour this is
if (sessionList.length) {
const lastSession = sessionList[sessionList.length - 1];
if (lastSession.status !== SessionStatus.STOPPED) {
return new Promise((resolve, _reject) => {
lastSession.stop(() => {
resolve(createSessionObject());
});
});
}
}
return createSessionObject();
}
onMessage(async message => {
switch (message.subject) { switch (message.subject) {
case "shim:initialized": { case "shim:initialized": {
isAvailable = true; isAvailable = true;
@@ -295,37 +170,136 @@ onMessage(async message => {
} }
/** /**
* Cast destination found (serviceUp). Set the API availability * Once the bridge detects a session creation, session info
* property and call the page event function (__onGCastApiAvailable). * and data needed to create cast API objects is sent.
*/ */
case "shim:serviceUp": { case "shim:castSessionCreated": {
const receiver = message.data; // Notify background to close UI
sendMessageResponse({
subject: "main:sessionCreated"
});
const status = message.data;
// TODO: Implement persistent per-origin receiver IDs
const receiver = new Receiver(
status.receiverFriendlyName // label
, status.receiverFriendlyName // friendlyName
, [ Capability.VIDEO_OUT
, Capability.AUDIO_OUT ] // capabilities
, status.volume); // volume
const session = new Session(
status.sessionId // sessionId
, status.appId // appId
, status.displayName // displayName
, status.appImages // appImages
, receiver); // receiver
session.senderApps = status.senderApps;
session.transportId = status.transportId;
sessions.set(session.sessionId, session);
}
// eslint-disable-next-line no-fallthrough
case "shim:castSessionUpdated": {
const status = message.data;
const session = sessions.get(status.sessionId);
if (!session) {
logger.error(`Session not found (${status.sessionId})`);
return;
}
session.statusText = status.statusText;
session.namespaces = status.namespaces;
session.receiver.volume = status.volume;
if (requestSessionSuccessCallback) {
requestSessionSuccessCallback(session);
requestSessionSuccessCallback = null;
requestSessionErrorCallback = null;
}
if (receiverList.find(r => r.id === receiver.id)) {
break; break;
} }
receiverList.push(receiver); case "shim:castSessionStopped": {
const { sessionId } = message.data;
const session = sessions.get(sessionId);
if (session) {
session.status = SessionStatus.STOPPED;
for (const listener of session?._updateListeners) {
listener(false);
}
}
break;
}
case "shim:receivedCastSessionMessage": {
const { sessionId, namespace, messageData } = message.data;
const session = sessions.get(sessionId);
if (session) {
const _messageListeners = session._messageListeners;
const listeners = _messageListeners.get(namespace);
if (listeners) {
for (const listener of listeners) {
listener(namespace, messageData);
}
}
}
break;
}
case "shim:impl_sendCastMessage": {
const { sessionId, messageId, error } = message.data;
const session = sessions.get(sessionId);
if (!session) {
break;
}
const callbacks = session._sendMessageCallbacks.get(messageId);
if (callbacks) {
const [ successCallback, errorCallback ] = callbacks;
if (error) {
errorCallback?.(new Error_(error));
return;
}
successCallback?.();
}
break;
}
case "shim:serviceUp": {
const { receiverDevice } = message.data;
if (receiverDevices.has(receiverDevice.id)) {
break;
}
receiverDevices.set(receiverDevice.id, receiverDevice);
if (apiConfig) { if (apiConfig) {
// Notify listeners of new cast destination // Notify listeners of new cast destination
apiConfig.receiverListener(ReceiverAvailability.AVAILABLE); apiConfig.receiverListener(
ReceiverAvailability.AVAILABLE);
} }
break; break;
} }
/**
* Cast destination lost (serviceDown). Remove from the receiver list
* and update availability state.
*/
case "shim:serviceDown": { case "shim:serviceDown": {
const receiverIndex = receiverList.findIndex( const { receiverDeviceId } = message.data;
receiver => receiver.id === message.data.id);
receiverList.splice(receiverIndex, 1); receiverDevices.delete(receiverDeviceId);
if (receiverList.length === 0) { if (receiverDevices.size === 0) {
if (apiConfig) { if (apiConfig) {
apiConfig.receiverListener( apiConfig.receiverListener(
ReceiverAvailability.UNAVAILABLE); ReceiverAvailability.UNAVAILABLE);
@@ -338,42 +312,44 @@ onMessage(async message => {
case "shim:selectReceiver/selected": { case "shim:selectReceiver/selected": {
logger.info("Selected receiver"); logger.info("Selected receiver");
if (!sessionRequestInProgress) { if (!sessionRequest) {
break; break;
} }
const { receiver } = message.data; const { receiver: receiverDevice } = message.data;
for (const listener of receiverActionListeners) { for (const listener of receiverActionListeners) {
logger.info("Calling receiver action listener", receiver); const receiver = new Receiver(
receiverDevice.id
, receiverDevice.friendlyName);
const castReceiver = new Receiver( listener(receiver, ReceiverAction.CAST);
receiver.id, receiver.friendlyName);
listener(castReceiver, ReceiverAction.CAST);
} }
const session = await createSession(receiver); sendMessageResponse({
sessionRequestInProgress = false; subject: "bridge:createCastSession"
if (sessionSuccessCallback) { , data: {
sessionSuccessCallback(session); appId: sessionRequest.appId
, receiverDevice: receiverDevice
} }
});
break; break;
} }
case "shim:selectReceiver/stopped": { case "shim:selectReceiver/stopped": {
const { receiver } = message.data;
logger.info("Stopped receiver"); logger.info("Stopped receiver");
if (sessionRequestInProgress) { if (sessionRequest) {
sessionRequestInProgress = false; sessionRequest = null;
for (const listener of receiverActionListeners) { for (const listener of receiverActionListeners) {
const castReceiver = new Receiver( const castReceiver = new Receiver(
message.data.receiver.id receiver.id
, message.data.receiver.friendlyName); , receiver.friendlyName);
logger.info("Calling receiver action listener (STOP)"
, message.data.receiver);
listener(castReceiver, ReceiverAction.STOP); listener(castReceiver, ReceiverAction.STOP);
} }
} }
@@ -385,25 +361,15 @@ onMessage(async message => {
* Popup closed before session established. * Popup closed before session established.
*/ */
case "shim:selectReceiver/cancelled": { case "shim:selectReceiver/cancelled": {
if (sessionRequestInProgress) { if (sessionRequest) {
sessionRequestInProgress = false; sessionRequest = null;
if (sessionErrorCallback) { requestSessionErrorCallback?.(
sessionErrorCallback(new Error_(ErrorCode.CANCEL)); new Error_(ErrorCode.CANCEL));
} }
}
break;
}
case "shim:launchApp": {
const receiver: ReceiverDevice = message.data.receiver;
_requestSession(receiver
, session => {
apiConfig.sessionListener(session);
});
break; break;
} }
} }
}); });

View File

@@ -15,8 +15,6 @@ import { BreakStatus, EditTracksInfoRequest, GetStatusRequest, LiveSeekableRange
import { PlayerState, RepeatMode } from "./enums"; import { PlayerState, RepeatMode } from "./enums";
import { ErrorCode } from "../enums"; import { ErrorCode } from "../enums";
import { onMessage, sendMessageResponse } from "../../eventMessageChannel";
import { ErrorCallback import { ErrorCallback
, SuccessCallback , SuccessCallback
, UpdateListener } from "../../types"; , UpdateListener } from "../../types";
@@ -25,114 +23,54 @@ import { SenderMediaMessage } from "../types";
export default class Media { export default class Media {
#id = uuid(); #id = uuid();
#isActive = true;
/** // Timestamp of last status update
* Timestamp of last status update _lastUpdateTime = 0;
*/ _updateListeners = new Set<UpdateListener>();
#lastUpdateTime = 0;
#updateListeners = new Set<UpdateListener>();
#sendMediaMessageCallbacks =
new Map<string, [ SuccessCallback?, ErrorCallback? ]>();
#listener = onMessage(message => {
if ((message as any).data._id !== this.#id) {
return;
}
switch (message.subject) {
case "shim:media/updateStatus": {
const { status } = message.data;
// Store current update time
this.#lastUpdateTime = Date.now();
this.currentTime = status.currentTime;
this.mediaSessionId = status.mediaSessionId;
this.playbackRate = status.playbackRate;
this.playerState = status.playerState;
this.repeatMode = status.repeatMode;
this.volume = status.volume;
if (status.customData) {
this.customData = status.customData;
}
if (status.media) {
this.media = status.media as MediaInfo;
}
// Call update listeners
for (const listener of this.#updateListeners) {
listener(this.#isActive);
}
break;
}
case "shim:media/sendMediaMessageResponse": {
const { messageId, error } = message.data;
const [ successCallback, errorCallback ] =
this.#sendMediaMessageCallbacks
.get(messageId) ?? [];
if (error && errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR));
} else if (successCallback) {
successCallback();
}
break;
}
}
});
activeTrackIds: Nullable<number[]> = null; activeTrackIds: Nullable<number[]> = null;
breakStatus?: BreakStatus; breakStatus?: BreakStatus;
currentItemId: Nullable<number> = null;
currentTime = 0; currentTime = 0;
customData: any = null; customData: any = null;
idleReason: Nullable<string> = null; idleReason: Nullable<string> = null;
items: Nullable<QueueItem[]> = null;
liveSeekableRange?: LiveSeekableRange; liveSeekableRange?: LiveSeekableRange;
loadingItemId: Nullable<number> = null;
media: Nullable<MediaInfo> = null; media: Nullable<MediaInfo> = null;
playbackRate = 1; playbackRate = 1;
playerState: string = PlayerState.IDLE; playerState = PlayerState.IDLE;
preloadedItemId: Nullable<number> = null; repeatMode = RepeatMode.OFF;
queueData?: QueueData;
repeatMode: string = RepeatMode.OFF;
supportedMediaCommands: string[] = []; supportedMediaCommands: string[] = [];
videoInfo?: VideoInformation; videoInfo?: VideoInformation;
volume: Volume = new Volume(); volume: Volume = new Volume();
// Queues
items: Nullable<QueueItem[]> = null;
currentItemId: Nullable<number> = null;
loadingItemId: Nullable<number> = null;
preloadedItemId: Nullable<number> = null;
queueData?: QueueData;
constructor(public sessionId: string constructor(public sessionId: string
, public mediaSessionId: number , public mediaSessionId: number
, _internalSessionId: string) { , public _sendMediaMessage: (message: DistributiveOmit<
SenderMediaMessage, "requestId">) => Promise<void>) {
sendMessageResponse({
subject: "bridge:media/initialize"
, data: {
sessionId
, mediaSessionId
, _internalSessionId
, _id: this.#id
}
});
} }
addUpdateListener(listener: UpdateListener) { addUpdateListener(listener: UpdateListener) {
this.#updateListeners.add(listener); this._updateListeners.add(listener);
}
removeUpdateListener(listener: UpdateListener) {
this._updateListeners.delete(listener);
} }
editTracksInfo(editTracksInfoRequest: EditTracksInfoRequest editTracksInfo(editTracksInfoRequest: EditTracksInfoRequest
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ type: "EDIT_TRACKS_INFO", ...editTracksInfoRequest }) { ...editTracksInfoRequest
, type: "EDIT_TRACKS_INFO"
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -153,9 +91,9 @@ export default class Media {
* rate. * rate.
*/ */
getEstimatedTime(): number { getEstimatedTime(): number {
if (this.playerState === PlayerState.PLAYING) { if (this.playerState === PlayerState.PLAYING && this._lastUpdateTime) {
let estimatedTime = this.currentTime + let estimatedTime = this.currentTime +
((Date.now() - this.#lastUpdateTime) / 1000); (((Date.now() - this._lastUpdateTime) / 1000));
// Enforce valid range // Enforce valid range
if (estimatedTime < 0) { if (estimatedTime < 0) {
@@ -179,8 +117,10 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ type: "MEDIA_GET_STATUS", ...getStatusRequest }) { ...getStatusRequest
, type: "MEDIA_GET_STATUS"
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -189,8 +129,10 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ type: "PAUSE", ...pauseRequest }) { ...pauseRequest
, type: "PAUSE"
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -199,8 +141,10 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ type: "PLAY", ...playRequest }) { ...playRequest
, type: "PLAY"
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -209,11 +153,11 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ { ...new QueueInsertItemsRequest([ item ])
...new QueueInsertItemsRequest([ item ])
, type: "QUEUE_INSERT" , type: "QUEUE_INSERT"
}) , sessionId: this.sessionId
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -222,11 +166,11 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ { ...queueInsertItemsRequest
...queueInsertItemsRequest
, type: "QUEUE_INSERT" , type: "QUEUE_INSERT"
}) , sessionId: this.sessionId
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
@@ -240,8 +184,11 @@ export default class Media {
const jumpRequest = new QueueJumpRequest(); const jumpRequest = new QueueJumpRequest();
jumpRequest.currentItemId = itemId; jumpRequest.currentItemId = itemId;
this.#sendMediaMessage( this._sendMediaMessage(
{ ...jumpRequest, type: "QUEUE_UPDATE" }) { ...jumpRequest
, type: "QUEUE_UPDATE"
, sessionId: this.sessionId
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -280,8 +227,11 @@ export default class Media {
reorderItemsRequest.insertBefore = existingItem.itemId; reorderItemsRequest.insertBefore = existingItem.itemId;
} }
this.#sendMediaMessage( this._sendMediaMessage(
{ ...reorderItemsRequest, type: "QUEUE_REORDER" }) { ...reorderItemsRequest
, type: "QUEUE_REORDER"
, sessionId: this.sessionId
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -293,8 +243,11 @@ export default class Media {
const jumpRequest = new QueueJumpRequest(); const jumpRequest = new QueueJumpRequest();
jumpRequest.jump = 1; jumpRequest.jump = 1;
this.#sendMediaMessage( this._sendMediaMessage(
{ ...jumpRequest, type: "QUEUE_UPDATE" }) { ...jumpRequest
, type: "QUEUE_UPDATE"
, sessionId: this.sessionId
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -305,8 +258,11 @@ export default class Media {
const jumpRequest = new QueueJumpRequest(); const jumpRequest = new QueueJumpRequest();
jumpRequest.jump = -1; jumpRequest.jump = -1;
this.#sendMediaMessage( this._sendMediaMessage(
{ ...jumpRequest, type: "QUEUE_UPDATE" }) { ...jumpRequest
, type: "QUEUE_UPDATE"
, sessionId: this.sessionId
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -327,8 +283,12 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ ...queueRemoveItemsRequest, type: "QUEUE_REMOVE" }) { ...queueRemoveItemsRequest
, mediaSessionId: this.mediaSessionId
, type: "QUEUE_REMOVE"
, sessionId: this.sessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -337,8 +297,12 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ ...queueReorderItemsRequest, type: "QUEUE_REORDER" }) { ...queueReorderItemsRequest
, mediaSessionId: this.mediaSessionId
, type: "QUEUE_REORDER"
, sessionId: this.sessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -350,8 +314,11 @@ export default class Media {
const setPropertiesRequest = new QueueSetPropertiesRequest(); const setPropertiesRequest = new QueueSetPropertiesRequest();
setPropertiesRequest.repeatMode = repeatMode; setPropertiesRequest.repeatMode = repeatMode;
this.#sendMediaMessage( this._sendMediaMessage(
{ ...setPropertiesRequest, type: "QUEUE_UPDATE" }) { ...setPropertiesRequest
, type: "QUEUE_UPDATE"
, sessionId: this.sessionId
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -360,22 +327,23 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ ...queueUpdateItemsRequest, type: "QUEUE_UPDATE" }) { ...queueUpdateItemsRequest
, type: "QUEUE_UPDATE"
, sessionId: this.sessionId
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
removeUpdateListener(listener: UpdateListener) {
this.#updateListeners.delete(listener);
}
seek(seekRequest: SeekRequest seek(seekRequest: SeekRequest
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ type: "SEEK", ...seekRequest }) { ...seekRequest
, type: "SEEK"
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -384,8 +352,10 @@ export default class Media {
, successCallback?: SuccessCallback , successCallback?: SuccessCallback
, errorCallback?: ErrorCallback) { , errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this._sendMediaMessage(
{ type: "MEDIA_SET_VOLUME", ...volumeRequest }) { ...volumeRequest
, type: "MEDIA_SET_VOLUME"
, mediaSessionId: this.mediaSessionId })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -398,13 +368,11 @@ export default class Media {
stopRequest = new StopRequest(); stopRequest = new StopRequest();
} }
this.#sendMediaMessage({ this._sendMediaMessage({
type: "STOP" ...stopRequest
, ...stopRequest , type: "STOP"
, mediaSessionId: this.mediaSessionId
}).then(() => { }).then(() => {
this.#isActive = false;
this.#listener.disconnect();
if (successCallback) { if (successCallback) {
successCallback(); successCallback();
} }
@@ -414,49 +382,4 @@ export default class Media {
supportsCommand(command: string): boolean { supportsCommand(command: string): boolean {
return this.supportedMediaCommands.includes(command); return this.supportedMediaCommands.includes(command);
} }
#sendMediaMessage = async (
// Allow messages without requestId
message: Omit<SenderMediaMessage, "requestId">
& { requestId?: Nullable<number> }) => {
if (!this.media) {
return;
}
// TODO: Handle this and other errors better
if (!this.#isActive) {
throw new _Error(ErrorCode.SESSION_ERROR
, "INVALID_MEDIA_SESSION_ID"
, {
type: "INVALID_REQUEST"
, reason: "INVALID_MEDIA_SESSION_ID"
});
return;
}
return new Promise<void>((resolve, reject) => {
const messageId = uuid();
this.#sendMediaMessageCallbacks.set(messageId, [
resolve, reject
]);
sendMessageResponse({
subject: "bridge:media/sendMediaMessage"
, data: {
message: {
// Default properties
requestId: 0
, mediaSessionId: this.mediaSessionId
, ...message
}
, messageId
, _id: this.#id
}
});
});
}
} }

View File

@@ -22,7 +22,6 @@ export class AudiobookChapterMediaMetadata {
type = MetadataType.AUDIOBOOK_CHAPTER; type = MetadataType.AUDIOBOOK_CHAPTER;
} }
export class AudiobookContainerMetadata { export class AudiobookContainerMetadata {
authors?: string[]; authors?: string[];
narrators?: string[]; narrators?: string[];
@@ -30,7 +29,6 @@ export class AudiobookContainerMetadata {
releaseDate?: string; releaseDate?: string;
} }
export class Break { export class Break {
duration?: number; duration?: number;
isEmbedded?: boolean; isEmbedded?: boolean;
@@ -42,7 +40,6 @@ export class Break {
, public position: number) {} , public position: number) {}
} }
export class BreakClip { export class BreakClip {
clickThroughUrl?: string; clickThroughUrl?: string;
contentId?: string; contentId?: string;
@@ -59,7 +56,6 @@ export class BreakClip {
constructor(public id: string) {} constructor(public id: string) {}
} }
export class BreakStatus { export class BreakStatus {
breakClipId?: string; breakClipId?: string;
breakId?: string; breakId?: string;
@@ -68,7 +64,6 @@ export class BreakStatus {
whenSkippable?: number; whenSkippable?: number;
} }
export class ContainerMetadata { export class ContainerMetadata {
containerDuration?: number; containerDuration?: number;
containerImages?: Image[]; containerImages?: Image[];
@@ -80,7 +75,6 @@ export class ContainerMetadata {
ContainerType.GENERIC_CONTAINER) {} ContainerType.GENERIC_CONTAINER) {}
} }
export class EditTracksInfoRequest { export class EditTracksInfoRequest {
requestId = 0; requestId = 0;
@@ -90,7 +84,6 @@ export class EditTracksInfoRequest {
} }
} }
export class GenericMediaMetadata { export class GenericMediaMetadata {
images?: Image[]; images?: Image[];
metadataType = MetadataType.GENERIC; metadataType = MetadataType.GENERIC;
@@ -101,12 +94,10 @@ export class GenericMediaMetadata {
type = MetadataType.GENERIC; type = MetadataType.GENERIC;
} }
export class GetStatusRequest { export class GetStatusRequest {
customData: any = null; customData: any = null;
} }
export class LiveSeekableRange { export class LiveSeekableRange {
constructor( constructor(
public start?: number public start?: number
@@ -115,7 +106,6 @@ export class LiveSeekableRange {
, public isLiveDone?: boolean) {} , public isLiveDone?: boolean) {}
} }
export class LoadRequest { export class LoadRequest {
activeTrackIds: Nullable<number[]> = null; activeTrackIds: Nullable<number[]> = null;
atvCredentials?: string; atvCredentials?: string;
@@ -180,7 +170,6 @@ export class MediaMetadata {
} }
} }
export class MovieMediaMetadata { export class MovieMediaMetadata {
images?: Image[]; images?: Image[];
metadataType = MetadataType.MOVIE; metadataType = MetadataType.MOVIE;
@@ -192,7 +181,6 @@ export class MovieMediaMetadata {
type = MetadataType.MOVIE; type = MetadataType.MOVIE;
} }
export class MusicTrackMediaMetadata { export class MusicTrackMediaMetadata {
albumArtist?: string; albumArtist?: string;
albumName?: string; albumName?: string;
@@ -210,12 +198,10 @@ export class MusicTrackMediaMetadata {
type = MetadataType.MUSIC_TRACK; type = MetadataType.MUSIC_TRACK;
} }
export class PauseRequest { export class PauseRequest {
customData: any = null; customData: any = null;
} }
export class PhotoMediaMetadata { export class PhotoMediaMetadata {
artist?: string; artist?: string;
creationDateTime?: string; creationDateTime?: string;
@@ -230,12 +216,10 @@ export class PhotoMediaMetadata {
width?: number; width?: number;
} }
export class PlayRequest { export class PlayRequest {
customData: any = null; customData: any = null;
} }
export class QueueData { export class QueueData {
shuffle = false; shuffle = false;
@@ -249,7 +233,6 @@ export class QueueData {
, public startTime?: number) {} , public startTime?: number) {}
} }
export class QueueInsertItemsRequest { export class QueueInsertItemsRequest {
customData: any = null; customData: any = null;
insertBefore: Nullable<number> = null; insertBefore: Nullable<number> = null;
@@ -261,7 +244,6 @@ export class QueueInsertItemsRequest {
public items: QueueItem[]) {} public items: QueueItem[]) {}
} }
export class QueueItem { export class QueueItem {
activeTrackIds: Nullable<number[]> = null; activeTrackIds: Nullable<number[]> = null;
autoplay = true; autoplay = true;
@@ -277,70 +259,47 @@ export class QueueItem {
} }
} }
export class QueueJumpRequest { export class QueueJumpRequest {
type = "QUEUE_UPDATE";
jump: Nullable<number> = null; jump: Nullable<number> = null;
currentItemId: Nullable<number> = null; currentItemId: Nullable<number> = null;
sessionId: Nullable<number> = null;
requestId: Nullable<number> = null;
type = "QUEUE_UPDATE";
} }
export class QueueLoadRequest { export class QueueLoadRequest {
type = "QUEUE_LOAD";
customData: any = null; customData: any = null;
repeatMode: string = RepeatMode.OFF; repeatMode: string = RepeatMode.OFF;
requestId: Nullable<number> = null;
sessionId: Nullable<string> = null;
startIndex = 0; startIndex = 0;
type = "QUEUE_LOAD";
constructor( constructor(public items: QueueItem[]) {}
public items: QueueItem[]) {}
} }
export class QueueRemoveItemsRequest { export class QueueRemoveItemsRequest {
customData: any = null;
requestId: Nullable<number> = null;
sessionId: Nullable<string> = null;
type = "QUEUE_REMOVE"; type = "QUEUE_REMOVE";
customData: any = null;
constructor( constructor(public itemIds: number[]) {}
public itemIds: number[]) {}
} }
export class QueueReorderItemsRequest { export class QueueReorderItemsRequest {
customData: any = null; customData: any = null;
insertBefore: Nullable<number> = null; insertBefore: Nullable<number> = null;
requestId: Nullable<number> = null;
sessionId: Nullable<string> = null;
type = "QUEUE_REORDER"; type = "QUEUE_REORDER";
constructor( constructor(public itemIds: number[]) {}
public itemIds: number[]) {}
} }
export class QueueSetPropertiesRequest { export class QueueSetPropertiesRequest {
type = "QUEUE_UPDATE";
customData: any = null; customData: any = null;
repeatMode: Nullable<string> = null; repeatMode: Nullable<string> = null;
requestId: Nullable<number> = null;
sessionId: Nullable<string> = null;
type = "QUEUE_UPDATE";
} }
export class QueueUpdateItemsRequest { export class QueueUpdateItemsRequest {
customData: any = null;
requestId: Nullable<number> = null;
sessionId: Nullable<string> = null;
type = "QUEUE_UPDATE"; type = "QUEUE_UPDATE";
customData: any = null;
constructor( constructor(public items: QueueItem[]) {}
public items: QueueItem[]) {}
} }
@@ -350,12 +309,10 @@ export class SeekRequest {
resumeState: Nullable<ResumeState> = null; resumeState: Nullable<ResumeState> = null;
} }
export class StopRequest { export class StopRequest {
customData: any = null; customData: any = null;
} }
export class TextTrackStyle { export class TextTrackStyle {
backgroundColor: Nullable<string> = null; backgroundColor: Nullable<string> = null;
customData: any = null; customData: any = null;
@@ -371,7 +328,6 @@ export class TextTrackStyle {
windowType: Nullable<string> = null; windowType: Nullable<string> = null;
} }
export class Track { export class Track {
customData: any = null; customData: any = null;
language: Nullable<string> = null; language: Nullable<string> = null;
@@ -385,7 +341,6 @@ export class Track {
, public type: TrackType) {} , public type: TrackType) {}
} }
export class TvShowMediaMetadata { export class TvShowMediaMetadata {
episode?: number; episode?: number;
episodeNumber?: number; episodeNumber?: number;
@@ -401,7 +356,6 @@ export class TvShowMediaMetadata {
type = MetadataType.TV_SHOW; type = MetadataType.TV_SHOW;
} }
export class UserActionState { export class UserActionState {
customData: any = null; customData: any = null;
@@ -409,13 +363,11 @@ export class UserActionState {
public userAction: UserAction) {} public userAction: UserAction) {}
} }
export class VastAdsRequest { export class VastAdsRequest {
adsResponse?: string; adsResponse?: string;
adTagUrl?: string; adTagUrl?: string;
} }
export class VideoInformation { export class VideoInformation {
constructor( constructor(
public width: number public width: number
@@ -423,7 +375,6 @@ export class VideoInformation {
, public hdrType: HdrType) {} , public hdrType: HdrType) {}
} }
export class VolumeRequest { export class VolumeRequest {
customData: any = null; customData: any = null;

View File

@@ -5,7 +5,7 @@
* app/src/bridge/components/chromecast/types.ts * app/src/bridge/components/chromecast/types.ts
*/ */
import { Volume } from "./dataClasses"; import { SenderApplication, Volume, Image } from "./dataClasses";
import { MediaInfo, QueueItem } from "./media/dataClasses"; import { MediaInfo, QueueItem } from "./media/dataClasses";
import { IdleReason import { IdleReason
, PlayerState , PlayerState
@@ -19,6 +19,7 @@ export interface MediaStatus {
playbackRate: number; playbackRate: number;
playerState: PlayerState; playerState: PlayerState;
idleReason?: IdleReason; idleReason?: IdleReason;
items?: QueueItem[];
currentTime: number; currentTime: number;
supportedMediaCommands: number; supportedMediaCommands: number;
repeatMode: RepeatMode; repeatMode: RepeatMode;
@@ -48,6 +49,23 @@ export interface ReceiverStatus {
} }
export interface CastSessionUpdated {
sessionId: string
, statusText: string
, namespaces: Array<{ name: string }>
, volume: Volume
}
export interface CastSessionCreated extends CastSessionUpdated {
appId: string
, appImages: Image[]
, displayName: string
, receiverFriendlyName: string
, senderApps: SenderApplication[]
, transportId: string
}
interface ReqBase { interface ReqBase {
requestId: number; requestId: number;
} }
@@ -61,13 +79,12 @@ export type SenderMessage =
| ReqBase & { type: "SET_VOLUME", volume: Partial<Volume> }; | ReqBase & { type: "SET_VOLUME", volume: Partial<Volume> };
export type ReceiverMessage = export type ReceiverMessage =
ReqBase & { ReqBase & { type: "RECEIVER_STATUS", status: ReceiverStatus }
type: "RECEIVER_STATUS" | ReqBase & { type: "LAUNCH_ERROR", reason: string }
, status: ReceiverStatus
};
interface MediaReqBase extends ReqBase { interface MediaReqBase extends ReqBase {
mediaSessionId: number;
customData?: unknown; customData?: unknown;
} }
@@ -79,16 +96,15 @@ export type SenderMediaMessage =
| MediaReqBase & { type: "STOP" } | MediaReqBase & { type: "STOP" }
| MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Partial<Volume> } | MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Partial<Volume> }
| MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number } | MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number }
| MediaReqBase & { | ReqBase & {
type: "LOAD" type: "LOAD"
, activeTrackIds: Nullable<number[]> , activeTrackIds: Nullable<number[]>
, atvCredentials?: string , atvCredentials?: string
, atvCredentialsType?: string , atvCredentialsType?: string
, autoplay: Nullable<boolean> , autoplay: Nullable<boolean>
, currentTime: Nullable<number> , currentTime: Nullable<number>
, customData: any , customData?: unknown
, media: MediaInfo , media: MediaInfo
, requestId: number
, sessionId: Nullable<string> , sessionId: Nullable<string>
} }
| MediaReqBase & { | MediaReqBase & {
@@ -127,7 +143,7 @@ export type SenderMediaMessage =
type: "QUEUE_UPDATE" type: "QUEUE_UPDATE"
, jump: Nullable<number> , jump: Nullable<number>
, currentItemId: Nullable<number> , currentItemId: Nullable<number>
, sessionId: Nullable<number> , sessionId: Nullable<string>
} }
// QueueRemoveItemsRequest // QueueRemoveItemsRequest
| MediaReqBase & { | MediaReqBase & {

View File

@@ -14,9 +14,6 @@ if (!_window.chrome) {
} }
// Remove private APIs
delete (cast as any)._requestSession;
// Create page-accessible API object // Create page-accessible API object
_window.chrome.cast = cast; _window.chrome.cast = cast;