mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-11 01:59:58 +00:00
Fix longstanding architectural issues
This commit is contained in:
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user