Improve session/media bridge messaging

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

View File

@@ -2,27 +2,16 @@
import castv2 from "castv2"; import castv2 from "castv2";
import Session from "./Session"; import { ReceiverMediaMessage } from "./types";
import { Message } from "../../messaging"; import { Message } from "../../messaging";
import { sendMessage } from "../../lib/nativeMessaging"; import { sendMessage } from "../../lib/nativeMessaging";
import Session from "./Session";
const NS_MEDIA = "urn:x-cast:com.google.cast.media"; 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 { export default class Media {
private channel: castv2.Channel; private channel: castv2.Channel;
@@ -31,40 +20,32 @@ export default class Media {
private referenceId: string private referenceId: string
, private session: Session) { , private session: Session) {
// Ensure channel exists
this.session.createChannel(NS_MEDIA); this.session.createChannel(NS_MEDIA);
this.channel = this.session.channelMap.get(NS_MEDIA)!;
this.channel.on("message", (data: any) => { const channel = this.session.channelMap.get(NS_MEDIA);
if (data && data.type === "MEDIA_STATUS" if (!channel) {
&& data.status && data.status.length > 0) { 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 = { private onMediaMessage = (message: ReceiverMediaMessage) => {
_lastCurrentTime: Date.now() / 1000 switch (message.type) {
case "MEDIA_STATUS": {
// TODO: Fix for multiple media statuses
const status = message.status[0];
, currentTime: status.currentTime this.sendMessage({
, customData: status.customData subject: "shim:media/updateStatus"
, playbackRate: status.playbackRate , data: { status }
, playerState: status.playerState });
, repeatMode: status.repeatMode
};
if (status.volume) { break;
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);
} }
}); }
} }
public messageHandler(message: Message) { public messageHandler(message: Message) {
@@ -77,9 +58,12 @@ export default class Media {
error = true; error = true;
} }
this.sendMessage("shim:media/sendMediaMessageResponse", { this.sendMessage({
messageId: message.data.messageId subject: "shim:media/sendMediaMessageResponse"
, error , data: {
messageId: message.data.messageId
, error
}
}); });
break; break;
@@ -87,11 +71,8 @@ export default class Media {
} }
} }
private sendMessage(subject: string, data: any) { private sendMessage(message: Message) {
data._id = this.referenceId; (message.data as any)._id = this.referenceId;
(sendMessage as any)({ sendMessage(message);
subject
, data
});
} }
} }

View File

@@ -5,32 +5,39 @@ import { Channel, Client } from "castv2";
import { Message } from "../../messaging"; import { Message } from "../../messaging";
import { sendMessage } from "../../lib/nativeMessaging"; 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_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat"; export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver"; export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
const HEARTBEAT_INTERVAL = 5000;
export default class Session { export default class Session {
public channelMap = new Map<string, Channel>(); private isSessionCreated = false;
private client: Client; private client: Client;
private clientConnection?: Channel; private clientId = `client-${Math.floor(Math.random() * 10e5)}`;
private clientHeartbeat?: Channel;
private clientReceiver?: Channel;
private clientHeartbeatIntervalId?: NodeJS.Timeout;
private isSessionCreated = false;
private clientId?: string;
private transportId?: string; private transportId?: string;
public channelMap = new Map<string, Channel>();
private platformConnection?: Channel;
private platformHeartbeat?: Channel;
private platformReceiver?: Channel;
private platformHeartbeatIntervalId?: NodeJS.Timeout;
private transportConnection?: Channel; private transportConnection?: Channel;
private app: any; private transportHeartbeat?: Channel;
private app?: ReceiverApplication;
constructor( constructor(
public host: string public host: string
, public port: number , public port: number
, private appId: string , private appId: string
, private sessionId: string
, private referenceId: string) { , private referenceId: string) {
const client = new Client(); const client = new Client();
@@ -38,135 +45,18 @@ export default class Session {
client.on("error", err => { client.on("error", err => {
console.error(`castv2 error: ${err}`); console.error(`castv2 error: ${err}`);
}); });
client.on("close", () => { client.on("close", () => {
// TODO: Don't send new data // TODO: Don't send new data
if (this.platformHeartbeatIntervalId) {
clearInterval(this.platformHeartbeatIntervalId);
}
}); });
client.connect({ host, port }, this.onConnect.bind(this)); client.connect({ host, port }, this.onConnect.bind(this));
this.client = client; 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) { public createChannel(namespace: string) {
if (!this.channelMap.has(namespace)) { if (!this.channelMap.has(namespace)) {
this.channelMap.set(namespace, this.client.createChannel( 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() { public close() {
this.clientConnection?.send({ type: "CLOSE" }); this.platformConnection?.send({ type: "CLOSE" });
this.transportConnection?.send({ type: "CLOSE" }); this.transportConnection?.send({ type: "CLOSE" });
} }
public stop() { public stop() {
this.clientConnection?.send({ type: "STOP" }); this.platformConnection?.send({ type: "STOP" });
} }
private sendMessage(subject: string, data: any = {}) { private sendMessage(message: Message) {
data._id = this.referenceId; (message.data as any)._id = this.referenceId;
sendMessage({ sendMessage(message);
// @ts-ignore
subject
, data
});
} }
private _impl_addMessageListener(namespace: string) { private _impl_addMessageListener(namespace: string) {
// TODO: Limit to one listener per namespace
this.createChannel(namespace); this.createChannel(namespace);
this.channelMap.get(namespace)?.on("message", (data: any) => { this.channelMap.get(namespace)?.on("message", (message: any) => {
this.sendMessage("shim:session/impl_addMessageListener", { this.sendMessage({
namespace subject: "shim:session/impl_addMessageListener"
, data: JSON.stringify(data) , data: {
namespace
, message: JSON.stringify(message)
}
}); });
}); });
} }
private _impl_sendMessage( private _impl_sendMessage(
namespace: string namespace: string
, message: {} | string , message: object | string
, messageId: string) { , messageId: string) {
let error = false; let wasError = false;
try { try {
// Decode string messages // Decode string messages
@@ -219,12 +221,34 @@ export default class Session {
this.createChannel(namespace); this.createChannel(namespace);
this.channelMap.get(namespace)?.send(message); this.channelMap.get(namespace)?.send(message);
} catch (err) { } catch (err) {
error = true; wasError = true;
} }
this.sendMessage("shim:session/impl_sendMessage", { this.sendMessage({
messageId subject: "shim:session/impl_sendMessage"
, error , data: { messageId, wasError }
});
}
private impl_sendReceiverMessage(
message: SenderMessage
, messageId: string) {
let wasError = false;
try {
this.platformReceiver?.send(message);
} catch (err) {
wasError = true;
}
// Handle stop message
if (message.type === "STOP") {
this.client.close();
}
this.sendMessage({
subject: "shim:session/impl_sendReceiverMessage"
, data: { messageId, wasError }
}); });
} }

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,29 @@
"use strict"; "use strict";
import { MediaStatus, ReceiverStatus, ReceiverApplication, SenderMessage }
from "./components/chromecast/types";
import { ReceiverDevice import { ReceiverDevice
, ReceiverMessage
, ReceiverSelectionCast , ReceiverSelectionCast
, ReceiverSelectionStop , ReceiverSelectionStop } from "./types";
, ReceiverStatus
, Volume } from "./types";
type MessageDefinitions = { type MessageDefinitions = {
// Session messages // Session messages
"shim:session/stopped": {} "shim:session/connected": { application: ReceiverApplication }
, "shim:session/connected": { , "shim:session/updateStatus": { status: ReceiverStatus }
sessionId: string , "shim:session/stopped": {}
, namespaces: Array<{ name: string }>
, displayName: string
, statusText: string
}
, "shim:session/updateStatus": { volume: Volume }
, "shim:session/sendReceiverMessageResponse": {
messageId: string
, wasError: boolean
}
, "shim:session/impl_addMessageListener": { , "shim:session/impl_addMessageListener": {
namespace: string namespace: string
, data: string , message: string
} }
, "shim:session/impl_sendMessage": { , "shim:session/impl_sendMessage": {
messageId: string messageId: string
, error: boolean , wasError: boolean
}
, "shim:session/impl_sendReceiverMessage": {
messageId: string
, wasError: boolean
} }
// Bridge session messages // Bridge session messages
@@ -40,11 +35,6 @@ type MessageDefinitions = {
, _id: string , _id: string
} }
, "bridge:session/close": {} , "bridge:session/close": {}
, "bridge:session/sendReceiverMessage": {
message: ReceiverMessage
, messageId: string
, _id: string
}
, "bridge:session/impl_leave": { , "bridge:session/impl_leave": {
id: string id: string
, _id: string , _id: string
@@ -55,23 +45,19 @@ type MessageDefinitions = {
, messageId: string , messageId: string
, _id: string , _id: string
} }
, "bridge:session/impl_sendReceiverMessage": {
message: SenderMessage
, messageId: string
, _id: string
}
, "bridge:session/impl_addMessageListener": { , "bridge:session/impl_addMessageListener": {
namespace: string; namespace: string;
_id: string; _id: string;
} }
// Media messages // Media messages
, "shim:media/update": { , "shim:media/updateStatus": {
currentTime: number status: MediaStatus
, _lastCurrentTime: number
, customData: any
, playbackRate: number
, playerState: string
, repeatMode: string
, _volumeLevel: number
, _volumeMuted: boolean
, media: unknown // MediaInfo
, mediaSessionId: number
} }
, "shim:media/sendMediaMessageResponse": { , "shim:media/sendMediaMessageResponse": {
messageId: string messageId: string

View File

@@ -1,60 +1,8 @@
"use strict"; "use strict";
export interface ReceiverStatus { import { ReceiverStatus } from "./components/chromecast/types";
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 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 { export enum ReceiverSelectorMediaType {
App = 1 App = 1
, Tab = 2 , Tab = 2
@@ -86,32 +34,3 @@ export interface ReceiverDevice {
port: number; port: number;
status?: ReceiverStatus; 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 }
};

1
app/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare type Nullable<T> = T | null;

View File

@@ -4,8 +4,9 @@ import bridge from "../lib/bridge";
import logger from "../lib/logger"; import logger from "../lib/logger";
import { TypedEventTarget } from "../lib/TypedEventTarget"; import { TypedEventTarget } from "../lib/TypedEventTarget";
import messaging, { Message, Port } from "../messaging"; import { Message, Port } from "../messaging";
import { ReceiverDevice, ReceiverStatus } from "../types"; import { ReceiverDevice } from "../types";
import { ReceiverStatus } from "../shim/cast/types";
interface EventMap { interface EventMap {
@@ -124,7 +125,6 @@ export default new class extends TypedEventTarget<EventMap> {
if (receiverDevice.status) { if (receiverDevice.status) {
receiverDevice.status.isActiveInput = status.isActiveInput; receiverDevice.status.isActiveInput = status.isActiveInput;
receiverDevice.status.isStandBy = status.isStandBy; receiverDevice.status.isStandBy = status.isStandBy;
receiverDevice.status.userEq = status.userEq;
receiverDevice.status.volume = status.volume; receiverDevice.status.volume = status.volume;
if (status.applications) { if (status.applications) {

View File

@@ -5,9 +5,7 @@ import Messenger from "./lib/Messenger";
import { TypedPort } from "./lib/TypedPort"; import { TypedPort } from "./lib/TypedPort";
import { BridgeInfo } from "./lib/bridge"; import { BridgeInfo } from "./lib/bridge";
import { ReceiverDevice import { ReceiverDevice } from "./types";
, SessionReceiverMessage
, ReceiverStatus } from "./types";
import { ReceiverSelectorMediaType } from "./background/receiverSelector"; import { ReceiverSelectorMediaType } from "./background/receiverSelector";
import { ReceiverSelection import { ReceiverSelection
@@ -16,7 +14,10 @@ import { ReceiverSelection
from "./background/receiverSelector/ReceiverSelector"; from "./background/receiverSelector/ReceiverSelector";
import { Volume } from "./shim/cast/dataClasses"; 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 = { type AppMessageDefinitions = {
// Session messages // Session messages
"shim:session/stopped": {} "shim:session/stopped": {}
, "shim:session/connected": { , "shim:session/connected": { application: ReceiverApplication }
sessionId: string , "shim:session/updateStatus": { status: ReceiverStatus }
, namespaces: Array<{ name: string }> , "shim:session/impl_addMessageListener": {
, displayName: string namespace: string
, statusText: string , message: string
} }
, "shim:session/updateStatus": { volume: Volume } , "shim:session/impl_sendMessage": {
, "shim:session/sendReceiverMessageResponse": {
messageId: string messageId: string
, wasError: boolean , wasError: boolean
} }
, "shim:session/impl_addMessageListener": { , "shim:session/impl_sendReceiverMessage": {
namespace: string
, data: string
}
, "shim:session/impl_sendMessage": {
messageId: string messageId: string
, error: boolean , wasError: boolean
} }
// Bridge session messages // Bridge session messages
@@ -102,11 +98,6 @@ type AppMessageDefinitions = {
, _id: string , _id: string
} }
, "bridge:session/close": {} , "bridge:session/close": {}
, "bridge:session/sendReceiverMessage": {
message: SessionReceiverMessage
, messageId: string
, _id: string
}
, "bridge:session/impl_leave": { , "bridge:session/impl_leave": {
id: string id: string
, _id: string , _id: string
@@ -117,23 +108,19 @@ type AppMessageDefinitions = {
, messageId: string , messageId: string
, _id: string , _id: string
} }
, "bridge:session/impl_sendReceiverMessage": {
message: SenderMessage
, messageId: string
, _id: string
}
, "bridge:session/impl_addMessageListener": { , "bridge:session/impl_addMessageListener": {
namespace: string; namespace: string;
_id: string; _id: string;
} }
// Media messages // Media messages
, "shim:media/update": { , "shim:media/updateStatus": {
currentTime: number status: MediaStatus
, _lastCurrentTime: number
, customData: any
, playbackRate: number
, playerState: string
, repeatMode: string
, _volumeLevel: number
, _volumeMuted: boolean
, media: MediaInfo
, mediaSessionId: number
} }
, "shim:media/sendMediaMessageResponse": { , "shim:media/sendMediaMessageResponse": {
messageId: string messageId: string

View File

@@ -3,19 +3,19 @@
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import logger from "../../lib/logger"; import logger from "../../lib/logger";
import { SessionReceiverMessage } from "../../types";
import { onMessage import { onMessage
, sendMessageResponse } from "../eventMessageChannel"; , sendMessageResponse } from "../eventMessageChannel";
import { Callbacks import { ErrorCallback
, ErrorCallback
, LoadSuccessCallback , LoadSuccessCallback
, MediaListener , MediaListener
, MessageListener , MessageListener
, SuccessCallback , SuccessCallback
, UpdateListener } from "../types"; , UpdateListener } from "../types";
import { SenderMediaMessage, SenderMessage } from "./types";
import { Error as _Error import { Error as _Error
, Image, Receiver , Image, Receiver
, SenderApplication, Volume } from "./dataClasses"; , SenderApplication, Volume } from "./dataClasses";
@@ -23,23 +23,29 @@ import { ErrorCode, SessionStatus } from "./enums";
import { Media import { Media
, LoadRequest , LoadRequest
, QueueLoadRequest , QueueLoadRequest } from "./media";
// Enums
, RepeatMode } from "./media";
type SenderMessageData<T = SenderMessage> =
T extends any
? Omit<T, "requestId">
: never;
type SessionSuccessCallback = (session: Session) => void; type SessionSuccessCallback = (session: Session) => void;
export default class Session { export default class Session {
#id = uuid(); #id = uuid();
#isConnected = false;
#successCallback?: SessionSuccessCallback; #successCallback?: SessionSuccessCallback;
#messageListeners = new Map<string, Set<MessageListener>>(); #messageListeners = new Map<string, Set<MessageListener>>();
#updateListeners = new Set<UpdateListener>(); #updateListeners = new Set<UpdateListener>();
#sendMessageCallbacks = new Map<string, Callbacks>(); #sendMessageCallbacks =
#sendReceiverMessageCallbacks = new Map<string, Function>(); new Map<string, [ SuccessCallback?, ErrorCallback? ]>();
#sendReceiverMessageCallbacks =
new Map<string, (wasError: boolean) => void>();
#listener = onMessage(message => { #listener = onMessage(message => {
// Filter other session messages // Filter other session messages
@@ -61,38 +67,31 @@ export default class Session {
break; 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": { case "shim:session/updateStatus": {
const status = message.data; const { status } = message.data;
if (status.volume) { // First status message indicates session creation
if (!this.receiver.volume) { if (!this.#isConnected && status.applications) {
const receiverVolume = new Volume( this.#isConnected = true;
status.volume.level, status.volume.muted);
receiverVolume.controlType = status.volume.controlType; this.status = SessionStatus.CONNECTED;
receiverVolume.stepInterval =
status.volume.stepInterval; // Update app props
} else { const app = status.applications[0];
this.receiver.volume.level = status.volume.level; this.sessionId = app.sessionId;
this.receiver.volume.muted = status.volume.muted; 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) { for (const listener of this.#updateListeners) {
listener(true); listener(true);
} }
@@ -100,25 +99,14 @@ export default class Session {
break; 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": { case "shim:session/impl_addMessageListener": {
const { namespace, data } = message.data; const { namespace, message: newMessage } = message.data;
const messageListeners = this.#messageListeners.get(namespace); const messageListeners = this.#messageListeners.get(namespace);
if (messageListeners) { if (messageListeners) {
for (const listener of 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": { case "shim:session/impl_sendMessage": {
const { messageId, error } = message.data; const { messageId, wasError } = message.data;
const [ successCallback, errorCallback ] = const [ successCallback, errorCallback ] =
this.#sendMessageCallbacks.get(messageId) ?? []; this.#sendMessageCallbacks.get(messageId) ?? [];
if (error && errorCallback) { if (wasError && errorCallback) {
errorCallback(new _Error(ErrorCode.SESSION_ERROR)); errorCallback(new _Error(ErrorCode.SESSION_ERROR));
} else if (successCallback) { } else if (successCallback) {
successCallback(); successCallback();
@@ -140,31 +128,69 @@ export default class Session {
break; break;
} }
}
})
media: Media[]; case "shim:session/impl_sendReceiverMessage": {
namespaces: Array<{ name: string }>; const { messageId, wasError } = message.data;
senderApps: SenderApplication[]; const callback =
status: SessionStatus; this.#sendReceiverMessageCallbacks.get(messageId);
statusText: Nullable<string>; 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<void>((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<string> = null;
transportId: string; transportId: string;
constructor( constructor(public sessionId: string
public sessionId: string , public appId: string
, public appId: string , public displayName: string
, public displayName: string , public appImages: Image[]
, public appImages: Image[] , public receiver: Receiver
, public receiver: Receiver , _successCallback: SessionSuccessCallback) {
, _successCallback: SessionSuccessCallback) {
this.#successCallback = _successCallback; this.#successCallback = _successCallback;
this.media = [];
this.namespaces = [];
this.senderApps = [];
this.status = SessionStatus.CONNECTED;
this.statusText = null;
this.transportId = sessionId || ""; this.transportId = sessionId || "";
if (receiver) { if (receiver) {
@@ -186,9 +212,8 @@ export default class Session {
logger.info("STUB :: Session#addMediaListener"); logger.info("STUB :: Session#addMediaListener");
} }
addMessageListener( addMessageListener(namespace: string
namespace: string , listener: MessageListener) {
, listener: MessageListener) {
if (!this.#messageListeners.has(namespace)) { if (!this.#messageListeners.has(namespace)) {
this.#messageListeners.set(namespace, new Set()); this.#messageListeners.set(namespace, new Set());
@@ -209,28 +234,17 @@ export default class Session {
this.#updateListeners.add(listener); this.#updateListeners.add(listener);
} }
leave( leave(_successCallback?: SuccessCallback
_successCallback?: SuccessCallback , _errorCallback?: ErrorCallback): void {
, _errorCallback?: ErrorCallback): void {
logger.info("STUB :: Session#leave"); logger.info("STUB :: Session#leave");
} }
loadMedia( loadMedia(loadRequest: LoadRequest
loadRequest: LoadRequest , successCallback?: LoadSuccessCallback
, successCallback?: LoadSuccessCallback , errorCallback?: ErrorCallback): void {
, errorCallback?: ErrorCallback): void {
this._sendMediaMessage({ this._sendMediaMessage(loadRequest);
type: "LOAD"
, requestId: 0
, media: loadRequest.media
, activeTrackIds: loadRequest.activeTrackIds || []
, autoplay: loadRequest.autoplay || false
, currentTime: loadRequest.currentTime || 0
, customData: loadRequest.customData || {}
, repeatMode: RepeatMode.OFF
});
let hasResponded = false; let hasResponded = false;
@@ -274,10 +288,9 @@ export default class Session {
}); });
} }
queueLoad( queueLoad(_queueLoadRequest: QueueLoadRequest
_queueLoadRequest: QueueLoadRequest , _successCallback?: LoadSuccessCallback
, _successCallback?: LoadSuccessCallback , _errorCallback?: ErrorCallback): void {
, _errorCallback?: ErrorCallback): void {
logger.info("STUB :: Session#queueLoad"); logger.info("STUB :: Session#queueLoad");
} }
@@ -285,26 +298,17 @@ export default class Session {
removeMediaListener(_mediaListener: MediaListener): void { removeMediaListener(_mediaListener: MediaListener): void {
logger.info("STUB :: Session#removeMediaListener"); logger.info("STUB :: Session#removeMediaListener");
} }
removeMessageListener(namespace: string, listener: MessageListener): void {
removeMessageListener(
namespace: string
, listener: MessageListener): void {
this.#messageListeners.get(namespace)?.delete(listener); this.#messageListeners.get(namespace)?.delete(listener);
} }
removeUpdateListener(_namespace: string, listener: UpdateListener): void {
removeUpdateListener(
_namespace: string
, listener: UpdateListener): void {
this.#updateListeners.delete(listener); this.#updateListeners.delete(listener);
} }
sendMessage( sendMessage(namespace: string
namespace: string , message: {} | string
, message: {} | string , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback): void {
, errorCallback?: ErrorCallback): void {
const messageId = uuid(); const messageId = uuid();
@@ -324,10 +328,9 @@ export default class Session {
]); ]);
} }
setReceiverMuted( setReceiverMuted(muted: boolean
muted: boolean , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendReceiverMessage( this.#sendReceiverMessage(
{ type: "SET_VOLUME" { type: "SET_VOLUME"
@@ -336,10 +339,9 @@ export default class Session {
.catch(errorCallback); .catch(errorCallback);
} }
setReceiverVolumeLevel( setReceiverVolumeLevel(newLevel: number
newLevel: number , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback): void {
, errorCallback?: ErrorCallback): void {
this.#sendReceiverMessage( this.#sendReceiverMessage(
{ type: "SET_VOLUME" { type: "SET_VOLUME"
@@ -348,9 +350,8 @@ export default class Session {
.catch(errorCallback); .catch(errorCallback);
} }
stop( stop(successCallback?: SuccessCallback
successCallback?: SuccessCallback , errorCallback?: ErrorCallback): void {
, errorCallback?: ErrorCallback): void {
this.#sendReceiverMessage( this.#sendReceiverMessage(
{ type: "STOP" { type: "STOP"
@@ -358,38 +359,4 @@ export default class Session {
.then(successCallback) .then(successCallback)
.catch(errorCallback); .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<void>((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);
}
} }

View File

@@ -30,7 +30,6 @@ import { AutoJoinPolicy
, SenderPlatform , SenderPlatform
, SessionStatus , SessionStatus
, VolumeControlType } from "./enums"; , VolumeControlType } from "./enums";
import messaging from "../../messaging";
export * as media from "./media"; export * as media from "./media";

View File

@@ -1,52 +1,40 @@
"use strict"; "use strict";
import logger from "../../../lib/logger";
import { v1 as uuid } from "uuid"; import { v1 as uuid } from "uuid";
import { BreakStatus import logger from "../../../lib/logger";
, EditTracksInfoRequest
, GetStatusRequest
, LiveSeekableRange
, MediaInfo
, PauseRequest
, PlayRequest
, QueueData
, QueueJumpRequest
, QueueInsertItemsRequest
, QueueItem
, QueueSetPropertiesRequest
, QueueRemoveItemsRequest
, QueueReorderItemsRequest
, QueueUpdateItemsRequest
, SeekRequest
, StopRequest
, VideoInformation
, VolumeRequest } from "./dataClasses";
import { Volume, Error as _Error } from "../dataClasses"; 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 import { PlayerState, RepeatMode } from "./enums";
, RepeatMode } from "./enums";
import { ErrorCode } from "../enums"; import { ErrorCode } from "../enums";
import { onMessage, sendMessageResponse } from "../../eventMessageChannel"; import { onMessage, sendMessageResponse } from "../../eventMessageChannel";
import { Callbacks import { ErrorCallback
, ErrorCallback
, SuccessCallback , SuccessCallback
, UpdateListener } from "../../types"; , UpdateListener } from "../../types";
import { SenderMediaMessage } from "../types";
import { SessionMediaMessage } from "../../../types";
export default class Media { export default class Media {
#id = uuid(); #id = uuid();
#isActive = true; #isActive = true;
/**
* Timestamp of last status update
*/
#lastUpdateTime = 0;
#updateListeners = new Set<UpdateListener>(); #updateListeners = new Set<UpdateListener>();
#sendMediaMessageCallbacks = new Map<string, Callbacks>(); #sendMediaMessageCallbacks =
#lastCurrentTime = 0; new Map<string, [ SuccessCallback?, ErrorCallback? ]>();
#listener = onMessage(message => { #listener = onMessage(message => {
if ((message as any).data._id !== this.#id) { if ((message as any).data._id !== this.#id) {
@@ -54,27 +42,24 @@ export default class Media {
} }
switch (message.subject) { switch (message.subject) {
case "shim:media/update": { case "shim:media/updateStatus": {
const status = message.data; const { status } = message.data;
// Store current update time
this.#lastUpdateTime = Date.now();
this.currentTime = status.currentTime; this.currentTime = status.currentTime;
this.#lastCurrentTime = status._lastCurrentTime; this.mediaSessionId = status.mediaSessionId;
this.customData = status.customData;
this.playbackRate = status.playbackRate; this.playbackRate = status.playbackRate;
this.playerState = status.playerState; this.playerState = status.playerState;
this.repeatMode = status.repeatMode; this.repeatMode = status.repeatMode;
this.volume = status.volume;
if (status._volumeLevel && status._volumeMuted) { if (status.customData) {
this.volume = new Volume( this.customData = status.customData;
status._volumeLevel
, status._volumeMuted);
} }
if (status.media) { if (status.media) {
this.media = status.media; this.media = status.media as MediaInfo;
}
if (status.mediaSessionId) {
this.mediaSessionId = status.mediaSessionId;
} }
// Call update listeners // Call update listeners
@@ -106,8 +91,8 @@ export default class Media {
activeTrackIds: Nullable<number[]> = null; activeTrackIds: Nullable<number[]> = null;
breakStatus?: BreakStatus; breakStatus?: BreakStatus;
currentItemId: Nullable<number> = null; currentItemId: Nullable<number> = null;
customData: any = null;
currentTime = 0; currentTime = 0;
customData: any = null;
idleReason: Nullable<string> = null; idleReason: Nullable<string> = null;
items: Nullable<QueueItem[]> = null; items: Nullable<QueueItem[]> = null;
liveSeekableRange?: LiveSeekableRange; liveSeekableRange?: LiveSeekableRange;
@@ -123,10 +108,9 @@ export default class Media {
volume: Volume = new Volume(); volume: Volume = new Volume();
constructor( constructor(public sessionId: string
public sessionId: string , public mediaSessionId: number
, public mediaSessionId: number , _internalSessionId: string) {
, _internalSessionId: string) {
sendMessageResponse({ sendMessageResponse({
subject: "bridge:media/initialize" subject: "bridge:media/initialize"
@@ -143,10 +127,9 @@ export default class Media {
this.#updateListeners.add(listener); this.#updateListeners.add(listener);
} }
editTracksInfo( editTracksInfo(editTracksInfoRequest: EditTracksInfoRequest
editTracksInfoRequest: EditTracksInfoRequest , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this.#sendMediaMessage(
{ type: "EDIT_TRACKS_INFO", ...editTracksInfoRequest }) { type: "EDIT_TRACKS_INFO", ...editTracksInfoRequest })
@@ -171,8 +154,8 @@ export default class Media {
*/ */
getEstimatedTime(): number { getEstimatedTime(): number {
if (this.playerState === PlayerState.PLAYING) { if (this.playerState === PlayerState.PLAYING) {
let estimatedTime = this.currentTime + (this.playbackRate * ( let estimatedTime = this.currentTime +
Date.now() - this.#lastCurrentTime) / 1000); ((Date.now() - this.#lastUpdateTime) / 1000);
// Enforce valid range // Enforce valid range
if (estimatedTime < 0) { if (estimatedTime < 0) {
@@ -192,10 +175,9 @@ export default class Media {
* Request media status from the receiver application. This * Request media status from the receiver application. This
* will also trigger any added media update listeners. * will also trigger any added media update listeners.
*/ */
getStatus( getStatus(getStatusRequest = new GetStatusRequest()
getStatusRequest = new GetStatusRequest() , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this.#sendMediaMessage(
{ type: "MEDIA_GET_STATUS", ...getStatusRequest }) { type: "MEDIA_GET_STATUS", ...getStatusRequest })
@@ -203,10 +185,9 @@ export default class Media {
.catch(errorCallback); .catch(errorCallback);
} }
pause( pause(pauseRequest = new PauseRequest()
pauseRequest = new PauseRequest() , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this.#sendMediaMessage(
{ type: "PAUSE", ...pauseRequest }) { type: "PAUSE", ...pauseRequest })
@@ -214,10 +195,9 @@ export default class Media {
.catch(errorCallback); .catch(errorCallback);
} }
play( play(playRequest = new PlayRequest()
playRequest = new PlayRequest() , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this.#sendMediaMessage(
{ type: "PLAY", ...playRequest }) { type: "PLAY", ...playRequest })
@@ -225,47 +205,52 @@ export default class Media {
.catch(errorCallback); .catch(errorCallback);
} }
queueAppendItem( queueAppendItem(item: QueueItem
item: QueueItem , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage(new QueueInsertItemsRequest([ item ])) this.#sendMediaMessage(
{
...new QueueInsertItemsRequest([ item ])
, type: "QUEUE_INSERT"
})
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
queueInsertItems( queueInsertItems(queueInsertItemsRequest: QueueInsertItemsRequest
queueInsertItemsRequest: QueueInsertItemsRequest , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage(queueInsertItemsRequest) this.#sendMediaMessage(
{
...queueInsertItemsRequest
, type: "QUEUE_INSERT"
})
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
queueJumpToItem( queueJumpToItem(itemId: number
itemId: number , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
if (this.items?.find(item => item.itemId === itemId)) { if (this.items?.find(item => item.itemId === itemId)) {
const jumpRequest = new QueueJumpRequest(); const jumpRequest = new QueueJumpRequest();
jumpRequest.currentItemId = itemId; jumpRequest.currentItemId = itemId;
this.#sendMediaMessage(jumpRequest) this.#sendMediaMessage(
{ ...jumpRequest, type: "QUEUE_UPDATE" })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
} }
queueMoveItemToNewIndex( queueMoveItemToNewIndex(itemId: number
itemId: number , newIndex: number
, newIndex: number , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
// Return early if not in queue // Return early if not in queue
if (!this.items) { if (!this.items) {
@@ -295,41 +280,41 @@ export default class Media {
reorderItemsRequest.insertBefore = existingItem.itemId; reorderItemsRequest.insertBefore = existingItem.itemId;
} }
this.#sendMediaMessage(reorderItemsRequest) this.#sendMediaMessage(
{ ...reorderItemsRequest, type: "QUEUE_REORDER" })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
} }
queueNext( queueNext(successCallback?: SuccessCallback
successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
const jumpRequest = new QueueJumpRequest(); const jumpRequest = new QueueJumpRequest();
jumpRequest.jump = 1; jumpRequest.jump = 1;
this.#sendMediaMessage(jumpRequest) this.#sendMediaMessage(
{ ...jumpRequest, type: "QUEUE_UPDATE" })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
queuePrev( queuePrev(successCallback?: SuccessCallback
successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
const jumpRequest = new QueueJumpRequest(); const jumpRequest = new QueueJumpRequest();
jumpRequest.jump = -1; jumpRequest.jump = -1;
this.#sendMediaMessage(jumpRequest) this.#sendMediaMessage(
{ ...jumpRequest, type: "QUEUE_UPDATE" })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
queueRemoveItem( queueRemoveItem(itemId: number
itemId: number , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
const item = this.items?.find(item => item.itemId === itemId); const item = this.items?.find(item => item.itemId === itemId);
if (item) { if (item) {
this.queueRemoveItems( this.queueRemoveItems(
@@ -338,45 +323,45 @@ export default class Media {
} }
} }
queueRemoveItems( queueRemoveItems(queueRemoveItemsRequest: QueueRemoveItemsRequest
queueRemoveItemsRequest: QueueRemoveItemsRequest , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage(queueRemoveItemsRequest) this.#sendMediaMessage(
{ ...queueRemoveItemsRequest, type: "QUEUE_REMOVE" })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
queueReorderItems( queueReorderItems(queueReorderItemsRequest: QueueReorderItemsRequest
queueReorderItemsRequest: QueueReorderItemsRequest , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage(queueReorderItemsRequest) this.#sendMediaMessage(
{ ...queueReorderItemsRequest, type: "QUEUE_REORDER" })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
queueSetRepeatMode( queueSetRepeatMode(repeatMode: string
repeatMode: string , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
const setPropertiesRequest = new QueueSetPropertiesRequest(); const setPropertiesRequest = new QueueSetPropertiesRequest();
setPropertiesRequest.repeatMode = repeatMode; setPropertiesRequest.repeatMode = repeatMode;
this.#sendMediaMessage(setPropertiesRequest) this.#sendMediaMessage(
{ ...setPropertiesRequest, type: "QUEUE_UPDATE" })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
queueUpdateItems( queueUpdateItems(queueUpdateItemsRequest: QueueUpdateItemsRequest
queueUpdateItemsRequest: QueueUpdateItemsRequest , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage(queueUpdateItemsRequest) this.#sendMediaMessage(
{ ...queueUpdateItemsRequest, type: "QUEUE_UPDATE" })
.then(successCallback) .then(successCallback)
.catch(errorCallback); .catch(errorCallback);
} }
@@ -385,10 +370,9 @@ export default class Media {
this.#updateListeners.delete(listener); this.#updateListeners.delete(listener);
} }
seek( seek(seekRequest: SeekRequest
seekRequest: SeekRequest , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this.#sendMediaMessage(
{ type: "SEEK", ...seekRequest }) { type: "SEEK", ...seekRequest })
@@ -396,10 +380,9 @@ export default class Media {
.catch(errorCallback); .catch(errorCallback);
} }
setVolume( setVolume(volumeRequest: VolumeRequest
volumeRequest: VolumeRequest , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
this.#sendMediaMessage( this.#sendMediaMessage(
{ type: "MEDIA_SET_VOLUME", ...volumeRequest }) { type: "MEDIA_SET_VOLUME", ...volumeRequest })
@@ -407,10 +390,9 @@ export default class Media {
.catch(errorCallback); .catch(errorCallback);
} }
stop( stop(stopRequest?: StopRequest
stopRequest?: StopRequest , successCallback?: SuccessCallback
, successCallback?: SuccessCallback , errorCallback?: ErrorCallback) {
, errorCallback?: ErrorCallback) {
if (!stopRequest) { if (!stopRequest) {
stopRequest = new StopRequest(); stopRequest = new StopRequest();
@@ -434,7 +416,11 @@ export default class Media {
} }
#sendMediaMessage = async (message: SessionMediaMessage) => { #sendMediaMessage = async (
// Allow messages without requestId
message: Omit<SenderMediaMessage, "requestId">
& { requestId?: Nullable<number> }) => {
if (!this.media) { if (!this.media) {
return; return;
} }
@@ -465,10 +451,10 @@ export default class Media {
sendMessageResponse({ sendMessageResponse({
subject: "bridge:media/sendMediaMessage" subject: "bridge:media/sendMediaMessage"
, data: { , data: {
message message
, messageId , messageId
, _id: this.#id , _id: this.#id
} }
}); });
}); });

View File

@@ -8,8 +8,8 @@ import { ContainerType
, HlsVideoSegmentFormat , HlsVideoSegmentFormat
, MetadataType , MetadataType
, RepeatMode , RepeatMode
, StreamType , ResumeState, StreamType
, UserAction } from "./enums"; , TrackType, UserAction } from "./enums";
export class AudiobookChapterMediaMetadata { export class AudiobookChapterMediaMetadata {
@@ -93,12 +93,12 @@ export class EditTracksInfoRequest {
export class GenericMediaMetadata { export class GenericMediaMetadata {
images?: Image[]; images?: Image[];
metadataType: number = MetadataType.GENERIC; metadataType = MetadataType.GENERIC;
releaseDate?: string; releaseDate?: string;
releaseYear?: number; releaseYear?: number;
subtitle?: string; subtitle?: string;
title?: string; title?: string;
type: number = MetadataType.GENERIC; type = MetadataType.GENERIC;
} }
@@ -126,7 +126,7 @@ export class LoadRequest {
media: MediaInfo; media: MediaInfo;
requestId = 0; requestId = 0;
sessionId: Nullable<string> = null; sessionId: Nullable<string> = null;
type = "LOAD"; type: "LOAD" = "LOAD";
constructor(mediaInfo: MediaInfo) { constructor(mediaInfo: MediaInfo) {
this.media = mediaInfo; this.media = mediaInfo;
@@ -134,7 +134,7 @@ export class LoadRequest {
} }
type Metadata = export type Metadata =
GenericMediaMetadata GenericMediaMetadata
| MovieMediaMetadata | MovieMediaMetadata
| MusicTrackMediaMetadata | MusicTrackMediaMetadata
@@ -183,13 +183,13 @@ export class MediaMetadata {
export class MovieMediaMetadata { export class MovieMediaMetadata {
images?: Image[]; images?: Image[];
metadataType: number = MetadataType.MOVIE; metadataType = MetadataType.MOVIE;
releaseDate?: string; releaseDate?: string;
releaseYear?: number; releaseYear?: number;
studio?: string; studio?: string;
subtitle?: string; subtitle?: string;
title?: string; title?: string;
type: number = MetadataType.MOVIE; type = MetadataType.MOVIE;
} }
@@ -201,13 +201,13 @@ export class MusicTrackMediaMetadata {
composer?: string; composer?: string;
discNumber?: number; discNumber?: number;
images?: Image[]; images?: Image[];
metadataType: number = MetadataType.MUSIC_TRACK; metadataType = MetadataType.MUSIC_TRACK;
releaseDate?: string; releaseDate?: string;
releaseYear?: number; releaseYear?: number;
songName?: string; songName?: string;
title?: string; title?: string;
trackNumber?: number; trackNumber?: number;
type: number = MetadataType.MUSIC_TRACK; type = MetadataType.MUSIC_TRACK;
} }
@@ -224,9 +224,9 @@ export class PhotoMediaMetadata {
latitude?: number; latitude?: number;
location?: string; location?: string;
longitude?: number; longitude?: number;
metadataType: number = MetadataType.PHOTO; metadataType = MetadataType.PHOTO;
title?: string; title?: string;
type: number = MetadataType.PHOTO; type = MetadataType.PHOTO;
width?: number; width?: number;
} }
@@ -347,7 +347,7 @@ export class QueueUpdateItemsRequest {
export class SeekRequest { export class SeekRequest {
currentTime: Nullable<number> = null; currentTime: Nullable<number> = null;
customData: any = null; customData: any = null;
resumeState: Nullable<string> = null; resumeState: Nullable<ResumeState> = null;
} }
@@ -382,7 +382,7 @@ export class Track {
constructor( constructor(
public trackId: number public trackId: number
, public type: string) {} , public type: TrackType) {}
} }
@@ -398,7 +398,7 @@ export class TvShowMediaMetadata {
seasonNumber?: number; seasonNumber?: number;
seriesTitle?: string; seriesTitle?: string;
title?: string; title?: string;
type: number = MetadataType.TV_SHOW; type = MetadataType.TV_SHOW;
} }

157
ext/src/shim/cast/types.ts Normal file
View File

@@ -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<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: Partial<Volume> }
| MediaReqBase & { type: "SET_PLAYBACK_RATE" , playbackRate: number }
| MediaReqBase & {
type: "LOAD"
, activeTrackIds: Nullable<number[]>
, atvCredentials?: string
, atvCredentialsType?: string
, autoplay: Nullable<boolean>
, currentTime: Nullable<number>
, customData: any
, media: MediaInfo
, requestId: number
, sessionId: Nullable<string>
}
| MediaReqBase & {
type: "SEEK"
, resumeState: Nullable<ResumeState>
, currentTime: Nullable<number>
}
| MediaReqBase & {
type: "EDIT_TRACKS_INFO"
, activeTrackIds: Nullable<number[]>
, textTrackStyle: Nullable<string>
}
// QueueLoadRequest
| MediaReqBase & {
type: "QUEUE_LOAD"
, items: QueueItem[]
, startIndex: number
, repeatMode: string
, sessionId: Nullable<string>
}
// QueueInsertItemsRequest
| MediaReqBase & {
type: "QUEUE_INSERT"
, items: QueueItem[]
, insertBefore: Nullable<number>
, sessionId: Nullable<string>
}
// QueueUpdateItemsRequest
| MediaReqBase & {
type: "QUEUE_UPDATE"
, items: QueueItem[]
, sessionId: Nullable<string>
}
// QueueJumpRequest
| MediaReqBase & {
type: "QUEUE_UPDATE"
, jump: Nullable<number>
, currentItemId: Nullable<number>
, sessionId: Nullable<number>
}
// QueueRemoveItemsRequest
| MediaReqBase & {
type: "QUEUE_REMOVE"
, itemIds: number[]
, sessionId: Nullable<string>
}
// QueueReorderItemsRequest
| MediaReqBase & {
type: "QUEUE_REORDER"
, itemIds: number[]
, insertBefore: Nullable<number>
, sessionId: Nullable<string>
}
// QueueSetPropertiesRequest
| MediaReqBase & {
type: "QUEUE_UPDATE"
, repeatMode: Nullable<string>
, sessionId: Nullable<string>
};
export type ReceiverMediaMessage =
MediaReqBase & { type: "MEDIA_STATUS", status: MediaStatus[] }
| MediaReqBase & { type: "INVALID_PLAYER_STATE" }
| MediaReqBase & { type: "LOAD_FAILED" }
| MediaReqBase & { type: "LOAD_CANCELLED" }
| MediaReqBase & { type: "INVALID_REQUEST" };

View File

@@ -11,6 +11,3 @@ export type MediaListener = (media: Media) => void;
export type MessageListener = (namespace: string, message: string) => void; export type MessageListener = (namespace: string, message: string) => void;
export type UpdateListener = (isAlive: boolean) => void; export type UpdateListener = (isAlive: boolean) => void;
export type LoadSuccessCallback = (media: Media) => void; export type LoadSuccessCallback = (media: Media) => void;
export type Callbacks = [ SuccessCallback?, ErrorCallback? ];
export type CallbacksMap = Map<string, Callbacks>;

View File

@@ -1,13 +1,6 @@
"use strict"; "use strict";
import { Volume } from "./shim/cast/dataClasses"; import { ReceiverStatus } from "./shim/cast/types";
import { EditTracksInfoRequest, GetStatusRequest, LoadRequest, PauseRequest
, PlayRequest, QueueInsertItemsRequest, QueueJumpRequest
, QueueLoadRequest, QueueRemoveItemsRequest, QueueReorderItemsRequest
, QueueSetPropertiesRequest, QueueUpdateItemsRequest, SeekRequest
, StopRequest, VolumeRequest } from "./shim/cast/media";
export interface ReceiverDevice { export interface ReceiverDevice {
host: string host: string
@@ -16,49 +9,3 @@ export interface ReceiverDevice {
, port: number , port: number
, status?: ReceiverStatus , 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<Volume> };