Rename directory: ext -> extension

This commit is contained in:
hensm
2023-02-26 18:21:59 +00:00
parent 33bcbc0dca
commit a9406fde11
119 changed files with 40 additions and 42 deletions

View File

@@ -0,0 +1,48 @@
/**
* Cast Sender SDK page script loaded in place of remote cast_sender
* script. Handles API object creation and initializes sender apps.
*/
import logger from "../lib/logger";
import { loadScript } from "../lib/utils";
import pageMessaging from "./pageMessaging";
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!");
});
}
}
pageMessaging.page.addListener(async message => {
switch (message.subject) {
case "cast:instanceCreated": {
// If framework API is loading, wait until completed
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,19 @@
import messaging, { Message } from "../messaging";
import pageMessaging from "./pageMessaging";
// Message port to cast manager in background script
const managerPort = messaging.connect({ name: "cast" });
const forwardToPage = (message: Message) => {
pageMessaging.extension.sendMessage(message);
};
const forwardToMain = (message: Message) => {
managerPort.postMessage(message);
};
managerPort.onMessage.addListener(forwardToPage);
pageMessaging.extension.addListener(forwardToMain);
managerPort.onDisconnect.addListener(() => {
pageMessaging.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

@@ -0,0 +1,154 @@
import type { TypedMessagePort } from "../lib/TypedMessagePort";
import messaging, { Message } from "../messaging";
import type { ReceiverDevice } from "../types";
import pageMessaging from "./pageMessaging";
// Ensure extension-side is initialized first
void pageMessaging.extension;
import CastSDK from "./sdk";
export type CastPort = TypedMessagePort<Message>;
let existingPort: CastPort;
let existingInstance = new CastSDK();
export default existingInstance;
interface EnsureInitOpts {
contextTabId?: number;
/** Skip receiver selection. */
receiverDevice?: ReceiverDevice;
}
/**
* 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
* cast manager, hooks it up to the pageMessaging layer and also
* provides a messaging port so consumers of this module can communicate
* with the cast manager.
*/
export function ensureInit(opts: EnsureInitOpts): Promise<CastPort> {
return new Promise(async (resolve, reject) => {
// If already initialized
if (existingPort) {
existingPort.close();
existingInstance = new CastSDK();
}
/**
* 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:" &&
window.location.pathname === "_generated_background_page.html"
) {
const { default: castManager } = await import(
"../background/castManager"
);
/**
* port1 will handle cast manager messages.
* port2 will handle cast instance messages.
*/
const { port1: managerPort, port2: instancePort } =
new MessageChannel();
/**
* Provide cast manager with a port to send messages to
* cast instance.
*/
if (opts.contextTabId) {
await castManager.createInstance(instancePort, {
tabId: opts.contextTabId,
frameId: 0
});
} else {
await castManager.createInstance(instancePort);
}
// cast manager -> cast instance
managerPort.addEventListener("message", ev => {
const message = ev.data as Message;
if (message.subject === "cast:instanceCreated") {
if (message.data.isAvailable) {
resolve(existingPort);
} else {
reject();
}
}
pageMessaging.extension.sendMessage(message);
});
managerPort.start();
// Cast instance -> cast manager
pageMessaging.extension.addListener(message => {
// Skip receiver selection
if (opts.receiverDevice) {
message = rewriteTrustedRequestSession(
message,
opts.receiverDevice
);
}
managerPort.postMessage(message);
});
} else {
const managerPort = messaging.connect({ name: "trusted-cast" });
// Cast manager -> cast instance
managerPort.onMessage.addListener(message => {
if (message.subject === "cast:instanceCreated") {
if (message.data.isAvailable) {
resolve(pageMessaging.page.messagePort);
} else {
reject();
}
}
pageMessaging.extension.sendMessage(message);
});
// Cast instance -> cast manager
pageMessaging.extension.addListener(message => {
// Skip receiver selection
if (opts.receiverDevice) {
message = rewriteTrustedRequestSession(
message,
opts.receiverDevice
);
}
managerPort.postMessage(message);
});
managerPort.onDisconnect.addListener(() => {
pageMessaging.extension.close();
});
existingPort = pageMessaging.page.messagePort;
}
});
}
/**
* If a receiver device was passed to `ensureInit`, messages to the cast
* manager will be passed through this function and the receiver device
* will be added to the message payload. This tells the cast manager to
* skip receiver selection when requesting a session.
*/
function rewriteTrustedRequestSession(
message: Message,
receiverDevice: ReceiverDevice
) {
if (message.subject !== "main:requestSession") return message;
message.data.receiverDevice = receiverDevice;
return message;
}

View File

@@ -0,0 +1,40 @@
const _ = browser.i18n.getMessage;
export interface KnownApp {
name: string;
matches?: string;
}
/**
* TODO: Just keep a list of IDs and cache names from the Google API:
* https://clients3.google.com/cast/chromecast/device/app?a=[appId]
*
* Also, localization since the API supports it.
*/
export default {
// Web-supported
"CA5E8412": { name: "Netflix", matches: "https://www.netflix.com/*" },
"233637DE": { name: "YouTube", matches: "https://www.youtube.com/*" },
"2DB7CC49": {
name: "YouTube Music",
matches: "https://music.youtube.com/*"
},
"CC32E753": { name: "Spotify", matches: "https://open.spotify.com/*" },
"2BA92214": {
name: "BBC iPlayer",
matches: "https://www.bbc.co.uk/iplayer*"
},
"B3DCF968": { name: "Twitch", matches: "https://www.twitch.tv/*" },
"B88B034A": {
name: "Dailymotion",
matches: "https://www.dailymotion.com/*"
},
"C3DE6BC2": { name: "Disney+", matches: "https://www.disneyplus.com/*" },
"B143C57E": { name: "SoundCloud", matches: "https://soundcloud.com/*" },
"10AAD887": { name: "All 4", matches: "https://www.channel4.com/*" },
// Misc
"9AC194DC": { name: "Plex" },
"CC1AD845": { name: _("popupMediaTypeAppMedia") }
} as Record<string, KnownApp>;

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 messaging.
*
* 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 messaging.
*
* 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

@@ -0,0 +1,454 @@
import { v4 as uuid } from "uuid";
import { Logger } from "../../lib/logger";
import pageMessaging from "../pageMessaging";
import { convertSupportedMediaCommandsFlags } from "../utils";
import type {
MediaStatus,
ReceiverMediaMessage,
SenderMediaMessage,
SenderMessage
} from "./types";
import { ErrorCode, SessionStatus } from "./enums";
import {
Error as CastError,
Image,
Receiver,
SenderApplication
} from "./classes";
import { PlayerState } from "./media/enums";
import type { LoadRequest, QueueLoadRequest, QueueItem } from "./media/classes";
import Media, {
createMedia,
mediaLastUpdateTimes,
mediaUpdateListeners,
NS_MEDIA
} from "./media/Media";
const logger = new Logger("fx_cast [sdk :: cast.Session]");
/**
* Takes a media object and a media status object and merges the status
* with the existing media object, updating it with new properties.
*/
export function updateMedia(media: Media, status: MediaStatus) {
media.currentItemId = null;
media.loadingItemId = null;
media.preloadedItemId = null;
// Copy status properties to media
for (const prop in status) {
if (prop === "items") continue;
switch (prop) {
case "volume":
media.volume.level = status.volume.level;
media.volume.muted = status.volume.muted;
break;
case "supportedMediaCommands":
media.supportedMediaCommands =
convertSupportedMediaCommandsFlags(
status.supportedMediaCommands
);
break;
default:
(media as any)[prop] = (status as any)[prop];
}
}
if (!("idleReason" in status)) {
media.idleReason = null;
}
if (!("extendedStatus" in status)) {
// FIXME: Add extendedStatus types
(media as any).extendedStatus = null;
}
// Set last update time on currentTime change
if ("currentTime" in status) {
mediaLastUpdateTimes.set(media, Date.now());
}
if (
media.playerState === PlayerState.IDLE &&
media.loadingItemId === null
) {
media.currentItemId = null;
media.loadingItemId = null;
media.preloadedItemId = null;
media.items = null;
} else if (status.items) {
const newItems: QueueItem[] = [];
for (const newItem of status.items) {
if (!newItem.media) {
// Existing queue item with the same ID
const existingItem = media.items?.find(
item => item.itemId === newItem.itemId
);
/**
* Use existing queue item's media info if available
* otherwise, if the current queue item, use the main
* media item.
*/
if (existingItem?.media) {
newItem.media = existingItem.media;
} else if (
media.media &&
newItem.itemId === media.currentItemId
) {
newItem.media = media.media;
}
}
newItems.push(newItem);
}
media.items = newItems;
}
}
export const sessionMessageListeners = new WeakMap<
Session,
Map<string, Set<MessageListener>>
>();
export const sessionUpdateListeners = new WeakMap<
Session,
Set<UpdateListener>
>();
export const sessionSendMessageCallbacks = new WeakMap<
Session,
Map<string, SendMessageCallback>
>();
export const sessionLeaveSuccessCallback = new WeakMap<
Session,
Optional<() => void>
>();
type SendMediaMessage = (
message: DistributiveOmit<SenderMediaMessage, "requestId">
) => Promise<void>;
export const sessionSendMediaMessage = new WeakMap<Session, SendMediaMessage>();
interface MediaRequest {
successCallback: () => void;
errorCallback: (error: CastError) => void;
message: SenderMediaMessage;
requestId: number;
}
const sessionMediaRequests = new WeakMap<Session, Map<number, MediaRequest>>();
/** Creates a Session object and initializes private data. */
export function createSession(
sessionArgs: ConstructorParameters<typeof Session>
) {
const session = new Session(...sessionArgs);
sessionUpdateListeners.set(session, new Set());
sessionSendMessageCallbacks.set(session, new Map());
// Record of pending media requests
// FIXME: Handle request timeouts
const mediaRequests = new Map<number, MediaRequest>();
sessionMediaRequests.set(session, mediaRequests);
// Current media request ID
let mediaRequestId = 1;
/**
* Stores callbacks for request response, then adds current request
* ID to the message and sends it.
*/
sessionSendMediaMessage.set(session, message => {
return new Promise<void>((resolve, reject) => {
const requestId = mediaRequestId++;
const request: MediaRequest = {
successCallback: () => {
mediaRequests.delete(requestId);
resolve();
},
errorCallback: () => {
mediaRequests.delete(requestId);
reject();
},
message: { ...message, requestId },
requestId
};
mediaRequests.set(request.requestId, request);
session.sendMessage(NS_MEDIA, request.message, undefined, () => {
mediaRequests.delete(requestId);
reject();
});
});
});
return session;
}
type MessageListener = (namespace: string, message: string) => void;
type UpdateListener = (isAlive: boolean) => void;
type SendMessageCallback = [(() => void)?, ((err: CastError) => void)?];
export default class Session {
#loadMediaRequest?: LoadRequest;
#loadMediaSuccessCallback?: (media: Media) => void;
#loadMediaErrorCallback?: (err: CastError) => void;
get #messageListeners() {
const messageListeners = sessionMessageListeners.get(this);
if (!messageListeners)
throw logger.error("Missing session message listeners!");
return messageListeners;
}
get #updateListeners() {
const updateListeners = sessionUpdateListeners.get(this);
if (!updateListeners)
throw logger.error("Missing session update listeners!");
return updateListeners;
}
get #sendMessageCallbacks() {
const sendMessageCallback = sessionSendMessageCallbacks.get(this);
if (!sendMessageCallback)
throw logger.error("Missing session sendMessage callback!");
return sendMessageCallback;
}
get #sendMediaMessage() {
const sendMediaMessage = sessionSendMediaMessage.get(this);
if (!sendMediaMessage)
throw logger.error("Missing send media message function!");
return sendMediaMessage;
}
get #mediaRequests() {
const mediaRequests = sessionMediaRequests.get(this);
if (!mediaRequests)
throw logger.error("Missing session media requests!");
return mediaRequests;
}
get #leaveSuccessCallback() {
return sessionLeaveSuccessCallback.get(this);
}
set #leaveSuccessCallback(successCallback: Optional<() => void>) {
sessionLeaveSuccessCallback.set(this, successCallback);
}
media: Media[] = [];
namespaces: Array<{ name: string }> = [];
senderApps: SenderApplication[] = [];
status = SessionStatus.CONNECTED;
statusText: Nullable<string> = null;
transportId: string;
constructor(
public sessionId: string,
public appId: string,
public displayName: string,
public appImages: Image[],
public receiver: Receiver
) {
this.transportId = sessionId || "";
sessionMessageListeners.set(this, new Map());
this.addMessageListener(NS_MEDIA, this.#mediaMessageListener);
}
#mediaMessageListener = (namespace: string, messageString: string) => {
if (namespace !== NS_MEDIA) return;
const message: ReceiverMediaMessage = JSON.parse(messageString);
if (message.type !== "MEDIA_STATUS") return;
for (const status of message.status) {
let media = this.media.find(
media => media.mediaSessionId === status.mediaSessionId
);
if (!media) {
media = createMedia(
[this.sessionId, status.mediaSessionId],
this.#sendMediaMessage
);
this.media.push(media);
updateMedia(media, status);
} else {
updateMedia(media, status);
}
}
// Handle media request responses
const mediaRequest = this.#mediaRequests.get(message.requestId);
if (mediaRequest) {
mediaRequest.successCallback();
}
for (const status of message.status) {
const media = this.media.find(
media => media.mediaSessionId === status.mediaSessionId
);
if (!media) continue;
const updateListeners = mediaUpdateListeners.get(media);
if (updateListeners) {
for (const listener of updateListeners) {
listener(true);
}
}
}
};
#sendReceiverMessage = (
message: DistributiveOmit<SenderMessage, "requestId">
) => {
return new Promise<void>((resolve, reject) => {
const messageId = uuid();
pageMessaging.page.sendMessage({
subject: "bridge:sendCastReceiverMessage",
data: {
sessionId: this.sessionId,
messageData: message as SenderMessage,
messageId
}
});
this.#sendMessageCallbacks.set(messageId, [resolve, reject]);
});
};
addMediaListener(_mediaListener: (media: Media) => void) {
logger.info("STUB :: Session#addMediaListener");
}
removeMediaListener(_mediaListener: (media: Media) => void) {
logger.info("STUB :: Session#removeMediaListener");
}
addMessageListener(namespace: string, listener: MessageListener) {
if (!this.#messageListeners.has(namespace)) {
this.#messageListeners.set(namespace, new Set());
}
this.#messageListeners.get(namespace)?.add(listener);
}
removeMessageListener(namespace: string, listener: MessageListener) {
this.#messageListeners.get(namespace)?.delete(listener);
}
addUpdateListener(listener: UpdateListener) {
this.#updateListeners.add(listener);
}
removeUpdateListener(listener: UpdateListener) {
this.#updateListeners.delete(listener);
}
leave(
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
if (!this.sessionId) {
errorCallback?.(
new CastError(ErrorCode.INVALID_PARAMETER, "Session not active")
);
return;
}
this.#leaveSuccessCallback = successCallback;
pageMessaging.page.sendMessage({
subject: "main:leaveSession"
});
}
loadMedia(
loadRequest: LoadRequest,
successCallback?: (media: Media) => void,
errorCallback?: (err: CastError) => void
) {
if (!loadRequest) {
errorCallback?.(new CastError(ErrorCode.INVALID_PARAMETER));
return;
}
this.#loadMediaSuccessCallback = successCallback;
this.#loadMediaErrorCallback = errorCallback;
loadRequest.sessionId = this.sessionId;
this.#sendMediaMessage(loadRequest)
.then(() => {
successCallback?.(this.media[this.media.length - 1]);
})
.catch(errorCallback);
}
queueLoad(
_queueLoadRequest: QueueLoadRequest,
_successCallback?: (media: Media) => void,
_errorCallback?: (err: CastError) => void
) {
logger.info("STUB :: Session#queueLoad");
}
sendMessage(
namespace: string,
message: object | string,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const messageId = uuid();
pageMessaging.page.sendMessage({
subject: "bridge:sendCastSessionMessage",
data: {
sessionId: this.sessionId,
namespace,
messageData: message,
messageId
}
});
this.#sendMessageCallbacks.set(messageId, [
successCallback,
errorCallback
]);
}
setReceiverMuted(
muted: boolean,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#sendReceiverMessage({ type: "SET_VOLUME", volume: { muted } })
.then(successCallback)
.catch(errorCallback);
}
setReceiverVolumeLevel(
newLevel: number,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#sendReceiverMessage({
type: "SET_VOLUME",
volume: { level: newLevel }
})
.then(successCallback)
.catch(errorCallback);
}
stop(
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#sendReceiverMessage({ type: "STOP", sessionId: this.sessionId })
.then(successCallback)
.catch(errorCallback);
}
}

View File

@@ -0,0 +1,104 @@
import type Session from "./Session";
import {
AutoJoinPolicy,
Capability,
DefaultActionPolicy,
ErrorCode,
ReceiverAvailability,
ReceiverType,
VolumeControlType
} from "./enums";
export class ApiConfig {
constructor(
public sessionRequest: SessionRequest,
public sessionListener: (session: Session) => void,
public receiverListener: (availability: ReceiverAvailability) => void,
public autoJoinPolicy: string = AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED,
public defaultActionPolicy: string = DefaultActionPolicy.CREATE_SESSION
) {}
}
export class CredentialsData {
constructor(public credentials: string, public credentialsData: string) {}
}
export class DialRequest {
constructor(
public appName: string,
public launchParameter: Nullable<string> = null
) {}
}
export class Error {
constructor(
public code: ErrorCode,
public description: Nullable<string> = null,
public details: unknown = null
) {}
}
export class Image {
width: Nullable<number> = null;
height: Nullable<number> = null;
constructor(public url: string) {}
}
export class Receiver {
displayStatus: Nullable<ReceiverDisplayStatus> = null;
isActiveInput: Nullable<boolean> = null;
receiverType = ReceiverType.CAST;
constructor(
public label: string,
public friendlyName: string,
public capabilities: Capability[] = [],
public volume: Nullable<Volume> = null
) {}
}
export class ReceiverDisplayStatus {
showStop: Nullable<boolean> = null;
constructor(public statusText: string, public appImages: Image[]) {}
}
export class SenderApplication {
packageId: Nullable<string> = null;
url: Nullable<string> = null;
constructor(public platform: string) {}
}
export class SessionRequest {
language: Nullable<string> = null;
constructor(
public appId: string,
public capabilities: Capability[] = [],
public requestSessionTimeout = new Timeout().requestSession,
public androidReceiverCompatible = false,
public credentialsData: Nullable<CredentialsData> = null
) {}
}
export class Timeout {
leaveSession = 3000;
requestSession = 60000;
sendCustomMessage = 3000;
setReceiverVolume = 3000;
stopSession = 3000;
}
export class Volume {
controlType?: VolumeControlType;
stepInterval?: number;
constructor(
public level: Nullable<number> = null,
public muted: Nullable<boolean> = null
) {}
}

View File

@@ -0,0 +1,73 @@
export enum AutoJoinPolicy {
TAB_AND_ORIGIN_SCOPED = "tab_and_origin_scoped",
ORIGIN_SCOPED = "origin_scoped",
PAGE_SCOPED = "page_scoped",
CUSTOM_CONTROLLER_SCOPED = "custom_controller_scoped"
}
export enum Capability {
VIDEO_OUT = "video_out",
AUDIO_OUT = "audio_out",
VIDEO_IN = "video_in",
AUDIO_IN = "audio_in",
MULTIZONE_GROUP = "multizone_group"
}
export enum DefaultActionPolicy {
CREATE_SESSION = "create_session",
CAST_THIS_TAB = "cast_this_tab"
}
export enum DialAppState {
RUNNING = "running",
STOPPED = "stopped",
ERROR = "error"
}
export enum ErrorCode {
CANCEL = "cancel",
TIMEOUT = "timeout",
API_NOT_INITIALIZED = "api_not_initialized",
INVALID_PARAMETER = "invalid_parameter",
EXTENSION_NOT_COMPATIBLE = "extension_not_compatible",
EXTENSION_MISSING = "extension_missing",
RECEIVER_UNAVAILABLE = "receiver_unavailable",
SESSION_ERROR = "session_error",
CHANNEL_ERROR = "channel_error",
LOAD_MEDIA_FAILED = "load_media_failed"
}
export enum ReceiverAction {
CAST = "cast",
STOP = "stop"
}
export enum ReceiverAvailability {
AVAILABLE = "available",
UNAVAILABLE = "unavailable"
}
export enum ReceiverType {
CAST = "cast",
DIAL = "dial",
HANGOUT = "hangout",
CUSTOM = "custom"
}
export enum SenderPlatform {
CHROME = "chrome",
IOS = "ios",
ANDROID = "android"
}
export enum SessionStatus {
CONNECTED = "connected",
DISCONNECTED = "disconnected",
STOPPED = "stopped"
}
export enum VolumeControlType {
ATTENUATION = "attenuation",
FIXED = "fixed",
MASTER = "master"
}

View File

@@ -0,0 +1,426 @@
import { Logger } from "../../lib/logger";
import type { Message } from "../../messaging";
import pageMessaging from "../pageMessaging";
import {
AutoJoinPolicy,
Capability,
DefaultActionPolicy,
DialAppState,
ErrorCode,
ReceiverAction,
ReceiverAvailability,
ReceiverType,
SenderPlatform,
SessionStatus,
VolumeControlType
} from "./enums";
import {
ApiConfig,
CredentialsData,
DialRequest,
Error as CastError,
Image,
Receiver,
ReceiverDisplayStatus,
SenderApplication,
SessionRequest,
Timeout,
Volume
} from "./classes";
import Session, {
createSession,
sessionLeaveSuccessCallback,
sessionMessageListeners,
sessionSendMediaMessage,
sessionSendMessageCallbacks,
sessionUpdateListeners,
updateMedia
} from "./Session";
import * as media from "./media";
import { createMedia } from "./media/Media";
const logger = new Logger("fx_cast [sdk]");
type ReceiverActionListener = (
receiver: Receiver,
receiverAction: string
) => void;
type RequestSessionSuccessCallback = (session: Session) => void;
/** Cast SDK root class */
export default class {
#apiConfig?: ApiConfig;
#sessionRequest?: SessionRequest;
#isInitialized = false;
/** Current receiver availability. */
#receiverAvailability?: ReceiverAvailability;
#initializeSuccessCallback?: () => void;
#requestSessionSuccessCallback?: RequestSessionSuccessCallback;
#requestSessionErrorCallback?: (err: CastError) => void;
#receiverActionListeners = new Set<ReceiverActionListener>();
#sessions = new Map<string, Session>();
// Enums
AutoJoinPolicy = AutoJoinPolicy;
Capability = Capability;
DefaultActionPolicy = DefaultActionPolicy;
DialAppState = DialAppState;
ErrorCode = ErrorCode;
ReceiverAction = ReceiverAction;
ReceiverAvailability = ReceiverAvailability;
ReceiverType = ReceiverType;
SenderPlatform = SenderPlatform;
SessionStatus = SessionStatus;
VolumeControlType = VolumeControlType;
// Classes
ApiConfig = ApiConfig;
CredentialsData = CredentialsData;
DialRequest = DialRequest;
Error = CastError;
Image = Image;
Receiver = Receiver;
ReceiverDisplayStatus = ReceiverDisplayStatus;
SenderApplication = SenderApplication;
SessionRequest = SessionRequest;
Timeout = Timeout;
Volume = Volume;
Session = Session;
media = { ...media };
VERSION = [1, 2];
isAvailable = false;
timeout = new Timeout();
constructor() {
pageMessaging.page.addListener(this.#onMessage.bind(this));
}
#onMessage(message: Message) {
switch (message.subject) {
case "cast:instanceCreated":
this.isAvailable = true;
break;
case "cast:receiverAvailabilityUpdated": {
/**
* The first availability update happens after
* initialize is called.
*/
if (!this.#isInitialized) {
this.#isInitialized = true;
this.#initializeSuccessCallback?.();
}
const availability = message.data.isAvailable
? ReceiverAvailability.AVAILABLE
: ReceiverAvailability.UNAVAILABLE;
// If availability has changed, call receiver listeners
if (availability !== this.#receiverAvailability) {
this.#receiverAvailability = availability;
this.#apiConfig?.receiverListener(availability);
}
break;
}
case "cast:receiverAction":
for (const actionListener of this.#receiverActionListeners) {
actionListener(message.data.receiver, message.data.action);
}
break;
// Popup closed before session established
case "cast:sessionRequestCancelled":
if (this.#sessionRequest) {
this.#sessionRequest = undefined;
this.#requestSessionErrorCallback?.(
new CastError(ErrorCode.CANCEL)
);
}
break;
/**
* Once the bridge detects a session creation, session info
* and data needed to create cast API objects is sent.
*/
case "cast:sessionCreated": {
this.#sessionRequest = undefined;
const status = message.data;
status.receiver.volume = status.volume;
status.receiver.displayStatus = new ReceiverDisplayStatus(
status.statusText,
status.appImages
);
const session = createSession([
status.sessionId,
status.appId,
status.displayName,
status.appImages,
status.receiver
]);
session.namespaces = status.namespaces;
session.senderApps = status.senderApps;
session.statusText = status.statusText;
session.transportId = status.transportId;
if (status.media) {
const media = createMedia(
[status.sessionId, status.media.mediaSessionId],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sessionSendMediaMessage.get(session)!
);
updateMedia(media, status.media);
session.media = [media];
}
this.#sessions.set(session.sessionId, session);
/**
* If session created via requestSession, the success
* callback will be set, otherwise the session was
* created by the extension and the session listener
* should be called instead.
*/
if (this.#requestSessionSuccessCallback) {
this.#requestSessionSuccessCallback(session);
this.#requestSessionSuccessCallback = undefined;
this.#requestSessionErrorCallback = undefined;
} else {
this.#apiConfig?.sessionListener(session);
}
break;
}
case "cast:sessionUpdated": {
const status = message.data;
const session = this.#sessions.get(status.sessionId);
if (!session) {
logger.error(`Session not found (${status.sessionId})`);
break;
}
session.statusText = status.statusText;
session.namespaces = status.namespaces;
session.receiver.volume = status.volume;
const updateListeners = sessionUpdateListeners.get(session);
if (updateListeners) {
for (const listener of updateListeners) {
listener(session.status !== SessionStatus.STOPPED);
}
}
break;
}
case "cast:sessionStopped": {
const session = this.#sessions.get(message.data.sessionId);
if (session?.status === SessionStatus.CONNECTED) {
session.status = SessionStatus.STOPPED;
const updateListeners = sessionUpdateListeners.get(session);
if (updateListeners) {
for (const listener of updateListeners) {
listener(false);
}
}
}
break;
}
case "cast:sessionDisconnected": {
const session = this.#sessions.get(message.data.sessionId);
if (session?.status === SessionStatus.CONNECTED) {
session.status = SessionStatus.DISCONNECTED;
sessionLeaveSuccessCallback.get(session)?.();
const updateListeners = sessionUpdateListeners.get(session);
if (updateListeners) {
for (const listener of updateListeners) {
listener(true);
}
}
}
break;
}
case "cast:sessionMessageReceived": {
const { sessionId, namespace, messageData } = message.data;
const session = this.#sessions.get(sessionId);
if (session) {
const listeners = sessionMessageListeners
.get(session)
?.get(namespace);
if (listeners) {
for (const listener of listeners) {
listener(namespace, messageData);
}
}
}
break;
}
case "cast:impl_sendMessage": {
const { sessionId, messageId, error } = message.data;
const session = this.#sessions.get(sessionId);
if (!session) {
break;
}
const sendMessageCallback = sessionSendMessageCallbacks
.get(session)
?.get(messageId);
if (sendMessageCallback) {
const [successCallback, errorCallback] =
sendMessageCallback;
if (error) {
errorCallback?.(
new CastError(ErrorCode.CHANNEL_ERROR, error)
);
return;
}
successCallback?.();
}
break;
}
}
}
initialize(
apiConfig: ApiConfig,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
logger.info("cast.initialize");
// Already initialized
if (this.#apiConfig) {
errorCallback?.(new CastError(ErrorCode.INVALID_PARAMETER));
return;
}
this.#apiConfig = apiConfig;
if (successCallback) {
this.#initializeSuccessCallback = successCallback;
}
pageMessaging.page.sendMessage({
subject: "main:initializeCastSdk",
data: { apiConfig: this.#apiConfig }
});
}
requestSession(
successCallback: RequestSessionSuccessCallback,
errorCallback: (err: CastError) => void,
newSessionRequest?: SessionRequest
) {
logger.info("cast.requestSession");
// Not yet initialized
if (!this.#apiConfig) {
errorCallback?.(new CastError(ErrorCode.API_NOT_INITIALIZED));
return;
}
// Already requesting session
if (this.#sessionRequest) {
errorCallback?.(
new CastError(
ErrorCode.INVALID_PARAMETER,
"Session request already in progress."
)
);
return;
}
if (this.#receiverAvailability === ReceiverAvailability.UNAVAILABLE) {
errorCallback?.(new CastError(ErrorCode.RECEIVER_UNAVAILABLE));
return;
}
// Store used session request
this.#sessionRequest =
newSessionRequest ?? this.#apiConfig.sessionRequest;
this.#requestSessionSuccessCallback = successCallback;
this.#requestSessionErrorCallback = errorCallback;
// Open receiver selector UI
pageMessaging.page.sendMessage({
subject: "main:requestSession",
data: { sessionRequest: this.#sessionRequest }
});
}
requestSessionById(sessionId: string) {
pageMessaging.page.sendMessage({
subject: "main:requestSessionById",
data: { sessionId }
});
}
setCustomReceivers(
_receivers: Receiver[],
_successCallback?: () => void,
_errorCallback?: (err: CastError) => void
) {
logger.info("STUB :: cast.setCustomReceivers");
}
setPageContext(_win: Window) {
logger.info("STUB :: cast.setPageContext");
}
setReceiverDisplayStatus(_sessionId: string) {
logger.info("STUB :: cast.setReceiverDisplayStatus");
}
unescape(escaped: string): string {
return window.decodeURI(escaped);
}
addReceiverActionListener(listener: ReceiverActionListener) {
this.#receiverActionListeners.add(listener);
}
removeReceiverActionListener(listener: ReceiverActionListener) {
this.#receiverActionListeners.delete(listener);
}
logMessage(message: string) {
logger.info("(logMessage)", message);
}
precache(_data: string) {
logger.info("STUB :: cast.precache");
}
}

View File

@@ -0,0 +1,487 @@
import { v4 as uuid } from "uuid";
import { Logger } from "../../../lib/logger";
import { getEstimatedTime } from "../../utils";
import type { SenderMediaMessage } from "../types";
import { Volume, Error as CastError } from "../classes";
import { ErrorCode } from "../enums";
import {
BreakStatus,
EditTracksInfoRequest,
GetStatusRequest,
LiveSeekableRange,
MediaInfo,
PauseRequest,
PlayRequest,
QueueData,
QueueJumpRequest,
QueueInsertItemsRequest,
QueueItem,
QueueSetPropertiesRequest,
QueueRemoveItemsRequest,
QueueReorderItemsRequest,
QueueUpdateItemsRequest,
SeekRequest,
StopRequest,
VideoInformation,
VolumeRequest
} from "./classes";
import { PlayerState, RepeatMode } from "./enums";
const logger = new Logger("fx_cast [sdk :: cast.Media]");
export const NS_MEDIA = "urn:x-cast:com.google.cast.media";
type MediaMessageCallback = (
message: DistributiveOmit<SenderMediaMessage, "requestId">
) => Promise<void>;
const mediaMessageCallbacks = new WeakMap<Media, MediaMessageCallback>();
export const mediaUpdateListeners = new WeakMap<Media, Set<UpdateListener>>();
export const mediaLastUpdateTimes = new WeakMap<Media, number>();
/** Creates a Media object and initializes private data. */
export function createMedia(
mediaArgs: ConstructorParameters<typeof Media>,
mediaMessageCallback: MediaMessageCallback
) {
const media = new Media(...mediaArgs);
mediaMessageCallbacks.set(media, mediaMessageCallback);
mediaUpdateListeners.set(media, new Set());
mediaLastUpdateTimes.set(media, 0);
return media;
}
type UpdateListener = (isAlive: boolean) => void;
export default class Media {
#id = uuid();
get #updateListeners() {
const updateListeners = mediaUpdateListeners.get(this);
if (!updateListeners)
throw logger.error("Missing media update listeners!");
return updateListeners;
}
get #mediaMessageCallback() {
const callback = mediaMessageCallbacks.get(this);
if (!callback) throw logger.error("Missing media message callback!");
return callback;
}
get #lastUpdateTime() {
const lastUpdateTime = mediaLastUpdateTimes.get(this);
if (lastUpdateTime === undefined)
throw logger.error("Missing last update time!");
return lastUpdateTime;
}
activeTrackIds: Nullable<number[]> = null;
breakStatus?: BreakStatus;
currentTime = 0;
customData: unknown = null;
idleReason: Nullable<string> = null;
liveSeekableRange?: LiveSeekableRange;
media: Nullable<MediaInfo> = null;
playbackRate = 1;
playerState = PlayerState.IDLE;
repeatMode = RepeatMode.OFF;
supportedMediaCommands: string[] = [];
videoInfo?: VideoInformation;
volume: Volume = new Volume();
// Queues
items: Nullable<QueueItem[]> = null;
currentItemId: Nullable<number> = null;
loadingItemId: Nullable<number> = null;
preloadedItemId: Nullable<number> = null;
queueData?: QueueData;
constructor(public sessionId: string, public mediaSessionId: number) {}
addUpdateListener(listener: UpdateListener) {
this.#updateListeners?.add(listener);
}
removeUpdateListener(listener: UpdateListener) {
this.#updateListeners?.delete(listener);
}
editTracksInfo(
editTracksInfoRequest: EditTracksInfoRequest,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...editTracksInfoRequest,
type: "EDIT_TRACKS_INFO",
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
/**
* Estimates the current break clip position based on the last
* information reported by the receiver.
*/
getEstimatedBreakClipTime() {
if (this.breakStatus?.currentBreakClipTime === undefined) return;
if (this.playerState === PlayerState.PLAYING) {
return getEstimatedTime({
currentTime: this.breakStatus.currentBreakClipTime,
lastUpdateTime: this.#lastUpdateTime
});
}
return this.breakStatus.currentBreakClipTime;
}
/**
* Estimates the current break position based on the last
* information reported by the receiver.
*/
getEstimatedBreakTime() {
if (this.breakStatus?.currentBreakTime === undefined) return;
if (this.playerState === PlayerState.PLAYING) {
return getEstimatedTime({
currentTime: this.breakStatus.currentBreakTime,
lastUpdateTime: this.#lastUpdateTime
});
}
return this.breakStatus.currentBreakTime;
}
getEstimatedLiveSeekableRange() {
logger.info("STUB :: Media#getEstimatedLiveSeekableRange");
}
/**
* Estimates the current playback position based on the last
* information reported by the receiver.
*/
getEstimatedTime(): number {
if (this.playerState === PlayerState.PLAYING) {
return getEstimatedTime({
currentTime: this.currentTime,
lastUpdateTime: this.#lastUpdateTime,
playbackRate: this.playbackRate,
duration: this.media?.duration
});
}
return this.currentTime;
}
/**
* Request media status from the receiver application. This will
* also trigger any added media update listeners.
*/
getStatus(
getStatusRequest = new GetStatusRequest(),
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...getStatusRequest,
type: "MEDIA_GET_STATUS",
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
pause(
pauseRequest = new PauseRequest(),
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...pauseRequest,
type: "PAUSE",
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
play(
playRequest = new PlayRequest(),
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...playRequest,
type: "PLAY",
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
queueAppendItem(
item: QueueItem,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...new QueueInsertItemsRequest([item]),
type: "QUEUE_INSERT",
sessionId: this.sessionId,
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
queueInsertItems(
queueInsertItemsRequest: QueueInsertItemsRequest,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...queueInsertItemsRequest,
type: "QUEUE_INSERT",
sessionId: this.sessionId,
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
queueJumpToItem(
itemId: number,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
if (this.items?.find(item => item.itemId === itemId)) {
const jumpRequest = new QueueJumpRequest();
jumpRequest.currentItemId = itemId;
this.#mediaMessageCallback?.({
...jumpRequest,
type: "QUEUE_UPDATE",
sessionId: this.sessionId,
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
}
queueMoveItemToNewIndex(
itemId: number,
newIndex: number,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
// Return early if not in queue
if (!this.items) {
return;
}
const itemIndex = this.items.findIndex(item => item.itemId === itemId);
if (itemIndex !== -1) {
// New index must not be negative
if (newIndex < 0) {
if (errorCallback) {
errorCallback(new CastError(ErrorCode.INVALID_PARAMETER));
}
} else if (newIndex == itemIndex) {
if (successCallback) {
successCallback();
}
}
} else {
if (newIndex > itemIndex) {
newIndex++;
}
const reorderItemsRequest = new QueueReorderItemsRequest([itemId]);
if (newIndex < this.items.length) {
const existingItem = this.items[newIndex];
reorderItemsRequest.insertBefore = existingItem.itemId;
}
this.#mediaMessageCallback?.({
...reorderItemsRequest,
type: "QUEUE_REORDER",
sessionId: this.sessionId,
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
}
queueNext(
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const jumpRequest = new QueueJumpRequest();
jumpRequest.jump = 1;
this.#mediaMessageCallback?.({
...jumpRequest,
type: "QUEUE_UPDATE",
sessionId: this.sessionId,
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
queuePrev(
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const jumpRequest = new QueueJumpRequest();
jumpRequest.jump = -1;
this.#mediaMessageCallback?.({
...jumpRequest,
type: "QUEUE_UPDATE",
sessionId: this.sessionId,
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
queueRemoveItem(
itemId: number,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const item = this.items?.find(item => item.itemId === itemId);
if (item) {
this.queueRemoveItems(
new QueueRemoveItemsRequest([itemId]),
successCallback,
errorCallback
);
}
}
queueRemoveItems(
queueRemoveItemsRequest: QueueRemoveItemsRequest,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...queueRemoveItemsRequest,
mediaSessionId: this.mediaSessionId,
type: "QUEUE_REMOVE",
sessionId: this.sessionId
})
.then(successCallback)
.catch(errorCallback);
}
queueReorderItems(
queueReorderItemsRequest: QueueReorderItemsRequest,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...queueReorderItemsRequest,
mediaSessionId: this.mediaSessionId,
type: "QUEUE_REORDER",
sessionId: this.sessionId
})
.then(successCallback)
.catch(errorCallback);
}
queueSetRepeatMode(
repeatMode: string,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
const setPropertiesRequest = new QueueSetPropertiesRequest();
setPropertiesRequest.repeatMode = repeatMode;
this.#mediaMessageCallback?.({
...setPropertiesRequest,
type: "QUEUE_UPDATE",
sessionId: this.sessionId,
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
queueUpdateItems(
queueUpdateItemsRequest: QueueUpdateItemsRequest,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...queueUpdateItemsRequest,
type: "QUEUE_UPDATE",
sessionId: this.sessionId,
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
seek(
seekRequest: SeekRequest,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...seekRequest,
type: "SEEK",
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
setVolume(
volumeRequest: VolumeRequest,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
this.#mediaMessageCallback?.({
...volumeRequest,
type: "MEDIA_SET_VOLUME",
mediaSessionId: this.mediaSessionId
})
.then(successCallback)
.catch(errorCallback);
}
stop(
stopRequest?: StopRequest,
successCallback?: () => void,
errorCallback?: (err: CastError) => void
) {
if (!stopRequest) {
stopRequest = new StopRequest();
}
this.#mediaMessageCallback?.({
...stopRequest,
type: "STOP",
mediaSessionId: this.mediaSessionId
})
.then(() => {
if (successCallback) {
successCallback();
}
})
.catch(errorCallback);
}
supportsCommand(command: string): boolean {
return this.supportedMediaCommands.includes(command);
}
}

View File

@@ -0,0 +1,392 @@
import type { Image, Volume } from "../classes";
import {
ContainerType,
HdrType,
HlsSegmentFormat,
HlsVideoSegmentFormat,
MetadataType,
RepeatMode,
ResumeState,
StreamType,
TrackType,
UserAction
} from "./enums";
export class AudiobookContainerMetadata {
authors?: string[];
narrators?: string[];
publisher?: string;
releaseDate?: string;
}
export class Break {
duration?: number;
isEmbedded?: boolean;
isWatched = false;
constructor(
public id: string,
public breakClipIds: string[],
public position: number
) {}
}
export class BreakClip {
clickThroughUrl?: string;
contentId?: string;
contentType?: string;
contentUrl?: string;
customData?: unknown;
duration?: number;
hlsSegmentFormat?: HlsSegmentFormat;
posterUrl?: string;
title?: string;
vastAdsRequest?: VastAdsRequest;
whenSkippable?: number;
constructor(public id: string) {}
}
export class BreakStatus {
breakClipId?: string;
breakId?: string;
currentBreakClipTime?: number;
currentBreakTime?: number;
whenSkippable?: number;
}
export class ContainerMetadata {
containerDuration?: number;
containerImages?: Image[];
sections?: Metadata[];
title?: string;
constructor(
public containerType: ContainerType = ContainerType.GENERIC_CONTAINER
) {}
}
export class EditTracksInfoRequest {
requestId = 0;
constructor(
public activeTrackIds: Nullable<number[]> = null,
public textTrackStyle: Nullable<string> = null
) {}
}
export class GetStatusRequest {
customData: unknown = null;
}
export class LiveSeekableRange {
constructor(
public start?: number,
public end?: number,
public isMovingWindow?: boolean,
public isLiveDone?: boolean
) {}
}
export class LoadRequest {
activeTrackIds: Nullable<number[]> = null;
atvCredentials?: string;
atvCredentialsType?: string;
autoplay: Nullable<boolean> = true;
currentTime: Nullable<number> = null;
customData: unknown = null;
media: MediaInfo;
requestId = 0;
sessionId: Nullable<string> = null;
type: "LOAD" = "LOAD";
constructor(mediaInfo: MediaInfo) {
this.media = mediaInfo;
}
}
export type Metadata =
| AudiobookChapterMediaMetadata
| GenericMediaMetadata
| MovieMediaMetadata
| MusicTrackMediaMetadata
| PhotoMediaMetadata
| TvShowMediaMetadata;
export class MediaInfo {
atvEntity?: string;
breakClips?: BreakClip[];
breaks?: Break[];
customData: unknown = null;
contentUrl?: string;
duration: Nullable<number> = null;
entity?: string;
hlsSegmentFormat?: HlsSegmentFormat;
hlsVideoSegmentFormat?: HlsVideoSegmentFormat;
metadata: Nullable<Metadata> = null;
startAbsoluteTime?: number;
streamType: string = StreamType.BUFFERED;
textTrackStyle: Nullable<TextTrackStyle> = null;
tracks: Nullable<Track[]> = null;
userActionStates?: UserActionState[];
vmapAdsRequest?: VastAdsRequest;
constructor(public contentId: string, public contentType: string) {}
}
export abstract class MediaMetadata<T extends MetadataType> {
queueItemId?: number;
sectionDuration?: number;
sectionStartAbsoluteTime?: number;
sectionStartTimeInContainer?: number;
sectionStartTimeInMedia?: number;
type: T;
metadataType: T;
constructor(type: T) {
this.type = type;
this.metadataType = type;
}
}
export class AudiobookChapterMediaMetadata extends MediaMetadata<MetadataType.AUDIOBOOK_CHAPTER> {
bookTitle?: string;
chapterNumber?: number;
chapterTitle?: string;
images?: Image[];
subtitle?: string;
title?: string;
constructor() {
super(MetadataType.AUDIOBOOK_CHAPTER);
}
}
export class GenericMediaMetadata extends MediaMetadata<MetadataType.GENERIC> {
images?: Image[];
releaseDate?: string;
releaseYear?: number;
subtitle?: string;
title?: string;
constructor() {
super(MetadataType.GENERIC);
}
}
export class MovieMediaMetadata extends MediaMetadata<MetadataType.MOVIE> {
images?: Image[];
releaseDate?: string;
releaseYear?: number;
studio?: string;
subtitle?: string;
title?: string;
constructor() {
super(MetadataType.MOVIE);
}
}
export class MusicTrackMediaMetadata extends MediaMetadata<MetadataType.MUSIC_TRACK> {
albumArtist?: string;
albumName?: string;
artist?: string;
artistName?: string;
composer?: string;
discNumber?: number;
images?: Image[];
releaseDate?: string;
releaseYear?: number;
songName?: string;
title?: string;
trackNumber?: number;
constructor() {
super(MetadataType.MUSIC_TRACK);
}
}
export class PhotoMediaMetadata extends MediaMetadata<MetadataType.PHOTO> {
artist?: string;
creationDateTime?: string;
height?: number;
images?: Image[];
latitude?: number;
location?: string;
longitude?: number;
title?: string;
width?: number;
constructor() {
super(MetadataType.PHOTO);
}
}
export class TvShowMediaMetadata extends MediaMetadata<MetadataType.TV_SHOW> {
episode?: number;
episodeNumber?: number;
episodeTitle?: string;
images?: Image[];
originalAirdate?: string;
releaseYear?: number;
season?: number;
seasonNumber?: number;
seriesTitle?: string;
title?: string;
constructor() {
super(MetadataType.TV_SHOW);
}
}
export class PauseRequest {
customData: unknown = null;
}
export class PlayRequest {
customData: unknown = null;
}
export class QueueData {
shuffle = false;
constructor(
public id?: string,
public name?: string,
public description?: string,
public repeatMode?: RepeatMode,
public items?: QueueItem[],
public startIndex?: number,
public startTime?: number
) {}
}
export class QueueInsertItemsRequest {
customData: unknown = null;
insertBefore: Nullable<number> = null;
requestId: Nullable<number> = null;
sessionId: Nullable<string> = null;
type = "QUEUE_INSERT";
constructor(public items: QueueItem[]) {}
}
export class QueueItem {
activeTrackIds: Nullable<number[]> = null;
autoplay = true;
customData: unknown = null;
itemId: Nullable<number> = null;
media: MediaInfo;
playbackDuration: Nullable<number> = null;
preloadTime = 0;
startTime = 0;
constructor(mediaInfo: MediaInfo) {
this.media = mediaInfo;
}
}
export class QueueJumpRequest {
type = "QUEUE_UPDATE";
jump: Nullable<number> = null;
currentItemId: Nullable<number> = null;
}
export class QueueLoadRequest {
type = "QUEUE_LOAD";
customData: unknown = null;
repeatMode: string = RepeatMode.OFF;
startIndex = 0;
constructor(public items: QueueItem[]) {}
}
export class QueueRemoveItemsRequest {
type = "QUEUE_REMOVE";
customData: unknown = null;
constructor(public itemIds: number[]) {}
}
export class QueueReorderItemsRequest {
customData: unknown = null;
insertBefore: Nullable<number> = null;
type = "QUEUE_REORDER";
constructor(public itemIds: number[]) {}
}
export class QueueSetPropertiesRequest {
type = "QUEUE_UPDATE";
customData: unknown = null;
repeatMode: Nullable<string> = null;
}
export class QueueUpdateItemsRequest {
type = "QUEUE_UPDATE";
customData: unknown = null;
constructor(public items: QueueItem[]) {}
}
export class SeekRequest {
currentTime: Nullable<number> = null;
customData: unknown = null;
resumeState: Nullable<ResumeState> = null;
}
export class StopRequest {
customData: unknown = null;
}
export class TextTrackStyle {
backgroundColor: Nullable<string> = null;
customData: unknown = null;
edgeColor: Nullable<string> = null;
edgeType: Nullable<string> = null;
fontFamily: Nullable<string> = null;
fontGenericFamily: Nullable<string> = null;
fontScale: Nullable<number> = null;
fontStyle: Nullable<string> = null;
foregroundColor: Nullable<string> = null;
windowColor: Nullable<string> = null;
windowRoundedCornerRadius: Nullable<number> = null;
windowType: Nullable<string> = null;
}
export class Track {
customData: unknown = null;
language: Nullable<string> = null;
name: Nullable<string> = null;
subtype: Nullable<string> = null;
trackContentId: Nullable<string> = null;
trackContentType: Nullable<string> = null;
constructor(public trackId: number, public type: TrackType) {}
}
export class UserActionState {
customData: unknown = null;
constructor(public userAction: UserAction) {}
}
export class VastAdsRequest {
adsResponse?: string;
adTagUrl?: string;
}
export class VideoInformation {
constructor(
public width: number,
public height: number,
public hdrType: HdrType
) {}
}
export class VolumeRequest {
customData: unknown = null;
constructor(public volume: Volume) {}
}

View File

@@ -0,0 +1,137 @@
export enum ContainerType {
GENERIC_CONTAINER,
AUDIOBOOK_CONTAINER
}
export enum HdrType {
SDR = "sdr",
HDR = "hdr",
DV = "dv"
}
export enum HlsSegmentFormat {
AAC = "aac",
AC3 = "ac3",
MP3 = "mp3",
TS = "ts",
TS_AAC = "ts_aac",
E_AC3 = "e_ac3",
FMP4 = "fmp4"
}
export enum HlsVideoSegmentFormat {
MPEG2_TS = "mpeg2_ts",
FMP4 = "fmp4"
}
export enum IdleReason {
CANCELLED = "CANCELLED",
INTERRUPTED = "INTERRUPTED",
FINISHED = "FINISHED",
ERROR = "ERROR"
}
export enum MediaCommand {
PAUSE = "pause",
SEEK = "seek",
STREAM_VOLUME = "stream_volume",
STREAM_MUTE = "stream_mute"
}
export enum MetadataType {
GENERIC,
MOVIE,
TV_SHOW,
MUSIC_TRACK,
PHOTO,
AUDIOBOOK_CHAPTER
}
export enum PlayerState {
IDLE = "IDLE",
PLAYING = "PLAYING",
PAUSED = "PAUSED",
BUFFERING = "BUFFERING"
}
export enum QueueType {
ALBUM = "ALBUM",
PLAYLIST = "PLAYLIST",
AUDIOBOOK = "AUDIOBOOK",
RADIO_STATION = "RADIO_STATION",
PODCAST_SERIES = "PODCAST_SERIES",
TV_SERIES = "TV_SERIES",
VIDEO_PLAYLIST = "VIDEO_PLAYLIST",
LIVE_TV = "LIVETV",
MOVIE = "MOVIE"
}
export enum RepeatMode {
OFF = "REPEAT_OFF",
ALL = "REPEAT_ALL",
SINGLE = "REPEAT_SINGLE",
ALL_AND_SHUFFLE = "REPEAT_ALL_AND_SHUFFLE"
}
export enum ResumeState {
PLAYBACK_START = "PLAYBACK_START",
PLAYBACK_PAUSE = "PLAYBACK_PAUSE"
}
export enum StreamType {
BUFFERED = "BUFFERED",
LIVE = "LIVE",
OTHER = "OTHER"
}
export enum TextTrackEdgeType {
NONE = "NONE",
OUTLINE = "OUTLINE",
DROP_SHADOW = "DROP_SHADOW",
RAISED = "RAISED",
DEPRESSED = "DEPRESSED"
}
export enum TextTrackFontGenericFamily {
SANS_SERIF = "SANS_SERIF",
MONOSPACED_SANS_SERIF = "MONOSPACED_SANS_SERIF",
SERIF = "SERIF",
MONOSPACED_SERIF = "MONOSPACED_SERIF",
CASUAL = "CASUAL",
CURSIVE = "CURSIVE",
SMALL_CAPITALS = "SMALL_CAPITALS"
}
export enum TextTrackFontStyle {
NORMAL = "NORMAL",
BOLD = "BOLD",
BOLD_ITALIC = "BOLD_ITALIC",
ITALIC = "ITALIC"
}
export enum TextTrackType {
SUBTITLES = "SUBTITLES",
CAPTIONS = "CAPTIONS",
DESCRIPTIONS = "DESCRIPTIONS",
CHAPTERS = "CHAPTERS",
METADATA = "METADATA"
}
export enum TextTrackWindowType {
NONE = "NONE",
NORMAL = "NORMAL",
ROUNDED_CORNERS = "ROUNDED_CORNERS"
}
export enum TrackType {
TEXT = "TEXT",
AUDIO = "AUDIO",
VIDEO = "VIDEO"
}
export enum UserAction {
LIKE = "LIKE",
DISLIKE = "DISLIKE",
FOLLOW = "FOLLOW",
UNFOLLOW = "UNFOLLOW"
}

View File

@@ -0,0 +1,18 @@
export * from "./enums";
export * from "./classes";
export { default as Media } from "./Media";
export const DEFAULT_MEDIA_RECEIVER_APP_ID = "CC1AD845";
export const timeout = {
editTracksInfo: 0,
getStatus: 0,
load: 0,
pause: 0,
play: 0,
queue: 0,
seek: 0,
setVolume: 0,
stop: 0
};

View File

@@ -0,0 +1,208 @@
/**
* Keep in sync with bridge types at:
* app/src/bridge/components/cast/types.ts
*/
import type { SenderApplication, Volume, Image } from "./classes";
import type {
BreakStatus,
LiveSeekableRange,
MediaInfo,
QueueItem
} from "./media/classes";
import type {
IdleReason,
PlayerState,
RepeatMode,
ResumeState
} from "./media/enums";
export interface MediaStatus {
activeTrackIds?: number[];
breakStatus?: BreakStatus;
currentItemId?: number;
currentTime: Nullable<number>;
customData: unknown;
idleReason?: IdleReason;
items?: QueueItem[];
liveSeekableRange?: LiveSeekableRange;
media?: MediaInfo;
mediaSessionId: number;
playbackRate: number;
playerState: PlayerState;
repeatMode?: RepeatMode;
supportedMediaCommands: number;
volume: Volume;
}
export interface ReceiverApplication {
appId: string;
appType?: string;
displayName: string;
iconUrl: string;
isIdleScreen: boolean;
launchedFromCloud: boolean;
namespaces: Array<{ name: string }>;
sessionId: string;
statusText: string;
transportId: string;
universalAppId: string;
}
export interface ReceiverStatus {
applications?: ReceiverApplication[];
isActiveInput?: boolean;
isStandBy?: boolean;
volume: Volume;
}
export interface CastSessionUpdatedDetails {
sessionId: string;
statusText: string;
namespaces: Array<{ name: string }>;
volume: Volume;
}
export interface CastSessionCreatedDetails extends CastSessionUpdatedDetails {
appId: string;
appImages: Image[];
displayName: string;
receiverId: string;
receiverFriendlyName: string;
senderApps: SenderApplication[];
transportId: string;
}
/** supportedMediaCommands bitflag returned in MEDIA_STATUS messages */
export enum _MediaCommand {
PAUSE = 1,
SEEK = 2,
STREAM_VOLUME = 4,
STREAM_MUTE = 8,
QUEUE_NEXT = 64,
QUEUE_PREV = 128,
QUEUE_SHUFFLE = 256,
QUEUE_SKIP_AD = 512,
QUEUE_REPEAT_ALL = 1024,
QUEUE_REPEAT_ONE = 2048,
QUEUE_REPEAT = 3072,
EDIT_TRACKS = 4096,
PLAYBACK_RATE = 8192
}
interface ReqBase {
requestId: number;
}
// NS: urn:x-cast:com.google.cast.receiver
export type SenderMessage =
| (ReqBase & { type: "LAUNCH"; appId: string })
| (ReqBase & { type: "STOP"; sessionId?: string })
| (ReqBase & { type: "GET_STATUS" })
| (ReqBase & { type: "GET_APP_AVAILABILITY"; appId: string[] })
| (ReqBase & { type: "SET_VOLUME"; volume: Partial<Volume> });
export type ReceiverMessage =
| (ReqBase & { type: "RECEIVER_STATUS"; status: ReceiverStatus })
| (ReqBase & { type: "LAUNCH_ERROR"; reason: string });
interface MediaReqBase extends ReqBase {
mediaSessionId: number;
customData?: unknown;
}
// NS: urn:x-cast:com.google.cast.media
export type SenderMediaMessage =
| (MediaReqBase & { type: "PLAY" })
| (MediaReqBase & { type: "PAUSE" })
| {
type: "MEDIA_GET_STATUS";
mediaSessionId?: number;
customData?: unknown;
requestId: number;
}
| {
type: "GET_STATUS";
mediaSessionId?: number;
customData?: unknown;
requestId: number;
}
| (MediaReqBase & { type: "STOP" })
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
| (MediaReqBase & { type: "SET_VOLUME"; volume: Volume })
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
| (ReqBase & {
type: "LOAD";
activeTrackIds?: Nullable<number[]>;
atvCredentials?: string;
atvCredentialsType?: string;
autoplay?: Nullable<boolean>;
currentTime?: Nullable<number>;
customData?: unknown;
media: MediaInfo;
sessionId?: Nullable<string>;
})
| (MediaReqBase & {
type: "SEEK";
resumeState?: Nullable<ResumeState>;
currentTime?: Nullable<number>;
})
| (MediaReqBase & {
type: "EDIT_TRACKS_INFO";
activeTrackIds?: Nullable<number[]>;
textTrackStyle?: Nullable<string>;
})
// QueueLoadRequest
| (MediaReqBase & {
type: "QUEUE_LOAD";
items: QueueItem[];
startIndex: number;
repeatMode: string;
sessionId?: Nullable<string>;
})
// QueueInsertItemsRequest
| (MediaReqBase & {
type: "QUEUE_INSERT";
items: QueueItem[];
insertBefore?: Nullable<number>;
sessionId?: Nullable<string>;
})
// QueueUpdateItemsRequest
| (MediaReqBase & {
type: "QUEUE_UPDATE";
items: QueueItem[];
sessionId?: Nullable<string>;
})
// QueueJumpRequest
| (MediaReqBase & {
type: "QUEUE_UPDATE";
jump?: Nullable<number>;
currentItemId?: Nullable<number>;
sessionId?: Nullable<string>;
})
// QueueRemoveItemsRequest
| (MediaReqBase & {
type: "QUEUE_REMOVE";
itemIds: number[];
sessionId?: Nullable<string>;
})
// QueueReorderItemsRequest
| (MediaReqBase & {
type: "QUEUE_REORDER";
itemIds: number[];
insertBefore?: Nullable<number>;
sessionId?: Nullable<string>;
})
// QueueSetPropertiesRequest
| (MediaReqBase & {
type: "QUEUE_UPDATE";
repeatMode?: Nullable<string>;
sessionId?: Nullable<string>;
});
export type ReceiverMediaMessage =
| (MediaReqBase & { type: "MEDIA_STATUS"; status: MediaStatus[] })
| (MediaReqBase & { type: "INVALID_PLAYER_STATE" })
| (MediaReqBase & { type: "LOAD_FAILED" })
| (MediaReqBase & { type: "LOAD_CANCELLED" })
| (MediaReqBase & { type: "INVALID_REQUEST" });

View File

@@ -0,0 +1,367 @@
import { Logger } from "../../lib/logger";
import options from "../../lib/options";
import type { Message } from "../../messaging";
// Cast types
import { AutoJoinPolicy, ReceiverAvailability } from "../sdk/enums";
import type Session from "../sdk/Session";
import type Media from "../sdk/media/Media";
import cast, { ensureInit, CastPort } from "../export";
const logger = new Logger("fx_cast [media sender]");
interface MediaSenderOpts {
mediaUrl: string;
contextTabId?: number;
mediaElement?: HTMLMediaElement;
}
export default class MediaSender {
private port?: CastPort;
private mediaUrl: string;
private contextTabId?: number;
/** Target media element if loaded as a content script. */
private mediaElement?: HTMLMediaElement;
private isLocalMedia = false;
private isLocalMediaEnabled = false;
private wasSessionRequested = false;
// Cast API objects
private session?: Session;
private media?: Media;
constructor(opts: MediaSenderOpts) {
this.mediaUrl = opts.mediaUrl;
this.contextTabId = opts.contextTabId;
this.mediaElement = opts.mediaElement;
this.init();
}
stop() {
this.port?.postMessage({ subject: "bridge:stopMediaServer" });
this.session?.stop();
}
private async init() {
try {
this.port = await ensureInit({ contextTabId: this.contextTabId });
} catch (err) {
logger.error("Failed to initialize cast API", err);
}
window.addEventListener("beforeunload", async () => {
if (await options.get("mediaStopOnUnload")) {
this.port?.postMessage({
subject: "bridge:stopMediaServer"
});
this.session?.stop();
}
});
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 = [cast.Capability.AUDIO_OUT];
if (
this.mediaElement instanceof HTMLVideoElement ||
this.mediaElement instanceof HTMLImageElement
) {
capabilities.push(cast.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),
AutoJoinPolicy.PAGE_SCOPED
),
undefined,
err => {
logger.error("Failed to initialize cast SDK", err);
}
);
}
private sessionListener() {
// Unused
}
private receiverListener(availability: ReceiverAvailability) {
if (this.wasSessionRequested) return;
this.wasSessionRequested = false;
if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession(
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;
mediaInfo.tracks = [];
const activeTrackIds: number[] = [];
let trackIndex = 0;
for (const url of subtitleUrls) {
const track = new cast.media.Track(
trackIndex++,
cast.media.TrackType.TEXT
);
track.name = url.pathname;
track.trackContentId = url.href;
track.trackContentType = "text/vtt";
track.subtype = cast.media.TextTrackType.SUBTITLES;
mediaInfo.tracks.push(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, async media => {
this.media = media;
if (
(await options.get("mediaSyncElement")) &&
this.mediaElement instanceof HTMLMediaElement
) {
this.addMediaElementListeners(this.mediaElement);
}
});
}
private addMediaElementListeners(mediaElement: HTMLMediaElement) {
this.session?.addUpdateListener(isAlive => {
if (!isAlive) return;
// Update volume level
const volume = this.session?.receiver.volume;
if (!volume) return;
if (
volume?.level !== null &&
volume.level !== mediaElement.volume
) {
mediaElement.volume = volume.level;
}
// Update muted state
if (volume?.muted !== null && volume.muted !== mediaElement.muted) {
mediaElement.muted = volume.muted;
}
});
this.media?.addUpdateListener(isAlive => {
if (!isAlive || !this.media) return;
/**
* If media element time and estimated time are off by more
* than two seconds, set the media element time to the
* estimated time.
*/
const estimatedTime = this.media.getEstimatedTime();
if (Math.abs(mediaElement.currentTime - estimatedTime) > 2) {
mediaElement.currentTime = estimatedTime;
}
const mediaElementPlayerState = mediaElement.paused
? cast.media.PlayerState.PAUSED
: cast.media.PlayerState.PLAYING;
if (mediaElementPlayerState !== this.media.playerState) {
switch (this.media.playerState) {
case cast.media.PlayerState.PLAYING:
mediaElement.play();
break;
case cast.media.PlayerState.PAUSED:
case cast.media.PlayerState.BUFFERING:
case cast.media.PlayerState.IDLE:
mediaElement.pause();
break;
}
}
});
}
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 loaded as a content script, opts are stored on the window object.
*/
if (window.location.protocol !== "moz-extension:") {
const window_ = window as any;
let mediaElement: Optional<HTMLMediaElement>;
if (window_.targetElementId) {
mediaElement = browser.menus.getTargetElement(
window_.targetElementId
) as HTMLMediaElement;
}
new MediaSender({
mediaUrl: window_.mediaUrl,
mediaElement
});
}

View File

@@ -0,0 +1,223 @@
import options from "../../lib/options";
import { Logger } from "../../lib/logger";
import type { ReceiverDevice } from "../../types";
import { AutoJoinPolicy, ReceiverAvailability } from "../sdk/enums";
import type Session from "../sdk/Session";
import cast, { ensureInit } from "../export";
const logger = new Logger("fx_cast [mirroring sender]");
const NS_FX_CAST = "urn:x-cast:fx_cast";
type MirroringAppMessage =
| { subject: "peerConnectionOffer"; data: RTCSessionDescriptionInit }
| { subject: "peerConnectionAnswer"; data: RTCSessionDescriptionInit }
| { subject: "iceCandidate"; data: RTCIceCandidateInit }
| { subject: "close" };
interface MirroringSenderOpts {
receiverDevice: ReceiverDevice;
onSessionCreated: () => void;
onMirroringConnected: () => void;
onMirroringStopped: () => void;
}
export default class MirroringSender {
private receiverDevice: ReceiverDevice;
private sessionCreatedCallback: () => void;
private mirroringConnectedCallback: () => void;
private mirroringStoppedCallback: () => void;
private session?: Session;
private wasSessionRequested = false;
private peerConnection: Optional<RTCPeerConnection>;
// Stream opts
private streamMaxFrameRate = 1;
private streamMaxBitRate = 1;
private streamDownscaleFactor = 1;
private streamUseMaxResolution = false;
private streamMaxResolution: { width?: number; height?: number } = {};
constructor(opts: MirroringSenderOpts) {
this.receiverDevice = opts.receiverDevice;
this.sessionCreatedCallback = opts.onSessionCreated;
this.mirroringConnectedCallback = opts.onMirroringConnected;
this.mirroringStoppedCallback = opts.onMirroringStopped;
this.init();
}
private async init() {
try {
await ensureInit({ receiverDevice: this.receiverDevice });
} catch (err) {
logger.error("Failed to initialize cast API", err);
}
const {
mirroringAppId,
mirroringStreamMaxFrameRate,
mirroringStreamMaxBitRate,
mirroringStreamDownscaleFactor,
mirroringStreamUseMaxResolution,
mirroringStreamMaxResolution
} = await options.getAll();
this.streamMaxFrameRate = mirroringStreamMaxFrameRate;
this.streamMaxBitRate = mirroringStreamMaxBitRate;
this.streamDownscaleFactor = mirroringStreamDownscaleFactor;
this.streamUseMaxResolution = mirroringStreamUseMaxResolution;
this.streamMaxResolution = mirroringStreamMaxResolution;
const sessionRequest = new cast.SessionRequest(mirroringAppId);
const apiConfig = new cast.ApiConfig(
sessionRequest,
this.sessionListener,
this.receiverListener,
AutoJoinPolicy.PAGE_SCOPED
);
cast.initialize(apiConfig);
}
private sessionListener() {
// Unused
}
private receiverListener = (availability: ReceiverAvailability) => {
if (this.wasSessionRequested) return;
this.wasSessionRequested = true;
if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession(
session => {
this.session = session;
this.sessionCreatedCallback();
},
err => {
logger.error("Session request failed", err);
}
);
}
};
private sendMirroringAppMessage(message: MirroringAppMessage) {
if (!this.session) return;
this.session.sendMessage(NS_FX_CAST, message);
}
stop() {
this.peerConnection?.close();
this.session?.stop();
this.mirroringStoppedCallback();
}
async createMirroringConnection(stream: MediaStream) {
const pc = new RTCPeerConnection();
this.peerConnection = pc;
this.session?.addMessageListener(NS_FX_CAST, async (_ns, message) => {
const parsedMessage = JSON.parse(message) as MirroringAppMessage;
switch (parsedMessage.subject) {
case "peerConnectionAnswer":
pc.setRemoteDescription(parsedMessage.data);
break;
case "iceCandidate":
pc.addIceCandidate(parsedMessage.data);
break;
}
});
pc.addEventListener("negotiationneeded", async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.sendMirroringAppMessage({
subject: "peerConnectionOffer",
data: offer
});
});
pc.addEventListener("icecandidate", ev => {
if (!ev.candidate) return;
this.sendMirroringAppMessage({
subject: "iceCandidate",
data: ev.candidate
});
});
// Connection listener
pc.addEventListener("iceconnectionstatechange", async () => {
if (pc.iceConnectionState !== "connected") {
return;
}
this.mirroringConnectedCallback();
applyParameters();
});
/** Applies stream encoding parameters. */
const applyParameters = async () => {
// Set stream encoding parameters
const [sender] = pc.getSenders();
const params = sender.getParameters();
if (!params.encodings) {
params.encodings = [{}];
}
const [encoding] = params.encodings;
if (!(encoding as any).maxFramerate) {
(encoding as any).maxFramerate = this.streamMaxFrameRate;
}
if (!encoding.maxBitrate) {
encoding.maxBitrate = this.streamMaxBitRate;
}
encoding.scaleResolutionDownBy = this.streamDownscaleFactor;
// Handle limiting stream resolution
if (this.streamUseMaxResolution) {
const { width: trackWidth, height: trackHeight } =
sender.track?.getSettings() ?? {};
// Calculate downscale ratios for width/height
let widthRatio = 1;
let heightRatio = 1;
if (trackWidth && this.streamMaxResolution.width) {
widthRatio = trackWidth / this.streamMaxResolution.width;
}
if (trackHeight && this.streamMaxResolution.height) {
heightRatio = trackHeight / this.streamMaxResolution.height;
}
// Use the largest ratio to ensure below resolution limit
const downscaleRatio = Math.max(1, widthRatio, heightRatio);
// Multiply existing downscale
encoding.scaleResolutionDownBy *= downscaleRatio;
}
await sender.setParameters(params);
};
const [track] = stream.getVideoTracks();
pc.addTrack(track, stream);
track.addEventListener("ended", () => this.stop());
/**
* Use a video element to get stream resize events and update
* scaling parameters.
*/
const video = document.createElement("video");
video.srcObject = stream;
video.addEventListener("resize", () => applyParameters());
video.play();
}
}

View File

@@ -0,0 +1,41 @@
/**
* Cast Chrome Sender SDK loader script.
*
* Since the actual SDK script is hosted locally within Chrome,
* this script just acts a loader script whilst also doing some
* UA string checking.
*/
export const CAST_LOADER_SCRIPT_URL =
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js";
/**
* Cast Chrome Sender Framework API loader script.
*
* Same URL as the usual loader script, but the additional
* search parameter is checked from within the script and
* the framework API script is conditionally loaded in
* addition to the regular SDK script.
*/
export const CAST_FRAMEWORK_LOADER_SCRIPT_URL = `${CAST_LOADER_SCRIPT_URL}?loadCastFramework=1`;
/**
* Cast extension URLs.
*
* Cast functionality in Chrome was previously provided by
* an extension. The cast SDK scripts are still provided via
* chrome-extension: URLs for compatibility reasons (?).
*/
export const CAST_SCRIPT_URLS = [
"chrome-extension://pkedcjkdefgpdelpbcmbmeomcjbeemfm/cast_sender.js",
"chrome-extension://enhhojjnijigcajfphajepfemndkmdlo/cast_sender.js"
];
/**
* Cast Chrome Sender Framework API script.
*
* The Cast Application Framework (CAF) is implemented as a
* wrapper around the base SDK, and ditributed remotely, as
* opposed to within the cast extension.
*/
export const CAST_FRAMEWORK_SCRIPT_URL =
"https://www.gstatic.com/cast/sdk/libs/sender/1.0/cast_framework.js";

112
extension/src/cast/utils.ts Normal file
View File

@@ -0,0 +1,112 @@
import { ReceiverDevice, ReceiverDeviceCapabilities } from "../types";
import { Receiver } from "./sdk/classes";
import { Capability, ReceiverType } from "./sdk/enums";
import { MediaCommand } from "./sdk/media/enums";
import { _MediaCommand } from "./sdk/types";
/**
* Check receiver device capabilities bitflags against array of
* capability strings requested by the sender application.
*/
export function hasRequiredCapabilities(
receiverDevice: ReceiverDevice,
requiredCapabilities: Capability[] = []
) {
const { capabilities } = receiverDevice;
return requiredCapabilities.every(capability => {
switch (capability) {
case Capability.AUDIO_IN:
return capabilities & ReceiverDeviceCapabilities.AUDIO_IN;
case Capability.AUDIO_OUT:
return capabilities & ReceiverDeviceCapabilities.AUDIO_OUT;
case Capability.MULTIZONE_GROUP:
return (
capabilities & ReceiverDeviceCapabilities.MULTIZONE_GROUP
);
case Capability.VIDEO_IN:
return capabilities & ReceiverDeviceCapabilities.VIDEO_IN;
case Capability.VIDEO_OUT:
return capabilities & ReceiverDeviceCapabilities.VIDEO_OUT;
}
});
}
/** Convert capabilities bitflags to string array. */
export function convertCapabilitiesFlags(flags: ReceiverDeviceCapabilities) {
const capabilities: Capability[] = [];
if (flags & ReceiverDeviceCapabilities.VIDEO_OUT)
capabilities.push(Capability.VIDEO_OUT);
if (flags & ReceiverDeviceCapabilities.VIDEO_IN)
capabilities.push(Capability.VIDEO_IN);
if (flags & ReceiverDeviceCapabilities.AUDIO_OUT)
capabilities.push(Capability.AUDIO_OUT);
if (flags & ReceiverDeviceCapabilities.AUDIO_IN)
capabilities.push(Capability.AUDIO_IN);
if (flags & ReceiverDeviceCapabilities.MULTIZONE_GROUP)
capabilities.push(Capability.MULTIZONE_GROUP);
return capabilities;
}
/** Convert media commands bitflags to string array. */
export function convertSupportedMediaCommandsFlags(flags: _MediaCommand) {
const supportedMediaCommands: string[] = [];
if (flags & _MediaCommand.PAUSE) {
supportedMediaCommands.push(MediaCommand.PAUSE);
}
if (flags & _MediaCommand.SEEK) {
supportedMediaCommands.push(MediaCommand.SEEK);
}
if (flags & _MediaCommand.STREAM_VOLUME) {
supportedMediaCommands.push(MediaCommand.STREAM_VOLUME);
}
if (flags & _MediaCommand.STREAM_MUTE) {
supportedMediaCommands.push(MediaCommand.STREAM_MUTE);
}
if (flags & _MediaCommand.QUEUE_NEXT) {
supportedMediaCommands.push("queue_next");
}
if (flags & _MediaCommand.QUEUE_PREV) {
supportedMediaCommands.push("queue_prev");
}
return supportedMediaCommands;
}
interface GetEstimatedTimeOpts {
currentTime: number;
lastUpdateTime: number;
playbackRate?: number;
duration?: Nullable<number>;
}
export function getEstimatedTime(opts: GetEstimatedTimeOpts) {
let estimatedTime =
opts.currentTime +
(opts.playbackRate ?? 1) * ((Date.now() - opts.lastUpdateTime) / 1000);
// Enforce valid range
if (estimatedTime < 0) {
estimatedTime = 0;
} else if (opts.duration && estimatedTime > opts.duration) {
estimatedTime = opts.duration;
}
return estimatedTime;
}
/**
* Create `chrome.cast.Receiver` object from receiver device info.
*/
export function createReceiver(device: ReceiverDevice) {
const receiver = new Receiver(
device.id,
device.friendlyName,
convertCapabilitiesFlags(device.capabilities)
);
// Currently only supports CAST receivers
receiver.receiverType = ReceiverType.CAST;
return receiver;
}