Fix longstanding architectural issues

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,60 +0,0 @@
import logger from "../lib/logger";
import { TypedStorageArea } from "../lib/TypedStorageArea";
const ENDPOINT = "https://clients3.google.com/cast/chromecast/device";
export interface BaseConfig {
app_tags: Array<{
supports_audio_only: boolean;
suports_video: boolean;
app_id: number;
}>;
}
export const baseConfigStorage = new TypedStorageArea<{
baseConfig: BaseConfig;
baseConfigUpdated: number;
}>(browser.storage.local);
/**
* Fetches Chromecast base config data subset.
*/
export async function fetchBaseConfig(): Promise<BaseConfig | null> {
try {
const res = await fetch(`${ENDPOINT}/baseconfig`);
const baseConfig = JSON.parse((await res.text()).slice(4));
// Strip other properties
return { app_tags: baseConfig.app_tags };
} catch (err) {
logger.error("Failed to fetch Chromecast base config!");
return null;
}
}
/**
* Get app tag from base config.
* @param baseConfig Base config data.
* @param appId Chromecast app ID.
*/
export function getAppTag(baseConfig: BaseConfig, appId: string) {
// App tag IDs are represented as 32-bit signed integers
const signedAppId = (parseInt(appId, 16) << 32) >> 32;
return baseConfig.app_tags.find(tag => tag.app_id === signedAppId);
}
/**
* Fetches Chromecast app config.
*
* @param appId Chromecast app ID
* @returns
*/
export async function fetchAppConfig(appId: string) {
try {
const res = await fetch(`${ENDPOINT}/app?a=${appId}`);
return JSON.parse((await res.text()).slice(4));
} catch (err) {
logger.error("Failed to fetch Chromecast app config!", { appId });
return null;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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