Fix longstanding architectural issues

This commit is contained in:
hensm
2022-09-01 02:14:00 +01:00
committed by Matt Hensman
parent 83c81219d7
commit 7a35da2ba1
30 changed files with 1274 additions and 1282 deletions

View File

@@ -4,23 +4,15 @@ import logger from "../lib/logger";
import messaging, { Port, Message } from "../messaging";
import options from "../lib/options";
import { TypedEventTarget } from "../lib/TypedEventTarget";
import { getMediaTypesForPageUrl } from "../lib/utils";
import {
import type { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
import type {
ReceiverDevice,
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "../types";
import deviceManager from "./deviceManager";
import castManager from "./castManager";
import { BaseConfig, baseConfigStorage, getAppTag } from "../cast/googleApi";
import type { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
import type { SessionRequest } from "../cast/sdk/classes";
import { ReceiverAction } from "../cast/sdk/enums";
import { createReceiver } from "../cast/utils";
const POPUP_URL = browser.runtime.getURL("ui/popup/index.html");
export interface ReceiverSelection {
@@ -47,8 +39,6 @@ interface ReceiverSelectorEvents {
mediaMessage: ReceiverSelectorMediaMessage;
}
let baseConfig: BaseConfig;
/**
* Manages the receiver selector popup window and communication with the
* extension page hosted within.
@@ -68,8 +58,7 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
private wasReceiverSelected = false;
private appId?: string;
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
constructor() {
@@ -102,10 +91,10 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
receiverDevices: ReceiverDevice[];
defaultMediaType: ReceiverSelectorMediaType;
availableMediaTypes: ReceiverSelectorMediaType;
appId?: string;
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
}) {
this.appId = opts.appId;
this.appInfo = opts.appInfo;
this.pageInfo = opts.pageInfo;
// If popup already exists, close it
@@ -178,8 +167,6 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
await browser.windows.remove(this.windowId);
}
this.appId = undefined;
if (this.messagePort && !this.messagePortDisconnected) {
this.messagePort.disconnect();
}
@@ -220,7 +207,10 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
this.messagePort.postMessage({
subject: "popup:init",
data: { appId: this.appId, pageInfo: this.pageInfo }
data: {
appInfo: this.appInfo,
pageInfo: this.pageInfo
}
});
this.messagePort.postMessage({
@@ -303,226 +293,4 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
}
}
}
static sharedInstance = new ReceiverSelector();
/**
* Opens a receiver selector with the specified default/available media
* types.
*
* Returns a promise that:
* - Resolves to a ReceiverSelection object if selection is
* successful.
* - Resolves to null if the selection is cancelled.
* - Rejects if the selection fails.
*/
static async getSelection(
contextTabId: number,
contextFrameId = 0,
selectionOpts?: {
sessionRequest?: SessionRequest;
withMediaSender?: boolean;
}
): Promise<ReceiverSelection | null> {
let castInstance = castManager.getInstance(
contextTabId,
contextFrameId
);
/**
* If the current context is running the mirroring app, pretend
* it doesn't exist because it shouldn't be launched like this.
*/
if (
castInstance?.apiConfig?.sessionRequest.appId ===
(await options.get("mirroringAppId"))
) {
castInstance = undefined;
}
let defaultMediaType = ReceiverSelectorMediaType.Tab;
let availableMediaTypes = ReceiverSelectorMediaType.None;
let pageUrl: string | undefined;
try {
pageUrl = (
await browser.webNavigation.getFrame({
tabId: contextTabId,
frameId: contextFrameId
})
).url;
availableMediaTypes = getMediaTypesForPageUrl(pageUrl);
} catch {
logger.error(
"Failed to locate frame, falling back to default available media types."
);
}
// Enable app media type if sender application is present
if (castInstance || selectionOpts?.withMediaSender) {
defaultMediaType = ReceiverSelectorMediaType.App;
availableMediaTypes |= ReceiverSelectorMediaType.App;
}
const opts = await options.getAll();
// Disable mirroring media types if mirroring is not enabled
if (!opts.mirroringEnabled) {
availableMediaTypes &= ~(
ReceiverSelectorMediaType.Tab | ReceiverSelectorMediaType.Screen
);
}
// Remove file media type if local media is not enabled
if (!opts.mediaEnabled || !opts.localMediaEnabled) {
availableMediaTypes &= ~ReceiverSelectorMediaType.File;
}
// Ensure status manager is initialized
await deviceManager.init();
let isRequestAppAudioCompatible: Optional<boolean>;
if (castInstance?.apiConfig?.sessionRequest.appId) {
if (!baseConfig) {
try {
baseConfig = (await baseConfigStorage.get("baseConfig"))
.baseConfig;
} catch (err) {
throw logger.error("Failed to get Chromecast base config!");
}
}
isRequestAppAudioCompatible = getAppTag(
baseConfig,
castInstance.apiConfig?.sessionRequest.appId
)?.supports_audio_only;
}
return new Promise(async (resolve, reject) => {
// Close an existing open selector
if (ReceiverSelector.sharedInstance.isOpen) {
await ReceiverSelector.sharedInstance.close();
}
const selector = createSelector();
ReceiverSelector.sharedInstance = selector;
// Handle selected return value
const onSelected = (ev: CustomEvent<ReceiverSelection>) =>
resolve(ev.detail);
selector.addEventListener("selected", onSelected);
// Handle cancelled return value
const onCancelled = () => resolve(null);
selector.addEventListener("cancelled", onCancelled);
const onError = (ev: CustomEvent<string>) => reject(ev.detail);
selector.addEventListener("error", onError);
// Cleanup listeners
selector.addEventListener(
"close",
() => {
selector.removeEventListener("selected", onSelected);
selector.removeEventListener("cancelled", onCancelled);
selector.removeEventListener("error", onError);
},
{ once: true }
);
selector.open({
receiverDevices: deviceManager.getDevices(),
defaultMediaType,
availableMediaTypes,
appId: castInstance?.apiConfig?.sessionRequest.appId,
// Create page info
pageInfo: pageUrl
? {
url: pageUrl,
tabId: contextTabId,
frameId: contextFrameId,
sessionRequest: selectionOpts?.sessionRequest,
isRequestAppAudioCompatible
}
: undefined
});
});
}
}
/**
* Creates new ReceiverSelector object and adds listeners for
* updates/messages.
*/
function createSelector() {
// Get a new selector for each selection
const selector = new ReceiverSelector();
ReceiverSelector.sharedInstance = selector;
/**
* Sends message to cast instance to trigger stopped receiver action
* (if applicable).
*/
const onStop = (ev: CustomEvent<{ deviceId: string }>) => {
const castInstance = castManager.getInstanceByDeviceId(
ev.detail.deviceId
);
if (!castInstance) return;
const device = deviceManager.getDeviceById(ev.detail.deviceId);
if (!device) return;
castInstance.contentPort.postMessage({
subject: "cast:sendReceiverAction",
data: {
receiver: createReceiver(device),
action: ReceiverAction.STOP
}
});
};
selector.addEventListener("stop", onStop);
// Forward receiver messages
const onReceiverMessage = (
ev: CustomEvent<ReceiverSelectorReceiverMessage>
) =>
deviceManager.sendReceiverMessage(
ev.detail.deviceId,
ev.detail.message
);
selector.addEventListener("receiverMessage", onReceiverMessage);
// Forward media messages
const onMediaMessage = (ev: CustomEvent<ReceiverSelectorMediaMessage>) =>
deviceManager.sendMediaMessage(ev.detail.deviceId, ev.detail.message);
selector.addEventListener("mediaMessage", onMediaMessage);
// Update selector data whenever devices change/update
const onDeviceChange = () => selector.update(deviceManager.getDevices());
deviceManager.addEventListener("deviceUp", onDeviceChange);
deviceManager.addEventListener("deviceDown", onDeviceChange);
deviceManager.addEventListener("deviceUpdated", onDeviceChange);
deviceManager.addEventListener("deviceMediaUpdated", onDeviceChange);
// Cleanup listeners
selector.addEventListener(
"close",
() => {
deviceManager.removeEventListener("deviceUp", onDeviceChange);
deviceManager.removeEventListener("deviceDown", onDeviceChange);
deviceManager.removeEventListener("deviceUpdated", onDeviceChange);
deviceManager.removeEventListener(
"deviceMediaUpdated",
onDeviceChange
);
selector.removeEventListener("stop", onStop);
selector.removeEventListener("receiverMessage", onReceiverMessage);
selector.removeEventListener("mediaMessage", onMediaMessage);
},
{ once: true }
);
return selector;
}

View File

@@ -7,11 +7,10 @@ import bridge, { BridgeInfo } from "../lib/bridge";
import castManager from "./castManager";
import deviceManager from "./deviceManager";
import ReceiverSelector from "./ReceiverSelector";
import { initMenus } from "./menus";
import { initWhitelist } from "./whitelist";
import { baseConfigStorage, fetchBaseConfig } from "../cast/googleApi";
import { baseConfigStorage, fetchBaseConfig } from "../lib/chromecastConfigApi";
const _ = browser.i18n.getMessage;
@@ -133,26 +132,13 @@ async function init() {
await initMenus();
await initWhitelist();
/**
* When the browser action is clicked, open a receiver selector and
* load a sender for the response. The mirroring sender is loaded
* into the current tab at the
* top-level frame.
*/
browser.browserAction.onClicked.addListener(async tab => {
if (tab.id === undefined) {
logger.error("Tab ID not found in browser action handler.");
return;
}
const selection = await ReceiverSelector.getSelection(tab.id);
if (selection) {
castManager.loadSender({
tabId: tab.id,
frameId: 0,
selection
});
}
castManager.triggerCast(tab.id);
});
}

View File

@@ -1,32 +1,40 @@
"use strict";
import bridge from "../lib/bridge";
import {
BaseConfig,
baseConfigStorage,
getAppTag
} from "../lib/chromecastConfigApi";
import logger from "../lib/logger";
import messaging, { Message, Port } from "../messaging";
import options from "../lib/options";
import { stringify } from "../lib/utils";
import { getMediaTypesForPageUrl, stringify } from "../lib/utils";
import type { TypedMessagePort } from "../lib/TypedMessagePort";
import { ReceiverSelectorMediaType } from "../types";
import {
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "../types";
import type { ApiConfig } from "../cast/sdk/classes";
import { ReceiverAction } from "../cast/sdk/enums";
import { createReceiver } from "../cast/utils";
import deviceManager from "./deviceManager";
import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector";
type AnyPort = Port | MessagePort;
import ReceiverSelector, {
ReceiverSelection,
ReceiverSelectorMediaMessage,
ReceiverSelectorReceiverMessage
} from "./ReceiverSelector";
export interface CastInstance {
bridgePort: Port;
contentPort: AnyPort;
contentTabId?: number;
contentFrameId?: number;
type AnyPort = Port | TypedMessagePort<Message>;
/** ApiConfig provided on initialization. */
apiConfig?: ApiConfig;
/** Established session details. */
session?: CastSession;
export interface ContentContext {
tabId: number;
frameId: number;
}
interface CastSession {
@@ -34,11 +42,73 @@ interface CastSession {
deviceId: string;
}
export interface CastInstance {
bridgePort: Port;
contentPort: AnyPort;
contentContext?: ContentContext;
/** ApiConfig provided on initialization. */
apiConfig?: ApiConfig;
/** Established session details. */
session?: CastSession;
}
/** Creates a cast instance object and associated bridge instance. */
async function createCastInstance(opts: {
bridgePort?: Port;
contentPort: AnyPort;
contentContext?: { tabId: number; frameId?: number };
}) {
const instance: CastInstance = {
bridgePort: opts.bridgePort ?? (await bridge.connect()),
contentPort: opts.contentPort
};
/**
* Set content context with fallback to extension message sender
* context for content scripts.
*/
if (opts.contentContext) {
instance.contentContext = {
tabId: opts.contentContext.tabId,
frameId: opts.contentContext.frameId ?? 0
};
} else if (
!(opts.contentPort instanceof MessagePort) &&
opts.contentPort.sender?.tab?.id
) {
instance.contentContext = {
tabId: opts.contentPort.sender.tab.id,
frameId: opts.contentPort.sender.frameId ?? 0
};
}
return instance;
}
/** Disconnects either instance content port type. */
function disconnectContentPort(port: AnyPort) {
if (port instanceof MessagePort) {
port.close();
} else {
port.disconnect();
}
}
/** 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;
}
let baseConfig: BaseConfig;
let receiverSelector: Optional<ReceiverSelector>;
/** Keeps track of cast API instances and provides bridge messaging. */
export default new (class {
const castManager = new (class {
private activeInstances = new Set<CastInstance>();
public async init() {
async init() {
// Handle incoming instance connections
messaging.onConnect.addListener(async port => {
if (port.name === "cast") {
@@ -52,7 +122,7 @@ export default new (class {
for (const instance of this.activeInstances) {
instance.contentPort.postMessage({
subject: "cast:updateReceiverAvailability",
subject: "cast:receiverAvailabilityUpdated",
data: { isAvailable }
});
}
@@ -68,11 +138,11 @@ export default new (class {
/**
* Finds a cast instance at the given tab (and optionally frame) ID.
*/
public getInstance(tabId: number, frameId?: number) {
getInstanceAt(tabId: number, frameId?: number) {
for (const instance of this.activeInstances) {
if (instance.contentTabId === tabId) {
if (instance.contentContext?.tabId === tabId) {
// If frame ID doesn't match go to next instance
if (frameId && instance.contentFrameId !== frameId) {
if (frameId && instance.contentContext.frameId !== frameId) {
continue;
}
@@ -81,7 +151,7 @@ export default new (class {
}
}
public getInstanceByDeviceId(deviceId: string) {
getInstanceByDeviceId(deviceId: string) {
for (const instance of this.activeInstances) {
if (instance.session?.deviceId === deviceId) return instance;
}
@@ -91,9 +161,9 @@ export default new (class {
* Creates a cast instance with a given port and connects messaging
* correctly depending on the type of port.
*/
public async createInstance(port: AnyPort) {
async createInstance(port: AnyPort, contentContext?: ContentContext) {
const instance = await (port instanceof MessagePort
? this.createInstanceFromBackground(port)
? this.createInstanceFromBackground(port, contentContext)
: this.createInstanceFromContent(port));
this.activeInstances.add(instance);
@@ -108,27 +178,41 @@ export default new (class {
/** Creates a cast instance with a `MessagePort` content port. */
private async createInstanceFromBackground(
contentPort: MessagePort
contentPort: MessagePort,
contentContext?: ContentContext
): Promise<CastInstance> {
const instance: CastInstance = {
const instance = await createCastInstance({
bridgePort: await bridge.connect(),
contentPort
};
contentPort,
contentContext
});
// Ensure only one instance per context
if (contentContext) {
for (const instance of this.activeInstances) {
if (isSameContext(instance.contentContext, contentContext)) {
instance.bridgePort.disconnect();
this.activeInstances.delete(instance);
break;
}
}
}
instance.bridgePort.onDisconnect.addListener(() => {
contentPort.close();
this.activeInstances.delete(instance);
});
// bridge -> content
// bridge -> cast instance
instance.bridgePort.onMessage.addListener(message => {
this.handleBridgeMessage(instance, message);
});
// content -> (any)
// cast instance -> (any)
contentPort.addEventListener("message", ev => {
this.handleContentMessage(instance, ev.data);
});
contentPort.start();
return instance;
}
@@ -148,33 +232,27 @@ export default new (class {
);
}
/**
* If there's already an active instance for the sender
* tab/frame ID, disconnect it.
*
* TODO: Fix this behaviour!
*/
// Ensure only one instance per context
for (const instance of this.activeInstances) {
if (
instance.contentTabId === contentPort.sender.tab.id &&
instance.contentFrameId === contentPort.sender.frameId
isSameContext(
instance.contentContext,
contentPort.sender as ContentContext
)
) {
instance.bridgePort.disconnect();
disconnectContentPort(instance.contentPort);
break;
}
}
const instance: CastInstance = {
bridgePort: await bridge.connect(),
contentPort,
contentTabId: contentPort.sender.tab.id,
contentFrameId: contentPort.sender.frameId
};
const instance = await createCastInstance({ contentPort });
// content -> (any)
// cast instance -> (any)
const onContentPortMessage = (message: Message) => {
this.handleContentMessage(instance, message);
};
// bridge -> content
// bridge -> cast instance
const onBridgePortMessage = (message: Message) => {
this.handleBridgeMessage(instance, message);
};
@@ -206,16 +284,17 @@ export default new (class {
switch (message.subject) {
case "main:castSessionCreated": {
// Close after session is created
const selector = ReceiverSelector.sharedInstance;
if (
selector.isOpen &&
receiverSelector?.isOpen &&
// If selector context is the same as the instance context
selector.pageInfo?.tabId === instance.contentTabId &&
selector.pageInfo?.frameId === instance.contentFrameId &&
receiverSelector.pageInfo?.tabId ===
instance.contentContext?.tabId &&
receiverSelector.pageInfo?.frameId ===
instance.contentContext?.frameId &&
// If selector is supposed to close
(await options.get("receiverSelectorWaitForConnection"))
) {
selector.close();
receiverSelector.close();
}
const { receiverId: deviceId } = message.data;
@@ -270,44 +349,30 @@ export default new (class {
}
switch (message.subject) {
// Cast API has been initialized
case "main:initializeCast": {
case "main:initializeCast":
instance.apiConfig = message.data.apiConfig;
instance.contentPort.postMessage({
subject: "cast:updateReceiverAvailability",
subject: "cast:receiverAvailabilityUpdated",
data: {
isAvailable: deviceManager.getDevices().length > 0
}
});
break;
}
// User has triggered receiver selection via the cast API
case "main:selectReceiver": {
if (
instance.contentTabId === undefined ||
instance.contentFrameId === undefined
) {
throw logger.error(
"Cast instance associated with content sender missing tab/frame ID"
);
}
case "main:requestSession": {
const { sessionRequest } = message.data;
try {
const selection = await ReceiverSelector.getSelection(
instance.contentTabId,
instance.contentFrameId,
{ sessionRequest }
);
const selection = await getReceiverSelection({
castInstance: instance
});
// Handle cancellation
if (!selection) {
instance.contentPort.postMessage({
subject: "cast:selectReceiver/cancelled"
subject: "cast:sessionRequestCancelled"
});
break;
@@ -320,14 +385,13 @@ export default new (class {
*/
if (selection.mediaType !== ReceiverSelectorMediaType.App) {
instance.contentPort.postMessage({
subject: "cast:selectReceiver/cancelled"
subject: "cast:sessionRequestCancelled"
});
this.loadSender({
tabId: instance.contentTabId,
frameId: instance.contentFrameId,
selection
});
if (!instance.contentContext) {
throw logger.error("Missing content context");
}
this.loadSender(selection, instance.contentContext);
break;
}
@@ -342,7 +406,7 @@ export default new (class {
} catch (err) {
// TODO: Report errors properly
instance.contentPort.postMessage({
subject: "cast:selectReceiver/cancelled"
subject: "cast:sessionRequestCancelled"
});
}
@@ -352,25 +416,45 @@ export default new (class {
}
/**
* Loads the appropriate sender for a given receiver selector
* response.
* Gets a receiver selection and loads the appropriate sender for a
* given context.
*/
public async loadSender(opts: {
tabId: number;
frameId?: number;
selection: ReceiverSelection;
}) {
// Cancelled
if (!opts.selection) {
async triggerCast(tabId: number, frameId = 0) {
let selection: Nullable<ReceiverSelection>;
try {
selection = await getReceiverSelection({ tabId, frameId });
} catch (err) {
logger.error("Failed to get receiver selection (triggerCast)", err);
return;
}
switch (opts.selection.mediaType) {
if (!selection) return;
this.loadSender(selection, { tabId, frameId });
}
/**
* Loads the appropriate sender for a given receiver selector
* response.
*/
private async loadSender(
selection: ReceiverSelection,
contentContext: ContentContext
) {
// Cancelled
if (!selection) {
return;
}
switch (selection.mediaType) {
case ReceiverSelectorMediaType.App: {
const instance = this.getInstance(opts.tabId, opts.frameId);
const instance = this.getInstanceAt(
contentContext.tabId,
contentContext.frameId
);
if (!instance) {
throw logger.error(
`Cast instance not found at tabId ${opts.tabId} / frameId ${opts.frameId}`
`Cast instance not found at tabId ${contentContext.tabId} / frameId ${contentContext.frameId}`
);
}
@@ -379,9 +463,9 @@ export default new (class {
}
instance.contentPort.postMessage({
subject: "cast:sendReceiverAction",
subject: "cast:receiverAction",
data: {
receiver: createReceiver(opts.selection.receiverDevice),
receiver: createReceiver(selection.receiverDevice),
action: ReceiverAction.CAST
}
});
@@ -390,7 +474,7 @@ export default new (class {
subject: "bridge:createCastSession",
data: {
appId: instance.apiConfig?.sessionRequest.appId,
receiverDevice: opts.selection.receiverDevice
receiverDevice: selection.receiverDevice
}
});
@@ -398,34 +482,239 @@ export default new (class {
}
case ReceiverSelectorMediaType.Tab:
case ReceiverSelectorMediaType.Screen: {
await browser.tabs.executeScript(opts.tabId, {
case ReceiverSelectorMediaType.Screen:
await browser.tabs.executeScript(contentContext.tabId, {
code: stringify`
window.selectedMedia = ${opts.selection.mediaType};
window.selectedReceiver = ${opts.selection.receiverDevice};
window.selectedMedia = ${selection.mediaType};
window.selectedReceiver = ${selection.receiverDevice};
`,
frameId: opts.frameId
frameId: contentContext.frameId
});
await browser.tabs.executeScript(opts.tabId, {
await browser.tabs.executeScript(contentContext.tabId, {
file: "cast/senders/mirroring.js",
frameId: opts.frameId
frameId: contentContext.frameId
});
break;
}
case ReceiverSelectorMediaType.File: {
const fileUrl = new URL(`file://${opts.selection.filePath}`);
const { init } = await import("../cast/senders/media");
init({
mediaUrl: fileUrl.href,
receiver: opts.selection.receiverDevice
});
break;
}
}
}
})();
/**
* Opens a receiver selector with the specified default/available media
* types.
*
* Returns a promise that:
* - Resolves to a ReceiverSelection object if selection is
* successful.
* - Resolves to null if the selection is cancelled.
* - Rejects if the selection fails.
*/
async function getReceiverSelection(selectionOpts: {
tabId?: number;
frameId?: number;
castInstance?: CastInstance;
}): Promise<ReceiverSelection | null> {
/**
* If the current context is running the mirroring app, pretend
* it doesn't exist because it shouldn't be launched like this.
*/
if (
selectionOpts.castInstance?.apiConfig?.sessionRequest.appId ===
(await options.get("mirroringAppId"))
) {
selectionOpts.castInstance = undefined;
}
let defaultMediaType = ReceiverSelectorMediaType.Tab;
let availableMediaTypes = ReceiverSelectorMediaType.None;
// Default frame ID
if (!selectionOpts.frameId) selectionOpts.frameId = 0;
// Fallback to instance context
if (!selectionOpts.tabId && selectionOpts.castInstance?.contentContext) {
selectionOpts.tabId = selectionOpts.castInstance.contentContext.tabId;
selectionOpts.frameId =
selectionOpts.castInstance.contentContext.frameId;
}
let pageInfo: Optional<ReceiverSelectorPageInfo>;
if (selectionOpts.tabId) {
try {
pageInfo = {
tabId: selectionOpts.tabId,
frameId: selectionOpts.frameId,
url: (
await browser.webNavigation.getFrame({
tabId: selectionOpts.tabId,
frameId: selectionOpts.frameId
})
).url
};
availableMediaTypes = getMediaTypesForPageUrl(pageInfo.url);
} catch {
logger.error(
"Failed to locate frame, falling back to default available media types."
);
}
}
// Enable app media type if sender application is present
if (selectionOpts.castInstance) {
defaultMediaType = ReceiverSelectorMediaType.App;
availableMediaTypes |= ReceiverSelectorMediaType.App;
}
const opts = await options.getAll();
// Disable mirroring media types if mirroring is not enabled
if (!opts.mirroringEnabled) {
availableMediaTypes &= ~(
ReceiverSelectorMediaType.Tab | ReceiverSelectorMediaType.Screen
);
}
// Ensure status manager is initialized
await deviceManager.init();
let appInfo: Optional<ReceiverSelectorAppInfo>;
if (selectionOpts.castInstance?.apiConfig) {
if (!baseConfig) {
try {
baseConfig = (await baseConfigStorage.get("baseConfig"))
.baseConfig;
} catch (err) {
throw logger.error("Failed to get Chromecast base config!");
}
}
appInfo = {
sessionRequest:
selectionOpts.castInstance.apiConfig?.sessionRequest,
isRequestAppAudioCompatible: getAppTag(
baseConfig,
selectionOpts.castInstance.apiConfig?.sessionRequest.appId
)?.supports_audio_only
};
}
return new Promise(async (resolve, reject) => {
// Close an existing open selector
if (receiverSelector?.isOpen) {
await receiverSelector.close();
}
receiverSelector = createSelector();
// Handle selected return value
const onSelected = (ev: CustomEvent<ReceiverSelection>) =>
resolve(ev.detail);
receiverSelector.addEventListener("selected", onSelected);
// Handle cancelled return value
const onCancelled = () => resolve(null);
receiverSelector.addEventListener("cancelled", onCancelled);
const onError = (ev: CustomEvent<string>) => reject(ev.detail);
receiverSelector.addEventListener("error", onError);
// Cleanup listeners
receiverSelector.addEventListener(
"close",
() => {
receiverSelector?.removeEventListener("selected", onSelected);
receiverSelector?.removeEventListener("cancelled", onCancelled);
receiverSelector?.removeEventListener("error", onError);
},
{ once: true }
);
receiverSelector.open({
receiverDevices: deviceManager.getDevices(),
defaultMediaType,
availableMediaTypes,
appInfo,
pageInfo
});
});
}
/**
* Creates new ReceiverSelector object and adds listeners for
* updates/messages.
*/
function createSelector() {
// Get a new selector for each selection
const selector = new ReceiverSelector();
/**
* Sends message to cast instance to trigger stopped receiver action
* (if applicable).
*/
const onStop = (ev: CustomEvent<{ deviceId: string }>) => {
const castInstance = castManager.getInstanceByDeviceId(
ev.detail.deviceId
);
if (!castInstance) return;
const device = deviceManager.getDeviceById(ev.detail.deviceId);
if (!device) return;
castInstance.contentPort.postMessage({
subject: "cast:receiverAction",
data: {
receiver: createReceiver(device),
action: ReceiverAction.STOP
}
});
};
selector.addEventListener("stop", onStop);
// Forward receiver messages
const onReceiverMessage = (
ev: CustomEvent<ReceiverSelectorReceiverMessage>
) =>
deviceManager.sendReceiverMessage(
ev.detail.deviceId,
ev.detail.message
);
selector.addEventListener("receiverMessage", onReceiverMessage);
// Forward media messages
const onMediaMessage = (ev: CustomEvent<ReceiverSelectorMediaMessage>) =>
deviceManager.sendMediaMessage(ev.detail.deviceId, ev.detail.message);
selector.addEventListener("mediaMessage", onMediaMessage);
// Update selector data whenever devices change/update
const onDeviceChange = () => selector.update(deviceManager.getDevices());
deviceManager.addEventListener("deviceUp", onDeviceChange);
deviceManager.addEventListener("deviceDown", onDeviceChange);
deviceManager.addEventListener("deviceUpdated", onDeviceChange);
deviceManager.addEventListener("deviceMediaUpdated", onDeviceChange);
// Cleanup listeners
selector.addEventListener(
"close",
() => {
deviceManager.removeEventListener("deviceUp", onDeviceChange);
deviceManager.removeEventListener("deviceDown", onDeviceChange);
deviceManager.removeEventListener("deviceUpdated", onDeviceChange);
deviceManager.removeEventListener(
"deviceMediaUpdated",
onDeviceChange
);
selector.removeEventListener("stop", onStop);
selector.removeEventListener("receiverMessage", onReceiverMessage);
selector.removeEventListener("mediaMessage", onMediaMessage);
},
{ once: true }
);
return selector;
}
export default castManager;

View File

@@ -4,13 +4,10 @@ import logger from "../lib/logger";
import options from "../lib/options";
import { stringify } from "../lib/utils";
import { ReceiverSelectorMediaType } from "../types";
import ReceiverSelector, { ReceiverSelection } from "./ReceiverSelector";
import castManager from "./castManager";
import * as menuIds from "../menuIds";
import castManager from "./castManager";
const _ = browser.i18n.getMessage;
const URL_PATTERN_HTTP = "http://*/*";
@@ -176,49 +173,21 @@ async function onMenuClicked(
return;
}
// Handle cast menus
const castMenuClicked = info.menuItemId === menuIdCast;
const castMediaMenuClicked = info.menuItemId === menuIdCastMedia;
if (castMenuClicked || castMediaMenuClicked) {
if (tab?.id === undefined) {
throw logger.error("Menu handler tab ID not found.");
}
if (!info.pageUrl) {
throw logger.error("Menu handler page URL not found.");
if (tab?.id === undefined) {
logger.error("Menu handler tab ID not found.");
return;
}
switch (info.menuItemId) {
case menuIdCast: {
castManager.triggerCast(tab.id, info.frameId);
break;
}
let selection: Nullable<ReceiverSelection> = null;
try {
selection = await ReceiverSelector.getSelection(
tab.id,
info.frameId,
{
withMediaSender: castMediaMenuClicked
}
);
} catch (err) {
logger.error("Failed to get receiver selection (cast menu)", err);
return;
}
// Invalid selection result
if (!selection) return;
if (castMenuClicked) {
castManager.loadSender({
tabId: tab.id,
frameId: info.frameId,
selection
});
} else if (castMediaMenuClicked) {
/**
* If the selected media type is App, that refers to
* the media sender in this context, so load media
* sender.
*/
if (selection.mediaType === ReceiverSelectorMediaType.App) {
case menuIdCastMedia:
if (info.srcUrl) {
await browser.tabs.executeScript(tab.id, {
code: stringify`
window.receiver = ${selection.receiverDevice};
window.mediaUrl = ${info.srcUrl};
window.targetElementId = ${info.targetElementId};
`,
@@ -226,18 +195,11 @@ async function onMenuClicked(
});
await browser.tabs.executeScript(tab.id, {
file: "cast/senders/media/index.js",
file: "cast/senders/media.js",
frameId: info.frameId
});
} else {
// Handle other responses
castManager.loadSender({
tabId: tab.id,
frameId: info.frameId,
selection
});
}
}
break;
}
}

View File

@@ -9,7 +9,7 @@ import { RemoteMatchPattern } from "../lib/matchPattern";
import {
CAST_FRAMEWORK_LOADER_SCRIPT_URL,
CAST_LOADER_SCRIPT_URL
} from "../cast/endpoints";
} from "../cast/urls";
// Missing on @types/firefox-webext-browser
type OnBeforeSendHeadersDetails = Parameters<
@@ -207,7 +207,7 @@ async function onBeforeCastSDKRequest(details: OnBeforeRequestDetails) {
});
return {
redirectUrl: browser.runtime.getURL("cast/index.js")
redirectUrl: browser.runtime.getURL("cast/content.js")
};
}
@@ -250,6 +250,13 @@ async function registerSiteWhitelist() {
{ urls: ["<all_urls>"] },
["blocking", "requestHeaders"]
);
browser.contentScripts.register({
matches: siteWhitelist.map(item => item.pattern),
js: [{ file: "cast/contentInitial.js" }],
runAt: "document_start",
allFrames: true
});
}
function unregisterSiteWhitelist() {

View File

@@ -1,42 +1,48 @@
"use strict";
import { CAST_LOADER_SCRIPT_URL, CAST_SCRIPT_URLS } from "./endpoints";
const _window = window.wrappedJSObject as any;
_window.chrome = cloneInto({}, window);
/**
* YouTube won't load the cast SDK unless it thinks the
* presentation API exists.
* Cast Sender SDK page script loaded in place of remote cast_sender
* script. Handles API object creation and initializes sender apps.
*/
if (window.location.host === "www.youtube.com") {
_window.navigator.presentation = cloneInto({}, window);
import logger from "../lib/logger";
import { loadScript } from "../lib/utils";
import pageMessenging from "./pageMessenging";
import CastSDK from "./sdk";
import { CAST_FRAMEWORK_SCRIPT_URL } from "./urls";
// Create page-accessible API object
window.chrome.cast = new CastSDK();
let frameworkScriptPromise: Promise<HTMLScriptElement> | undefined;
// Load remote CAF script if requested in script URL params.
if (document.currentScript) {
const currentScript = document.currentScript as HTMLScriptElement;
const currentScriptParams = new URLSearchParams(
new URL(currentScript.src).search
);
if (currentScriptParams.get("loadCastFramework") === "1") {
frameworkScriptPromise = loadScript(CAST_FRAMEWORK_SCRIPT_URL);
frameworkScriptPromise.catch(() => {
logger.error("Failed to load CAF script!");
});
}
}
/**
* Replace the src property setter on <script> elements to
* intercept the new value.
*
* If it matches one of Chrome's cast extension sender script
* URLs, replace it with the standard API URL, the request for
* which is handled in the main script.
*/
const desc = Reflect.getOwnPropertyDescriptor(
HTMLScriptElement.prototype.wrappedJSObject,
"src"
);
pageMessenging.page.addListener(async message => {
switch (message.subject) {
case "cast:initialized": {
// If framework API is loading, wait until completed
await frameworkScriptPromise;
Reflect.defineProperty(HTMLScriptElement.prototype.wrappedJSObject, "src", {
configurable: true,
enumerable: true,
get: desc?.get,
// Call page script/framework API script's init function
const initFn = window.__onGCastApiAvailable;
if (initFn && typeof initFn === "function") {
initFn(message.data.isAvailable);
}
set: exportFunction(function (this: HTMLScriptElement, value: string) {
if (CAST_SCRIPT_URLS.includes(value)) {
return desc?.set?.call(this, CAST_LOADER_SCRIPT_URL);
break;
}
return desc?.set?.call(this, value);
}, window)
}
});

View File

@@ -1,30 +1,19 @@
"use strict";
import messaging, { Message } from "../messaging";
import { PageEventMessenger, ExtensionEventMessenger } from "./eventMessaging";
// Create messengers manually instead of relying on getters
const eventMessaging = {
page: new PageEventMessenger(),
extension: new ExtensionEventMessenger()
};
import pageMessenging from "./pageMessenging";
// Message port to background script
export const backgroundPort = messaging.connect({ name: "cast" });
export const managerPort = messaging.connect({ name: "cast" });
const forwardToPage = (message: Message) => {
eventMessaging.extension.sendMessage(message);
pageMessenging.extension.sendMessage(message);
};
const forwardToMain = (message: Message) => {
backgroundPort.postMessage(message);
managerPort.postMessage(message);
};
// Add message listeners
backgroundPort.onMessage.addListener(forwardToPage);
eventMessaging.extension.addListener(forwardToMain);
managerPort.onMessage.addListener(forwardToPage);
pageMessenging.extension.addListener(forwardToMain);
// Remove listeners
backgroundPort.onDisconnect.addListener(() => {
backgroundPort.onMessage.removeListener(forwardToPage);
eventMessaging.extension.addListener(forwardToMain);
managerPort.onDisconnect.addListener(() => {
pageMessenging.extension.close();
});

View File

@@ -0,0 +1,57 @@
/**
* Content script loaded on whitelisted URLs. Sets some window
* properties to help with Chrome compatibility and handles dynamic
* chrome-extension:// cast script loads.
*/
import { CAST_LOADER_SCRIPT_URL, CAST_SCRIPT_URLS } from "./urls";
declare global {
interface Object {
wrappedJSObject: this;
}
interface Window {
wrappedJSObject: Window;
chrome: {
cast?: object;
};
__onGCastApiAvailable: (isAvailable: boolean) => void;
}
interface Navigator {
presentation: object;
}
}
window.wrappedJSObject.chrome = cloneInto({}, window);
/**
* YouTube won't load the cast SDK unless it thinks the presentation API
* exists.
*/
if (window.location.host === "www.youtube.com") {
window.wrappedJSObject.navigator.presentation = cloneInto({}, window);
}
const srcPropDesc = Reflect.getOwnPropertyDescriptor(
HTMLScriptElement.prototype.wrappedJSObject,
"src"
);
/**
* Intercept script element src attribute changes and rewrite cast
* script URLs to the remote loader script URL to be redirected by the
* extension's webRequest handlers in the background script.
*/
Reflect.defineProperty(HTMLScriptElement.prototype.wrappedJSObject, "src", {
configurable: true,
enumerable: true,
get: srcPropDesc?.get,
set: exportFunction(function (this: HTMLScriptElement, value: string) {
if (CAST_SCRIPT_URLS.includes(value)) {
return srcPropDesc?.set?.call(this, CAST_LOADER_SCRIPT_URL);
}
return srcPropDesc?.set?.call(this, value);
}, window)
});

View File

@@ -1,92 +0,0 @@
"use strict";
import logger from "../lib/logger";
import type { Message } from "../messaging";
type EventMessengerListener = (message: Message) => void;
/**
* Messenger class for cross-context messages via CustomEvent.
*
* Supplied with an incoming and outgoing event name, it provides a
* message channel from content scripts to page scripts provided that
* the opposite event names are used with instances on either side.
*
* Note:
* Extending EventTarget seems to cause issues with dispatching custom
* events in WebExtension content scripts (sandbox issue?), so custom
* addListener/removeListener methods are used instead.
*/
abstract class EventMessenger {
private listeners = new Set<EventMessengerListener>();
constructor(
private incomingMessageEventName: string,
private outgoingMessageEventName: string
) {
// @ts-ignore
document.addEventListener(
this.incomingMessageEventName,
(ev: CustomEvent<string>) => {
for (const listener of this.listeners) {
listener(JSON.parse(ev.detail));
}
}
);
}
addListener(listener: EventMessengerListener) {
this.listeners.add(listener);
}
removeListener(listener: EventMessengerListener) {
this.listeners.delete(listener);
}
sendMessage(message: Message) {
document.dispatchEvent(
new CustomEvent<string>(this.outgoingMessageEventName, {
detail: JSON.stringify(message)
})
);
}
}
const EV_TO_PAGE = "__castMessage";
const EV_FROM_PAGE = "__castMessageResponse";
export class PageEventMessenger extends EventMessenger {
constructor() {
super(EV_TO_PAGE, EV_FROM_PAGE);
}
}
export class ExtensionEventMessenger extends EventMessenger {
constructor() {
super(EV_FROM_PAGE, EV_TO_PAGE);
}
}
// Ensure only one instance of the type initially created is used
let messenger: EventMessenger;
function getMessenger(messengerType: { new (): EventMessenger }) {
if (!messenger) {
messenger = new messengerType();
} else if (!(messenger instanceof messengerType)) {
throw logger.error(
"Requested messenger does not match type of instantiated messenger!"
);
}
return messenger;
}
export default {
/** Event messenger for page scripts. */
get page() {
return getMessenger(PageEventMessenger);
},
/** Event messenger for extension content scripts. */
get extension() {
return getMessenger(ExtensionEventMessenger);
}
};

View File

@@ -1,111 +1,106 @@
/* eslint-disable @typescript-eslint/no-namespace */
"use strict";
import type { TypedMessagePort } from "../lib/TypedMessagePort";
import type { Message } from "../messaging";
import type { BridgeInfo } from "../lib/bridge";
import type { TypedMessagePort } from "../lib/TypedMessagePort";
import pageMessenging from "./pageMessenging";
import CastSDK from "./sdk";
import { PageEventMessenger, ExtensionEventMessenger } from "./eventMessaging";
export type CastPort = TypedMessagePort<Message>;
// Create messengers manually instead of relying on getters
const eventMessaging = {
page: new PageEventMessenger(),
extension: new ExtensionEventMessenger()
};
let existingPort: CastPort;
let existingInstance = new CastSDK();
let initializedBridgeInfo: BridgeInfo;
let initializedBackgroundPort: MessagePort;
export default existingInstance;
/**
* To support exporting an API from a module, we need to
* retain the event-based message passing despite not
* actually crossing any context boundaries. The cast instance
* listens for and emits these messages, and changing that
* behavior is too messy.
* To support exporting the API from a module, we need to retain the
* MessageChannel-based pageMessaging layer despite not crossing any
* context boundaries.
*
* The ensureInit function creates a messaging connection to the
* castManager, hooks it up to the pageMessaging layer and also provides
* a messaging port so consumers of this module can communicate with the
* castManager.
*/
export function ensureInit(): Promise<TypedMessagePort<Message>> {
export function ensureInit(contextTabId?: number): Promise<CastPort> {
return new Promise(async (resolve, reject) => {
// If already initialized, just return existing bridge info
if (initializedBridgeInfo) {
if (initializedBridgeInfo.isVersionCompatible) {
resolve(initializedBackgroundPort);
} else {
reject();
}
return;
// If already initialized
if (existingPort) {
existingPort.close();
existingInstance = new CastSDK();
}
const channel = new MessageChannel();
initializedBackgroundPort = channel.port1;
/**
* If the module is imported into a background script
* context, the location will be the internal extension URL,
* whereas in a content script, it will be the content page
* URL.
* If imported into a background script context, the location
* will be the internal extension URL, whereas in a content
* script, it will be the content page URL.
*/
if (window.location.protocol === "moz-extension:") {
const { default: castManager } = await import(
"../background/castManager"
);
// port2 will post bridge messages to port 1
await castManager.init();
await castManager.createInstance(channel.port2);
// bridge -> cast instance
channel.port1.onmessage = ev => {
const message = ev.data as Message;
// Send message to cast instance
eventMessaging.extension.sendMessage(message);
handleIncomingMessageToCast(message);
};
// cast instance -> bridge
eventMessaging.extension.addListener(message =>
channel.port1.postMessage(message)
);
} else {
/**
* Import reference to message port created by contentBridge.
* Creation of the port triggers side-effects in the
* background script.
* port1 will handle castManager messages.
* port2 will handle cast instance messages.
*/
const { backgroundPort } = await import("./contentBridge");
const { port1: managerPort, port2: instancePort } =
new MessageChannel();
// backgroundPort -> channel.port2
backgroundPort.onMessage.addListener((message: Message) => {
channel.port2.postMessage(message);
});
/**
* Provide castManager with a port to send messages to
* cast instance.
*/
if (contextTabId) {
await castManager.createInstance(instancePort, {
tabId: contextTabId,
frameId: 0
});
} else {
await castManager.createInstance(instancePort);
}
// channel.port2 -> backgroundPort
channel.port2.onmessage = ev => {
// castManager -> cast instance
managerPort.addEventListener("message", ev => {
const message = ev.data as Message;
backgroundPort.postMessage(message);
};
// Handle cast messages
eventMessaging.page.addListener(message =>
handleIncomingMessageToCast(message)
);
}
function handleIncomingMessageToCast(message: Message) {
switch (message.subject) {
case "cast:initialized": {
if (message.subject === "cast:initialized") {
if (message.data.isAvailable) {
resolve(initializedBackgroundPort);
resolve(existingPort);
} else {
reject();
}
}
}
pageMessenging.extension.sendMessage(message);
});
managerPort.start();
// Cast instance -> castManager
pageMessenging.extension.addListener(message => {
managerPort.postMessage(message);
});
} else {
// Let contentBridge hook up pageMessaging
const { managerPort: backgroundPort } = await import(
"./contentBridge"
);
existingPort = pageMessenging.page.messagePort;
backgroundPort.onMessage.addListener(function onManagerMessage(
message: Message
) {
if (message.subject === "cast:initialized") {
if (message.data.isAvailable) {
resolve(pageMessenging.page.messagePort);
} else {
reject();
}
backgroundPort.onMessage.removeListener(onManagerMessage);
}
});
}
});
}
export default new CastSDK();

View File

@@ -1,58 +0,0 @@
"use strict";
import logger from "../lib/logger";
import { loadScript } from "../lib/utils";
import { CAST_FRAMEWORK_SCRIPT_URL } from "./endpoints";
import eventMessaging from "./eventMessaging";
import CastSDK from "./sdk";
const _window = window as any;
if (!_window.chrome) {
_window.chrome = {};
}
// Create page-accessible API object
_window.chrome.cast = new CastSDK();
let frameworkScriptPromise: Promise<HTMLScriptElement> | undefined;
/**
* If loaded within a page via a <script> element,
* document.currentScript should exist and we can check its
* [src] query string for the loadCastFramework param.
*/
if (document.currentScript) {
const currentScript = document.currentScript as HTMLScriptElement;
const currentScriptParams = new URLSearchParams(
new URL(currentScript.src).search
);
// Load Framework API if requested
if (currentScriptParams.get("loadCastFramework") === "1") {
// Queue up the framework script load to speed up init
frameworkScriptPromise = loadScript(CAST_FRAMEWORK_SCRIPT_URL);
frameworkScriptPromise.catch(() => {
logger.error("Failed to load CAF script!");
});
}
}
eventMessaging.page.addListener(async message => {
switch (message.subject) {
case "cast:initialized": {
// If framework API is requested, ensure loaded
await frameworkScriptPromise;
// Call page script/framework API script's init function
const initFn = _window.__onGCastApiAvailable;
if (initFn && typeof initFn === "function") {
initFn(message.data.isAvailable);
}
break;
}
}
});

View File

@@ -0,0 +1,140 @@
import type { TypedMessagePort } from "../lib/TypedMessagePort";
import type { Message } from "../messaging";
const INIT_MESSAGE = "__pageMessenger_init__";
/** Strip anything non-serializable for message channel. */
function simplify(input: any) {
return JSON.parse(JSON.stringify(input));
}
type MessengerListener = (message: Message) => void;
/**
* Abstract messenger class for cross-context messages via
* MessageChannel.
*
* Facilitates a message channel between page scripts running in the
* page script context and the extension scripts running in the
* sandboxed content script context.
*/
abstract class Messenger {
private listeners = new Set<MessengerListener>();
protected onMessage = (ev: MessageEvent<Message>) => {
for (const listener of this.listeners) {
listener(simplify(ev.data));
}
};
addListener(listener: MessengerListener) {
this.listeners.add(listener);
}
removeListener(listener: MessengerListener) {
this.listeners.delete(listener);
}
/** Sends a message across the */
abstract sendMessage(message: Message): void;
close() {
this.listeners.clear();
}
}
/**
* Page-side of page script messenging.
*
* Creates a message channel, then sends an INIT_MESSAGE window message
* with a port that is handled by an ExtensionScriptMessenger in the
* content script.
*/
export class PageScriptMessenger extends Messenger {
private port: TypedMessagePort<Message>;
constructor() {
super();
// Create message channel and send port2 to
const { port1, port2 } = new MessageChannel();
window.postMessage(INIT_MESSAGE, window.location.href, [port2]);
this.port = port1;
this.port.addEventListener("message", this.onMessage);
this.port.start();
}
sendMessage(message: Message) {
this.port.postMessage(simplify(message));
}
get messagePort() {
return this.port;
}
close() {
super.close();
this.port.removeEventListener("message", this.onMessage);
this.port.close();
}
}
/**
* Extension-side of page script messenging.
*
* Listens for a INIT_MESSAGE window message from a PageScriptMessenger
* running in a page script and establishes a message channel connection
* once received.
*/
export class ExtensionScriptMessenger extends Messenger {
private port?: TypedMessagePort<Message>;
constructor() {
super();
window.addEventListener("message", this.onWindowMessage);
}
/** Handles init message from window and stores transferred port. */
private onWindowMessage = (ev: MessageEvent<any>) => {
if (ev.source !== window || ev.data !== INIT_MESSAGE) return;
window.removeEventListener("message", this.onWindowMessage);
this.port = ev.ports[0];
this.port.addEventListener("message", ev => this.onMessage(ev));
this.port.start();
};
sendMessage(message: Message) {
this.port?.postMessage(simplify(message));
}
close() {
super.close();
window.removeEventListener("message", this.onWindowMessage);
this.port?.removeEventListener("message", this.onMessage);
this.port?.close();
}
}
let pageMessenger: Nullable<PageScriptMessenger> = null;
let extensionMessenger: Nullable<ExtensionScriptMessenger> = null;
export default {
/** Messenger for page scripts. */
get page() {
if (!pageMessenger) {
pageMessenger = new PageScriptMessenger();
}
return pageMessenger;
},
/** Messenger for extension scripts. */
get extension() {
if (!extensionMessenger) {
extensionMessenger = new ExtensionScriptMessenger();
}
return extensionMessenger;
}
};

View File

@@ -4,16 +4,7 @@ import { v4 as uuid } from "uuid";
import logger from "../../lib/logger";
import eventMessaging from "../eventMessaging";
import type {
ErrorCallback,
LoadSuccessCallback,
MediaListener,
MessageListener,
SuccessCallback,
UpdateListener
} from "../types";
import eventMessaging from "../pageMessenging";
import {
MediaStatus,
@@ -24,7 +15,12 @@ import {
} from "./types";
import { SessionStatus } from "./enums";
import type { Image, Receiver, SenderApplication } from "./classes";
import type {
Error as CastError,
Image,
Receiver,
SenderApplication
} from "./classes";
import { MediaCommand } from "./media/enums";
import type { LoadRequest, QueueLoadRequest, QueueItem } from "./media/classes";
@@ -101,17 +97,20 @@ function updateMedia(media: Media, status: MediaStatus) {
}
}
type MessageListener = (namespace: string, message: string) => void;
type UpdateListener = (isAlive: boolean) => void;
export default class Session {
#loadMediaSuccessCallback?: (media: Media) => void;
#loadMediaErrorCallback?: ErrorCallback;
#loadMediaRequest?: LoadRequest;
#loadMediaSuccessCallback?: (media: Media) => void;
#loadMediaErrorCallback?: (err: CastError) => void;
_messageListeners = new Map<string, Set<MessageListener>>();
_updateListeners = new Set<UpdateListener>();
_sendMessageCallbacks = new Map<
string,
[SuccessCallback?, ErrorCallback?]
[(() => void)?, ((err: CastError) => void)?]
>();
media: Media[] = [];
@@ -203,10 +202,10 @@ export default class Session {
});
};
addMediaListener(_mediaListener: MediaListener) {
addMediaListener(_mediaListener: (media: Media) => void) {
logger.info("STUB :: Session#addMediaListener");
}
removeMediaListener(_mediaListener: MediaListener) {
removeMediaListener(_mediaListener: (media: Media) => void) {
logger.info("STUB :: Session#removeMediaListener");
}
@@ -228,14 +227,17 @@ export default class Session {
this._updateListeners.delete(listener);
}
leave(_successCallback?: SuccessCallback, _errorCallback?: ErrorCallback) {
leave(
_successCallback?: () => void,
_errorCallback?: (err: CastError) => void
) {
logger.info("STUB :: Session#leave");
}
loadMedia(
loadRequest: LoadRequest,
successCallback?: LoadSuccessCallback,
errorCallback?: ErrorCallback
successCallback?: (media: Media) => void,
errorCallback?: (err: CastError) => void
) {
this.#loadMediaSuccessCallback = successCallback;
this.#loadMediaErrorCallback = errorCallback;
@@ -246,8 +248,8 @@ export default class Session {
queueLoad(
_queueLoadRequest: QueueLoadRequest,
_successCallback?: LoadSuccessCallback,
_errorCallback?: ErrorCallback
_successCallback?: (media: Media) => void,
_errorCallback?: (err: CastError) => void
) {
logger.info("STUB :: Session#queueLoad");
}
@@ -255,8 +257,8 @@ export default class Session {
sendMessage(
namespace: string,
message: object | string,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const messageId = uuid();
@@ -278,8 +280,8 @@ export default class Session {
setReceiverMuted(
muted: boolean,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#sendReceiverMessage({ type: "SET_VOLUME", volume: { muted } })
.then(successCallback)
@@ -288,8 +290,8 @@ export default class Session {
setReceiverVolumeLevel(
newLevel: number,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#sendReceiverMessage({
type: "SET_VOLUME",
@@ -299,7 +301,10 @@ export default class Session {
.catch(errorCallback);
}
stop(successCallback?: SuccessCallback, errorCallback?: ErrorCallback) {
stop(
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#sendReceiverMessage({ type: "STOP", sessionId: this.sessionId })
.then(successCallback)
.catch(errorCallback);

View File

@@ -6,6 +6,7 @@ import {
AutoJoinPolicy,
Capability,
DefaultActionPolicy,
ReceiverAvailability,
ReceiverType,
VolumeControlType
} from "./enums";
@@ -14,7 +15,7 @@ export class ApiConfig {
constructor(
public sessionRequest: SessionRequest,
public sessionListener: (session: Session) => void,
public receiverListener: (availability: string) => void,
public receiverListener: (availability: ReceiverAvailability) => void,
public autoJoinPolicy: string = AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED,
public defaultActionPolicy: string = DefaultActionPolicy.CREATE_SESSION

View File

@@ -3,9 +3,7 @@
import logger from "../../lib/logger";
import type { Message } from "../../messaging";
import eventMessaging from "../eventMessaging";
import type { ErrorCallback, SuccessCallback } from "../types";
import pageMessenging from "../pageMessenging";
import {
AutoJoinPolicy,
@@ -25,7 +23,7 @@ import {
ApiConfig,
CredentialsData,
DialRequest,
Error as Error_,
Error as CastError,
Image,
Receiver,
ReceiverDisplayStatus,
@@ -51,15 +49,16 @@ export default class {
#apiConfig?: ApiConfig;
#sessionRequest?: SessionRequest;
#receiverAvailability = ReceiverAvailability.UNAVAILABLE;
#initializeSuccessCallback?: () => void;
#requestSessionSuccessCallback?: RequestSessionSuccessCallback;
#requestSessionErrorCallback?: ErrorCallback;
#requestSessionErrorCallback?: (err: CastError) => void;
#initializeSuccessCallback?: SuccessCallback;
#sessions = new Map<string, Session>();
#receiverActionListeners = new Set<ReceiverActionListener>();
#receiverAvailability = ReceiverAvailability.UNAVAILABLE;
#sessions = new Map<string, Session>();
// Enums
AutoJoinPolicy = AutoJoinPolicy;
@@ -78,7 +77,7 @@ export default class {
ApiConfig = ApiConfig;
CredentialsData = CredentialsData;
DialRequest = DialRequest;
Error = Error_;
Error = CastError;
Image = Image;
Receiver = Receiver;
ReceiverDisplayStatus = ReceiverDisplayStatus;
@@ -95,17 +94,17 @@ export default class {
timeout = new Timeout();
constructor() {
eventMessaging.page.addListener(this.#onMessage.bind(this));
pageMessenging.page.addListener(this.#onMessage.bind(this));
}
#onMessage(message: Message) {
switch (message.subject) {
case "cast:initialized":
this.isAvailable = true;
this.#initializeSuccessCallback?.();
this.#apiConfig?.receiverListener(this.#receiverAvailability);
this.isAvailable = true;
break;
/**
@@ -185,7 +184,7 @@ export default class {
break;
}
case "cast:receivedSessionMessage": {
case "cast:sessionMessageReceived": {
const { sessionId, namespace, messageData } = message.data;
const session = this.#sessions.get(sessionId);
if (session) {
@@ -213,7 +212,7 @@ export default class {
const [successCallback, errorCallback] = callbacks;
if (error) {
errorCallback?.(new Error_(error));
errorCallback?.(new CastError(error));
return;
}
@@ -223,7 +222,7 @@ export default class {
break;
}
case "cast:updateReceiverAvailability": {
case "cast:receiverAvailabilityUpdated": {
const availability = message.data.isAvailable
? ReceiverAvailability.AVAILABLE
: ReceiverAvailability.UNAVAILABLE;
@@ -238,19 +237,19 @@ export default class {
}
// Popup closed before session established
case "cast:selectReceiver/cancelled": {
case "cast:sessionRequestCancelled": {
if (this.#sessionRequest) {
this.#sessionRequest = undefined;
this.#requestSessionErrorCallback?.(
new Error_(ErrorCode.CANCEL)
new CastError(ErrorCode.CANCEL)
);
}
break;
}
case "cast:sendReceiverAction": {
case "cast:receiverAction": {
for (const actionListener of this.#receiverActionListeners) {
actionListener(message.data.receiver, message.data.action);
}
@@ -262,14 +261,14 @@ export default class {
initialize(
apiConfig: ApiConfig,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
logger.info("cast.initialize");
// Already initialized
if (this.#apiConfig) {
errorCallback?.(new Error_(ErrorCode.INVALID_PARAMETER));
errorCallback?.(new CastError(ErrorCode.INVALID_PARAMETER));
return;
}
@@ -279,7 +278,7 @@ export default class {
this.#initializeSuccessCallback = successCallback;
}
eventMessaging.page.sendMessage({
pageMessenging.page.sendMessage({
subject: "main:initializeCast",
data: { apiConfig: this.#apiConfig }
});
@@ -287,21 +286,21 @@ export default class {
requestSession(
successCallback: RequestSessionSuccessCallback,
errorCallback: ErrorCallback,
errorCallback: (err: CastError) => void,
newSessionRequest?: SessionRequest
) {
logger.info("cast.requestSession");
// Not yet initialized
if (!this.#apiConfig) {
errorCallback?.(new Error_(ErrorCode.API_NOT_INITIALIZED));
errorCallback?.(new CastError(ErrorCode.API_NOT_INITIALIZED));
return;
}
// Already requesting session
if (this.#sessionRequest) {
errorCallback?.(
new Error_(
new CastError(
ErrorCode.INVALID_PARAMETER,
"Session request already in progress."
)
@@ -310,7 +309,7 @@ export default class {
}
if (this.#receiverAvailability === ReceiverAvailability.UNAVAILABLE) {
errorCallback?.(new Error_(ErrorCode.RECEIVER_UNAVAILABLE));
errorCallback?.(new CastError(ErrorCode.RECEIVER_UNAVAILABLE));
return;
}
@@ -322,8 +321,8 @@ export default class {
this.#requestSessionErrorCallback = errorCallback;
// Open receiver selector UI
eventMessaging.page.sendMessage({
subject: "main:selectReceiver",
pageMessenging.page.sendMessage({
subject: "main:requestSession",
data: { sessionRequest: this.#sessionRequest }
});
}
@@ -334,8 +333,8 @@ export default class {
setCustomReceivers(
_receivers: Receiver[],
_successCallback?: SuccessCallback,
_errorCallback?: ErrorCallback
_successCallback?: () => void,
_errorCallback?: (err: CastError) => void
): void {
logger.info("STUB :: cast.setCustomReceivers");
}

View File

@@ -1,10 +1,10 @@
"use strict";
import { v1 as uuid } from "uuid";
import { v4 as uuid } from "uuid";
import logger from "../../../lib/logger";
import { Volume, Error as _Error } from "../classes";
import { Volume, Error as CastError } from "../classes";
import {
BreakStatus,
EditTracksInfoRequest,
@@ -30,16 +30,13 @@ import {
import { PlayerState, RepeatMode } from "./enums";
import { ErrorCode } from "../enums";
import type {
ErrorCallback,
SuccessCallback,
UpdateListener
} from "../../types";
import type { SenderMediaMessage } from "../types";
import { getEstimatedTime } from "../../utils";
export const NS_MEDIA = "urn:x-cast:com.google.cast.media";
type UpdateListener = (isAlive: boolean) => void;
export default class Media {
#id = uuid();
@@ -85,8 +82,8 @@ export default class Media {
editTracksInfo(
editTracksInfoRequest: EditTracksInfoRequest,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...editTracksInfoRequest,
@@ -161,8 +158,8 @@ export default class Media {
*/
getStatus(
getStatusRequest = new GetStatusRequest(),
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...getStatusRequest,
@@ -175,8 +172,8 @@ export default class Media {
pause(
pauseRequest = new PauseRequest(),
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...pauseRequest,
@@ -189,8 +186,8 @@ export default class Media {
play(
playRequest = new PlayRequest(),
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...playRequest,
@@ -203,8 +200,8 @@ export default class Media {
queueAppendItem(
item: QueueItem,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...new QueueInsertItemsRequest([item]),
@@ -218,8 +215,8 @@ export default class Media {
queueInsertItems(
queueInsertItemsRequest: QueueInsertItemsRequest,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...queueInsertItemsRequest,
@@ -233,8 +230,8 @@ export default class Media {
queueJumpToItem(
itemId: number,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
if (this.items?.find(item => item.itemId === itemId)) {
const jumpRequest = new QueueJumpRequest();
@@ -254,8 +251,8 @@ export default class Media {
queueMoveItemToNewIndex(
itemId: number,
newIndex: number,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
// Return early if not in queue
if (!this.items) {
@@ -268,7 +265,7 @@ export default class Media {
// New index must not be negative
if (newIndex < 0) {
if (errorCallback) {
errorCallback(new _Error(ErrorCode.INVALID_PARAMETER));
errorCallback(new CastError(ErrorCode.INVALID_PARAMETER));
}
} else if (newIndex == itemIndex) {
if (successCallback) {
@@ -298,8 +295,8 @@ export default class Media {
}
queueNext(
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const jumpRequest = new QueueJumpRequest();
jumpRequest.jump = 1;
@@ -315,8 +312,8 @@ export default class Media {
}
queuePrev(
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const jumpRequest = new QueueJumpRequest();
jumpRequest.jump = -1;
@@ -333,8 +330,8 @@ export default class Media {
queueRemoveItem(
itemId: number,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const item = this.items?.find(item => item.itemId === itemId);
if (item) {
@@ -348,8 +345,8 @@ export default class Media {
queueRemoveItems(
queueRemoveItemsRequest: QueueRemoveItemsRequest,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...queueRemoveItemsRequest,
@@ -364,8 +361,8 @@ export default class Media {
queueReorderItems(
queueReorderItemsRequest: QueueReorderItemsRequest,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...queueReorderItemsRequest,
@@ -380,8 +377,8 @@ export default class Media {
queueSetRepeatMode(
repeatMode: string,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const setPropertiesRequest = new QueueSetPropertiesRequest();
setPropertiesRequest.repeatMode = repeatMode;
@@ -398,8 +395,8 @@ export default class Media {
queueUpdateItems(
queueUpdateItemsRequest: QueueUpdateItemsRequest,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...queueUpdateItemsRequest,
@@ -413,8 +410,8 @@ export default class Media {
seek(
seekRequest: SeekRequest,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...seekRequest,
@@ -427,8 +424,8 @@ export default class Media {
setVolume(
volumeRequest: VolumeRequest,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this._sendMediaMessage({
...volumeRequest,
@@ -441,8 +438,8 @@ export default class Media {
stop(
stopRequest?: StopRequest,
successCallback?: SuccessCallback,
errorCallback?: ErrorCallback
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
if (!stopRequest) {
stopRequest = new StopRequest();

View File

@@ -0,0 +1,279 @@
import { Logger } from "../../lib/logger";
import options from "../../lib/options";
import type { Message } from "../../messaging";
// Cast types
import { Capability, ReceiverAvailability } from "../sdk/enums";
import type Session from "../sdk/Session";
import cast, { ensureInit, CastPort } from "../export";
const logger = new Logger("fx_cast [media sender]");
interface MediaSenderOpts {
mediaUrl: string;
contextTabId?: number;
targetElementId?: number;
}
export default class MediaSender {
private port?: CastPort;
private mediaUrl: string;
private contextTabId?: number;
private mediaElement?: HTMLMediaElement;
private isLocalMedia = false;
private isLocalMediaEnabled = false;
// Cast API objects
private session?: Session;
constructor(opts: MediaSenderOpts) {
this.mediaUrl = opts.mediaUrl;
this.contextTabId = opts.contextTabId;
if (opts.targetElementId) {
this.mediaElement = browser.menus.getTargetElement(
opts.targetElementId
) as HTMLMediaElement;
}
this.init();
}
stop() {
this.port?.postMessage({ subject: "bridge:stopMediaServer" });
this.session?.stop();
}
private async init() {
try {
this.port = await ensureInit(this.contextTabId);
} catch (err) {
logger.error("Failed to initialize cast API", err);
}
this.isLocalMedia = this.mediaUrl.startsWith("file://");
this.isLocalMediaEnabled = await options.get("localMediaEnabled");
if (this.isLocalMedia && !this.isLocalMediaEnabled) {
throw logger.error("Local media casting not enabled");
}
const capabilities = [Capability.AUDIO_OUT];
if (this.mediaElement instanceof HTMLVideoElement) {
capabilities.push(Capability.VIDEO_OUT);
}
cast.initialize(
new cast.ApiConfig(
new cast.SessionRequest(
cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
capabilities
),
this.sessionListener.bind(this),
this.receiverListener.bind(this)
),
undefined,
err => {
logger.error("Failed to initialize cast API", err);
}
);
}
private sessionListener() {
// Unused
}
private receiverListener(availability: ReceiverAvailability) {
// Already have session
if (this.session) return;
if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession(
(session: Session) => {
this.session = session;
this.loadMedia();
},
err => {
logger.error("Session request failed", err);
}
);
}
}
private async loadMedia() {
let mediaUrl = new URL(this.mediaUrl);
const mediaTitle = mediaUrl.pathname.slice(1);
const subtitleUrls: URL[] = [];
if (this.isLocalMedia) {
const port = await options.get("localMediaServerPort");
try {
const { localAddress, mediaPath, subtitlePaths } =
await this.startMediaServer(mediaTitle, port);
const baseUrl = new URL(`http://${localAddress}:${port}/`);
mediaUrl = new URL(mediaPath, baseUrl);
subtitleUrls.push(
...subtitlePaths.map(path => new URL(path, baseUrl))
);
} catch (err) {
throw logger.error("Failed to start media server", err);
}
}
const mediaInfo = new cast.media.MediaInfo(mediaUrl.href, "");
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
mediaInfo.metadata.title = mediaTitle;
const activeTrackIds: number[] = [];
mediaInfo.tracks = subtitleUrls.map((url, index) => {
const track = new cast.media.Track(
index,
cast.media.TrackType.TEXT
);
track.name = url.pathname;
track.trackContentId = url.href;
track.trackContentType = "text/vtt";
track.subtype = cast.media.TextTrackType.SUBTITLES;
return track;
});
if (this.mediaElement instanceof HTMLMediaElement) {
if (this.mediaElement instanceof HTMLVideoElement) {
if (this.mediaElement.poster) {
mediaInfo.metadata.images = [
new cast.Image(this.mediaElement.poster)
];
}
}
if (this.mediaElement.textTracks.length) {
const textTracks = Array.from(this.mediaElement.textTracks);
const trackElements =
this.mediaElement.querySelectorAll("track");
let mediaTrackIndex = mediaInfo.tracks.length;
textTracks.forEach((track, index) => {
const trackElement = trackElements[index];
/**
* Create media.Track object with the index as the track ID
* and type as TrackType.TEXT.
*/
const castTrack = new cast.media.Track(
mediaTrackIndex,
cast.media.TrackType.TEXT
);
// Copy TextTrack properties
castTrack.name = track.label || `track-${mediaTrackIndex}`;
castTrack.language = track.language;
castTrack.trackContentId = trackElement.src;
castTrack.trackContentType = "text/vtt";
switch (track.kind) {
case "subtitles":
castTrack.subtype =
cast.media.TextTrackType.SUBTITLES;
break;
case "captions":
castTrack.subtype =
cast.media.TextTrackType.CAPTIONS;
break;
case "descriptions":
castTrack.subtype =
cast.media.TextTrackType.DESCRIPTIONS;
break;
case "chapters":
castTrack.subtype =
cast.media.TextTrackType.CHAPTERS;
break;
case "metadata":
castTrack.subtype =
cast.media.TextTrackType.METADATA;
break;
// Default to subtitles
default:
castTrack.subtype =
cast.media.TextTrackType.SUBTITLES;
}
// Add track to mediaInfo
mediaInfo.tracks?.push(castTrack);
// If enabled, mark as active track for load request
if (track.mode === "showing" || trackElement.default) {
activeTrackIds.push(mediaTrackIndex);
}
mediaTrackIndex++;
});
}
}
const loadRequest = new cast.media.LoadRequest(mediaInfo);
loadRequest.autoplay = true;
loadRequest.activeTrackIds = activeTrackIds;
this.session?.loadMedia(loadRequest);
}
private startMediaServer(
filePath: string,
port: number
): Promise<{
mediaPath: string;
subtitlePaths: string[];
localAddress: string;
}> {
return new Promise((resolve, reject) => {
if (!this.port) {
reject();
return;
}
this.port.postMessage({
subject: "bridge:startMediaServer",
data: {
filePath: decodeURI(filePath),
port: port
}
});
const onMessage = (ev: MessageEvent<Message>) => {
const message = ev.data;
if (message.subject.startsWith("mediaCast:mediaServer")) {
this.port?.removeEventListener("message", onMessage);
}
switch (message.subject) {
case "mediaCast:mediaServerStarted":
resolve(message.data);
break;
case "mediaCast:mediaServerError":
reject(message.data);
break;
}
};
this.port.addEventListener("message", onMessage);
this.port.start();
});
}
}
if (window.location.protocol !== "moz-extension:") {
const window_ = window as any;
new MediaSender({
mediaUrl: window_.mediaUrl,
targetElementId: window_.targetElementId
});
}

View File

@@ -1,403 +0,0 @@
"use strict";
import logger from "../../../lib/logger";
import options from "../../../lib/options";
import cast, { ensureInit } from "../../export";
import type { Message } from "../../../messaging";
import type { ReceiverDevice } from "../../../types";
import type Session from "../../sdk/Session";
import type Media from "../../sdk/media/Media";
import type { Error as Error_ } from "../../sdk/classes";
function startMediaServer(
filePath: string,
port: number
): Promise<{
mediaPath: string;
subtitlePaths: string[];
localAddress: string;
}> {
return new Promise((resolve, reject) => {
backgroundPort.postMessage({
subject: "bridge:startMediaServer",
data: {
filePath: decodeURI(filePath),
port
}
} as Message);
backgroundPort.addEventListener("message", function onMessage(ev) {
const message = ev.data as Message;
if (message.subject.startsWith("mediaCast:mediaServer")) {
backgroundPort.removeEventListener("message", onMessage);
}
switch (message.subject) {
case "mediaCast:mediaServerStarted": {
resolve(message.data);
break;
}
case "mediaCast:mediaServerError": {
reject(message.data);
break;
}
}
});
backgroundPort.start();
});
}
let backgroundPort: MessagePort;
let currentSession: Session;
let currentMedia: Media;
let targetElement: HTMLElement;
function getSession(opts: InitOptions): Promise<Session> {
return new Promise(async (resolve, reject) => {
/**
* If a receiver is available, call requestSession. If a
* specific receiver was specified, bypass receiver selector
* and create session directly.
*/
function receiverListener(availability: string) {
if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession(
onRequestSessionSuccess,
onRequestSessionError,
undefined,
opts.receiver
);
}
}
function sessionListener() {
// TODO: Handle this
}
function onRequestSessionSuccess(session: Session) {
resolve(session);
}
function onRequestSessionError(err: Error_) {
reject(err.description);
}
const sessionRequest = new cast.SessionRequest(
cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID
);
const apiConfig = new cast.ApiConfig(
sessionRequest,
sessionListener, // sessionListener
receiverListener
); // receiverListener
cast.initialize(apiConfig);
});
}
function getMedia(opts: InitOptions): Promise<Media> {
return new Promise(async (resolve, reject) => {
let mediaUrl = new URL(opts.mediaUrl);
const mediaTitle = mediaUrl.pathname.slice(1);
const subtitleUrls: URL[] = [];
/**
* If the media is a local file, start an HTTP media server
* and change the media URL to point to it.
*/
if (opts.mediaUrl.startsWith("file://")) {
const port = await options.get("localMediaServerPort");
try {
// Wait until media server is listening
const { localAddress, mediaPath, subtitlePaths } =
await startMediaServer(mediaTitle, port);
const baseUrl = new URL(`http://${localAddress}:${port}/`);
mediaUrl = new URL(mediaPath, baseUrl);
subtitleUrls.push(
...subtitlePaths.map(path => new URL(path, baseUrl))
);
console.info(mediaUrl);
} catch (err) {
throw logger.error("Failed to start media server", err);
}
}
const activeTrackIds: number[] = [];
const mediaInfo = new cast.media.MediaInfo(mediaUrl.href, "");
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
mediaInfo.metadata.metadataType = cast.media.MetadataType.GENERIC;
mediaInfo.metadata.title = mediaTitle;
mediaInfo.tracks = [];
let trackIndex = 0;
for (const subtitleUrl of subtitleUrls) {
const castTrack = new cast.media.Track(
trackIndex,
cast.media.TrackType.TEXT
);
castTrack.name = subtitleUrl.pathname;
castTrack.trackContentId = subtitleUrl.href;
castTrack.trackContentType = "text/vtt";
castTrack.subtype = cast.media.TextTrackType.SUBTITLES;
mediaInfo.tracks.push(castTrack);
}
if (targetElement instanceof HTMLMediaElement) {
if (targetElement instanceof HTMLVideoElement) {
if (targetElement.poster) {
mediaInfo.metadata.images = [
new cast.Image(targetElement.poster)
];
}
}
if (targetElement.textTracks.length) {
const tracks = Array.from(targetElement.textTracks);
const trackElements = targetElement.querySelectorAll("track");
tracks.forEach((track, index) => {
const trackElement = trackElements[index];
/**
* Create media.Track object with the index as the track ID
* and type as TrackType.TEXT.
*/
const castTrack = new cast.media.Track(
trackIndex,
cast.media.TrackType.TEXT
);
// Copy TextTrack properties
castTrack.name = track.label || `track-${trackIndex}`;
castTrack.language = track.language;
castTrack.trackContentId = trackElement.src;
castTrack.trackContentType = "text/vtt";
switch (track.kind) {
case "subtitles":
castTrack.subtype =
cast.media.TextTrackType.SUBTITLES;
break;
case "captions":
castTrack.subtype =
cast.media.TextTrackType.CAPTIONS;
break;
case "descriptions":
castTrack.subtype =
cast.media.TextTrackType.DESCRIPTIONS;
break;
case "chapters":
castTrack.subtype =
cast.media.TextTrackType.CHAPTERS;
break;
case "metadata":
castTrack.subtype =
cast.media.TextTrackType.METADATA;
break;
// Default to subtitles
default:
castTrack.subtype =
cast.media.TextTrackType.SUBTITLES;
}
// Add track to mediaInfo
mediaInfo.tracks?.push(castTrack);
// If enabled, mark as active track for load request
if (track.mode === "showing" || trackElement.default) {
activeTrackIds.push(trackIndex);
}
trackIndex++;
});
}
}
const loadRequest = new cast.media.LoadRequest(mediaInfo);
loadRequest.autoplay = true;
loadRequest.activeTrackIds = activeTrackIds;
currentSession.loadMedia(loadRequest, resolve, reject);
});
}
let ignoreMediaEvents = false;
async function registerMediaElementListeners(mediaElement: HTMLMediaElement) {
function checkIgnore(ev: Event) {
if (ignoreMediaEvents) {
ignoreMediaEvents = false;
ev.stopImmediatePropagation();
}
}
if (await options.get("mediaSyncElement")) {
mediaElement.addEventListener("play", checkIgnore, true);
mediaElement.addEventListener("pause", checkIgnore, true);
mediaElement.addEventListener("suspend", checkIgnore, true);
mediaElement.addEventListener("seeking", checkIgnore, true);
mediaElement.addEventListener("ratechange", checkIgnore, true);
mediaElement.addEventListener("volumechange", checkIgnore, true);
mediaElement.addEventListener("play", () => {
currentMedia.play();
});
mediaElement.addEventListener("pause", () => {
currentMedia.pause();
});
mediaElement.addEventListener("suspend", () => {
// currentMedia.stop(null, null, null);
});
mediaElement.addEventListener("seeked", () => {
const seekRequest = new cast.media.SeekRequest();
seekRequest.currentTime = mediaElement.currentTime;
currentMedia.seek(seekRequest);
});
mediaElement.addEventListener("ratechange", () => {
// TODO: Re-implement this
});
mediaElement.addEventListener("volumechange", () => {
const newVolume = new cast.Volume(
currentMedia.volume.level,
currentMedia.volume.muted
);
const volumeRequest = new cast.media.VolumeRequest(newVolume);
currentMedia.setVolume(volumeRequest);
});
currentMedia.addUpdateListener(isAlive => {
if (!isAlive) {
return;
}
const localPlayerState = mediaElement.paused
? cast.media.PlayerState.PAUSED
: cast.media.PlayerState.PLAYING;
if (localPlayerState !== currentMedia.playerState) {
ignoreMediaEvents = true;
switch (currentMedia.playerState) {
case cast.media.PlayerState.PLAYING: {
mediaElement.play();
break;
}
case cast.media.PlayerState.PAUSED: {
mediaElement.pause();
break;
}
}
}
const localRepeatMode = mediaElement.loop
? cast.media.RepeatMode.SINGLE
: cast.media.RepeatMode.OFF;
if (localRepeatMode !== currentMedia.repeatMode) {
ignoreMediaEvents = true;
switch (currentMedia.repeatMode) {
case cast.media.RepeatMode.SINGLE: {
mediaElement.loop = true;
break;
}
case cast.media.RepeatMode.OFF: {
mediaElement.loop = false;
break;
}
}
}
if (currentMedia.currentTime !== mediaElement.currentTime) {
ignoreMediaEvents = true;
mediaElement.currentTime = currentMedia.currentTime;
}
});
}
}
interface InitOptions {
mediaUrl: string;
receiver?: ReceiverDevice;
targetElementId?: number;
}
export async function init(opts: InitOptions) {
backgroundPort = await ensureInit();
backgroundPort.addEventListener("message", ev => {
const message = ev.data as Message;
switch (message.subject) {
case "mediaCast:mediaServerError":
logger.error("Media server error", message.data);
}
});
const isLocalMedia = opts.mediaUrl.startsWith("file://");
const isLocalMediaEnabled = await options.get("localMediaEnabled");
if (isLocalMedia && !isLocalMediaEnabled) {
cast.logMessage("Local media casting not enabled");
return;
}
if (!opts.targetElementId) {
cast.logMessage("Target element ID not found");
return;
}
targetElement = browser.menus.getTargetElement(
opts.targetElementId
) as HTMLMediaElement;
currentSession = await getSession(opts);
currentMedia = await getMedia(opts);
if (targetElement instanceof HTMLMediaElement) {
registerMediaElementListeners(targetElement);
}
window.addEventListener("beforeunload", async () => {
backgroundPort.postMessage({
subject: "bridge:mediaServer/stop"
});
if (await options.get("mediaStopOnUnload")) {
currentSession.stop();
}
});
}
/**
* If loaded as a content script, the init values are
* provided on the window object.
*/
if (window.location.protocol !== "moz-extension:") {
const _window = window as any;
init({
mediaUrl: _window.mediaUrl,
receiver: _window.receiver,
targetElementId: _window.targetElementId
});
}

View File

@@ -1,12 +0,0 @@
"use strict";
import type { Error as Error_ } from "./sdk/classes";
import type Media from "./sdk/media/Media";
export type SuccessCallback = () => void;
export type ErrorCallback = (err: Error_) => void;
export type MediaListener = (media: Media) => void;
export type MessageListener = (namespace: string, message: string) => void;
export type UpdateListener = (isAlive: boolean) => void;
export type LoadSuccessCallback = (media: Media) => void;

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

@@ -11,11 +11,6 @@ declare type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never;
declare interface Object {
// eslint-disable-next-line @typescript-eslint/ban-types
wrappedJSObject: Object;
}
declare interface CanvasRenderingContext2D {
DRAWWINDOW_DRAW_CARET: 0x01;
DRAWWINDOW_DO_NOT_FLUSH: 0x02;

View File

@@ -1,5 +1,5 @@
import logger from "../lib/logger";
import { TypedStorageArea } from "../lib/TypedStorageArea";
import logger from "./logger";
import { TypedStorageArea } from "./TypedStorageArea";
const ENDPOINT = "https://clients3.google.com/cast/chromecast/device";

View File

@@ -28,15 +28,6 @@
"scripts": ["background/background.js"]
},
"content_scripts": [
{
"all_frames": true,
"js": ["cast/content.js"],
"matches": ["<all_urls>"],
"run_at": "document_start"
}
],
"default_locale": "en",
"icons": {
@@ -62,5 +53,5 @@
"webRequestBlocking",
"<all_urls>"
],
"web_accessible_resources": ["cast/index.js"]
"web_accessible_resources": ["cast/content.js"]
}

View File

@@ -18,7 +18,12 @@ import type {
} from "./cast/sdk/types";
import type { ApiConfig, Receiver, SessionRequest } from "./cast/sdk/classes";
import type { ReceiverDevice, ReceiverSelectorMediaType } from "./types";
import type {
ReceiverDevice,
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "./types";
import type { ReceiverAction } from "./cast/sdk/enums";
/**
@@ -41,12 +46,8 @@ import type { ReceiverAction } from "./cast/sdk/enums";
type ExtMessageDefinitions = {
/** Initial data to send to selector popup. */
"popup:init": {
appId?: string;
pageInfo?: {
url: string;
tabId: number;
frameId: number;
};
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
};
/** Updates selector popup with new data. */
"popup:update": {
@@ -74,17 +75,17 @@ type ExtMessageDefinitions = {
* Sent from the cast API to trigger receiver selection on session
* request.
*/
"main:selectReceiver": {
"main:requestSession": {
sessionRequest: SessionRequest;
};
/** Return message to the cast API when a selection is cancelled. */
"cast:selectReceiver/cancelled": undefined;
"cast:sessionRequestCancelled": undefined;
/**
* Sent to the cast API when a session is requested or stopped via
* the extension UI.
*/
"cast:sendReceiverAction": { receiver: Receiver; action: ReceiverAction };
"cast:receiverAction": { receiver: Receiver; action: ReceiverAction };
/**
* Tells the cast manager to provide the cast API instance with
@@ -96,7 +97,7 @@ type ExtMessageDefinitions = {
"cast:sessionCreated": CastSessionCreatedDetails & { receiver: Receiver };
"cast:sessionUpdated": CastSessionUpdatedDetails;
"cast:updateReceiverAvailability": { isAvailable: boolean };
"cast:receiverAvailabilityUpdated": { isAvailable: boolean };
};
/**
@@ -227,7 +228,7 @@ type AppMessageDefinitions = {
* Sent to cast API instance from bridge when session message
* received from a receiver device.
*/
"cast:receivedSessionMessage": {
"cast:sessionMessageReceived": {
sessionId: string;
namespace: string;
messageData: string;

View File

@@ -27,14 +27,17 @@ export enum ReceiverSelectorMediaType {
None = 0,
App = 1,
Tab = 2,
Screen = 4,
File = 8
Screen = 4
}
export interface ReceiverSelectorAppInfo {
sessionRequest: SessionRequest;
isRequestAppAudioCompatible?: boolean;
}
/** Info about sender page context. */
export interface ReceiverSelectorPageInfo {
url: string;
tabId: number;
frameId: number;
sessionRequest?: SessionRequest;
isRequestAppAudioCompatible?: boolean;
}

View File

@@ -10,6 +10,7 @@
import {
ReceiverDevice,
ReceiverDeviceCapabilities,
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "../../types";
@@ -29,8 +30,8 @@
/** Devices to display. */
let devices: ReceiverDevice[] = [];
/** Sender app ID (if available). */
let appId: Optional<string>;
/** Sender app info (if available). */
let appInfo: Optional<ReceiverSelectorAppInfo>;
/** Page info (if launched from page context). */
let pageInfo: Optional<ReceiverSelectorPageInfo>;
@@ -70,14 +71,14 @@
// If device is audio-only, check app's audio support flag
if (
!(device.capabilities & ReceiverDeviceCapabilities.VIDEO_OUT) &&
pageInfo?.isRequestAppAudioCompatible === false
appInfo?.isRequestAppAudioCompatible === false
) {
return false;
}
return hasRequiredCapabilities(
device,
pageInfo?.sessionRequest?.capabilities
appInfo?.sessionRequest?.capabilities
);
}
@@ -148,7 +149,7 @@
function onMessage(message: Message) {
switch (message.subject) {
case "popup:init":
appId = message.data.appId;
appInfo = message.data.appInfo;
pageInfo = message.data.pageInfo;
break;
@@ -191,8 +192,8 @@
* Check knownApps for an app with an ID matching the registered
* app on the target page.
*/
if (isAppMediaTypeAvailable && appId) {
newKnownApp = knownApps[appId];
if (isAppMediaTypeAvailable && appInfo?.sessionRequest.appId) {
newKnownApp = knownApps[appInfo.sessionRequest.appId];
} else if (pageInfo) {
const pageUrl = pageInfo.url;