mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-08 08:39:59 +00:00
Fix longstanding architectural issues
This commit is contained in:
@@ -1,15 +1,101 @@
|
||||
# Implementation Details (WIP)
|
||||
# Implementation Details
|
||||
|
||||
## WebExtension
|
||||
### Permissions
|
||||
|Permission|Description|Usage|
|
||||
|--|--|--|
|
||||
|`browser.history`|Access browsing history|When opening receiver selection popup windows, the history log is polluted unless these entries are removed.|
|
||||
|`menus`|N/A|Display context menus for casting on pages/media, and whitelist menus on the toolbar button.|
|
||||
|`nativeMessaging`|Exchange messages with programs other than Firefox|Allows communciation with the bridge.|
|
||||
|`notifications`|Display notifications to you|Show notifications if a bridge issue is found on startup.|
|
||||
|`storage`|N/A|Store options data.|
|
||||
|`tabs`|Access browser tabs|Execute scripts within a sender application's tab content script context (possibly unnecessary due to host permissions).|
|
||||
|`webNavigation`|Access browser activity during navigation|Get URL of frame to determine available cast media types.|
|
||||
|`webRequest`|N/A|Intercept and redirect Cast SDK requests.|
|
||||
|`<all_urls>`|Acess your data for all websites|Wildcard host permission (may want to switch to optional host permissions).|
|
||||
**Note:** This is probably a bit verbose since I'm not too experienced with technical writing, but hopefully it will be of some use in explaining the more complex extension functionality.
|
||||
|
||||
## Messaging
|
||||
|
||||
The extension and bridge use a unified messaging format consisting of a JSON object with a `subject` string property and optional `data` property:
|
||||
|
||||
```ts
|
||||
interface Message {
|
||||
subject: string;
|
||||
data?: any;
|
||||
}
|
||||
```
|
||||
|
||||
The message payloads are all fully-typed and defined in [`ext/src/messaging.ts`](./ext/src/messaging.ts). Wrappers around both WebExtension messaging and MessagePort APIs are used to provide type checking based on these message definitions. Almost all messages are sent via messaging connections, rather than one-off `sendMessage`/`postMessage` calls.
|
||||
|
||||
## Cast Instances
|
||||
|
||||
A cast instance is an initialized Web Sender SDK instance with the extension components that handle communication with receiver devices and other required functionality (like receiver selection) and is managed by the [Cast Manager](./ext/src/background/castManager.ts) background script module.
|
||||
|
||||
Only the [Base API](https://web.archive.org/web/20150318065431/https://developers.google.com/cast/docs/chrome_sender) (`chrome.cast`) is implemented, since the Framework API (`chrome.cast.framework`) is a wrapper around the Base API and doesn't require any extra functionality on the extension-side.
|
||||
|
||||
For some background, see [Cast SDK terminology](https://developers.google.com/cast/docs/web_sender/integrate#terminology).
|
||||
|
||||
### Communication
|
||||
|
||||
SDK instances send messages through a MessageChannel managed by the [`pageMessaging`](./ext/src/cast/pageMessenging.ts) module. One side listens for an initialization message containing a MessagePort, then receives messages from the SDK on that port and calls its message listeners so that they can be forwarded to the Cast Manager. The other side sends that initialization message and handles responses back from the Cast Manager.
|
||||
|
||||
### Initialization
|
||||
|
||||
The SDK can be initialized from page scripts (for web sender apps), or from extension scripts in a content script, extension page or background script context. Depending on the context, the way the cast instance is created happens differently.
|
||||
|
||||
#### Page script
|
||||
|
||||
In-page sender apps enable cast functionality by loading the remote Web Sender SDK script:
|
||||
`https://www.gstatic.com/cv/js/sender/v1/cast_sender.js`
|
||||
|
||||
This points to a loader script that checks the user agent string before injecting the proper SDK script into the page. In Chrome, the SDK script is actually hosted via a `chrome-extension://` URL as a [web accessible resource](https://developer.chrome.com/docs/extensions/mv3/manifest/web_accessible_resources/).
|
||||
|
||||
For an instance created for a page script SDK:
|
||||
|
||||
1. The [`contentInitial.ts`](./ext/src/cast/contentInitial.ts) content script is run at document start and handles some compatibility issues that can't be addressed via extension APIs (like SDK scripts directly loaded from `chrome-extension://` URLs).
|
||||
2. The page loads the SDK via the usual Google-hosted `cast_sender.js` loader script.
|
||||
3. The extension intercepts this script load, injects the [`contentBridge.ts`](./ext/src/cast/contentBridge.ts) script that creates a messaging connection to the Cast Manager (via extension messaging) that registers an instance for that context, and waits for a page messaging connection to forward messages through (as described [here](#communication)). The initial request is then transparently redirected to the extension-hosted SDK page script at [`src/cast/content.ts`](./src/cast/content.ts).
|
||||
4. The SDK page script then creates the SDK objects ([`window.chrome.cast`](https://developers.google.com/cast/docs/reference/web_sender/chrome.cast)), handles loading the Framework API (if requested) and adds a page messaging listener for `cast:intitialized` events.
|
||||
5. The Cast Manager sends a `cast:initialized` message to the SDK, which then calls the app's initialization handler ([`window.__onGCastApiAvailable`](https://developers.google.com/cast/docs/web_sender/integrate#initialization)).
|
||||
|
||||
#### Extension script
|
||||
|
||||
For an instance created for an extension script:
|
||||
|
||||
1. The extension script imports the [`cast/export.ts`](./ext/src/cast/export.ts) module which creates an SDK instance. Page messaging is still used to communicate with the SDK, despite the lack of a script context boundary to avoid complicating the SDK implementation.
|
||||
2. The extension script calls the exported `ensureInit` async function.
|
||||
Depending on the extension script context:
|
||||
- If **background**: The Cast Manager is called directly, registering a new cast instance, providing it with a port for a newly-created message channel (since extension messaging is only supported between contexts). Page messaging is hooked up such that messages from the SDK are sent to the Cast Manager through this channel and vice versa.
|
||||
- If **content/extension page**: The `contentBridge.ts` script is imported as a module, with the usual side-effects of creating a messaging connection to the Cast Manager and hooking up page messaging (as described for page script instances).
|
||||
3. Listeners are added for the `cast:initialized` message, so that the `ensureInit` function can resolve its promise and provide a Cast Manager port after initialization.
|
||||
|
||||
#### All contexts
|
||||
|
||||
The process now continues identically for all contexts:
|
||||
|
||||
1. The page's now-active sender app calls the [`chrome.cast.initialize`](https://developers.google.com/cast/docs/reference/web_sender/chrome.cast#.initialize) API function, sending a `main:initializeCast` message to the Cast Manager, providing it with the [`chrome.cast.ApiConfig`](https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.ApiConfig) data and prompting a receiver availability update.
|
||||
2. The page is now free to request a session if receivers are available.
|
||||
|
||||
### Sessions
|
||||
|
||||
A sender app can request a session by calling the SDK's [`chrome.cast.requestSession`](https://developers.google.com/cast/docs/reference/web_sender/chrome.cast#.requestSession) method. This sends a `main:requestSession` message to the Cast Manager with a [`chrome.cast.SessionRequest`](https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.SessionRequest?hl=en) object. This will trigger a receiver selection where the Cast Manager opens the popup UI and waits for the user to select a receiver device.
|
||||
|
||||
If the selection is cancelled, a `cast:sessionRequestCancelled` message is sent to the SDK instance allowing.
|
||||
|
||||
Otherwise, if a device is selected, the Cast Manager sends a `bridge:/createCastSession` message to the bridge instance which causes the bridge to launch the requested receiver app on the selected device. Once the app has launched and the cast session has been created, the bridge sends a `main:castSessionCreated` message and further `main:castSessionUpdated` messages back to the Cast Manager.
|
||||
|
||||
Upon receiving the session created/updated messages, the Cast Manager forwards the message to the SDK instance which creates/updates the [`chrome.cast.Session`](https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.Session) object and calls the relevant app listener functions.
|
||||
|
||||
## Bridge
|
||||
|
||||
The bridge is a Node.js-based [native messaging](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging) host application that is launched by Firefox when the extension requests a bridge instance. It receives messages from the extension and provides service discovery and Chromecast device messaging/session management since the WebExtension APIs are too limited to implement this functionality.
|
||||
|
||||
### Daemon
|
||||
|
||||
The bridge can also be run as a daemon by launching the executable with the `-d`/`--daemon` flag. Instead of running as a messaging host, the bridge starts a WebSocket server and listens for incoming connections. On the extension-side, daemon support can be enabled which will automatically connect to a specified WebSocket server address whenever the WebExtension native messaging connection fails.
|
||||
|
||||
When an incoming connection is received the daemon acts as a native messaging server and spawns bridge instances as child processes. The daemon forwards incoming WebSocket messages to the bridge instances and sends responses back over `stdin`/`stdout` as usual.
|
||||
|
||||
Since the daemon is just a WebSocket server, it can configured to be used remotely, so the bridge doesn't have to be running on the same machine as the extension. However, remote connections could cause performance issues due to increased latency and may be unstable or insecure. Local media casting will also be unavailable.
|
||||
|
||||
## WebExtension Permissions
|
||||
|
||||
| Permission | Description | Usage |
|
||||
| ----------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `browser.history` | Access browsing history | When opening receiver selection popup windows, the history log is polluted unless these entries are removed. |
|
||||
| `menus` | N/A | Display context menus for casting on pages/media, and whitelist menus on the toolbar button. |
|
||||
| `nativeMessaging` | Exchange messages with programs other than Firefox | Allows communciation with the bridge. |
|
||||
| `notifications` | Display notifications to you | Show notifications if a bridge issue is found on startup. |
|
||||
| `storage` | N/A | Store options data. |
|
||||
| `tabs` | Access browser tabs | Execute scripts within a sender application's tab content script context (possibly unnecessary due to host permissions). |
|
||||
| `webNavigation` | Access browser activity during navigation | Get URL of frame to determine available cast media types. |
|
||||
| `webRequest` | N/A | Intercept and redirect Cast SDK requests. |
|
||||
| `<all_urls>` | Acess your data for all websites | Wildcard host permission since the extension uses its own match pattern whitelist (may want to switch to optional host permissions in the future). |
|
||||
|
||||
@@ -140,7 +140,7 @@ export default class Session extends CastClient {
|
||||
messageData = JSON.stringify(messageData);
|
||||
|
||||
messaging.sendMessage({
|
||||
subject: "cast:receivedSessionMessage",
|
||||
subject: "cast:sessionMessageReceived",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
namespace,
|
||||
|
||||
@@ -144,7 +144,7 @@ type MessageDefinitions = {
|
||||
* 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;
|
||||
|
||||
@@ -66,11 +66,11 @@ const buildOpts = {
|
||||
// Main
|
||||
path.join(srcPath, "background/background.ts"),
|
||||
// Cast
|
||||
path.join(srcPath, "cast/index.ts"),
|
||||
path.join(srcPath, "cast/content.ts"),
|
||||
path.join(srcPath, "cast/contentInitial.ts"),
|
||||
path.join(srcPath, "cast/contentBridge.ts"),
|
||||
// Media sender
|
||||
path.join(srcPath, "cast/senders/media/index.ts"),
|
||||
path.join(srcPath, "cast/senders/media.ts"),
|
||||
// Mirroring sender
|
||||
path.join(srcPath, "/cast/senders/mirroring.ts"),
|
||||
// UI
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
57
ext/src/cast/contentInitial.ts
Normal file
57
ext/src/cast/contentInitial.ts
Normal 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)
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
140
ext/src/cast/pageMessenging.ts
Normal file
140
ext/src/cast/pageMessenging.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
279
ext/src/cast/senders/media.ts
Normal file
279
ext/src/cast/senders/media.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
5
ext/src/global.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user