mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-11 18:19:58 +00:00
Rename directory: ext -> extension
This commit is contained in:
48
extension/src/cast/content.ts
Normal file
48
extension/src/cast/content.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
19
extension/src/cast/contentBridge.ts
Normal file
19
extension/src/cast/contentBridge.ts
Normal 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();
|
||||
});
|
||||
57
extension/src/cast/contentInitial.ts
Normal file
57
extension/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)
|
||||
});
|
||||
154
extension/src/cast/export.ts
Normal file
154
extension/src/cast/export.ts
Normal 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;
|
||||
}
|
||||
40
extension/src/cast/knownApps.ts
Normal file
40
extension/src/cast/knownApps.ts
Normal 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>;
|
||||
140
extension/src/cast/pageMessaging.ts
Normal file
140
extension/src/cast/pageMessaging.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 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;
|
||||
}
|
||||
};
|
||||
454
extension/src/cast/sdk/Session.ts
Normal file
454
extension/src/cast/sdk/Session.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
104
extension/src/cast/sdk/classes.ts
Normal file
104
extension/src/cast/sdk/classes.ts
Normal 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
|
||||
) {}
|
||||
}
|
||||
73
extension/src/cast/sdk/enums.ts
Normal file
73
extension/src/cast/sdk/enums.ts
Normal 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"
|
||||
}
|
||||
426
extension/src/cast/sdk/index.ts
Normal file
426
extension/src/cast/sdk/index.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
487
extension/src/cast/sdk/media/Media.ts
Normal file
487
extension/src/cast/sdk/media/Media.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
392
extension/src/cast/sdk/media/classes.ts
Normal file
392
extension/src/cast/sdk/media/classes.ts
Normal 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) {}
|
||||
}
|
||||
137
extension/src/cast/sdk/media/enums.ts
Normal file
137
extension/src/cast/sdk/media/enums.ts
Normal 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"
|
||||
}
|
||||
18
extension/src/cast/sdk/media/index.ts
Normal file
18
extension/src/cast/sdk/media/index.ts
Normal 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
|
||||
};
|
||||
208
extension/src/cast/sdk/types.ts
Normal file
208
extension/src/cast/sdk/types.ts
Normal 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" });
|
||||
367
extension/src/cast/senders/media.ts
Normal file
367
extension/src/cast/senders/media.ts
Normal 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
|
||||
});
|
||||
}
|
||||
223
extension/src/cast/senders/mirroring.ts
Normal file
223
extension/src/cast/senders/mirroring.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
41
extension/src/cast/urls.ts
Normal file
41
extension/src/cast/urls.ts
Normal 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
112
extension/src/cast/utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user