diff --git a/.eslintrc.json b/.eslintrc.json index ef386cd..ba51098 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -55,5 +55,8 @@ , "@typescript-eslint/no-unused-vars": "off" , "@typescript-eslint/ban-types": "off" , "@typescript-eslint/ban-ts-comment": "off" + , "@typescript-eslint/no-this-alias": [ "error", { + "allowedNames": [ "this_" ] + }] } } diff --git a/app/src/bridge/components/cast/Session.ts b/app/src/bridge/components/cast/Session.ts new file mode 100644 index 0000000..b6cbcda --- /dev/null +++ b/app/src/bridge/components/cast/Session.ts @@ -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((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(); + + /** + * 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) { + 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 + }); + } +} diff --git a/app/src/bridge/components/cast/index.ts b/app/src/bridge/components/cast/index.ts new file mode 100644 index 0000000..116f824 --- /dev/null +++ b/app/src/bridge/components/cast/index.ts @@ -0,0 +1,114 @@ +"use strict"; + +import { sendMessage } from "../../lib/nativeMessaging"; +import { Message } from "../../messaging"; + +import Session from "./Session"; + + +const sessions = new Map(); + +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; + } + } +} diff --git a/app/src/bridge/components/chromecast/types.ts b/app/src/bridge/components/cast/types.ts similarity index 83% rename from app/src/bridge/components/chromecast/types.ts rename to app/src/bridge/components/cast/types.ts index bc2450d..deb0f07 100644 --- a/app/src/bridge/components/chromecast/types.ts +++ b/app/src/bridge/components/cast/types.ts @@ -2,8 +2,29 @@ export interface Image { url: string; - height?: number; - width?: number; + height: Nullable; + width: Nullable; +} + +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; + platform: string; + url: Nullable; } enum VolumeControlType { @@ -257,13 +278,13 @@ interface QueueItem { startTime: number; } - export interface MediaStatus { mediaSessionId: number; media?: MediaInformation; playbackRate: number; playerState: PlayerState; idleReason?: IdleReason; + items?: QueueItem[] currentTime: number; supportedMediaCommands: number; repeatMode: RepeatMode; @@ -271,25 +292,41 @@ export interface MediaStatus { customData: unknown; } +interface ReceiverDisplayStatus { + showStop: Nullable; + statusText: string; + appImages: Image[]; +} + +export interface Receiver { + displayStatus: Nullable; + isActiveInput: Nullable; + receiverType: ReceiverType; + label: string; + friendlyName: string; + capabilities: Capability[]; + volume: Nullable; +} + export interface ReceiverApplication { - appId: string - , appType: string - , displayName: string - , iconUrl: string - , isIdleScreen: boolean - , launchedFromCloud: boolean - , namespaces: Array<{ name: string }> - , sessionId: string - , statusText: string - , transportId: string - , universalAppId: string + appId: string; + appType: string; + displayName: string; + iconUrl: string; + isIdleScreen: boolean; + launchedFromCloud: boolean; + namespaces: Array<{ name: string }>; + sessionId: string; + statusText: string; + transportId: string; + universalAppId: string; } export interface ReceiverStatus { - applications?: ReceiverApplication[] - , isActiveInput?: boolean - , isStandBy?: boolean - , volume: Volume + applications?: ReceiverApplication[]; + isActiveInput?: boolean; + isStandBy?: boolean; + volume: Volume; } @@ -306,13 +343,12 @@ export type SenderMessage = | ReqBase & { type: "SET_VOLUME", volume: Volume }; export type ReceiverMessage = - ReqBase & { - type: "RECEIVER_STATUS" - , status: ReceiverStatus - }; + ReqBase & { type: "RECEIVER_STATUS", status: ReceiverStatus } + | ReqBase & { type: "LAUNCH_ERROR", reason: string } interface MediaReqBase extends ReqBase { + mediaSessionId: number; customData?: unknown; } @@ -324,11 +360,16 @@ export type SenderMediaMessage = | MediaReqBase & { type: "STOP" } | MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Volume } | MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number } - | MediaReqBase & { + | ReqBase & { type: "LOAD" - , media: MediaInformation + , activeTrackIds: Nullable + , atvCredentials?: string + , atvCredentialsType?: string , autoplay: Nullable , currentTime: Nullable + , customData?: unknown + , media: MediaInformation + , sessionId: Nullable } | MediaReqBase & { type: "SEEK" @@ -366,7 +407,7 @@ export type SenderMediaMessage = type: "QUEUE_UPDATE" , jump: Nullable , currentItemId: Nullable - , sessionId: Nullable + , sessionId: Nullable } // QueueRemoveItemsRequest | MediaReqBase & { diff --git a/app/src/bridge/components/chromecast/Media.ts b/app/src/bridge/components/chromecast/Media.ts deleted file mode 100644 index 0df89c1..0000000 --- a/app/src/bridge/components/chromecast/Media.ts +++ /dev/null @@ -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); - } -} diff --git a/app/src/bridge/components/chromecast/Session.ts b/app/src/bridge/components/chromecast/Session.ts deleted file mode 100644 index 8c732d8..0000000 --- a/app/src/bridge/components/chromecast/Session.ts +++ /dev/null @@ -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(); - - 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 } - }); - } - -} diff --git a/app/src/bridge/components/chromecast/index.ts b/app/src/bridge/components/chromecast/index.ts deleted file mode 100644 index 0d05f66..0000000 --- a/app/src/bridge/components/chromecast/index.ts +++ /dev/null @@ -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 = new Map(); -const existingMedia: Map = 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}`); - }); -} diff --git a/app/src/bridge/components/discovery.ts b/app/src/bridge/components/discovery.ts index 852730b..b10108c 100644 --- a/app/src/bridge/components/discovery.ts +++ b/app/src/bridge/components/discovery.ts @@ -8,10 +8,9 @@ import mdns from "mdns"; import { sendMessage } from "../lib/nativeMessaging"; -import { ReceiverStatus } from "./chromecast/types"; -import { NS_CONNECTION - , NS_HEARTBEAT - , NS_RECEIVER } from "./chromecast/Session"; +import { ReceiverStatus } from "./cast/types"; +import { NS_CONNECTION, NS_HEARTBEAT, NS_RECEIVER } + from "./cast/Session"; interface CastTxtRecord { @@ -152,8 +151,10 @@ export function stopDiscovery() { * Closes status listener connection. */ public deregister(): void { - if (this.clientReceiver) { - this.clientReceiver.send({ type: "CLOSE" }); + try { + this.clientReceiver?.send({ type: "CLOSE" }); + } catch (err) { + // Supress } this.client.close(); diff --git a/app/src/bridge/index.ts b/app/src/bridge/index.ts index abaf08a..4c6cd52 100755 --- a/app/src/bridge/index.ts +++ b/app/src/bridge/index.ts @@ -3,8 +3,7 @@ import { decodeTransform, encodeTransform } from "./lib/nativeMessaging"; import { Message } from "./messaging"; -import { handleSessionMessage, handleMediaMessage, stopReceiverApp } - from "./components/chromecast"; +import { handleCastMessage } from "./components/cast"; import { startDiscovery, stopDiscovery } from "./components/discovery"; import { startMediaServer, stopMediaServer } from "./components/mediaServer"; import { startReceiverSelector, stopReceiverSelector } @@ -28,16 +27,6 @@ process.on("SIGTERM", () => { * for managing existing ones. */ decodeTransform.on("data", (message: Message) => { - if (message.subject.startsWith("bridge:session/")) { - handleSessionMessage(message); - return; - } - if (message.subject.startsWith("bridge:media/")) { - handleMediaMessage(message); - return; - } - - switch (message.subject) { case "bridge:getInfo": case "bridge:/getInfo": { @@ -50,12 +39,6 @@ decodeTransform.on("data", (message: Message) => { break; } - case "bridge:stopReceiverApp": { - const { receiverDevice } = message.data; - stopReceiverApp(receiverDevice.host, receiverDevice.port); - break; - } - // Receiver selector case "bridge:openReceiverSelector": { startReceiverSelector(message.data); break; @@ -74,5 +57,9 @@ decodeTransform.on("data", (message: Message) => { stopMediaServer(); break; } + + default: { + handleCastMessage(message); + } } }); diff --git a/app/src/bridge/messaging.ts b/app/src/bridge/messaging.ts index 4f6dd7e..1195424 100644 --- a/app/src/bridge/messaging.ts +++ b/app/src/bridge/messaging.ts @@ -1,84 +1,70 @@ "use strict"; -import { MediaStatus, ReceiverStatus, ReceiverApplication, SenderMessage } - from "./components/chromecast/types"; +import { Image + , ReceiverStatus + , SenderApplication + , SenderMessage + , Volume } from "./components/cast/types"; import { ReceiverDevice , ReceiverSelectionCast , ReceiverSelectionStop } from "./types"; +interface CastSessionUpdated { + sessionId: string + , statusText: string + , namespaces: Array<{ name: string }> + , volume: Volume +} + +interface CastSessionCreated extends CastSessionUpdated { + appId: string + , appImages: Image[] + , displayName: string + , receiverFriendlyName: string + , senderApps: SenderApplication[] + , transportId: string +} + type MessageDefinitions = { - // Session messages - "shim:session/connected": { application: ReceiverApplication } - , "shim:session/updateStatus": { status: ReceiverStatus } - , "shim:session/stopped": {} - , "shim:session/impl_addMessageListener": { - namespace: string - , message: string - } - , "shim:session/impl_sendMessage": { - messageId: string - , wasError: boolean - } - , "shim:session/impl_sendReceiverMessage": { - messageId: string - , wasError: boolean - } - - // Bridge session messages - , "bridge:session/initialize": { - address: string - , port: number - , appId: string - , sessionId: string - , _id: string - } - , "bridge:session/close": {} - , "bridge:session/impl_leave": { - id: string - , _id: string - } - , "bridge:session/impl_sendMessage": { - namespace: string - , message: any - , messageId: string - , _id: string - } - , "bridge:session/impl_sendReceiverMessage": { - message: SenderMessage - , messageId: string - , _id: string - } - , "bridge:session/impl_addMessageListener": { - namespace: string; - _id: string; - } - - // Media messages - , "shim:media/updateStatus": { - status: MediaStatus - } - , "shim:media/sendMediaMessageResponse": { - messageId: string - , error: boolean - } - - // Bridge media messages - , "bridge:media/initialize": { + "shim:castSessionCreated": CastSessionCreated + , "shim:castSessionUpdated": CastSessionUpdated + , "shim:castSessionStopped": { sessionId: string - , mediaSessionId: number - , _internalSessionId: string - , _id: string } - , "bridge:media/sendMediaMessage": { - message: any + , "shim:receivedCastSessionMessage": { + sessionId: string + , namespace: string + , messageData: string + } + + , "shim:impl_sendCastMessage": { + sessionId: string , messageId: string - , _id: string + , error?: string } - // Bridge messages + , "bridge:createCastSession": { + appId: string + , receiverDevice: ReceiverDevice + } + , "bridge:sendCastReceiverMessage": { + sessionId: string + , messageData: SenderMessage + , messageId: string + } + , "bridge:sendCastSessionMessage": { + sessionId: string + , namespace: string + , messageData: object | string + , messageId: string + } + + , "bridge:stopCastApp": { receiverDevice: ReceiverDevice } + + // Bridge messages , "main:receiverSelector/selected": ReceiverSelectionCast , "main:receiverSelector/stopped": ReceiverSelectionStop , "main:receiverSelector/cancelled": {} @@ -98,9 +84,6 @@ type MessageDefinitions = { , "bridge:openReceiverSelector": string , "bridge:closeReceiverSelector": {} - , "bridge:stopReceiverApp": { receiverDevice: ReceiverDevice } - - , "bridge:startMediaServer": { filePath: string , port: number diff --git a/app/src/bridge/types.ts b/app/src/bridge/types.ts index 39227ec..40cdcde 100644 --- a/app/src/bridge/types.ts +++ b/app/src/bridge/types.ts @@ -1,6 +1,6 @@ "use strict"; -import { ReceiverStatus } from "./components/chromecast/types"; +import { ReceiverStatus } from "./components/cast/types"; export enum ReceiverSelectorMediaType { diff --git a/app/src/global.d.ts b/app/src/global.d.ts index 91824b3..a25669e 100644 --- a/app/src/global.d.ts +++ b/app/src/global.d.ts @@ -1 +1,6 @@ declare type Nullable = T | null; + +declare type DistributiveOmit = + T extends any + ? Omit + : never; diff --git a/ext/src/background/ShimManager.ts b/ext/src/background/ShimManager.ts index e6b40e2..c34c768 100644 --- a/ext/src/background/ShimManager.ts +++ b/ext/src/background/ShimManager.ts @@ -41,7 +41,7 @@ export default new class ShimManager { for (const shim of this.activeShims) { shim.contentPort.postMessage({ 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) { shim.contentPort.postMessage({ 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()) { shim.contentPort.postMessage({ subject: "shim:serviceUp" - , data: { id: receiverDevice.id } + , data: { receiverDevice } }); } diff --git a/ext/src/background/receiverDevices.ts b/ext/src/background/receiverDevices.ts index 781587b..bc2159b 100644 --- a/ext/src/background/receiverDevices.ts +++ b/ext/src/background/receiverDevices.ts @@ -76,7 +76,7 @@ export default new class extends TypedEventTarget { const receiverDevice = this.receiverDevices.get(receiverDeviceId); if (receiverDevice) { this.bridgePort.postMessage({ - subject: "bridge:stopReceiverApp" + subject: "bridge:stopCastApp" , data: { receiverDevice } }); } diff --git a/ext/src/global.d.ts b/ext/src/global.d.ts index cb568c5..aea1541 100644 --- a/ext/src/global.d.ts +++ b/ext/src/global.d.ts @@ -3,8 +3,15 @@ declare const MIRRORING_APP_ID: string; declare const APPLICATION_NAME: string; declare const APPLICATION_VERSION: string; + declare type Nullable = T | null; +declare type DistributiveOmit = + T extends any + ? Omit + : never; + + declare interface Object { // eslint-disable-next-line @typescript-eslint/ban-types wrappedJSObject: Object; diff --git a/ext/src/messaging.ts b/ext/src/messaging.ts index dda3875..d0288a8 100644 --- a/ext/src/messaging.ts +++ b/ext/src/messaging.ts @@ -5,19 +5,18 @@ import Messenger from "./lib/Messenger"; import { TypedPort } from "./lib/TypedPort"; import { BridgeInfo } from "./lib/bridge"; -import { ReceiverDevice } from "./types"; - import { ReceiverSelectorMediaType } from "./background/receiverSelector"; import { ReceiverSelection , ReceiverSelectionCast , ReceiverSelectionStop } from "./background/receiverSelector/ReceiverSelector"; -import { Volume } from "./shim/cast/dataClasses"; -import { MediaStatus - , SenderMessage - , ReceiverApplication - , ReceiverStatus } from "./shim/cast/types"; +import { CastSessionCreated + , CastSessionUpdated + , ReceiverStatus + , SenderMessage } from "./shim/cast/types"; + +import { ReceiverDevice } from "./types"; /** @@ -60,8 +59,8 @@ type ExtMessageDefinitions = { , "main:sessionCreated": {} , "shim:initialized": BridgeInfo - , "shim:serviceUp": { id: ReceiverDevice["id"] } - , "shim:serviceDown": { id: ReceiverDevice["id"] } + , "shim:serviceUp": { receiverDevice: ReceiverDevice } + , "shim:serviceDown": { receiverDeviceId: ReceiverDevice["id"] } , "shim:launchApp": { receiver: ReceiverDevice } } @@ -72,74 +71,42 @@ type ExtMessageDefinitions = { * app/bridge/messaging.ts > MessagesBase */ type AppMessageDefinitions = { - // Session messages - "shim:session/stopped": {} - , "shim:session/connected": { application: ReceiverApplication } - , "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": { + "shim:castSessionCreated": CastSessionCreated + , "shim:castSessionUpdated": CastSessionUpdated + , "shim:castSessionStopped": { sessionId: string - , mediaSessionId: number - , _internalSessionId: string - , _id: string } - , "bridge:media/sendMediaMessage": { - message: any + + , "shim:receivedCastSessionMessage": { + sessionId: string + , namespace: string + , messageData: string + } + + , "shim:impl_sendCastMessage": { + sessionId: string , messageId: string - , _id: string + , error?: string } + , "bridge:createCastSession": { + appId: string + , receiverDevice: ReceiverDevice + } + , "bridge:sendCastReceiverMessage": { + sessionId: string + , messageData: SenderMessage + , messageId: string + } + , "bridge:sendCastSessionMessage": { + sessionId: string + , namespace: string + , messageData: object | string + , messageId: string + } + + , "bridge:stopCastApp": { receiverDevice: ReceiverDevice } + // Bridge messages , "main:receiverSelector/selected": ReceiverSelectionCast , "main:receiverSelector/stopped": ReceiverSelectionStop @@ -160,9 +127,6 @@ type AppMessageDefinitions = { , "bridge:openReceiverSelector": string , "bridge:closeReceiverSelector": {} - , "bridge:stopReceiverApp": { receiverDevice: ReceiverDevice } - - , "bridge:startMediaServer": { filePath: string , port: number diff --git a/ext/src/senders/media/index.ts b/ext/src/senders/media/index.ts index 1e50bab..f18f1c3 100644 --- a/ext/src/senders/media/index.ts +++ b/ext/src/senders/media/index.ts @@ -64,10 +64,10 @@ function getSession(opts: InitOptions): Promise { function receiverListener(availability: string) { if (availability === cast.ReceiverAvailability.AVAILABLE) { if (opts.receiver) { - cast._requestSession( + /*cast._requestSession( opts.receiver , onRequestSessionSuccess - , onRequestSessionError); + , onRequestSessionError);*/ } else { cast.requestSession( onRequestSessionSuccess diff --git a/ext/src/senders/mirroring.ts b/ext/src/senders/mirroring.ts index 9e21f31..7f3bd4c 100644 --- a/ext/src/senders/mirroring.ts +++ b/ext/src/senders/mirroring.ts @@ -162,10 +162,10 @@ function receiverListener(availability: string) { if (availability === cast.ReceiverAvailability.AVAILABLE) { wasSessionRequested = true; - cast._requestSession( + /*cast._requestSession( selectedReceiver , onRequestSessionSuccess - , onRequestSessionError); + , onRequestSessionError);*/ } } diff --git a/ext/src/shim/cast/Session.ts b/ext/src/shim/cast/Session.ts index d4153ff..73cc2d1 100644 --- a/ext/src/shim/cast/Session.ts +++ b/ext/src/shim/cast/Session.ts @@ -4,8 +4,7 @@ import { v4 as uuid } from "uuid"; import logger from "../../lib/logger"; -import { onMessage - , sendMessageResponse } from "../eventMessageChannel"; +import { sendMessageResponse } from "../eventMessageChannel"; import { ErrorCallback , LoadSuccessCallback @@ -14,168 +13,158 @@ import { ErrorCallback , SuccessCallback , UpdateListener } from "../types"; -import { SenderMediaMessage, SenderMessage } from "./types"; +import { MediaStatus + , ReceiverMediaMessage + , SenderMediaMessage + , SenderMessage } from "./types"; -import { Error as _Error - , Image, Receiver - , SenderApplication, Volume } from "./dataClasses"; -import { ErrorCode, SessionStatus } from "./enums"; - -import { Media - , LoadRequest - , QueueLoadRequest } from "./media"; +import { Image, Receiver, SenderApplication } from "./dataClasses"; +import { SessionStatus } from "./enums"; +import { Media, LoadRequest, QueueLoadRequest, QueueItem } from "./media"; -type SenderMessageData = - T extends any - ? Omit - : never; +const NS_MEDIA = "urn:x-cast:com.google.cast.media"; + + +/** + * 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 { #id = uuid(); #isConnected = false; - #successCallback?: SessionSuccessCallback; - #messageListeners = new Map>(); - #updateListeners = new Set(); + #loadMediaSuccessCallback?: (media: Media) => void; + #loadMediaErrorCallback?: ErrorCallback; + #loadMediaRequest?: LoadRequest; - #sendMessageCallbacks = + _messageListeners = new Map>(); + _updateListeners = new Set(); + + + _sendMessageCallbacks = new Map(); - #sendReceiverMessageCallbacks = - new Map void>(); - - #listener = onMessage(message => { - // Filter other session messages - if ((message as any).data._id !== this.#id) { - return; - } - - switch (message.subject) { - case "shim:session/stopped": { - // Disconnect from extension messages - this.#listener.disconnect(); - - this.status = SessionStatus.STOPPED; - - 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); - } - - 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 - * receiver device. Promise resolves once the message is sent - * or an error occurs. + * */ - #sendReceiverMessage = (message: SenderMessageData) => { - const messageId = uuid(); - sendMessageResponse({ - subject: "bridge:session/impl_sendReceiverMessage" - , data: { - message: { requestId: 0, ...message } - , messageId - , _id: this.#id - } - }); + #mediaMessageListener = (namespace: string, messageString: string) => { + if (namespace !== NS_MEDIA) return; - return new Promise((resolve, reject) => { - this.#sendReceiverMessageCallbacks.set(messageId - , (wasError: boolean) => { + 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); - if (wasError) { - reject(new _Error(ErrorCode.SESSION_ERROR)); - return; + console.info(media); + + // Handle Media creation + if (!media) { + media = new Media( + this.sessionId + , mediaStatus.mediaSessionId + , this.#sendMediaMessage); + + this.media.push(media); + this.#loadMediaSuccessCallback?.(media); + } + + updateMedia(media, mediaStatus); + + for (const listener of media._updateListeners) { + listener(true); + } + + break; } + } + } + } + + /** + * Sends a media message to the app receiver. + * urn:x-cast:com.google.cast.media + */ + #sendMediaMessage = (message: DistributiveOmit< + SenderMediaMessage, "requestId">) => { + + return new Promise((resolve, reject) => { + this.sendMessage( + "urn:x-cast:com.google.cast.media" + , { ...message, requestId: 0 } + , resolve, reject); - resolve(); - }); }); } - private _sendMediaMessage(message: SenderMediaMessage) { - this.sendMessage("urn:x-cast:com.google.cast.media", message); + #sendReceiverMessage = (message: DistributiveOmit< + SenderMessage, "requestId">) => { + + return new Promise((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[] = []; namespaces: Array<{ name: string }> = []; senderApps: SenderApplication[] = []; @@ -187,51 +176,37 @@ export default class Session { , public appId: string , public displayName: string , public appImages: Image[] - , public receiver: Receiver - , _successCallback: SessionSuccessCallback) { + , public receiver: Receiver) { - this.#successCallback = _successCallback; this.transportId = sessionId || ""; - if (receiver) { - sendMessageResponse({ - subject: "bridge:session/initialize" - , data: { - address: (receiver as any)._address - , port: (receiver as any)._port - , appId - , sessionId - , _id: this.#id - } - }); - } + this.addMessageListener(NS_MEDIA, this.#mediaMessageListener); } addMediaListener(_mediaListener: MediaListener) { logger.info("STUB :: Session#addMediaListener"); } + removeMediaListener(_mediaListener: MediaListener): void { + logger.info("STUB :: Session#removeMediaListener"); + } - addMessageListener(namespace: string - , listener: MessageListener) { - - if (!this.#messageListeners.has(namespace)) { - this.#messageListeners.set(namespace, new Set()); + addMessageListener(namespace: string, listener: MessageListener) { + if (!this._messageListeners.has(namespace)) { + this._messageListeners.set(namespace, new Set()); } - this.#messageListeners.get(namespace)?.add(listener); - - sendMessageResponse({ - 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) { - this.#updateListeners.add(listener); + this._updateListeners.add(listener); + } + removeUpdateListener(listener: UpdateListener): void { + this._updateListeners.delete(listener); } leave(_successCallback?: SuccessCallback @@ -244,48 +219,13 @@ export default class Session { , successCallback?: LoadSuccessCallback , errorCallback?: ErrorCallback): void { - this._sendMediaMessage(loadRequest); + this.#loadMediaSuccessCallback = successCallback; + this.#loadMediaErrorCallback = errorCallback; + this.#loadMediaRequest = loadRequest; - - let hasResponded = false; - - 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)); - } - } - }); + loadRequest.sessionId = this.sessionId; + this.#sendMediaMessage(loadRequest) + .catch(errorCallback); } queueLoad(_queueLoadRequest: QueueLoadRequest @@ -295,34 +235,24 @@ export default class Session { 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 - , message: {} | string + , message: object | string , successCallback?: SuccessCallback , errorCallback?: ErrorCallback): void { const messageId = uuid(); sendMessageResponse({ - subject: "bridge:session/impl_sendMessage" + subject: "bridge:sendCastSessionMessage" , data: { - namespace - , message + sessionId: this.sessionId + , namespace + , messageData: message , messageId - , _id: this.#id } }); - this.#sendMessageCallbacks.set(messageId, [ + this._sendMessageCallbacks.set(messageId, [ successCallback , errorCallback ]); diff --git a/ext/src/shim/cast/dataClasses.ts b/ext/src/shim/cast/dataClasses.ts index 7ee7033..57729fd 100644 --- a/ext/src/shim/cast/dataClasses.ts +++ b/ext/src/shim/cast/dataClasses.ts @@ -55,7 +55,7 @@ export class Image { export class Receiver { displayStatus: Nullable = null; isActiveInput: Nullable = null; - receiverType: string = ReceiverType.CAST; + receiverType = ReceiverType.CAST; constructor( public label: string diff --git a/ext/src/shim/cast/index.ts b/ext/src/shim/cast/index.ts index bfbe896..c7ba796 100755 --- a/ext/src/shim/cast/index.ts +++ b/ext/src/shim/cast/index.ts @@ -3,60 +3,23 @@ import logger from "../../lib/logger"; import { ReceiverDevice } from "../../types"; + 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 { 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 = ( - receiver: Receiver - , receiverAction: string) => void; + receiver: Receiver + , receiverAction: string) => void; type RequestSessionSuccessCallback = (session: Session) => void; @@ -64,41 +27,48 @@ type SuccessCallback = () => void; type ErrorCallback = (err: Error_) => void; -let apiConfig: ApiConfig; +let apiConfig: Nullable; +let sessionRequest: Nullable; -const receiverList: Array<{ id: string }> = []; -const sessionList: Session[] = []; +let requestSessionSuccessCallback: Nullable< + RequestSessionSuccessCallback>; +let requestSessionErrorCallback: Nullable; const receiverActionListeners = new Set(); -let sessionRequestInProgress = false; -let sessionSuccessCallback: RequestSessionSuccessCallback; -let sessionErrorCallback: ErrorCallback; +const receiverDevices = new Map(); +const sessions = new Map(); -export function addReceiverActionListener( - listener: ReceiverActionListener): void { +export { AutoJoinPolicy, Capability, DefaultActionPolicy, DialAppState + , 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( - newApiConfig: ApiConfig - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback): void { +export const VERSION = [ 1, 2 ]; +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 + , errorCallback?: ErrorCallback) { logger.info("cast.initialize"); // Already initialized if (apiConfig) { - if (errorCallback) { - errorCallback(new Error_(ErrorCode.INVALID_PARAMETER)); - } - + errorCallback?.(new Error_(ErrorCode.INVALID_PARAMETER)); return; } - apiConfig = newApiConfig; sendMessageResponse({ @@ -106,127 +76,61 @@ export function initialize( , data: { appId: apiConfig.sessionRequest.appId } }); - if (successCallback) { - successCallback(); - } + successCallback?.(); - apiConfig.receiverListener(receiverList.length + apiConfig.receiverListener(receiverDevices.size ? ReceiverAvailability.AVAILABLE : ReceiverAvailability.UNAVAILABLE); } -export function logMessage(message: string): void { - // 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 - , _sessionRequest: SessionRequest = apiConfig.sessionRequest): void { +export function requestSession(successCallback: RequestSessionSuccessCallback + , errorCallback: ErrorCallback + , newSessionRequest?: SessionRequest) { logger.info("cast.requestSession"); - // Called before initialization + // Not yet initialized if (!apiConfig) { - if (errorCallback) { - errorCallback(new Error_(ErrorCode.API_NOT_INITIALIZED)); - } - + errorCallback?.(new Error_(ErrorCode.API_NOT_INITIALIZED)); return; } // Already requesting session - if (sessionRequestInProgress) { - if (errorCallback) { - errorCallback(new Error_(ErrorCode.INVALID_PARAMETER - , "Session request already in progress.")); - } - + if (sessionRequest) { + errorCallback?.(new Error_( + ErrorCode.INVALID_PARAMETER + , "Session request already in progress.")); return; } - // No available receivers - if (!receiverList.length) { - if (errorCallback) { - errorCallback(new Error_(ErrorCode.RECEIVER_UNAVAILABLE)); - } - + // No receivers available + if (!receiverDevices.size) { + errorCallback?.(new Error_(ErrorCode.RECEIVER_UNAVAILABLE)); return; } - sessionRequestInProgress = true; + /** + * Store session request for use in return message from + * receiver selection. + */ + sessionRequest = newSessionRequest ?? apiConfig.sessionRequest; - sessionSuccessCallback = successCallback; - sessionErrorCallback = errorCallback; + requestSessionSuccessCallback = successCallback; + requestSessionErrorCallback = errorCallback; - // Open destination chooser + // Open receiver selector UI sendMessageResponse({ 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 { logger.info("STUB :: cast.requestSessionById"); } -export function setCustomReceivers( - _receivers: Receiver[] - , _successCallback?: SuccessCallback - , _errorCallback?: ErrorCallback): void { - +export function setCustomReceivers(_receivers: Receiver[] + , _successCallback?: SuccessCallback + , _errorCallback?: ErrorCallback): void { logger.info("STUB :: cast.setCustomReceivers"); } @@ -239,55 +143,26 @@ export function setReceiverDisplayStatus(_sessionId: string): void { } 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 { - const selectedReceiver = new Receiver( - receiver.id - , receiver.friendlyName); - - (selectedReceiver as any)._address = receiver.host; - (selectedReceiver as any)._port = receiver.port; - - async function createSessionObject(): Promise { - 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 => { +onMessage(message => { switch (message.subject) { case "shim:initialized": { isAvailable = true; @@ -295,37 +170,136 @@ onMessage(async message => { } /** - * Cast destination found (serviceUp). Set the API availability - * property and call the page event function (__onGCastApiAvailable). + * Once the bridge detects a session creation, session info + * and data needed to create cast API objects is sent. */ - case "shim:serviceUp": { - const receiver = message.data; + case "shim:castSessionCreated": { + // Notify background to close UI + sendMessageResponse({ + subject: "main:sessionCreated" + }); - if (receiverList.find(r => r.id === receiver.id)) { - break; + 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; } - receiverList.push(receiver); + session.statusText = status.statusText; + session.namespaces = status.namespaces; + session.receiver.volume = status.volume; - if (apiConfig) { - // Notify listeners of new cast destination - apiConfig.receiverListener(ReceiverAvailability.AVAILABLE); + if (requestSessionSuccessCallback) { + requestSessionSuccessCallback(session); + requestSessionSuccessCallback = null; + requestSessionErrorCallback = null; + } + + break; + } + + 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) { + // Notify listeners of new cast destination + apiConfig.receiverListener( + ReceiverAvailability.AVAILABLE); } break; } - /** - * Cast destination lost (serviceDown). Remove from the receiver list - * and update availability state. - */ case "shim:serviceDown": { - const receiverIndex = receiverList.findIndex( - receiver => receiver.id === message.data.id); + const { receiverDeviceId } = message.data; - receiverList.splice(receiverIndex, 1); + receiverDevices.delete(receiverDeviceId); - if (receiverList.length === 0) { + if (receiverDevices.size === 0) { if (apiConfig) { apiConfig.receiverListener( ReceiverAvailability.UNAVAILABLE); @@ -338,42 +312,44 @@ onMessage(async message => { case "shim:selectReceiver/selected": { logger.info("Selected receiver"); - if (!sessionRequestInProgress) { + if (!sessionRequest) { break; } - const { receiver } = message.data; + const { receiver: receiverDevice } = message.data; for (const listener of receiverActionListeners) { - logger.info("Calling receiver action listener", receiver); + const receiver = new Receiver( + receiverDevice.id + , receiverDevice.friendlyName); - const castReceiver = new Receiver( - receiver.id, receiver.friendlyName); - listener(castReceiver, ReceiverAction.CAST); + listener(receiver, ReceiverAction.CAST); } - const session = await createSession(receiver); - sessionRequestInProgress = false; - if (sessionSuccessCallback) { - sessionSuccessCallback(session); - } + sendMessageResponse({ + subject: "bridge:createCastSession" + , data: { + appId: sessionRequest.appId + , receiverDevice: receiverDevice + } + }); break; } case "shim:selectReceiver/stopped": { + const { receiver } = message.data; + logger.info("Stopped receiver"); - if (sessionRequestInProgress) { - sessionRequestInProgress = false; + if (sessionRequest) { + sessionRequest = null; for (const listener of receiverActionListeners) { const castReceiver = new Receiver( - message.data.receiver.id - , message.data.receiver.friendlyName); + receiver.id + , receiver.friendlyName); - logger.info("Calling receiver action listener (STOP)" - , message.data.receiver); listener(castReceiver, ReceiverAction.STOP); } } @@ -385,25 +361,15 @@ onMessage(async message => { * Popup closed before session established. */ case "shim:selectReceiver/cancelled": { - if (sessionRequestInProgress) { - sessionRequestInProgress = false; + if (sessionRequest) { + sessionRequest = null; - if (sessionErrorCallback) { - sessionErrorCallback(new Error_(ErrorCode.CANCEL)); - } + requestSessionErrorCallback?.( + new Error_(ErrorCode.CANCEL)); } break; } - - case "shim:launchApp": { - const receiver: ReceiverDevice = message.data.receiver; - _requestSession(receiver - , session => { - apiConfig.sessionListener(session); - }); - - break; - } } }); + diff --git a/ext/src/shim/cast/media/Media.ts b/ext/src/shim/cast/media/Media.ts index 87a7826..0ffd8e8 100644 --- a/ext/src/shim/cast/media/Media.ts +++ b/ext/src/shim/cast/media/Media.ts @@ -15,8 +15,6 @@ import { BreakStatus, EditTracksInfoRequest, GetStatusRequest, LiveSeekableRange import { PlayerState, RepeatMode } from "./enums"; import { ErrorCode } from "../enums"; -import { onMessage, sendMessageResponse } from "../../eventMessageChannel"; - import { ErrorCallback , SuccessCallback , UpdateListener } from "../../types"; @@ -25,114 +23,54 @@ import { SenderMediaMessage } from "../types"; export default class Media { #id = uuid(); - #isActive = true; - /** - * Timestamp of last status update - */ - #lastUpdateTime = 0; - - #updateListeners = new Set(); - #sendMediaMessageCallbacks = - new Map(); - - #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; - } - - } - }); + // Timestamp of last status update + _lastUpdateTime = 0; + _updateListeners = new Set(); activeTrackIds: Nullable = null; breakStatus?: BreakStatus; - currentItemId: Nullable = null; currentTime = 0; customData: any = null; idleReason: Nullable = null; - items: Nullable = null; liveSeekableRange?: LiveSeekableRange; - loadingItemId: Nullable = null; media: Nullable = null; playbackRate = 1; - playerState: string = PlayerState.IDLE; - preloadedItemId: Nullable = null; - queueData?: QueueData; - repeatMode: string = RepeatMode.OFF; + playerState = PlayerState.IDLE; + repeatMode = RepeatMode.OFF; supportedMediaCommands: string[] = []; videoInfo?: VideoInformation; volume: Volume = new Volume(); + // Queues + items: Nullable = null; + currentItemId: Nullable = null; + loadingItemId: Nullable = null; + preloadedItemId: Nullable = null; + queueData?: QueueData; + constructor(public sessionId: string , public mediaSessionId: number - , _internalSessionId: string) { - - sendMessageResponse({ - subject: "bridge:media/initialize" - , data: { - sessionId - , mediaSessionId - , _internalSessionId - , _id: this.#id - } - }); + , public _sendMediaMessage: (message: DistributiveOmit< + SenderMediaMessage, "requestId">) => Promise) { } addUpdateListener(listener: UpdateListener) { - this.#updateListeners.add(listener); + this._updateListeners.add(listener); + } + removeUpdateListener(listener: UpdateListener) { + this._updateListeners.delete(listener); } editTracksInfo(editTracksInfoRequest: EditTracksInfoRequest , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { type: "EDIT_TRACKS_INFO", ...editTracksInfoRequest }) + this._sendMediaMessage( + { ...editTracksInfoRequest + , type: "EDIT_TRACKS_INFO" + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -153,9 +91,9 @@ export default class Media { * rate. */ getEstimatedTime(): number { - if (this.playerState === PlayerState.PLAYING) { + if (this.playerState === PlayerState.PLAYING && this._lastUpdateTime) { let estimatedTime = this.currentTime + - ((Date.now() - this.#lastUpdateTime) / 1000); + (((Date.now() - this._lastUpdateTime) / 1000)); // Enforce valid range if (estimatedTime < 0) { @@ -179,8 +117,10 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { type: "MEDIA_GET_STATUS", ...getStatusRequest }) + this._sendMediaMessage( + { ...getStatusRequest + , type: "MEDIA_GET_STATUS" + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -189,8 +129,10 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { type: "PAUSE", ...pauseRequest }) + this._sendMediaMessage( + { ...pauseRequest + , type: "PAUSE" + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -199,8 +141,10 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { type: "PLAY", ...playRequest }) + this._sendMediaMessage( + { ...playRequest + , type: "PLAY" + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -209,11 +153,11 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { - ...new QueueInsertItemsRequest([ item ]) - , type: "QUEUE_INSERT" - }) + this._sendMediaMessage( + { ...new QueueInsertItemsRequest([ item ]) + , type: "QUEUE_INSERT" + , sessionId: this.sessionId + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -222,11 +166,11 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { - ...queueInsertItemsRequest - , type: "QUEUE_INSERT" - }) + this._sendMediaMessage( + { ...queueInsertItemsRequest + , type: "QUEUE_INSERT" + , sessionId: this.sessionId + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); @@ -240,8 +184,11 @@ export default class Media { const jumpRequest = new QueueJumpRequest(); jumpRequest.currentItemId = itemId; - this.#sendMediaMessage( - { ...jumpRequest, type: "QUEUE_UPDATE" }) + this._sendMediaMessage( + { ...jumpRequest + , type: "QUEUE_UPDATE" + , sessionId: this.sessionId + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -280,8 +227,11 @@ export default class Media { reorderItemsRequest.insertBefore = existingItem.itemId; } - this.#sendMediaMessage( - { ...reorderItemsRequest, type: "QUEUE_REORDER" }) + this._sendMediaMessage( + { ...reorderItemsRequest + , type: "QUEUE_REORDER" + , sessionId: this.sessionId + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -293,8 +243,11 @@ export default class Media { const jumpRequest = new QueueJumpRequest(); jumpRequest.jump = 1; - this.#sendMediaMessage( - { ...jumpRequest, type: "QUEUE_UPDATE" }) + this._sendMediaMessage( + { ...jumpRequest + , type: "QUEUE_UPDATE" + , sessionId: this.sessionId + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -305,8 +258,11 @@ export default class Media { const jumpRequest = new QueueJumpRequest(); jumpRequest.jump = -1; - this.#sendMediaMessage( - { ...jumpRequest, type: "QUEUE_UPDATE" }) + this._sendMediaMessage( + { ...jumpRequest + , type: "QUEUE_UPDATE" + , sessionId: this.sessionId + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -327,8 +283,12 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { ...queueRemoveItemsRequest, type: "QUEUE_REMOVE" }) + this._sendMediaMessage( + { ...queueRemoveItemsRequest + + , mediaSessionId: this.mediaSessionId + , type: "QUEUE_REMOVE" + , sessionId: this.sessionId }) .then(successCallback) .catch(errorCallback); } @@ -337,8 +297,12 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { ...queueReorderItemsRequest, type: "QUEUE_REORDER" }) + this._sendMediaMessage( + { ...queueReorderItemsRequest + + , mediaSessionId: this.mediaSessionId + , type: "QUEUE_REORDER" + , sessionId: this.sessionId }) .then(successCallback) .catch(errorCallback); } @@ -350,8 +314,11 @@ export default class Media { const setPropertiesRequest = new QueueSetPropertiesRequest(); setPropertiesRequest.repeatMode = repeatMode; - this.#sendMediaMessage( - { ...setPropertiesRequest, type: "QUEUE_UPDATE" }) + this._sendMediaMessage( + { ...setPropertiesRequest + , type: "QUEUE_UPDATE" + , sessionId: this.sessionId + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -360,22 +327,23 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { ...queueUpdateItemsRequest, type: "QUEUE_UPDATE" }) + this._sendMediaMessage( + { ...queueUpdateItemsRequest + , type: "QUEUE_UPDATE" + , sessionId: this.sessionId + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } - removeUpdateListener(listener: UpdateListener) { - this.#updateListeners.delete(listener); - } - seek(seekRequest: SeekRequest , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { type: "SEEK", ...seekRequest }) + this._sendMediaMessage( + { ...seekRequest + , type: "SEEK" + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -384,8 +352,10 @@ export default class Media { , successCallback?: SuccessCallback , errorCallback?: ErrorCallback) { - this.#sendMediaMessage( - { type: "MEDIA_SET_VOLUME", ...volumeRequest }) + this._sendMediaMessage( + { ...volumeRequest + , type: "MEDIA_SET_VOLUME" + , mediaSessionId: this.mediaSessionId }) .then(successCallback) .catch(errorCallback); } @@ -398,13 +368,11 @@ export default class Media { stopRequest = new StopRequest(); } - this.#sendMediaMessage({ - type: "STOP" - , ...stopRequest + this._sendMediaMessage({ + ...stopRequest + , type: "STOP" + , mediaSessionId: this.mediaSessionId }).then(() => { - this.#isActive = false; - this.#listener.disconnect(); - if (successCallback) { successCallback(); } @@ -414,49 +382,4 @@ export default class Media { supportsCommand(command: string): boolean { return this.supportedMediaCommands.includes(command); } - - - #sendMediaMessage = async ( - // Allow messages without requestId - message: Omit - & { requestId?: Nullable }) => { - - 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((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 - } - }); - }); - } } diff --git a/ext/src/shim/cast/media/dataClasses.ts b/ext/src/shim/cast/media/dataClasses.ts index 7137486..550394c 100644 --- a/ext/src/shim/cast/media/dataClasses.ts +++ b/ext/src/shim/cast/media/dataClasses.ts @@ -22,7 +22,6 @@ export class AudiobookChapterMediaMetadata { type = MetadataType.AUDIOBOOK_CHAPTER; } - export class AudiobookContainerMetadata { authors?: string[]; narrators?: string[]; @@ -30,7 +29,6 @@ export class AudiobookContainerMetadata { releaseDate?: string; } - export class Break { duration?: number; isEmbedded?: boolean; @@ -42,7 +40,6 @@ export class Break { , public position: number) {} } - export class BreakClip { clickThroughUrl?: string; contentId?: string; @@ -59,7 +56,6 @@ export class BreakClip { constructor(public id: string) {} } - export class BreakStatus { breakClipId?: string; breakId?: string; @@ -68,7 +64,6 @@ export class BreakStatus { whenSkippable?: number; } - export class ContainerMetadata { containerDuration?: number; containerImages?: Image[]; @@ -80,7 +75,6 @@ export class ContainerMetadata { ContainerType.GENERIC_CONTAINER) {} } - export class EditTracksInfoRequest { requestId = 0; @@ -90,7 +84,6 @@ export class EditTracksInfoRequest { } } - export class GenericMediaMetadata { images?: Image[]; metadataType = MetadataType.GENERIC; @@ -101,12 +94,10 @@ export class GenericMediaMetadata { type = MetadataType.GENERIC; } - export class GetStatusRequest { customData: any = null; } - export class LiveSeekableRange { constructor( public start?: number @@ -115,7 +106,6 @@ export class LiveSeekableRange { , public isLiveDone?: boolean) {} } - export class LoadRequest { activeTrackIds: Nullable = null; atvCredentials?: string; @@ -180,7 +170,6 @@ export class MediaMetadata { } } - export class MovieMediaMetadata { images?: Image[]; metadataType = MetadataType.MOVIE; @@ -192,7 +181,6 @@ export class MovieMediaMetadata { type = MetadataType.MOVIE; } - export class MusicTrackMediaMetadata { albumArtist?: string; albumName?: string; @@ -210,12 +198,10 @@ export class MusicTrackMediaMetadata { type = MetadataType.MUSIC_TRACK; } - export class PauseRequest { customData: any = null; } - export class PhotoMediaMetadata { artist?: string; creationDateTime?: string; @@ -230,12 +216,10 @@ export class PhotoMediaMetadata { width?: number; } - export class PlayRequest { customData: any = null; } - export class QueueData { shuffle = false; @@ -249,7 +233,6 @@ export class QueueData { , public startTime?: number) {} } - export class QueueInsertItemsRequest { customData: any = null; insertBefore: Nullable = null; @@ -261,7 +244,6 @@ export class QueueInsertItemsRequest { public items: QueueItem[]) {} } - export class QueueItem { activeTrackIds: Nullable = null; autoplay = true; @@ -277,70 +259,47 @@ export class QueueItem { } } - export class QueueJumpRequest { + type = "QUEUE_UPDATE"; jump: Nullable = null; currentItemId: Nullable = null; - sessionId: Nullable = null; - requestId: Nullable = null; - - type = "QUEUE_UPDATE"; } - export class QueueLoadRequest { + type = "QUEUE_LOAD"; customData: any = null; repeatMode: string = RepeatMode.OFF; - requestId: Nullable = null; - sessionId: Nullable = null; startIndex = 0; - type = "QUEUE_LOAD"; - constructor( - public items: QueueItem[]) {} + constructor(public items: QueueItem[]) {} } - export class QueueRemoveItemsRequest { - customData: any = null; - requestId: Nullable = null; - sessionId: Nullable = null; type = "QUEUE_REMOVE"; + customData: any = null; - constructor( - public itemIds: number[]) {} + constructor(public itemIds: number[]) {} } - export class QueueReorderItemsRequest { customData: any = null; insertBefore: Nullable = null; - requestId: Nullable = null; - sessionId: Nullable = null; type = "QUEUE_REORDER"; - constructor( - public itemIds: number[]) {} + constructor(public itemIds: number[]) {} } - export class QueueSetPropertiesRequest { + type = "QUEUE_UPDATE"; customData: any = null; repeatMode: Nullable = null; - requestId: Nullable = null; - sessionId: Nullable = null; - type = "QUEUE_UPDATE"; } - export class QueueUpdateItemsRequest { - customData: any = null; - requestId: Nullable = null; - sessionId: Nullable = null; type = "QUEUE_UPDATE"; + customData: any = null; - constructor( - public items: QueueItem[]) {} + constructor(public items: QueueItem[]) {} } @@ -350,12 +309,10 @@ export class SeekRequest { resumeState: Nullable = null; } - export class StopRequest { customData: any = null; } - export class TextTrackStyle { backgroundColor: Nullable = null; customData: any = null; @@ -371,7 +328,6 @@ export class TextTrackStyle { windowType: Nullable = null; } - export class Track { customData: any = null; language: Nullable = null; @@ -385,7 +341,6 @@ export class Track { , public type: TrackType) {} } - export class TvShowMediaMetadata { episode?: number; episodeNumber?: number; @@ -401,7 +356,6 @@ export class TvShowMediaMetadata { type = MetadataType.TV_SHOW; } - export class UserActionState { customData: any = null; @@ -409,13 +363,11 @@ export class UserActionState { public userAction: UserAction) {} } - export class VastAdsRequest { adsResponse?: string; adTagUrl?: string; } - export class VideoInformation { constructor( public width: number @@ -423,7 +375,6 @@ export class VideoInformation { , public hdrType: HdrType) {} } - export class VolumeRequest { customData: any = null; diff --git a/ext/src/shim/cast/types.ts b/ext/src/shim/cast/types.ts index a3f283b..128fb95 100644 --- a/ext/src/shim/cast/types.ts +++ b/ext/src/shim/cast/types.ts @@ -5,7 +5,7 @@ * app/src/bridge/components/chromecast/types.ts */ -import { Volume } from "./dataClasses"; +import { SenderApplication, Volume, Image } from "./dataClasses"; import { MediaInfo, QueueItem } from "./media/dataClasses"; import { IdleReason , PlayerState @@ -19,6 +19,7 @@ export interface MediaStatus { playbackRate: number; playerState: PlayerState; idleReason?: IdleReason; + items?: QueueItem[]; currentTime: number; supportedMediaCommands: number; 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 { requestId: number; } @@ -61,13 +79,12 @@ export type SenderMessage = | ReqBase & { type: "SET_VOLUME", volume: Partial }; export type ReceiverMessage = - ReqBase & { - type: "RECEIVER_STATUS" - , status: ReceiverStatus - }; + ReqBase & { type: "RECEIVER_STATUS", status: ReceiverStatus } + | ReqBase & { type: "LAUNCH_ERROR", reason: string } interface MediaReqBase extends ReqBase { + mediaSessionId: number; customData?: unknown; } @@ -79,16 +96,15 @@ export type SenderMediaMessage = | MediaReqBase & { type: "STOP" } | MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Partial } | MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number } - | MediaReqBase & { + | ReqBase & { type: "LOAD" , activeTrackIds: Nullable , atvCredentials?: string , atvCredentialsType?: string , autoplay: Nullable , currentTime: Nullable - , customData: any + , customData?: unknown , media: MediaInfo - , requestId: number , sessionId: Nullable } | MediaReqBase & { @@ -127,7 +143,7 @@ export type SenderMediaMessage = type: "QUEUE_UPDATE" , jump: Nullable , currentItemId: Nullable - , sessionId: Nullable + , sessionId: Nullable } // QueueRemoveItemsRequest | MediaReqBase & { diff --git a/ext/src/shim/index.ts b/ext/src/shim/index.ts index 7fdcbbe..84ab377 100755 --- a/ext/src/shim/index.ts +++ b/ext/src/shim/index.ts @@ -14,9 +14,6 @@ if (!_window.chrome) { } -// Remove private APIs -delete (cast as any)._requestSession; - // Create page-accessible API object _window.chrome.cast = cast;