diff --git a/app/src/bridge/components/chromecast/Media.ts b/app/src/bridge/components/chromecast/Media.ts index 7ff3ffb..0df89c1 100644 --- a/app/src/bridge/components/chromecast/Media.ts +++ b/app/src/bridge/components/chromecast/Media.ts @@ -2,27 +2,16 @@ import castv2 from "castv2"; -import Session from "./Session"; +import { ReceiverMediaMessage } from "./types"; import { Message } from "../../messaging"; import { sendMessage } from "../../lib/nativeMessaging"; +import Session from "./Session"; + const NS_MEDIA = "urn:x-cast:com.google.cast.media"; -export interface UpdateMessageData { - _volumeLevel?: number; - _volumeMuted?: boolean; - _lastCurrentTime: number; - currentTime: number; - customData?: any; - playbackRate: number; - playerState: string; - repeatMode?: string; - media?: any; - mediaSessionId?: number; -} - export default class Media { private channel: castv2.Channel; @@ -31,40 +20,32 @@ export default class Media { private referenceId: string , private session: Session) { + // Ensure channel exists this.session.createChannel(NS_MEDIA); - this.channel = this.session.channelMap.get(NS_MEDIA)!; - this.channel.on("message", (data: any) => { - if (data && data.type === "MEDIA_STATUS" - && data.status && data.status.length > 0) { + const channel = this.session.channelMap.get(NS_MEDIA); + if (!channel) { + throw new Error("Media message cannel not found"); + } - const status = data.status[0]; + this.channel = channel; + this.channel.on("message", this.onMediaMessage); + } - const messageData: UpdateMessageData = { - _lastCurrentTime: Date.now() / 1000 + private onMediaMessage = (message: ReceiverMediaMessage) => { + switch (message.type) { + case "MEDIA_STATUS": { + // TODO: Fix for multiple media statuses + const status = message.status[0]; - , currentTime: status.currentTime - , customData: status.customData - , playbackRate: status.playbackRate - , playerState: status.playerState - , repeatMode: status.repeatMode - }; + this.sendMessage({ + subject: "shim:media/updateStatus" + , data: { status } + }); - if (status.volume) { - messageData._volumeLevel = status.volume.level; - messageData._volumeMuted = status.volume.muted; - } - - if (status.media) { - messageData.media = status.media; - } - if (status.mediaSessionId) { - messageData.mediaSessionId = status.mediaSessionId; - } - - this.sendMessage("shim:media/update", messageData); + break; } - }); + } } public messageHandler(message: Message) { @@ -77,9 +58,12 @@ export default class Media { error = true; } - this.sendMessage("shim:media/sendMediaMessageResponse", { - messageId: message.data.messageId - , error + this.sendMessage({ + subject: "shim:media/sendMediaMessageResponse" + , data: { + messageId: message.data.messageId + , error + } }); break; @@ -87,11 +71,8 @@ export default class Media { } } - private sendMessage(subject: string, data: any) { - data._id = this.referenceId; - (sendMessage as any)({ - subject - , data - }); + private sendMessage(message: Message) { + (message.data as any)._id = this.referenceId; + sendMessage(message); } } diff --git a/app/src/bridge/components/chromecast/Session.ts b/app/src/bridge/components/chromecast/Session.ts index dc47466..8c732d8 100644 --- a/app/src/bridge/components/chromecast/Session.ts +++ b/app/src/bridge/components/chromecast/Session.ts @@ -5,32 +5,39 @@ import { Channel, Client } from "castv2"; import { Message } from "../../messaging"; import { sendMessage } from "../../lib/nativeMessaging"; +import { ReceiverApplication, ReceiverMessage, SenderMessage } from "./types"; + export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection"; export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat"; export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver"; +const HEARTBEAT_INTERVAL = 5000; + + export default class Session { - public channelMap = new Map(); + private isSessionCreated = false; private client: Client; - private clientConnection?: Channel; - private clientHeartbeat?: Channel; - private clientReceiver?: Channel; - private clientHeartbeatIntervalId?: NodeJS.Timeout; - - private isSessionCreated = false; - - private clientId?: string; + private clientId = `client-${Math.floor(Math.random() * 10e5)}`; private transportId?: string; + + public channelMap = new Map(); + + private platformConnection?: Channel; + private platformHeartbeat?: Channel; + private platformReceiver?: Channel; + private platformHeartbeatIntervalId?: NodeJS.Timeout; + private transportConnection?: Channel; - private app: any; + private transportHeartbeat?: Channel; + + private app?: ReceiverApplication; constructor( public host: string , public port: number , private appId: string - , private sessionId: string , private referenceId: string) { const client = new Client(); @@ -38,135 +45,18 @@ export default class Session { client.on("error", err => { console.error(`castv2 error: ${err}`); }); + client.on("close", () => { // TODO: Don't send new data + if (this.platformHeartbeatIntervalId) { + clearInterval(this.platformHeartbeatIntervalId); + } }); client.connect({ host, port }, this.onConnect.bind(this)); this.client = client; } - private onConnect() { - let transportHeartbeat: Channel; - - const sourceId = "sender-0"; - const destinationId = "receiver-0"; - - this.clientConnection = this.client.createChannel( - sourceId, destinationId, NS_CONNECTION, "JSON"); - this.clientHeartbeat = this.client.createChannel( - sourceId, destinationId, NS_HEARTBEAT, "JSON"); - this.clientReceiver = this.client.createChannel( - sourceId, destinationId, NS_RECEIVER, "JSON"); - - this.clientConnection.send({ type: "CONNECT" }); - this.clientHeartbeat.send({ type: "PING" }); - - this.clientHeartbeatIntervalId = setInterval(() => { - if (transportHeartbeat) { - transportHeartbeat.send({ type: "PING" }); - } - - this.clientHeartbeat?.send({ type: "PING" }); - }, 5000); - - this.clientReceiver.send({ - type: "LAUNCH" - , appId: this.appId - , requestId: 1 - }); - - this.clientReceiver.on("message", (message: any) => { - if (message.type === "RECEIVER_STATUS") { - this.sendMessage("shim:session/updateStatus", message.status); - - if (message.status.applications) { - const receiverApp = message.status.applications[0]; - const receiverAppId = receiverApp.appId; - - this.app = receiverApp; - - if (receiverAppId !== this.appId) { - // Close session - this.sendMessage("shim:session/stopped"); - this.client.close(); - clearInterval(this.clientHeartbeatIntervalId!); - return; - } - - if (!this.isSessionCreated) { - this.isSessionCreated = true; - - this.transportId = this.app.transportId; - this.clientId = - `client-${Math.floor(Math.random() * 10e5)}`; - - this.transportConnection = this.client.createChannel( - this.clientId, this.transportId! - , NS_CONNECTION, "JSON"); - transportHeartbeat = this.client.createChannel( - this.clientId, this.transportId! - , NS_HEARTBEAT, "JSON"); - - this.transportConnection.send({ type: "CONNECT" }); - - this.sessionId = this.app.sessionId; - - this.sendMessage("shim:session/connected", { - sessionId: this.app.sessionId - , namespaces: this.app.namespaces - , displayName: this.app.displayName - , statusText: this.app.displayName - }); - } - } - } - }); - } - - public messageHandler(message: Message) { - switch (message.subject) { - case "bridge:session/close": - this.close(); - break; - - case "bridge:session/sendReceiverMessage": { - let wasError = false; - try { - this.clientReceiver?.send(message.data.message); - } catch (err) { - wasError = true; - } - - if (message.data.message.type === "STOP") { - if (this.clientHeartbeatIntervalId) { - clearInterval(this.clientHeartbeatIntervalId); - } - - this.client.close(); - } - - this.sendMessage("shim:session/sendReceiverMessageResponse", { - messageId: message.data.messageId - , wasError - }); - - break; - } - - case "bridge:session/impl_addMessageListener": - this._impl_addMessageListener(message.data.namespace); - break; - - case "bridge:session/impl_sendMessage": - this._impl_sendMessage( - message.data.namespace - , message.data.message - , message.data.messageId); - break; - } - } - public createChannel(namespace: string) { if (!this.channelMap.has(namespace)) { this.channelMap.set(namespace, this.client.createChannel( @@ -175,40 +65,152 @@ export default class Session { } } + private establishSession(app: ReceiverApplication) { + this.transportId = app.transportId; + + // Mesage channel to app + this.transportConnection = this.client.createChannel( + this.clientId, this.transportId, NS_CONNECTION, "JSON"); + this.transportHeartbeat = this.client.createChannel( + this.clientId, this.transportId, NS_HEARTBEAT, "JSON"); + + this.transportConnection.send({ + type: "CONNECT" + }); + } + + private onConnect() { + const sourceId = "sender-0"; + const destinationId = "receiver-0"; + + this.platformConnection = this.client.createChannel( + sourceId, destinationId, NS_CONNECTION, "JSON"); + this.platformHeartbeat = this.client.createChannel( + sourceId, destinationId, NS_HEARTBEAT, "JSON"); + this.platformReceiver = this.client.createChannel( + sourceId, destinationId, NS_RECEIVER, "JSON"); + + this.platformConnection.send({ type: "CONNECT" }); + this.platformHeartbeat.send({ type: "PING" }); + + this.platformHeartbeatIntervalId = setInterval(() => { + this.platformHeartbeat?.send({ type: "PING" }); + + if (this.transportHeartbeat) { + this.transportHeartbeat.send({ type: "PING" }); + } + }, HEARTBEAT_INTERVAL); + + this.platformReceiver.send({ + type: "LAUNCH" + , appId: this.appId + , requestId: 0 + }); + + this.platformReceiver.on("message", (message: ReceiverMessage) => { + switch (message.type) { + case "RECEIVER_STATUS": { + const { status } = message; + + if (status.applications) { + // TODO: Fix for multiple applications? + const app = status.applications[0]; + + if (app.appId !== this.appId) { + this.sendMessage({ + subject: "shim:session/stopped" + }); + + this.client.close(); + return; + } + + if (!this.isSessionCreated) { + this.isSessionCreated = true; + this.establishSession(app); + } + } + + this.sendMessage({ + subject: "shim:session/updateStatus" + , data: { status: message.status } + }); + + break; + } + + default: { + console.error(message); + } + } + }); + } + + public messageHandler(message: Message) { + switch (message.subject) { + case "bridge:session/close": { + this.close(); + break; + } + + case "bridge:session/impl_addMessageListener": { + this._impl_addMessageListener(message.data.namespace); + break; + } + + case "bridge:session/impl_sendMessage": { + this._impl_sendMessage( + message.data.namespace + , message.data.message + , message.data.messageId); + break; + } + case "bridge:session/impl_sendReceiverMessage": { + const { message: receiverMessage + , messageId: receiverMessageId } = message.data; + + this.impl_sendReceiverMessage( + receiverMessage, receiverMessageId); + + break; + } + } + } + public close() { - this.clientConnection?.send({ type: "CLOSE" }); + this.platformConnection?.send({ type: "CLOSE" }); this.transportConnection?.send({ type: "CLOSE" }); } public stop() { - this.clientConnection?.send({ type: "STOP" }); + this.platformConnection?.send({ type: "STOP" }); } - private sendMessage(subject: string, data: any = {}) { - data._id = this.referenceId; - sendMessage({ - // @ts-ignore - subject - , data - }); + private sendMessage(message: Message) { + (message.data as any)._id = this.referenceId; + sendMessage(message); } private _impl_addMessageListener(namespace: string) { + // TODO: Limit to one listener per namespace this.createChannel(namespace); - this.channelMap.get(namespace)?.on("message", (data: any) => { - this.sendMessage("shim:session/impl_addMessageListener", { - namespace - , data: JSON.stringify(data) + this.channelMap.get(namespace)?.on("message", (message: any) => { + this.sendMessage({ + subject: "shim:session/impl_addMessageListener" + , data: { + namespace + , message: JSON.stringify(message) + } }); }); } private _impl_sendMessage( namespace: string - , message: {} | string + , message: object | string , messageId: string) { - let error = false; + let wasError = false; try { // Decode string messages @@ -219,12 +221,34 @@ export default class Session { this.createChannel(namespace); this.channelMap.get(namespace)?.send(message); } catch (err) { - error = true; + wasError = true; } - this.sendMessage("shim:session/impl_sendMessage", { - messageId - , error + this.sendMessage({ + subject: "shim:session/impl_sendMessage" + , data: { messageId, wasError } + }); + } + + private impl_sendReceiverMessage( + message: SenderMessage + , messageId: string) { + + let wasError = false; + try { + this.platformReceiver?.send(message); + } catch (err) { + wasError = true; + } + + // Handle stop message + if (message.type === "STOP") { + this.client.close(); + } + + this.sendMessage({ + subject: "shim:session/impl_sendReceiverMessage" + , data: { messageId, wasError } }); } diff --git a/app/src/bridge/components/chromecast/index.ts b/app/src/bridge/components/chromecast/index.ts index c7d5ff6..0d05f66 100644 --- a/app/src/bridge/components/chromecast/index.ts +++ b/app/src/bridge/components/chromecast/index.ts @@ -28,7 +28,6 @@ export function handleSessionMessage(message: any) { message.data.address , message.data.port , message.data.appId - , message.data.sessionId , sessionId)); } } diff --git a/app/src/bridge/components/chromecast/types.ts b/app/src/bridge/components/chromecast/types.ts new file mode 100644 index 0000000..bc2450d --- /dev/null +++ b/app/src/bridge/components/chromecast/types.ts @@ -0,0 +1,396 @@ +"use strict"; + +export interface Image { + url: string; + height?: number; + width?: number; +} + +enum VolumeControlType { + ATTENUATION = "attenuation" + , FIXED = "fixed" + , MASTER = "master" +} + +export interface Volume { + controlType?: VolumeControlType; + stepInterval?: number; + level: Nullable; + muted: Nullable; +} + +// Media + +enum IdleReason { + CANCELLED = "CANCELLED" + , INTERRUPTED = "INTERRUPTED" + , FINISHED = "FINISHED" + , ERROR = "ERROR" +} + +enum HlsSegmentFormat { + AAC = "aac" + , AC3 = "ac3" + , MP3 = "mp3" + , TS = "ts" + , TS_AAC = "ts_aac" + , E_AC3 = "e_ac3" + , FMP4 = "fmp4" +} + +export enum HlsVideoSegmentFormat { + MPEG2_TS = "mpeg2_ts" + , FMP4 = "fmp4" +} + +enum MetadataType { + GENERIC + , MOVIE + , TV_SHOW + , MUSIC_TRACK + , PHOTO + , AUDIOBOOK_CHAPTER +} + +enum PlayerState { + IDLE = "IDLE" + , PLAYING = "PLAYING" + , PAUSED = "PAUSED" + , BUFFERING = "BUFFERING" +} + +enum RepeatMode { + OFF = "REPEAT_OFF" + , ALL = "REPEAT_ALL" + , SINGLE = "REPEAT_SINGLE" + , ALL_AND_SHUFFLE = "REPEAT_ALL_AND_SHUFFLE" +} + +enum ResumeState { + PLAYBACK_START = "PLAYBACK_START" + , PLAYBACK_PAUSE = "PLAYBACK_PAUSE" +} + +enum StreamType { + BUFFERED = "BUFFERED" + , LIVE = "LIVE" + , OTHER = "OTHER" +} + +enum TrackType { + TEXT = "TEXT" + , AUDIO = "AUDIO" + , VIDEO = "VIDEO" +} + +export enum UserAction { + LIKE = "LIKE" + , DISLIKE = "DISLIKE" + , FOLLOW = "FOLLOW" + , UNFOLLOW = "UNFOLLOW" +} + + +interface Break { + breakClipIds: string[]; + duration?: number; + id: string; + isEmbedded?: boolean; + isWatched: boolean; + position: number; +} + +interface BreakClip { + clickThroughUrl?: string; + contentId?: string; + contentType?: string; + contentUrl?: string; + customData?: {}; + duration?: number; + id: string; + hlsSegmentFormat?: HlsSegmentFormat; + posterUrl?: string; + title?: string; + vastAdsRequest?: VastAdsRequest; + whenSkippable?: number; +} + +interface TextTrackStyle { + backgroundColor: Nullable; + customData: any; + edgeColor: Nullable; + edgeType: Nullable; + fontFamily: Nullable; + fontGenericFamily: Nullable; + fontScale: Nullable; + fontStyle: Nullable; + foregroundColor: Nullable; + windowColor: Nullable; + windowRoundedCornerRadius: Nullable; + windowType: Nullable; +} + +interface Track { + customData: any; + language: Nullable; + name: Nullable; + subtype: Nullable; + trackContentId: Nullable; + trackContentType: Nullable; + trackId: string; + type: TrackType; +} + +interface UserActionState { + customData: any; + userAction: UserAction; +} + +interface VastAdsRequest { + adsResponse?: string; + adTagUrl?: string; +} + +type Metadata = + GenericMediaMetadata + | MovieMediaMetadata + | MusicTrackMediaMetadata + | PhotoMediaMetadata + | TvShowMediaMetadata; + +interface MediaInformation { + atvEntity?: string; + breakClips?: BreakClip[]; + breaks?: Break[]; + contentId: string; + contentType: string; + contentUrl?: string; + customData: any; + duration: Nullable; + entity?: string; + hlsSegmentFormat?: HlsSegmentFormat; + hlsVideoSegmentFormat?: HlsVideoSegmentFormat; + metadata: Nullable; + startAbsoluteTime?: number; + streamType: StreamType; + textTrackStyle: Nullable; + tracks: Nullable; + userActionStates?: UserActionState[]; + vmapAdsRequest?: VastAdsRequest; +} + +interface GenericMediaMetadata { + images?: Image[]; + metadataType: number; + releaseDate?: string; + releaseYear?: number; + subtitle?: string; + title?: string; + type: MetadataType.GENERIC; +} + +interface MovieMediaMetadata { + images?: Image[]; + metadataType: number; + releaseDate?: string; + releaseYear?: number; + studio?: string; + subtitle?: string; + title?: string; + type: MetadataType.MOVIE; +} + +interface TvShowMediaMetadata { + episode?: number; + episodeNumber?: number; + episodeTitle?: string; + images?: Image[]; + metadataType: number; + originalAirdate?: string; + releaseYear?: number; + season?: number; + seasonNumber?: number; + seriesTitle?: string; + title?: string; + type: MetadataType.TV_SHOW; +} + +interface MusicTrackMediaMetadata { + albumArtist?: string; + albumName?: string; + artist?: string; + artistName?: string; + composer?: string; + discNumber?: number; + images?: Image[]; + metadataType: number; + releaseDate?: string; + releaseYear?: number; + songName?: string; + title?: string; + trackNumber?: number; + type: MetadataType.MUSIC_TRACK; +} + +interface PhotoMediaMetadata { + artist?: string; + creationDateTime?: string; + height?: number; + images?: Image[]; + latitude?: number; + location?: string; + longitude?: number; + metadataType: number; + title?: string; + type: MetadataType.PHOTO; + width?: number; +} + +interface QueueItem { + activeTrackIds: Nullable; + autoplay: boolean; + customData: any; + itemId: Nullable; + media: MediaInformation; + playbackDuration: Nullable; + preloadTime: number; + startTime: number; +} + + +export interface MediaStatus { + mediaSessionId: number; + media?: MediaInformation; + playbackRate: number; + playerState: PlayerState; + idleReason?: IdleReason; + currentTime: number; + supportedMediaCommands: number; + repeatMode: RepeatMode; + volume: Volume + customData: unknown; +} + +export interface ReceiverApplication { + appId: string + , appType: string + , displayName: string + , iconUrl: string + , isIdleScreen: boolean + , launchedFromCloud: boolean + , namespaces: Array<{ name: string }> + , sessionId: string + , statusText: string + , transportId: string + , universalAppId: string +} + +export interface ReceiverStatus { + applications?: ReceiverApplication[] + , isActiveInput?: boolean + , isStandBy?: boolean + , volume: Volume +} + + +interface ReqBase { + requestId: number; +} + +// NS: urn:x-cast:com.google.cast.receiver +export type SenderMessage = + ReqBase & { type: "LAUNCH", appId: string } + | ReqBase & { type: "STOP", sessionId: string } + | ReqBase & { type: "GET_STATUS" } + | ReqBase & { type: "GET_APP_AVAILABILITY", appId: string[] } + | ReqBase & { type: "SET_VOLUME", volume: Volume }; + +export type ReceiverMessage = + ReqBase & { + type: "RECEIVER_STATUS" + , status: ReceiverStatus + }; + + +interface MediaReqBase extends ReqBase { + customData?: unknown; +} + +// NS: urn:x-cast:com.google.cast.media +export type SenderMediaMessage = + | MediaReqBase & { type: "PLAY" } + | MediaReqBase & { type: "PAUSE" } + | MediaReqBase & { type: "MEDIA_GET_STATUS" } + | MediaReqBase & { type: "STOP" } + | MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Volume } + | MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number } + | MediaReqBase & { + type: "LOAD" + , media: MediaInformation + , autoplay: Nullable + , currentTime: Nullable + } + | MediaReqBase & { + type: "SEEK" + , resumeState: Nullable + , currentTime: Nullable + } + | MediaReqBase & { + type: "EDIT_TRACKS_INFO" + , activeTrackIds: Nullable + , textTrackStyle: Nullable + } + // QueueLoadRequest + | MediaReqBase & { + type: "QUEUE_LOAD" + , items: QueueItem[] + , startIndex: number + , repeatMode: string + , sessionId: Nullable + } + // QueueInsertItemsRequest + | MediaReqBase & { + type: "QUEUE_INSERT" + , items: QueueItem[] + , insertBefore: Nullable + , sessionId: Nullable + } + // QueueUpdateItemsRequest + | MediaReqBase & { + type: "QUEUE_UPDATE" + , items: QueueItem[] + , sessionId: Nullable + } + // QueueJumpRequest + | MediaReqBase & { + type: "QUEUE_UPDATE" + , jump: Nullable + , currentItemId: Nullable + , sessionId: Nullable + } + // QueueRemoveItemsRequest + | MediaReqBase & { + type: "QUEUE_REMOVE" + , itemIds: number[] + , sessionId: Nullable + } + // QueueReorderItemsRequest + | MediaReqBase & { + type: "QUEUE_REORDER" + , itemIds: number[] + , insertBefore: Nullable + , sessionId: Nullable + } + // QueueSetPropertiesRequest + | MediaReqBase & { + type: "QUEUE_UPDATE" + , repeatMode: Nullable + , sessionId: Nullable + }; + +export type ReceiverMediaMessage = + MediaReqBase & { type: "MEDIA_STATUS", status: MediaStatus[] } + | MediaReqBase & { type: "INVALID_PLAYER_STATE" } + | MediaReqBase & { type: "LOAD_FAILED" } + | MediaReqBase & { type: "LOAD_CANCELLED" } + | MediaReqBase & { type: "INVALID_REQUEST" }; diff --git a/app/src/bridge/components/discovery.ts b/app/src/bridge/components/discovery.ts index 812059a..852730b 100644 --- a/app/src/bridge/components/discovery.ts +++ b/app/src/bridge/components/discovery.ts @@ -3,13 +3,12 @@ import { EventEmitter } from "events"; import { Channel, Client } from "castv2"; + import mdns from "mdns"; import { sendMessage } from "../lib/nativeMessaging"; -import { ReceiverStatus } from "../types"; -import { Message } from "../messaging"; - +import { ReceiverStatus } from "./chromecast/types"; import { NS_CONNECTION , NS_HEARTBEAT , NS_RECEIVER } from "./chromecast/Session"; diff --git a/app/src/bridge/messaging.ts b/app/src/bridge/messaging.ts index 36081ad..4f6dd7e 100644 --- a/app/src/bridge/messaging.ts +++ b/app/src/bridge/messaging.ts @@ -1,34 +1,29 @@ "use strict"; +import { MediaStatus, ReceiverStatus, ReceiverApplication, SenderMessage } + from "./components/chromecast/types"; + import { ReceiverDevice - , ReceiverMessage , ReceiverSelectionCast - , ReceiverSelectionStop - , ReceiverStatus - , Volume } from "./types"; + , ReceiverSelectionStop } from "./types"; type MessageDefinitions = { // Session messages - "shim:session/stopped": {} - , "shim:session/connected": { - sessionId: string - , namespaces: Array<{ name: string }> - , displayName: string - , statusText: string - } - , "shim:session/updateStatus": { volume: Volume } - , "shim:session/sendReceiverMessageResponse": { - messageId: string - , wasError: boolean - } + "shim:session/connected": { application: ReceiverApplication } + , "shim:session/updateStatus": { status: ReceiverStatus } + , "shim:session/stopped": {} , "shim:session/impl_addMessageListener": { namespace: string - , data: string + , message: string } , "shim:session/impl_sendMessage": { messageId: string - , error: boolean + , wasError: boolean + } + , "shim:session/impl_sendReceiverMessage": { + messageId: string + , wasError: boolean } // Bridge session messages @@ -40,11 +35,6 @@ type MessageDefinitions = { , _id: string } , "bridge:session/close": {} - , "bridge:session/sendReceiverMessage": { - message: ReceiverMessage - , messageId: string - , _id: string - } , "bridge:session/impl_leave": { id: string , _id: string @@ -55,23 +45,19 @@ type MessageDefinitions = { , 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/update": { - currentTime: number - , _lastCurrentTime: number - , customData: any - , playbackRate: number - , playerState: string - , repeatMode: string - , _volumeLevel: number - , _volumeMuted: boolean - , media: unknown // MediaInfo - , mediaSessionId: number + , "shim:media/updateStatus": { + status: MediaStatus } , "shim:media/sendMediaMessageResponse": { messageId: string diff --git a/app/src/bridge/types.ts b/app/src/bridge/types.ts index 253a5ff..39227ec 100644 --- a/app/src/bridge/types.ts +++ b/app/src/bridge/types.ts @@ -1,60 +1,8 @@ "use strict"; -export interface ReceiverStatus { - applications?: Array<{ - appId: string - , appType: string - , displayName: string - , iconUrl: string - , isIdleScreen: boolean - , launchedFromCloud: boolean - , namespaces: Array<{ name: string }> - , sessionId: string - , statusText: string - , transportId: string - , universalAppId: string - }> - , isActiveInput?: boolean - , isStandBy?: boolean - , userEq: unknown - , volume: Volume -} +import { ReceiverStatus } from "./components/chromecast/types"; -export interface MediaStatus { - mediaSessionId: number; - supportedMediaCommands: number; - currentTime: number; - media: { - duration: number; - contentId: string; - streamType: string; - contentType: string; - }; - playbackRate: number; - volume: { - muted: boolean; - level: number; - }; - currentItemId: number; - idleReason: string; - playerState: string; - extendedStatus: { - playerState: string; - media: { - contentId: string; - streamType: string; - contentType: string; - metadata: { - images: Array<{ url: string }>; - metadataType: number; - artist: string; - title: string; - }; - } - }; -} - export enum ReceiverSelectorMediaType { App = 1 , Tab = 2 @@ -86,32 +34,3 @@ export interface ReceiverDevice { port: number; status?: ReceiverStatus; } - - -export enum VolumeControlType { - ATTENUATION = "attenuation" - , FIXED = "fixed" - , MASTER = "master" -} - - -export class Volume { - public controlType?: VolumeControlType; - public stepInterval?: number; - - constructor( - public level: (number | null) = null - , public muted: (boolean | null) = null) {} -} - - -export type ReceiverMessage = - { type: "LAUNCH", appId: string } - | { type: "STOP", sessionId: string } - | { type: "GET_STATUS" } - | { type: "GET_APP_AVAILABILITY", appId: string[] } - | { - type: "SET_VOLUME" - , volume: { level: number } - | { muted: boolean } - }; diff --git a/app/src/global.d.ts b/app/src/global.d.ts new file mode 100644 index 0000000..91824b3 --- /dev/null +++ b/app/src/global.d.ts @@ -0,0 +1 @@ +declare type Nullable = T | null; diff --git a/ext/src/background/receiverDevices.ts b/ext/src/background/receiverDevices.ts index 0d312cd..781587b 100644 --- a/ext/src/background/receiverDevices.ts +++ b/ext/src/background/receiverDevices.ts @@ -4,8 +4,9 @@ import bridge from "../lib/bridge"; import logger from "../lib/logger"; import { TypedEventTarget } from "../lib/TypedEventTarget"; -import messaging, { Message, Port } from "../messaging"; -import { ReceiverDevice, ReceiverStatus } from "../types"; +import { Message, Port } from "../messaging"; +import { ReceiverDevice } from "../types"; +import { ReceiverStatus } from "../shim/cast/types"; interface EventMap { @@ -124,7 +125,6 @@ export default new class extends TypedEventTarget { if (receiverDevice.status) { receiverDevice.status.isActiveInput = status.isActiveInput; receiverDevice.status.isStandBy = status.isStandBy; - receiverDevice.status.userEq = status.userEq; receiverDevice.status.volume = status.volume; if (status.applications) { diff --git a/ext/src/messaging.ts b/ext/src/messaging.ts index cd54876..dda3875 100644 --- a/ext/src/messaging.ts +++ b/ext/src/messaging.ts @@ -5,9 +5,7 @@ import Messenger from "./lib/Messenger"; import { TypedPort } from "./lib/TypedPort"; import { BridgeInfo } from "./lib/bridge"; -import { ReceiverDevice - , SessionReceiverMessage - , ReceiverStatus } from "./types"; +import { ReceiverDevice } from "./types"; import { ReceiverSelectorMediaType } from "./background/receiverSelector"; import { ReceiverSelection @@ -16,7 +14,10 @@ import { ReceiverSelection from "./background/receiverSelector/ReceiverSelector"; import { Volume } from "./shim/cast/dataClasses"; -import { MediaInfo } from "./shim/cast/media"; +import { MediaStatus + , SenderMessage + , ReceiverApplication + , ReceiverStatus } from "./shim/cast/types"; /** @@ -73,24 +74,19 @@ type ExtMessageDefinitions = { type AppMessageDefinitions = { // Session messages "shim:session/stopped": {} - , "shim:session/connected": { - sessionId: string - , namespaces: Array<{ name: string }> - , displayName: string - , statusText: string + , "shim:session/connected": { application: ReceiverApplication } + , "shim:session/updateStatus": { status: ReceiverStatus } + , "shim:session/impl_addMessageListener": { + namespace: string + , message: string } - , "shim:session/updateStatus": { volume: Volume } - , "shim:session/sendReceiverMessageResponse": { + , "shim:session/impl_sendMessage": { messageId: string , wasError: boolean } - , "shim:session/impl_addMessageListener": { - namespace: string - , data: string - } - , "shim:session/impl_sendMessage": { + , "shim:session/impl_sendReceiverMessage": { messageId: string - , error: boolean + , wasError: boolean } // Bridge session messages @@ -102,11 +98,6 @@ type AppMessageDefinitions = { , _id: string } , "bridge:session/close": {} - , "bridge:session/sendReceiverMessage": { - message: SessionReceiverMessage - , messageId: string - , _id: string - } , "bridge:session/impl_leave": { id: string , _id: string @@ -117,23 +108,19 @@ type AppMessageDefinitions = { , 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/update": { - currentTime: number - , _lastCurrentTime: number - , customData: any - , playbackRate: number - , playerState: string - , repeatMode: string - , _volumeLevel: number - , _volumeMuted: boolean - , media: MediaInfo - , mediaSessionId: number + , "shim:media/updateStatus": { + status: MediaStatus } , "shim:media/sendMediaMessageResponse": { messageId: string diff --git a/ext/src/shim/cast/Session.ts b/ext/src/shim/cast/Session.ts index c40e671..d4153ff 100644 --- a/ext/src/shim/cast/Session.ts +++ b/ext/src/shim/cast/Session.ts @@ -3,19 +3,19 @@ import { v4 as uuid } from "uuid"; import logger from "../../lib/logger"; -import { SessionReceiverMessage } from "../../types"; import { onMessage , sendMessageResponse } from "../eventMessageChannel"; -import { Callbacks - , ErrorCallback +import { ErrorCallback , LoadSuccessCallback , MediaListener , MessageListener , SuccessCallback , UpdateListener } from "../types"; +import { SenderMediaMessage, SenderMessage } from "./types"; + import { Error as _Error , Image, Receiver , SenderApplication, Volume } from "./dataClasses"; @@ -23,23 +23,29 @@ import { ErrorCode, SessionStatus } from "./enums"; import { Media , LoadRequest - , QueueLoadRequest - // Enums - , RepeatMode } from "./media"; + , QueueLoadRequest } from "./media"; +type SenderMessageData = + T extends any + ? Omit + : never; + type SessionSuccessCallback = (session: Session) => void; export default class Session { #id = uuid(); + #isConnected = false; #successCallback?: SessionSuccessCallback; #messageListeners = new Map>(); #updateListeners = new Set(); - #sendMessageCallbacks = new Map(); - #sendReceiverMessageCallbacks = new Map(); + #sendMessageCallbacks = + new Map(); + #sendReceiverMessageCallbacks = + new Map void>(); #listener = onMessage(message => { // Filter other session messages @@ -61,38 +67,31 @@ export default class Session { break; } - case "shim:session/connected": { - this.status = SessionStatus.CONNECTED; - this.sessionId = message.data.sessionId; - this.transportId = message.data.sessionId; - this.namespaces = message.data.namespaces; - this.displayName = message.data.displayName; - this.statusText = message.data.statusText; - - if (this.#successCallback) { - this.#successCallback(this); - } - - break; - } - case "shim:session/updateStatus": { - const status = message.data; + const { status } = message.data; - if (status.volume) { - if (!this.receiver.volume) { - const receiverVolume = new Volume( - status.volume.level, status.volume.muted); + // First status message indicates session creation + if (!this.#isConnected && status.applications) { + this.#isConnected = true; - receiverVolume.controlType = status.volume.controlType; - receiverVolume.stepInterval = - status.volume.stepInterval; - } else { - this.receiver.volume.level = status.volume.level; - this.receiver.volume.muted = status.volume.muted; + 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); } @@ -100,25 +99,14 @@ export default class Session { break; } - case "shim:session/sendReceiverMessageResponse": { - const { messageId, wasError } = message.data; - const callback = - this.#sendReceiverMessageCallbacks.get(messageId); - if (callback) { - callback(wasError); - } - - break; - } - case "shim:session/impl_addMessageListener": { - const { namespace, data } = message.data; + const { namespace, message: newMessage } = message.data; const messageListeners = this.#messageListeners.get(namespace); if (messageListeners) { for (const listener of messageListeners) { - listener(namespace, data); + listener(namespace, newMessage); } } @@ -126,11 +114,11 @@ export default class Session { } case "shim:session/impl_sendMessage": { - const { messageId, error } = message.data; + const { messageId, wasError } = message.data; const [ successCallback, errorCallback ] = this.#sendMessageCallbacks.get(messageId) ?? []; - if (error && errorCallback) { + if (wasError && errorCallback) { errorCallback(new _Error(ErrorCode.SESSION_ERROR)); } else if (successCallback) { successCallback(); @@ -140,31 +128,69 @@ export default class Session { break; } - } - }) - media: Media[]; - namespaces: Array<{ name: string }>; - senderApps: SenderApplication[]; - status: SessionStatus; - statusText: Nullable; + 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 + } + }); + + return new Promise((resolve, reject) => { + this.#sendReceiverMessageCallbacks.set(messageId + , (wasError: boolean) => { + + if (wasError) { + reject(new _Error(ErrorCode.SESSION_ERROR)); + return; + } + + resolve(); + }); + }); + } + + private _sendMediaMessage(message: SenderMediaMessage) { + this.sendMessage("urn:x-cast:com.google.cast.media", message); + } + + media: Media[] = []; + namespaces: Array<{ name: string }> = []; + senderApps: SenderApplication[] = []; + status = SessionStatus.CONNECTED; + statusText: Nullable = null; transportId: string; - constructor( - public sessionId: string - , public appId: string - , public displayName: string - , public appImages: Image[] - , public receiver: Receiver - , _successCallback: SessionSuccessCallback) { + constructor(public sessionId: string + , public appId: string + , public displayName: string + , public appImages: Image[] + , public receiver: Receiver + , _successCallback: SessionSuccessCallback) { this.#successCallback = _successCallback; - - this.media = []; - this.namespaces = []; - this.senderApps = []; - this.status = SessionStatus.CONNECTED; - this.statusText = null; this.transportId = sessionId || ""; if (receiver) { @@ -186,9 +212,8 @@ export default class Session { logger.info("STUB :: Session#addMediaListener"); } - addMessageListener( - namespace: string - , listener: MessageListener) { + addMessageListener(namespace: string + , listener: MessageListener) { if (!this.#messageListeners.has(namespace)) { this.#messageListeners.set(namespace, new Set()); @@ -209,28 +234,17 @@ export default class Session { this.#updateListeners.add(listener); } - leave( - _successCallback?: SuccessCallback - , _errorCallback?: ErrorCallback): void { + leave(_successCallback?: SuccessCallback + , _errorCallback?: ErrorCallback): void { logger.info("STUB :: Session#leave"); } - loadMedia( - loadRequest: LoadRequest - , successCallback?: LoadSuccessCallback - , errorCallback?: ErrorCallback): void { + loadMedia(loadRequest: LoadRequest + , successCallback?: LoadSuccessCallback + , errorCallback?: ErrorCallback): void { - this._sendMediaMessage({ - type: "LOAD" - , requestId: 0 - , media: loadRequest.media - , activeTrackIds: loadRequest.activeTrackIds || [] - , autoplay: loadRequest.autoplay || false - , currentTime: loadRequest.currentTime || 0 - , customData: loadRequest.customData || {} - , repeatMode: RepeatMode.OFF - }); + this._sendMediaMessage(loadRequest); let hasResponded = false; @@ -274,10 +288,9 @@ export default class Session { }); } - queueLoad( - _queueLoadRequest: QueueLoadRequest - , _successCallback?: LoadSuccessCallback - , _errorCallback?: ErrorCallback): void { + queueLoad(_queueLoadRequest: QueueLoadRequest + , _successCallback?: LoadSuccessCallback + , _errorCallback?: ErrorCallback): void { logger.info("STUB :: Session#queueLoad"); } @@ -285,26 +298,17 @@ export default class Session { removeMediaListener(_mediaListener: MediaListener): void { logger.info("STUB :: Session#removeMediaListener"); } - - removeMessageListener( - namespace: string - , listener: MessageListener): void { - + removeMessageListener(namespace: string, listener: MessageListener): void { this.#messageListeners.get(namespace)?.delete(listener); } - - removeUpdateListener( - _namespace: string - , listener: UpdateListener): void { - + removeUpdateListener(_namespace: string, listener: UpdateListener): void { this.#updateListeners.delete(listener); } - sendMessage( - namespace: string - , message: {} | string - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback): void { + sendMessage(namespace: string + , message: {} | string + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback): void { const messageId = uuid(); @@ -324,10 +328,9 @@ export default class Session { ]); } - setReceiverMuted( - muted: boolean - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + setReceiverMuted(muted: boolean + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { this.#sendReceiverMessage( { type: "SET_VOLUME" @@ -336,10 +339,9 @@ export default class Session { .catch(errorCallback); } - setReceiverVolumeLevel( - newLevel: number - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback): void { + setReceiverVolumeLevel(newLevel: number + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback): void { this.#sendReceiverMessage( { type: "SET_VOLUME" @@ -348,9 +350,8 @@ export default class Session { .catch(errorCallback); } - stop( - successCallback?: SuccessCallback - , errorCallback?: ErrorCallback): void { + stop(successCallback?: SuccessCallback + , errorCallback?: ErrorCallback): void { this.#sendReceiverMessage( { type: "STOP" @@ -358,38 +359,4 @@ export default class Session { .then(successCallback) .catch(errorCallback); } - - - /** - * 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: SessionReceiverMessage) => { - return new Promise((resolve, reject) => { - if (!(message as any).requestId) { - (message as any).requestId = 0; - } - - const messageId = uuid(); - sendMessageResponse({ - subject: "bridge:session/sendReceiverMessage" - , data: { message, messageId, _id: this.#id } - }); - - this.#sendReceiverMessageCallbacks.set( - messageId, (wasError: boolean) => { - if (wasError) { - reject(new _Error(ErrorCode.SESSION_ERROR)); - return; - } - - resolve(); - }); - }); - } - - private _sendMediaMessage(message: string | {}) { - this.sendMessage("urn:x-cast:com.google.cast.media", message); - } } diff --git a/ext/src/shim/cast/index.ts b/ext/src/shim/cast/index.ts index c6f418a..bfbe896 100755 --- a/ext/src/shim/cast/index.ts +++ b/ext/src/shim/cast/index.ts @@ -30,7 +30,6 @@ import { AutoJoinPolicy , SenderPlatform , SessionStatus , VolumeControlType } from "./enums"; -import messaging from "../../messaging"; export * as media from "./media"; diff --git a/ext/src/shim/cast/media/Media.ts b/ext/src/shim/cast/media/Media.ts index f9f5534..e1aa3ea 100644 --- a/ext/src/shim/cast/media/Media.ts +++ b/ext/src/shim/cast/media/Media.ts @@ -1,52 +1,40 @@ "use strict"; -import logger from "../../../lib/logger"; - import { v1 as uuid } from "uuid"; -import { BreakStatus - , EditTracksInfoRequest - , GetStatusRequest - , LiveSeekableRange - , MediaInfo - , PauseRequest - , PlayRequest - , QueueData - , QueueJumpRequest - , QueueInsertItemsRequest - , QueueItem - , QueueSetPropertiesRequest - , QueueRemoveItemsRequest - , QueueReorderItemsRequest - , QueueUpdateItemsRequest - , SeekRequest - , StopRequest - , VideoInformation - , VolumeRequest } from "./dataClasses"; +import logger from "../../../lib/logger"; import { Volume, Error as _Error } from "../dataClasses"; +import { BreakStatus, EditTracksInfoRequest, GetStatusRequest, LiveSeekableRange + , MediaInfo, PauseRequest, PlayRequest, QueueData, QueueJumpRequest + , QueueInsertItemsRequest, QueueItem, QueueSetPropertiesRequest + , QueueRemoveItemsRequest, QueueReorderItemsRequest + , QueueUpdateItemsRequest, SeekRequest, StopRequest, VideoInformation + , VolumeRequest } from "./dataClasses"; -import { PlayerState - , RepeatMode } from "./enums"; - +import { PlayerState, RepeatMode } from "./enums"; import { ErrorCode } from "../enums"; import { onMessage, sendMessageResponse } from "../../eventMessageChannel"; -import { Callbacks - , ErrorCallback +import { ErrorCallback , SuccessCallback , UpdateListener } from "../../types"; - -import { SessionMediaMessage } from "../../../types"; +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(); - #lastCurrentTime = 0; + #sendMediaMessageCallbacks = + new Map(); #listener = onMessage(message => { if ((message as any).data._id !== this.#id) { @@ -54,27 +42,24 @@ export default class Media { } switch (message.subject) { - case "shim:media/update": { - const status = message.data; + case "shim:media/updateStatus": { + const { status } = message.data; + + // Store current update time + this.#lastUpdateTime = Date.now(); this.currentTime = status.currentTime; - this.#lastCurrentTime = status._lastCurrentTime; - this.customData = status.customData; + this.mediaSessionId = status.mediaSessionId; this.playbackRate = status.playbackRate; this.playerState = status.playerState; this.repeatMode = status.repeatMode; + this.volume = status.volume; - if (status._volumeLevel && status._volumeMuted) { - this.volume = new Volume( - status._volumeLevel - , status._volumeMuted); + if (status.customData) { + this.customData = status.customData; } - if (status.media) { - this.media = status.media; - } - if (status.mediaSessionId) { - this.mediaSessionId = status.mediaSessionId; + this.media = status.media as MediaInfo; } // Call update listeners @@ -106,8 +91,8 @@ export default class Media { activeTrackIds: Nullable = null; breakStatus?: BreakStatus; currentItemId: Nullable = null; - customData: any = null; currentTime = 0; + customData: any = null; idleReason: Nullable = null; items: Nullable = null; liveSeekableRange?: LiveSeekableRange; @@ -123,10 +108,9 @@ export default class Media { volume: Volume = new Volume(); - constructor( - public sessionId: string - , public mediaSessionId: number - , _internalSessionId: string) { + constructor(public sessionId: string + , public mediaSessionId: number + , _internalSessionId: string) { sendMessageResponse({ subject: "bridge:media/initialize" @@ -143,10 +127,9 @@ export default class Media { this.#updateListeners.add(listener); } - editTracksInfo( - editTracksInfoRequest: EditTracksInfoRequest - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + editTracksInfo(editTracksInfoRequest: EditTracksInfoRequest + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { this.#sendMediaMessage( { type: "EDIT_TRACKS_INFO", ...editTracksInfoRequest }) @@ -171,8 +154,8 @@ export default class Media { */ getEstimatedTime(): number { if (this.playerState === PlayerState.PLAYING) { - let estimatedTime = this.currentTime + (this.playbackRate * ( - Date.now() - this.#lastCurrentTime) / 1000); + let estimatedTime = this.currentTime + + ((Date.now() - this.#lastUpdateTime) / 1000); // Enforce valid range if (estimatedTime < 0) { @@ -192,10 +175,9 @@ export default class Media { * Request media status from the receiver application. This * will also trigger any added media update listeners. */ - getStatus( - getStatusRequest = new GetStatusRequest() - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + getStatus(getStatusRequest = new GetStatusRequest() + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { this.#sendMediaMessage( { type: "MEDIA_GET_STATUS", ...getStatusRequest }) @@ -203,10 +185,9 @@ export default class Media { .catch(errorCallback); } - pause( - pauseRequest = new PauseRequest() - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + pause(pauseRequest = new PauseRequest() + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { this.#sendMediaMessage( { type: "PAUSE", ...pauseRequest }) @@ -214,10 +195,9 @@ export default class Media { .catch(errorCallback); } - play( - playRequest = new PlayRequest() - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + play(playRequest = new PlayRequest() + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { this.#sendMediaMessage( { type: "PLAY", ...playRequest }) @@ -225,47 +205,52 @@ export default class Media { .catch(errorCallback); } - queueAppendItem( - item: QueueItem - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queueAppendItem(item: QueueItem + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { - this.#sendMediaMessage(new QueueInsertItemsRequest([ item ])) + this.#sendMediaMessage( + { + ...new QueueInsertItemsRequest([ item ]) + , type: "QUEUE_INSERT" + }) .then(successCallback) .catch(errorCallback); } - queueInsertItems( - queueInsertItemsRequest: QueueInsertItemsRequest - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queueInsertItems(queueInsertItemsRequest: QueueInsertItemsRequest + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { - this.#sendMediaMessage(queueInsertItemsRequest) + this.#sendMediaMessage( + { + ...queueInsertItemsRequest + , type: "QUEUE_INSERT" + }) .then(successCallback) .catch(errorCallback); } - queueJumpToItem( - itemId: number - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queueJumpToItem(itemId: number + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { if (this.items?.find(item => item.itemId === itemId)) { const jumpRequest = new QueueJumpRequest(); jumpRequest.currentItemId = itemId; - this.#sendMediaMessage(jumpRequest) + this.#sendMediaMessage( + { ...jumpRequest, type: "QUEUE_UPDATE" }) .then(successCallback) .catch(errorCallback); } } - queueMoveItemToNewIndex( - itemId: number - , newIndex: number - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queueMoveItemToNewIndex(itemId: number + , newIndex: number + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { // Return early if not in queue if (!this.items) { @@ -295,41 +280,41 @@ export default class Media { reorderItemsRequest.insertBefore = existingItem.itemId; } - this.#sendMediaMessage(reorderItemsRequest) + this.#sendMediaMessage( + { ...reorderItemsRequest, type: "QUEUE_REORDER" }) .then(successCallback) .catch(errorCallback); } } - queueNext( - successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queueNext(successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { const jumpRequest = new QueueJumpRequest(); jumpRequest.jump = 1; - this.#sendMediaMessage(jumpRequest) + this.#sendMediaMessage( + { ...jumpRequest, type: "QUEUE_UPDATE" }) .then(successCallback) .catch(errorCallback); } - queuePrev( - successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queuePrev(successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { const jumpRequest = new QueueJumpRequest(); jumpRequest.jump = -1; - this.#sendMediaMessage(jumpRequest) + this.#sendMediaMessage( + { ...jumpRequest, type: "QUEUE_UPDATE" }) .then(successCallback) .catch(errorCallback); } - queueRemoveItem( - itemId: number - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { - + queueRemoveItem(itemId: number + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { + const item = this.items?.find(item => item.itemId === itemId); if (item) { this.queueRemoveItems( @@ -338,45 +323,45 @@ export default class Media { } } - queueRemoveItems( - queueRemoveItemsRequest: QueueRemoveItemsRequest - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queueRemoveItems(queueRemoveItemsRequest: QueueRemoveItemsRequest + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { - this.#sendMediaMessage(queueRemoveItemsRequest) + this.#sendMediaMessage( + { ...queueRemoveItemsRequest, type: "QUEUE_REMOVE" }) .then(successCallback) .catch(errorCallback); } - queueReorderItems( - queueReorderItemsRequest: QueueReorderItemsRequest - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queueReorderItems(queueReorderItemsRequest: QueueReorderItemsRequest + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { - this.#sendMediaMessage(queueReorderItemsRequest) + this.#sendMediaMessage( + { ...queueReorderItemsRequest, type: "QUEUE_REORDER" }) .then(successCallback) .catch(errorCallback); } - queueSetRepeatMode( - repeatMode: string - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { - + queueSetRepeatMode(repeatMode: string + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { + const setPropertiesRequest = new QueueSetPropertiesRequest(); setPropertiesRequest.repeatMode = repeatMode; - this.#sendMediaMessage(setPropertiesRequest) + this.#sendMediaMessage( + { ...setPropertiesRequest, type: "QUEUE_UPDATE" }) .then(successCallback) .catch(errorCallback); } - queueUpdateItems( - queueUpdateItemsRequest: QueueUpdateItemsRequest - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + queueUpdateItems(queueUpdateItemsRequest: QueueUpdateItemsRequest + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { - this.#sendMediaMessage(queueUpdateItemsRequest) + this.#sendMediaMessage( + { ...queueUpdateItemsRequest, type: "QUEUE_UPDATE" }) .then(successCallback) .catch(errorCallback); } @@ -385,10 +370,9 @@ export default class Media { this.#updateListeners.delete(listener); } - seek( - seekRequest: SeekRequest - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + seek(seekRequest: SeekRequest + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { this.#sendMediaMessage( { type: "SEEK", ...seekRequest }) @@ -396,10 +380,9 @@ export default class Media { .catch(errorCallback); } - setVolume( - volumeRequest: VolumeRequest - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + setVolume(volumeRequest: VolumeRequest + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { this.#sendMediaMessage( { type: "MEDIA_SET_VOLUME", ...volumeRequest }) @@ -407,10 +390,9 @@ export default class Media { .catch(errorCallback); } - stop( - stopRequest?: StopRequest - , successCallback?: SuccessCallback - , errorCallback?: ErrorCallback) { + stop(stopRequest?: StopRequest + , successCallback?: SuccessCallback + , errorCallback?: ErrorCallback) { if (!stopRequest) { stopRequest = new StopRequest(); @@ -434,7 +416,11 @@ export default class Media { } - #sendMediaMessage = async (message: SessionMediaMessage) => { + #sendMediaMessage = async ( + // Allow messages without requestId + message: Omit + & { requestId?: Nullable }) => { + if (!this.media) { return; } @@ -465,10 +451,10 @@ export default class Media { sendMessageResponse({ subject: "bridge:media/sendMediaMessage" - , data: { + , data: { message - , messageId - , _id: this.#id + , messageId + , _id: this.#id } }); }); diff --git a/ext/src/shim/cast/media/dataClasses.ts b/ext/src/shim/cast/media/dataClasses.ts index 6fc0abc..7137486 100644 --- a/ext/src/shim/cast/media/dataClasses.ts +++ b/ext/src/shim/cast/media/dataClasses.ts @@ -8,8 +8,8 @@ import { ContainerType , HlsVideoSegmentFormat , MetadataType , RepeatMode - , StreamType - , UserAction } from "./enums"; + , ResumeState, StreamType + , TrackType, UserAction } from "./enums"; export class AudiobookChapterMediaMetadata { @@ -93,12 +93,12 @@ export class EditTracksInfoRequest { export class GenericMediaMetadata { images?: Image[]; - metadataType: number = MetadataType.GENERIC; + metadataType = MetadataType.GENERIC; releaseDate?: string; releaseYear?: number; subtitle?: string; title?: string; - type: number = MetadataType.GENERIC; + type = MetadataType.GENERIC; } @@ -126,7 +126,7 @@ export class LoadRequest { media: MediaInfo; requestId = 0; sessionId: Nullable = null; - type = "LOAD"; + type: "LOAD" = "LOAD"; constructor(mediaInfo: MediaInfo) { this.media = mediaInfo; @@ -134,7 +134,7 @@ export class LoadRequest { } -type Metadata = +export type Metadata = GenericMediaMetadata | MovieMediaMetadata | MusicTrackMediaMetadata @@ -183,13 +183,13 @@ export class MediaMetadata { export class MovieMediaMetadata { images?: Image[]; - metadataType: number = MetadataType.MOVIE; + metadataType = MetadataType.MOVIE; releaseDate?: string; releaseYear?: number; studio?: string; subtitle?: string; title?: string; - type: number = MetadataType.MOVIE; + type = MetadataType.MOVIE; } @@ -201,13 +201,13 @@ export class MusicTrackMediaMetadata { composer?: string; discNumber?: number; images?: Image[]; - metadataType: number = MetadataType.MUSIC_TRACK; + metadataType = MetadataType.MUSIC_TRACK; releaseDate?: string; releaseYear?: number; songName?: string; title?: string; trackNumber?: number; - type: number = MetadataType.MUSIC_TRACK; + type = MetadataType.MUSIC_TRACK; } @@ -224,9 +224,9 @@ export class PhotoMediaMetadata { latitude?: number; location?: string; longitude?: number; - metadataType: number = MetadataType.PHOTO; + metadataType = MetadataType.PHOTO; title?: string; - type: number = MetadataType.PHOTO; + type = MetadataType.PHOTO; width?: number; } @@ -347,7 +347,7 @@ export class QueueUpdateItemsRequest { export class SeekRequest { currentTime: Nullable = null; customData: any = null; - resumeState: Nullable = null; + resumeState: Nullable = null; } @@ -382,7 +382,7 @@ export class Track { constructor( public trackId: number - , public type: string) {} + , public type: TrackType) {} } @@ -398,7 +398,7 @@ export class TvShowMediaMetadata { seasonNumber?: number; seriesTitle?: string; title?: string; - type: number = MetadataType.TV_SHOW; + type = MetadataType.TV_SHOW; } diff --git a/ext/src/shim/cast/types.ts b/ext/src/shim/cast/types.ts new file mode 100644 index 0000000..a3f283b --- /dev/null +++ b/ext/src/shim/cast/types.ts @@ -0,0 +1,157 @@ +"use strict"; + +/** + * Keep in sync with bridge types at: + * app/src/bridge/components/chromecast/types.ts + */ + +import { Volume } from "./dataClasses"; +import { MediaInfo, QueueItem } from "./media/dataClasses"; +import { IdleReason + , PlayerState + , RepeatMode + , ResumeState } from "./media/enums"; + + +export interface MediaStatus { + mediaSessionId: number; + media?: MediaInfo; + playbackRate: number; + playerState: PlayerState; + idleReason?: IdleReason; + currentTime: number; + supportedMediaCommands: number; + repeatMode: RepeatMode; + volume: Volume + customData: unknown; +} + +export interface ReceiverApplication { + appId: string + , appType?: string + , displayName: string + , iconUrl: string + , isIdleScreen: boolean + , launchedFromCloud: boolean + , namespaces: Array<{ name: string }> + , sessionId: string + , statusText: string + , transportId: string + , universalAppId: string +} + +export interface ReceiverStatus { + applications?: ReceiverApplication[] + , isActiveInput?: boolean + , isStandBy?: boolean + , volume: Volume +} + + +interface ReqBase { + requestId: number; +} + +// NS: urn:x-cast:com.google.cast.receiver +export type SenderMessage = + ReqBase & { type: "LAUNCH", appId: string } + | ReqBase & { type: "STOP", sessionId: string } + | ReqBase & { type: "GET_STATUS" } + | ReqBase & { type: "GET_APP_AVAILABILITY", appId: string[] } + | ReqBase & { type: "SET_VOLUME", volume: Partial }; + +export type ReceiverMessage = + ReqBase & { + type: "RECEIVER_STATUS" + , status: ReceiverStatus + }; + + +interface MediaReqBase extends ReqBase { + customData?: unknown; +} + +// NS: urn:x-cast:com.google.cast.media +export type SenderMediaMessage = + | MediaReqBase & { type: "PLAY" } + | MediaReqBase & { type: "PAUSE" } + | MediaReqBase & { type: "MEDIA_GET_STATUS" } + | MediaReqBase & { type: "STOP" } + | MediaReqBase & { type: "MEDIA_SET_VOLUME", volume: Partial } + | MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number } + | MediaReqBase & { + type: "LOAD" + , activeTrackIds: Nullable + , atvCredentials?: string + , atvCredentialsType?: string + , autoplay: Nullable + , currentTime: Nullable + , customData: any + , media: MediaInfo + , requestId: number + , sessionId: Nullable + } + | MediaReqBase & { + type: "SEEK" + , resumeState: Nullable + , currentTime: Nullable + } + | MediaReqBase & { + type: "EDIT_TRACKS_INFO" + , activeTrackIds: Nullable + , textTrackStyle: Nullable + } + // QueueLoadRequest + | MediaReqBase & { + type: "QUEUE_LOAD" + , items: QueueItem[] + , startIndex: number + , repeatMode: string + , sessionId: Nullable + } + // QueueInsertItemsRequest + | MediaReqBase & { + type: "QUEUE_INSERT" + , items: QueueItem[] + , insertBefore: Nullable + , sessionId: Nullable + } + // QueueUpdateItemsRequest + | MediaReqBase & { + type: "QUEUE_UPDATE" + , items: QueueItem[] + , sessionId: Nullable + } + // QueueJumpRequest + | MediaReqBase & { + type: "QUEUE_UPDATE" + , jump: Nullable + , currentItemId: Nullable + , sessionId: Nullable + } + // QueueRemoveItemsRequest + | MediaReqBase & { + type: "QUEUE_REMOVE" + , itemIds: number[] + , sessionId: Nullable + } + // QueueReorderItemsRequest + | MediaReqBase & { + type: "QUEUE_REORDER" + , itemIds: number[] + , insertBefore: Nullable + , sessionId: Nullable + } + // QueueSetPropertiesRequest + | MediaReqBase & { + type: "QUEUE_UPDATE" + , repeatMode: Nullable + , sessionId: Nullable + }; + +export type ReceiverMediaMessage = + MediaReqBase & { type: "MEDIA_STATUS", status: MediaStatus[] } + | MediaReqBase & { type: "INVALID_PLAYER_STATE" } + | MediaReqBase & { type: "LOAD_FAILED" } + | MediaReqBase & { type: "LOAD_CANCELLED" } + | MediaReqBase & { type: "INVALID_REQUEST" }; diff --git a/ext/src/shim/types.ts b/ext/src/shim/types.ts index 57b80c7..831b846 100644 --- a/ext/src/shim/types.ts +++ b/ext/src/shim/types.ts @@ -11,6 +11,3 @@ export type MediaListener = (media: Media) => void; export type MessageListener = (namespace: string, message: string) => void; export type UpdateListener = (isAlive: boolean) => void; export type LoadSuccessCallback = (media: Media) => void; - -export type Callbacks = [ SuccessCallback?, ErrorCallback? ]; -export type CallbacksMap = Map; diff --git a/ext/src/types.ts b/ext/src/types.ts index cf55283..958664d 100644 --- a/ext/src/types.ts +++ b/ext/src/types.ts @@ -1,13 +1,6 @@ "use strict"; -import { Volume } from "./shim/cast/dataClasses"; - -import { EditTracksInfoRequest, GetStatusRequest, LoadRequest, PauseRequest - , PlayRequest, QueueInsertItemsRequest, QueueJumpRequest - , QueueLoadRequest, QueueRemoveItemsRequest, QueueReorderItemsRequest - , QueueSetPropertiesRequest, QueueUpdateItemsRequest, SeekRequest - , StopRequest, VolumeRequest } from "./shim/cast/media"; - +import { ReceiverStatus } from "./shim/cast/types"; export interface ReceiverDevice { host: string @@ -16,49 +9,3 @@ export interface ReceiverDevice { , port: number , status?: ReceiverStatus } - -export interface ReceiverStatus { - applications?: Array<{ - appId: string - , appType: string - , displayName: string - , iconUrl: string - , isIdleScreen: boolean - , launchedFromCloud: boolean - , namespaces: Array<{ name: string }> - , sessionId: string - , statusText: string - , transportId: string - , universalAppId: string - }> - , isActiveInput?: boolean - , isStandBy?: boolean - , userEq: unknown - , volume: Volume -} - - -export type SessionMediaMessage = - { type: "PLAY" } & PlayRequest - | { type: "PAUSE" } & PauseRequest - | { type: "SEEK" } & SeekRequest - | { type: "STOP" } & StopRequest - | { type: "MEDIA_GET_STATUS" } & GetStatusRequest - | { type: "MEDIA_SET_VOLUME" } & VolumeRequest - | { type: "EDIT_TRACKS_INFO" } & EditTracksInfoRequest - | { type: "SET_PLAYBACK_RATE", playbackRate: number } - | LoadRequest - | QueueLoadRequest - | QueueInsertItemsRequest - | QueueUpdateItemsRequest - | QueueJumpRequest - | QueueRemoveItemsRequest - | QueueReorderItemsRequest - | QueueSetPropertiesRequest; - -export type SessionReceiverMessage = - { type: "LAUNCH", appId: string } - | { type: "STOP", sessionId: string } - | { type: "GET_STATUS" } - | { type: "GET_APP_AVAILABILITY", appId: string[] } - | { type: "SET_VOLUME", volume: Partial };