Implement auto join policy handling

This commit is contained in:
Matt Hensman
2022-09-09 23:32:49 +01:00
committed by GitHub
parent f76e8194fb
commit 3eba371c5f
7 changed files with 493 additions and 313 deletions

View File

@@ -17,32 +17,78 @@ import {
} from "../types"; } from "../types";
import type { ApiConfig } from "../cast/sdk/classes"; import type { ApiConfig } from "../cast/sdk/classes";
import { ReceiverAction } from "../cast/sdk/enums"; import { AutoJoinPolicy, ReceiverAction } from "../cast/sdk/enums";
import { DEFAULT_MEDIA_RECEIVER_APP_ID } from "../cast/sdk/media";
import { createReceiver } from "../cast/utils"; import { createReceiver } from "../cast/utils";
import deviceManager from "./deviceManager";
import ReceiverSelector, { import ReceiverSelector, {
ReceiverSelection, ReceiverSelection,
ReceiverSelectorMediaMessage, ReceiverSelectorMediaMessage,
ReceiverSelectorReceiverMessage ReceiverSelectorReceiverMessage
} from "./ReceiverSelector"; } from "./ReceiverSelector";
import deviceManager from "./deviceManager";
type AnyPort = Port | TypedMessagePort<Message>; type AnyPort = Port | TypedMessagePort<Message>;
export interface ContentContext { export interface ContentContext {
tabId: number; tabId: number;
frameId: number; frameId: number;
origin?: string;
}
/** Checks if two content contexts match. */
function isSameContext(ctx1?: ContentContext, ctx2?: ContentContext) {
if (!ctx1 || !ctx2) return false;
return ctx1?.tabId === ctx2?.tabId && ctx1?.frameId === ctx2?.frameId;
} }
interface CastSession { interface CastSession {
sessionId: string; bridgePort: Port;
deviceId: string; deviceId: string;
appId: string;
sessionId?: string;
initialContentContext?: ContentContext;
}
/** Creates a cast session object and sets up messaging. */
async function createCastSession(opts: {
deviceId: string;
instance: CastInstance;
appId?: string;
}) {
// If not explicitly provided, use session request app ID
if (!opts.appId) {
if (!opts.instance.apiConfig?.sessionRequest) {
throw logger.error(
"App ID not provided and instance missing valid session request!"
);
}
opts.appId = opts.instance.apiConfig.sessionRequest.appId;
}
const session: CastSession = {
bridgePort: await bridge.connect(),
deviceId: opts.deviceId,
appId: opts.appId,
initialContentContext: opts.instance.contentContext
};
opts.instance.session = session;
opts.instance.bridgeMessageListener = message => {
handleBridgeMessage(opts.instance, message);
};
session.bridgePort.onMessage.addListener(
opts.instance.bridgeMessageListener
);
session.bridgePort.onDisconnect.addListener(() =>
destroyCastInstance(opts.instance)
);
return session;
} }
export interface CastInstance { export interface CastInstance {
bridgePort: Port;
contentPort: AnyPort; contentPort: AnyPort;
contentContext?: ContentContext; contentContext?: ContentContext;
@@ -53,17 +99,18 @@ export interface CastInstance {
apiConfig?: ApiConfig; apiConfig?: ApiConfig;
/** Established session details. */ /** Established session details. */
session?: CastSession; session?: CastSession;
/** Listener for bridge messages. */
bridgeMessageListener?: (message: Message) => void;
} }
/** Creates a cast instance object and associated bridge instance. */ /** Creates a cast instance object and associated bridge instance. */
async function createCastInstance(opts: { function createCastInstance(opts: {
bridgePort?: Port;
contentPort: AnyPort; contentPort: AnyPort;
contentContext?: { tabId: number; frameId?: number }; contentContext?: { tabId: number; frameId?: number };
isTrusted?: boolean; isTrusted?: boolean;
}) { }) {
const instance: CastInstance = { const instance: CastInstance = {
bridgePort: opts.bridgePort ?? (await bridge.connect()),
contentPort: opts.contentPort, contentPort: opts.contentPort,
isTrusted: opts.isTrusted ?? false isTrusted: opts.isTrusted ?? false
}; };
@@ -81,35 +128,43 @@ async function createCastInstance(opts: {
!(opts.contentPort instanceof MessagePort) && !(opts.contentPort instanceof MessagePort) &&
opts.contentPort.sender?.tab?.id opts.contentPort.sender?.tab?.id
) { ) {
// Get origin from content port
let origin: Optional<string>;
if (opts.contentPort.sender?.tab?.url) {
try {
({ origin } = new URL(opts.contentPort.sender.tab.url));
// eslint-disable-next-line no-empty
} catch {}
}
instance.contentContext = { instance.contentContext = {
tabId: opts.contentPort.sender.tab.id, tabId: opts.contentPort.sender.tab.id,
frameId: opts.contentPort.sender.frameId ?? 0 frameId: opts.contentPort.sender.frameId ?? 0,
origin
}; };
} }
return instance; return instance;
} }
/** Disconnects either instance content port type. */ /** Removes cast instance and disconnects messaging ports. */
function disconnectContentPort(port: AnyPort) { function destroyCastInstance(instance: CastInstance) {
if (port instanceof MessagePort) { if (instance.contentPort instanceof MessagePort) {
port.close(); instance.contentPort.close();
} else { } else {
port.disconnect(); instance.contentPort.disconnect();
} }
if (instance.session && instance.bridgeMessageListener) {
instance.session.bridgePort.onMessage.removeListener(
instance.bridgeMessageListener
);
}
activeInstances.delete(instance);
} }
/** Checks if two content contexts match. */ /** Whitelist of safe message types from content. */
function isSameContext(ctx1?: ContentContext, ctx2?: ContentContext) {
if (!ctx1 || !ctx2) return false;
return ctx1?.tabId === ctx2?.tabId && ctx1?.frameId === ctx2?.frameId;
}
let baseConfig: BaseConfig;
let receiverSelector: Optional<ReceiverSelector>;
const activeInstances = new Set<CastInstance>();
const allowedContentMessages: Array<Message["subject"]> = [ const allowedContentMessages: Array<Message["subject"]> = [
"main:initializeCastSdk", "main:initializeCastSdk",
"main:requestSession", "main:requestSession",
@@ -117,6 +172,17 @@ const allowedContentMessages: Array<Message["subject"]> = [
"bridge:sendCastSessionMessage" "bridge:sendCastSessionMessage"
]; ];
/** Chromecast base config to check compatibility with audio devices. */
let baseConfig: BaseConfig;
/** Shared receiver selector. */
let receiverSelector: Optional<ReceiverSelector>;
/** Set of active cast instances. */
const activeInstances = new Set<CastInstance>();
/** Map of active session IDs to session info objects. */
const activeSessions = new Map<string, CastSession>();
/** Keeps track of cast API instances and provides bridge messaging. */ /** Keeps track of cast API instances and provides bridge messaging. */
const castManager = new (class { const castManager = new (class {
async init() { async init() {
@@ -130,7 +196,7 @@ const castManager = new (class {
} }
}); });
// Pass receiver availability updates to cast API. // Pass receiver availability updates to cast API
const updateReceiverAvailability = () => { const updateReceiverAvailability = () => {
const isAvailable = deviceManager.getDevices().length > 0; const isAvailable = deviceManager.getDevices().length > 0;
@@ -147,6 +213,25 @@ const castManager = new (class {
"deviceDown", "deviceDown",
updateReceiverAvailability updateReceiverAvailability
); );
deviceManager.addEventListener("applicationClosed", ev => {
const session = activeSessions.get(ev.detail.sessionId);
if (!session?.sessionId) return;
// Remove session from instances and notify SDK
for (const instance of activeInstances) {
if (instance.session === session) {
instance.contentPort.postMessage({
subject: "cast:sessionStopped",
data: { sessionId: session.sessionId }
});
delete instance.session;
}
}
activeSessions.delete(session.sessionId);
});
} }
/** /**
@@ -200,7 +285,6 @@ const castManager = new (class {
contentContext?: ContentContext contentContext?: ContentContext
): Promise<CastInstance> { ): Promise<CastInstance> {
const instance = await createCastInstance({ const instance = await createCastInstance({
bridgePort: await bridge.connect(),
contentPort, contentPort,
contentContext, contentContext,
isTrusted: true isTrusted: true
@@ -210,26 +294,15 @@ const castManager = new (class {
if (contentContext) { if (contentContext) {
for (const instance of activeInstances) { for (const instance of activeInstances) {
if (isSameContext(instance.contentContext, contentContext)) { if (isSameContext(instance.contentContext, contentContext)) {
instance.bridgePort.disconnect(); destroyCastInstance(instance);
activeInstances.delete(instance);
break; break;
} }
} }
} }
instance.bridgePort.onDisconnect.addListener(() => {
contentPort.close();
activeInstances.delete(instance);
});
// bridge -> cast instance
instance.bridgePort.onMessage.addListener(message => {
this.handleBridgeMessage(instance, message);
});
// cast instance -> (any) // cast instance -> (any)
contentPort.addEventListener("message", ev => { contentPort.addEventListener("message", ev => {
this.handleContentMessage(instance, ev.data); handleContentMessage(instance, ev.data);
}); });
contentPort.start(); contentPort.start();
@@ -252,220 +325,21 @@ const castManager = new (class {
); );
} }
// Ensure only one instance per context
for (const instance of activeInstances) {
if (
isSameContext(
instance.contentContext,
contentPort.sender as ContentContext
)
) {
instance.bridgePort.disconnect();
disconnectContentPort(instance.contentPort);
break;
}
}
const instance = await createCastInstance({ contentPort, isTrusted }); const instance = await createCastInstance({ contentPort, isTrusted });
// cast instance -> (any) // cast instance -> (any)
const onContentPortMessage = (message: Message) => { const onContentPortMessage = (message: Message) => {
this.handleContentMessage(instance, message); handleContentMessage(instance, message);
};
// bridge -> cast instance
const onBridgePortMessage = (message: Message) => {
this.handleBridgeMessage(instance, message);
}; };
const onDisconnect = () => {
instance.bridgePort.onMessage.removeListener(onBridgePortMessage);
contentPort.onMessage.removeListener(onContentPortMessage);
instance.bridgePort.disconnect();
contentPort.disconnect();
activeInstances.delete(instance);
};
instance.bridgePort.onDisconnect.addListener(onDisconnect);
instance.bridgePort.onMessage.addListener(onBridgePortMessage);
contentPort.onDisconnect.addListener(onDisconnect);
contentPort.onMessage.addListener(onContentPortMessage); contentPort.onMessage.addListener(onContentPortMessage);
contentPort.onDisconnect.addListener(() => {
destroyCastInstance(instance);
});
return instance; return instance;
} }
private async handleBridgeMessage(
instance: CastInstance,
message: Message
) {
// Intercept messages to store relevant info
switch (message.subject) {
case "main:castSessionCreated": {
// Close after session is created
if (
receiverSelector?.isOpen &&
// If selector context is the same as the instance context
isSameContext(
receiverSelector.pageInfo,
instance.contentContext
) &&
// If selector is supposed to close
(await options.get("receiverSelectorWaitForConnection"))
) {
receiverSelector.close();
}
const { receiverId: deviceId } = message.data;
instance.session = {
deviceId,
sessionId: message.data.sessionId
};
const device = deviceManager.getDeviceById(deviceId);
if (!device) {
logger.error(
"[on main:castSessionCreated]: Could not find device with ID:",
deviceId
);
break;
}
instance.contentPort.postMessage({
subject: "cast:sessionCreated",
data: {
...message.data,
receiver: createReceiver(device)
}
});
break;
}
case "main:castSessionUpdated":
instance.contentPort.postMessage({
subject: "cast:sessionUpdated",
data: message.data
});
}
instance.contentPort.postMessage(message);
}
/**
* Handle content messages from the cast instance. These will either
* be handled here in the background script or forwarded to the
* bridge associated with the cast instance.
*/
private async handleContentMessage(
instance: CastInstance,
message: Message
) {
// Limit untrusted instances to allowed messages subset
if (
!allowedContentMessages.includes(message.subject) &&
!instance.isTrusted
) {
logger.error(`Forbidden message type! (${message.subject})`);
disconnectContentPort(instance.contentPort);
return;
}
const [destination] = message.subject.split(":");
if (destination === "bridge") {
instance.bridgePort.postMessage(message);
}
switch (message.subject) {
case "main:initializeCastSdk":
instance.apiConfig = message.data.apiConfig;
instance.contentPort.postMessage({
subject: "cast:receiverAvailabilityUpdated",
data: {
isAvailable: deviceManager.getDevices().length > 0
}
});
break;
// User has triggered receiver selection via the cast API
case "main:requestSession": {
const { sessionRequest, receiverDevice } = message.data;
// Handle trusted instance receiver selection bypass
if (receiverDevice) {
if (!instance.isTrusted) {
logger.error(
"Cast instance not trusted to bypass receiver selection!"
);
disconnectContentPort(instance.contentPort);
break;
}
instance.bridgePort.postMessage({
subject: "bridge:createCastSession",
data: {
appId: sessionRequest.appId,
receiverDevice
}
});
break;
}
try {
const selection = await getReceiverSelection({
castInstance: instance
});
// Handle cancellation
if (!selection) {
instance.contentPort.postMessage({
subject: "cast:sessionRequestCancelled"
});
break;
}
/**
* If the media type returned from the selector has
* been changed, we need to cancel the current
* sender and switch it out for the right one.
*/
if (selection.mediaType !== ReceiverSelectorMediaType.App) {
instance.contentPort.postMessage({
subject: "cast:sessionRequestCancelled"
});
if (!instance.contentContext) {
throw logger.error("Missing content context");
}
this.loadSender(selection, instance.contentContext);
break;
}
instance.bridgePort.postMessage({
subject: "bridge:createCastSession",
data: {
appId: sessionRequest.appId,
receiverDevice: selection.device
}
});
} catch (err) {
// TODO: Report errors properly
instance.contentPort.postMessage({
subject: "cast:sessionRequestCancelled"
});
}
break;
}
}
}
/** /**
* Gets a receiver selection and loads the appropriate sender for a * Gets a receiver selection and loads the appropriate sender for a
* given context. * given context.
@@ -481,63 +355,344 @@ const castManager = new (class {
if (!selection) return; if (!selection) return;
this.loadSender(selection, { tabId, frameId }); loadSender(selection, { tabId, frameId });
} }
})();
/** export default castManager;
* Loads the appropriate sender for a given receiver selector
* response. /** Handles messages to cast instances from bridge. */
*/ async function handleBridgeMessage(instance: CastInstance, message: Message) {
private async loadSender( // Intercept messages to store relevant info
selection: ReceiverSelection, switch (message.subject) {
contentContext: ContentContext case "main:castSessionCreated": {
) { // Close after session is created
// Cancelled if (
if (!selection) { receiverSelector?.isOpen &&
return; // If selector context is the same as the instance context
isSameContext(
receiverSelector.pageInfo,
instance.contentContext
) &&
// If selector is supposed to close
(await options.get("receiverSelectorWaitForConnection"))
) {
receiverSelector.close();
}
const { receiverId: deviceId } = message.data;
if (!instance.session) {
logger.error("Instance is missing session!");
break;
}
instance.session.sessionId = message.data.sessionId;
activeSessions.set(message.data.sessionId, instance.session);
const device = deviceManager.getDeviceById(deviceId);
if (!device) {
logger.error(
"[on main:castSessionCreated]: Could not find device with ID:",
deviceId
);
break;
}
instance.contentPort.postMessage({
subject: "cast:sessionCreated",
data: {
...message.data,
receiver: createReceiver(device)
}
});
break;
} }
switch (selection.mediaType) { case "main:castSessionUpdated":
case ReceiverSelectorMediaType.App: { instance.contentPort.postMessage({
const instance = this.getInstanceAt( subject: "cast:sessionUpdated",
contentContext.tabId, data: message.data
contentContext.frameId });
); }
if (!instance) {
throw logger.error( instance.contentPort.postMessage(message);
`Cast instance not found at tabId ${contentContext.tabId} / frameId ${contentContext.frameId}` }
);
/**
* Handle content messages from the cast instance. These will either
* be handled here in the background script or forwarded to the
* bridge associated with the cast instance.
*/
async function handleContentMessage(instance: CastInstance, message: Message) {
// Limit untrusted instances to allowed messages subset
if (
!allowedContentMessages.includes(message.subject) &&
!instance.isTrusted
) {
logger.error(`Forbidden message type! (${message.subject})`);
destroyCastInstance(instance);
return;
}
const [destination] = message.subject.split(":");
if (destination === "bridge") {
instance.session?.bridgePort.postMessage(message);
}
switch (message.subject) {
case "main:initializeCastSdk":
instance.apiConfig = message.data.apiConfig;
instance.contentPort.postMessage({
subject: "cast:receiverAvailabilityUpdated",
data: {
isAvailable: deviceManager.getDevices().length > 0
}
});
// No need to check for existing sessions if page-scoped
if (
instance.apiConfig.autoJoinPolicy === AutoJoinPolicy.PAGE_SCOPED
) {
break;
}
// Check existing sessions for a valid auto join target
sessionLoop: for (const [, session] of activeSessions) {
if (
!session.sessionId ||
session.appId !== instance.apiConfig.sessionRequest.appId
) {
continue;
} }
if (!instance.apiConfig?.sessionRequest.appId) { switch (instance.apiConfig.autoJoinPolicy) {
throw logger.error("Invalid session request"); case AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED:
} // Ensure matching content tontext
if (
!isSameContext(
instance.contentContext,
session.initialContentContext
)
)
break;
// eslint-disable-next-line no-fallthrough
case AutoJoinPolicy.ORIGIN_SCOPED: {
// Ensure matching origin
if (
instance.contentContext?.origin !==
session.initialContentContext?.origin
)
break;
instance.contentPort.postMessage({ instance.session = session;
subject: "cast:receiverAction", instance.bridgeMessageListener = message =>
data: { handleBridgeMessage(instance, message);
receiver: createReceiver(selection.device),
action: ReceiverAction.CAST session.bridgePort.onMessage.addListener(
instance.bridgeMessageListener
);
session.bridgePort.onDisconnect.addListener(() =>
destroyCastInstance(instance)
);
const device = deviceManager.getDeviceById(
session.deviceId
);
if (!device?.status?.applications?.length) {
throw logger.error("Invalid device state");
}
/**
* Re-create sessionCreated message. Since the
* sender app hasn't requested a session, this
* will be handled by calling the session
* listener.
*/
const application = device?.status?.applications[0];
instance.contentPort.postMessage({
subject: "cast:sessionCreated",
data: {
appId: application.appId,
appImages: [],
displayName: application.displayName,
namespaces: application.namespaces,
receiver: createReceiver(device),
receiverFriendlyName: device.friendlyName,
receiverId: device.id,
senderApps: [],
sessionId: session.sessionId,
statusText: application.statusText,
transportId: session.sessionId,
volume: device.status.volume
}
});
break sessionLoop;
} }
}
}
break;
// User has triggered receiver selection via the cast API
case "main:requestSession": {
const { sessionRequest, receiverDevice } = message.data;
// Handle trusted instance receiver selection bypass
if (receiverDevice) {
if (receiverSelector?.isOpen && instance.contentContext) {
receiverSelector.pageInfo = {
...instance.contentContext,
url: (
await browser.webNavigation.getFrame({
tabId: instance.contentContext?.tabId,
frameId: instance.contentContext?.frameId
})
).url
};
}
if (!instance.isTrusted) {
logger.error(
"Cast instance not trusted to bypass receiver selection!"
);
destroyCastInstance(instance);
break;
}
const session = await createCastSession({
instance,
deviceId: receiverDevice.id,
appId: sessionRequest.appId
}); });
instance.bridgePort.postMessage({ session.bridgePort.postMessage({
subject: "bridge:createCastSession", subject: "bridge:createCastSession",
data: { data: {
appId: instance.apiConfig?.sessionRequest.appId, appId: sessionRequest.appId,
receiverDevice: selection.device receiverDevice
} }
}); });
break; break;
} }
case ReceiverSelectorMediaType.Screen: try {
await createMirroringPopup(selection.device); const selection = await getReceiverSelection({
break; castInstance: instance
});
// Handle cancellation
if (!selection) {
instance.contentPort.postMessage({
subject: "cast:sessionRequestCancelled"
});
break;
}
/**
* If the media type returned from the selector has
* been changed, we need to cancel the current
* sender and switch it out for the right one.
*/
if (selection.mediaType !== ReceiverSelectorMediaType.App) {
instance.contentPort.postMessage({
subject: "cast:sessionRequestCancelled"
});
if (!instance.contentContext) {
throw logger.error("Missing content context");
}
loadSender(selection, instance.contentContext);
break;
}
const session = await createCastSession({
instance,
deviceId: selection.device.id,
appId: sessionRequest.appId
});
session.bridgePort.postMessage({
subject: "bridge:createCastSession",
data: {
appId: sessionRequest.appId,
receiverDevice: selection.device
}
});
} catch (err) {
// TODO: Report errors properly
instance.contentPort.postMessage({
subject: "cast:sessionRequestCancelled"
});
}
break;
} }
} }
})(); }
/**
* Loads the appropriate sender for a given receiver selector response.
*/
async function loadSender(
selection: ReceiverSelection,
contentContext: ContentContext
) {
// Cancelled
if (!selection) {
return;
}
switch (selection.mediaType) {
case ReceiverSelectorMediaType.App: {
const instance = castManager.getInstanceAt(
contentContext.tabId,
contentContext.frameId
);
if (!instance) {
throw logger.error(
`Cast instance not found at tabId ${contentContext.tabId} / frameId ${contentContext.frameId}`
);
}
if (!instance.apiConfig?.sessionRequest.appId) {
throw logger.error("Invalid session request");
}
instance.contentPort.postMessage({
subject: "cast:receiverAction",
data: {
receiver: createReceiver(selection.device),
action: ReceiverAction.CAST
}
});
const session = await createCastSession({
instance,
deviceId: selection.device.id
});
session.bridgePort.postMessage({
subject: "bridge:createCastSession",
data: {
appId: session.appId,
receiverDevice: selection.device
}
});
break;
}
case ReceiverSelectorMediaType.Screen:
await createMirroringPopup(selection.device);
break;
}
}
/** /**
* Opens a receiver selector with the specified default/available media * Opens a receiver selector with the specified default/available media
@@ -596,15 +751,9 @@ async function getReceiverSelection(selectionOpts: {
selectionOpts.tabId, selectionOpts.tabId,
selectionOpts.frameId selectionOpts.frameId
); );
/**
* If the app in that context is the extension mirroring app or // Ignore extension senders
* the default receiver, just ignore it. if (!contextInstance?.isTrusted) {
*/
const contextAppId = contextInstance?.apiConfig?.sessionRequest.appId;
if (
contextAppId !== opts.mirroringAppId &&
contextAppId !== DEFAULT_MEDIA_RECEIVER_APP_ID
) {
selectionOpts.castInstance = contextInstance; selectionOpts.castInstance = contextInstance;
} }
} }
@@ -754,7 +903,7 @@ function createSelector() {
const onDeviceChange = () => { const onDeviceChange = () => {
const connectedSessionIds: string[] = []; const connectedSessionIds: string[] = [];
for (const instance of activeInstances) { for (const instance of activeInstances) {
if (instance.session) { if (instance.session?.sessionId) {
connectedSessionIds.push(instance.session.sessionId); connectedSessionIds.push(instance.session.sessionId);
} }
} }
@@ -793,6 +942,7 @@ function createSelector() {
return selector; return selector;
} }
/** Creates and manages mirroring popup window. */
async function createMirroringPopup(device: ReceiverDevice) { async function createMirroringPopup(device: ReceiverDevice) {
let popup: browser.windows.Window; let popup: browser.windows.Window;
try { try {
@@ -826,5 +976,3 @@ async function createMirroringPopup(device: ReceiverDevice) {
browser.windows.onRemoved.removeListener(onWindowRemoved); browser.windows.onRemoved.removeListener(onWindowRemoved);
}); });
} }
export default castManager;

View File

@@ -16,14 +16,11 @@ import { PlayerState } from "../cast/sdk/media/enums";
interface EventMap { interface EventMap {
deviceUp: { deviceInfo: ReceiverDevice }; deviceUp: { deviceInfo: ReceiverDevice };
deviceDown: { deviceId: string }; deviceDown: { deviceId: string };
deviceUpdated: { deviceUpdated: { deviceId: string; status: ReceiverStatus };
deviceId: string; deviceMediaUpdated: { deviceId: string; status: MediaStatus };
status: ReceiverStatus;
}; applicationFound: { deviceId: string; appId: string };
deviceMediaUpdated: { applicationClosed: { deviceId: string; appId: string; sessionId: string };
deviceId: string;
status: MediaStatus;
};
} }
export default new (class extends TypedEventTarget<EventMap> { export default new (class extends TypedEventTarget<EventMap> {
@@ -168,10 +165,25 @@ export default new (class extends TypedEventTarget<EventMap> {
const device = this.receiverDevices.get(deviceId); const device = this.receiverDevices.get(deviceId);
if (!device) break; if (!device) break;
const oldApplication = device.status?.applications?.[0];
// Clear media status when app status changes // Clear media status when app status changes
const application = status.applications?.[0]; const application = status.applications?.[0];
if (!application || application.isIdleScreen) { if (!application || application.isIdleScreen) {
delete device.mediaStatus; delete device.mediaStatus;
// Send application closed event
if (oldApplication && !oldApplication.isIdleScreen) {
this.dispatchEvent(
new CustomEvent("applicationClosed", {
detail: {
deviceId,
appId: oldApplication.appId,
sessionId: oldApplication.transportId
}
})
);
}
} }
device.status = status; device.status = status;
@@ -185,6 +197,19 @@ export default new (class extends TypedEventTarget<EventMap> {
}) })
); );
// Send new application found event
if (
!oldApplication &&
application &&
!application.isIdleScreen
) {
this.dispatchEvent(
new CustomEvent("applicationFound", {
detail: { deviceId, appId: application.appId }
})
);
}
break; break;
} }

View File

@@ -16,7 +16,7 @@ export default {
"CA5E8412": { name: "Netflix", matches: "https://www.netflix.com/*" }, "CA5E8412": { name: "Netflix", matches: "https://www.netflix.com/*" },
"233637DE": { name: "YouTube", matches: "https://www.youtube.com/*" }, "233637DE": { name: "YouTube", matches: "https://www.youtube.com/*" },
"CC32E753": { name: "Spotify", matches: "https://open.spotify.com/*" }, "CC32E753": { name: "Spotify", matches: "https://open.spotify.com/*" },
"5E81F6DB": { "2BA92214": {
name: "BBC iPlayer", name: "BBC iPlayer",
matches: "https://www.bbc.co.uk/iplayer*" matches: "https://www.bbc.co.uk/iplayer*"
}, },

View File

@@ -12,8 +12,8 @@ import type {
SenderMessage SenderMessage
} from "./types"; } from "./types";
import { SessionStatus } from "./enums"; import { ErrorCode, SessionStatus } from "./enums";
import type { import {
Error as CastError, Error as CastError,
Image, Image,
Receiver, Receiver,
@@ -268,6 +268,11 @@ export default class Session {
successCallback?: (media: Media) => void, successCallback?: (media: Media) => void,
errorCallback?: (err: CastError) => void errorCallback?: (err: CastError) => void
) { ) {
if (!loadRequest) {
errorCallback?.(new CastError(ErrorCode.INVALID_PARAMETER));
return;
}
this.#loadMediaSuccessCallback = successCallback; this.#loadMediaSuccessCallback = successCallback;
this.#loadMediaErrorCallback = errorCallback; this.#loadMediaErrorCallback = errorCallback;

View File

@@ -222,7 +222,7 @@ export default class {
case "cast:sessionStopped": { case "cast:sessionStopped": {
const { sessionId } = message.data; const { sessionId } = message.data;
const session = this.#sessions.get(sessionId); const session = this.#sessions.get(sessionId);
if (session) { if (session?.status === SessionStatus.CONNECTED) {
session.status = SessionStatus.STOPPED; session.status = SessionStatus.STOPPED;
const updateListeners = SessionUpdateListeners.get(session); const updateListeners = SessionUpdateListeners.get(session);
@@ -349,7 +349,7 @@ export default class {
}); });
} }
requestSessionById(_sessionId: string): void { requestSessionById(_sessionId: string) {
logger.info("STUB :: cast.requestSessionById"); logger.info("STUB :: cast.requestSessionById");
} }
@@ -357,15 +357,15 @@ export default class {
_receivers: Receiver[], _receivers: Receiver[],
_successCallback?: () => void, _successCallback?: () => void,
_errorCallback?: (err: CastError) => void _errorCallback?: (err: CastError) => void
): void { ) {
logger.info("STUB :: cast.setCustomReceivers"); logger.info("STUB :: cast.setCustomReceivers");
} }
setPageContext(_win: Window): void { setPageContext(_win: Window) {
logger.info("STUB :: cast.setPageContext"); logger.info("STUB :: cast.setPageContext");
} }
setReceiverDisplayStatus(_sessionId: string): void { setReceiverDisplayStatus(_sessionId: string) {
logger.info("STUB :: cast.setReceiverDisplayStatus"); logger.info("STUB :: cast.setReceiverDisplayStatus");
} }

View File

@@ -4,7 +4,7 @@ import options from "../../lib/options";
import type { Message } from "../../messaging"; import type { Message } from "../../messaging";
// Cast types // Cast types
import type { ReceiverAvailability } from "../sdk/enums"; import { AutoJoinPolicy, ReceiverAvailability } from "../sdk/enums";
import type Session from "../sdk/Session"; import type Session from "../sdk/Session";
import type Media from "../sdk/media/Media"; import type Media from "../sdk/media/Media";
@@ -88,7 +88,8 @@ export default class MediaSender {
capabilities capabilities
), ),
this.sessionListener.bind(this), this.sessionListener.bind(this),
this.receiverListener.bind(this) this.receiverListener.bind(this),
AutoJoinPolicy.PAGE_SCOPED
), ),
undefined, undefined,
err => { err => {

View File

@@ -3,7 +3,7 @@ import { Logger } from "../../lib/logger";
import type { ReceiverDevice } from "../../types"; import type { ReceiverDevice } from "../../types";
import type { ReceiverAvailability } from "../sdk/enums"; import { AutoJoinPolicy, ReceiverAvailability } from "../sdk/enums";
import type Session from "../sdk/Session"; import type Session from "../sdk/Session";
import cast, { ensureInit } from "../export"; import cast, { ensureInit } from "../export";
@@ -79,7 +79,8 @@ export default class MirroringSender {
const apiConfig = new cast.ApiConfig( const apiConfig = new cast.ApiConfig(
sessionRequest, sessionRequest,
this.sessionListener, this.sessionListener,
this.receiverListener this.receiverListener,
AutoJoinPolicy.PAGE_SCOPED
); );
cast.initialize(apiConfig); cast.initialize(apiConfig);