Rename directory: ext -> extension

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

5
extension/.eslintrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"env": {
"browser": true
}
}

180
extension/bin/build.js Normal file
View File

@@ -0,0 +1,180 @@
// @ts-check
import fs from "fs-extra";
import path from "path";
import url from "url";
import esbuild from "esbuild";
import sveltePlugin from "esbuild-svelte";
import sveltePreprocess from "svelte-preprocess";
import yargs from "yargs";
import webExt from "web-ext";
import copyFilesPlugin from "./lib/copyFilesPlugin.js";
const BRIDGE_NAME = "fx_cast_bridge";
const BRIDGE_VERSION = "0.3.0";
const MIRRORING_APP_ID = "19A6F4AE";
const argv = yargs()
.help()
.version(false)
.option("watch", {
describe: "Rebuild on changes",
type: "boolean"
})
.option("package", {
describe: "Package with web-ext",
type: "boolean",
conflicts: "watch"
})
.option("mode", {
describe: "Set build mode",
choices: ["development", "production"],
default: "development"
})
.parseSync(process.argv);
// If packaging, use production mode
if (argv.package) {
argv.mode = "production";
}
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
// Paths
const rootPath = path.join(__dirname, "../");
const srcPath = path.join(rootPath, "src");
const distPath = path.join(rootPath, "../dist/extension/");
const unpackedPath = path.join(distPath, "unpacked");
const outPath = argv.package ? unpackedPath : distPath;
/** @type esbuild.BuildOptions */
const buildOpts = {
bundle: true,
target: "firefox64",
logLevel: "info",
sourcemap: "inline",
outdir: outPath,
outbase: srcPath,
entryPoints: [
// Main
path.join(srcPath, "background/background.ts"),
// Cast
path.join(srcPath, "cast/content.ts"),
path.join(srcPath, "cast/contentInitial.ts"),
path.join(srcPath, "cast/contentBridge.ts"),
// Media sender
path.join(srcPath, "cast/senders/media.ts"),
// Mirroring sender
path.join(srcPath, "/cast/senders/mirroring.ts"),
// UI
path.join(srcPath, "ui/popup/index.ts"),
path.join(srcPath, "ui/mirroring/index.ts"),
path.join(srcPath, "ui/options/index.ts")
],
define: {
BRIDGE_NAME: `"${BRIDGE_NAME}"`,
BRIDGE_VERSION: `"${BRIDGE_VERSION}"`,
MIRRORING_APP_ID: `"${MIRRORING_APP_ID}"`
},
plugins: [
// @ts-ignore
sveltePlugin({
// @ts-ignore
preprocess: sveltePreprocess()
}),
// Copy static files
copyFilesPlugin({
src: srcPath,
dest: outPath,
excludePattern: /^(manifest\.json|.*\.(ts|js|svelte))$/
})
]
};
// Set production options
if (argv.mode === "production") {
buildOpts.minify = true;
buildOpts.sourcemap = false;
}
/**
* Handle build results.
*
* @param {esbuild.BuildResult | null} result
*/
function onBuildResult(result) {
if (result?.errors.length) {
console.error("Build error!");
return;
}
const manifest = JSON.parse(
fs.readFileSync(`${srcPath}/manifest.json`, { encoding: "utf-8" })
);
manifest.content_security_policy =
argv.mode === "production"
? "script-src 'self'; object-src 'self'"
: "script-src 'self' 'unsafe-eval'; object-src 'self'";
fs.writeFileSync(`${outPath}/manifest.json`, JSON.stringify(manifest));
}
// Clean
fs.removeSync(distPath);
if (argv.watch) {
esbuild
.build({
...buildOpts,
watch: {
onRebuild(_err, result) {
return onBuildResult(result);
}
}
})
.then(onBuildResult);
} else {
esbuild.build(buildOpts).then(result => {
onBuildResult(result);
if (argv.package) {
webExt.cmd
.build(
{
/**
* Webpack output at sourceDir is built into an extension
* archive at artifactsDir.
*/
sourceDir: unpackedPath,
artifactsDir: distPath,
overwriteDest: true
},
{
// Prevent auto-exit
shouldExitProgram: false
}
)
.then(result => {
const outputName = path.basename(result.extensionPath);
// Rename output extension to XPI
fs.moveSync(
path.join(distPath, outputName),
path.join(distPath, outputName.replace("zip", "xpi"))
);
// Only need the built extension archive
fs.remove(unpackedPath);
});
}
});
}

View File

@@ -0,0 +1,107 @@
// @ts-check
"use strict";
import path from "path";
import fs from "fs";
// eslint-disable-next-line no-unused-vars
import esbuild from "esbuild";
/**
* Walks file tree from a given root path.
* @param {string} rootPath
*/
function* walk(rootPath) {
const pathsToWalk = [rootPath];
while (pathsToWalk.length > 0) {
const currentPath = /** @type {string} */ (pathsToWalk.pop());
if (fs.statSync(currentPath).isFile()) {
yield currentPath;
} else {
for (const child of fs.readdirSync(currentPath)) {
pathsToWalk.push(path.join(currentPath, child));
}
}
}
}
/**
* @typedef {object} CopyFilesPluginOpts
* @prop {string} src Source path
* @prop {string} dest Destination path
* @prop {RegExp=} excludePattern Exclude path pattern
*/
/**
* Plugin that copies files from specified source to destination after
* each build.
*
* @type {(opts: CopyFilesPluginOpts) => esbuild.Plugin}
*/
export default opts => {
if (!fs.existsSync(opts.src)) {
throw new Error("copyFilesPlugin: src path not found!");
}
const matchingPaths = [...walk(opts.src)].filter(
path => !opts.excludePattern?.test(path)
);
return {
name: "copy-files",
setup(build) {
/** First run for the set of import paths in each build. */
let isFirstRun = true;
build.onResolve({ filter: /.*/ }, () => {
/**
* Attach watch files to first resolve result.
* Presumably there is a much better way of doing
* this?
*/
if (isFirstRun) {
isFirstRun = false;
return {
watchFiles: matchingPaths
};
}
});
build.onEnd(() => {
isFirstRun = true;
// Copy any watched files that changed
for (const srcPath of matchingPaths) {
const destPath = path.resolve(
opts.dest,
path.relative(opts.src, srcPath)
);
// Ignore if source file is missing
if (!fs.existsSync(srcPath)) {
if (fs.existsSync(destPath)) {
fs.rmSync(destPath);
}
continue;
}
// Ensure containing destination directory exists
const dirName = path.dirname(destPath);
if (!fs.existsSync(dirName)) {
fs.mkdirSync(dirName, { recursive: true });
}
// Check if files match
if (fs.existsSync(destPath)) {
const srcContent = fs.readFileSync(srcPath);
const destContent = fs.readFileSync(destPath);
if (srcContent.equals(destContent)) {
continue;
}
}
fs.copyFileSync(srcPath, destPath);
}
});
}
};
};

13689
extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
extension/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"type": "module",
"scripts": {
"build": "node bin/build.js",
"package": "node bin/build.js --package",
"watch": "node bin/build.js --watch",
"start": "web-ext run -s ../dist/extension/",
"lint": "eslint src --ext .ts,.tsx"
},
"devDependencies": {
"@types/firefox-webext-browser": "^94.0.1",
"@types/semver": "^7.3.9",
"@types/uuid": "^8.3.4",
"esbuild": "^0.14.38",
"esbuild-svelte": "^0.7.1",
"fs-extra": "^10.1.0",
"fuzzysort": "^2.0.3",
"semver": "^7.3.7",
"svelte": "^3.48.0",
"svelte-preprocess": "^4.10.6",
"ts-loader": "^9.2.8",
"typescript": "^4.6.3",
"uuid": "^8.3.2",
"web-ext": "^6.8.0",
"yargs": "^17.5.1"
}
}

View File

@@ -0,0 +1,355 @@
{
"extensionDescription": {
"message": "Aktiviert Chromecast-Support zum Streamen von Web-Apps (wie Netflix oder BBC iPlayer), HTML5-Video und Bildschirm-/Tabfreigaben.",
"description": "Description of the extension shown in the add-ons manager."
},
"popupMediaTypeApp": {
"message": "Die App dieser Seite",
"description": "Receiver selector media type <option> text for current site's sender application."
},
"popupMediaTypeAppMedia": {
"message": "Diese Medien",
"description": "Receiver selector media type <option> text for media casting."
},
"popupMediaTypeScreen": {
"message": "Bildschirm",
"description": "Receiver selector media type <option> text for screen."
},
"popupMediaTypeFile": {
"message": "Durchsuchen...",
"description": "Receiver selector media type <option> text for opening a file selector dialog."
},
"popupMediaSelectCastLabel": {
"message": "",
"description": "(Cast) <select> to:"
},
"popupMediaSelectToLabel": {
"message": "streamen an:",
"description": "Cast <select> (to:)"
},
"popupNoReceiversFound": {
"message": "Keine Empfänger gefunden",
"description": "Message displayed in the receiver selector if there are no available receivers."
},
"popupCastButtonTitle": {
"message": "Streamen",
"description": "Button text for each receiver entry in the receiver selector."
},
"popupCastingButtonTitle": {
"message": "Streame$ellipsis$",
"description": "Button text while establishing a session in the receiver selector. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": "..."
}
}
},
"popupStopButtonTitle": {
"message": "Stopp",
"description": "Alternate action button text displayed instead of popupCastButtonTitle."
},
"contextCast": {
"message": "Streamen...",
"description": "Main context menu item title. Ellipsis indicates additional information required as it triggers opening of receiver selector."
},
"contextAddToWhitelist": {
"message": "Zur Whitelist hinzufügen",
"description": "Top-level whitelist context menu item title."
},
"contextAddToWhitelistRecommended": {
"message": "$matchPattern$ hinzufügen (empfohlen)",
"description": "Context menu item title for recomended match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "https://example.com/*"
}
}
},
"contextAddToWhitelistAdvancedAdd": {
"message": "$matchPattern$ hinzufügen",
"description": "Context menu item title for all other match patterns.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "*://*.example.com/*"
}
}
},
"optionsBridgeLoading": {
"message": "Lade Bridge-Informationen...",
"description": "Loading placeholder text for bridge section on options page."
},
"optionsBridgeFoundStatusTitle": {
"message": "Bridge gefunden",
"description": "Bridge OK status title text."
},
"optionsBridgeIssueStatusTitle": {
"message": "Bridge-Fehler",
"description": "Bridge error status title text."
},
"optionsBridgeNotFoundStatusTitle": {
"message": "Bridge nicht gefunden",
"description": "Bridge missing status title text."
},
"optionsBridgeNotFoundStatusText": {
"message": "Versuchen Sie die neuste Version herunterzuladen und zu installieren.",
"description": "Bridge not found additional description text"
},
"optionsBridgeStatsName": {
"message": "Name:",
"description": "Bridge stats name row title."
},
"optionsBridgeStatsVersion": {
"message": "Version:",
"description": "Bridge stats version row title."
},
"optionsBridgeStatsExpectedVersion": {
"message": "Erwartete Version:",
"description": "Bridge stats expected version row title."
},
"optionsBridgeStatsCompatibility": {
"message": "Kompatibilität:",
"description": "Bridge stats compatibility row title."
},
"optionsBridgeStatsRecommendedAction": {
"message": "Handlungsempfehlung:",
"description": "Bridge stats recommended action row title."
},
"optionsBridgeCompatible": {
"message": "KOMPATIBEL",
"description": "Compatibility status is definitely compatible."
},
"optionsBridgeLikelyCompatible": {
"message": "WAHRSCHEINLICH KOMPATIBEL",
"description": "Compatibility status is probably compatible."
},
"optionsBridgeIncompatible": {
"message": "NICHT KOMPATIBEL",
"description": "Compatibility status is definitely incompatible."
},
"optionsBridgeOlderAction": {
"message": "Bridge-Version älter als erwartet, versuchen Sie auf die neuste Version zu aktualisieren.",
"description": "Recommended action for when the installed bridge version is older than the installed extension version."
},
"optionsBridgeNewerAction": {
"message": "Bridge-Version neuer als erwartet, versuchen Sie die Erweiterung auf die neuste Version zu aktualisieren.",
"description": "Recommended action for when the installed bridge version is newer than the installed extension version."
},
"optionsBridgeNoAction": {
"message": "Kein Handlungsbedarf.",
"description": "Recommended action for when both bridge and extension versions are compatible or likely compatible."
},
"optionsBridgeUpdateCheck": {
"message": "Nach Aktualisierungen suchen",
"description": "Update check button title."
},
"optionsBridgeUpdateChecking": {
"message": "Suche nach Aktualisierungen$ellipsis$",
"description": "Update check button title while in progress. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": ".."
}
}
},
"optionsBridgeUpdateStatusNoUpdates": {
"message": "Keine Aktualisierungen verfügbar",
"description": "Update status if no updates are found."
},
"optionsBridgeUpdateStatusError": {
"message": "Fehler beim Suchen nach Aktualisierungen",
"description": "Update status if an error was encountered checking for updates."
},
"optionsBridgeUpdateAvailable": {
"message": "Eine Aktualisierung ist verfügbar:",
"description": "Update status if an update was found."
},
"optionsBridgeUpdate": {
"message": "Jetzt aktualisieren...",
"description": "Update now button title. Ellipsis indicates additional information as it triggers an update window popup."
},
"optionsBridgeBackupEnabled": {
"message": "Aktiviere Ausweich-Daemon-Verbindung auf $hostPort$",
"description": "Backup daemon checkbox label. Host/port inputs are inserted inline at the hostPort substitution.",
"placeholders": {
"hostPort": {
"content": "$1"
}
}
},
"optionsBridgeBackupEnabledDescription": {
"message": "Versuchen zur Bridge im Daemon-Modus zu verbinden, wenn die reguläre Bridge-Verbindung fehlschlägt.",
"description": "Backup daemon checkbox description."
},
"optionsMediaCategoryName": {
"message": "Medien streamen",
"description": "Options page media casting category title."
},
"optionsMediaCategoryDescription": {
"message": "HTML5-Video/-Audio Medien streamen.",
"description": "Options page media casting category description."
},
"optionsMediaEnabled": {
"message": "Streamen von Medien aktivieren",
"description": "Media casting enabled checkbox label."
},
"optionsMediaSyncElement": {
"message": "Empfängerstatus mit Media-Element synchronisieren",
"description": "Media casting sync checkbox label."
},
"optionsMediaSyncElementDescription": {
"message": "Status (Wiedergabe, Lautstärke, Untertitel, etc...) zwischen dem Media-Element und dem Empfängergerät synchronisieren.",
"description": "Media casting sync option description."
},
"optionsMediaStopOnUnload": {
"message": "Wiedergabe auf dem Empfänger beim verlassen der Seite beenden",
"description": "Media stop on unload checkbox label."
},
"optionsLocalMediaCategoryName": {
"message": "Streamen lokaler Medien",
"description": "Options page local media category title."
},
"optionsLocalMediaCategoryDescription": {
"message": "HTTP-Server, der von der Bridge zum Streamen lokaler Mediendateien an den Empfänger gestartet wird.",
"description": "Options page local media category description."
},
"optionsLocalMediaEnabled": {
"message": "Streamen lokaler Medien aktivieren",
"description": "Local media enabled checkbox label."
},
"optionsLocalMediaServerPort": {
"message": "HTTP-Serverport:",
"description": "HTTP server port input label."
},
"optionsReceiverSelectorCategoryName": {
"message": "Empfängerauswahl",
"description": "Options page receiver selector category title."
},
"optionsReceiverSelectorCategoryDescription": {
"message": "Auswahloberfläche für Empfängergeräte.",
"description": "Options page receiver selector category description."
},
"optionsReceiverSelectorWaitForConnection": {
"message": "Auf Verbindung warten",
"description": "Receiver selector wait for connection option checkbox label."
},
"optionsReceiverSelectorWaitForConnectionDescription": {
"message": "Empfängerauswahl bleibt geöffnet bis die Verbindung aufgebaut ist oder die Verbindung fehlschlägt.",
"description": "Receiver selector wait for connection option description."
},
"optionsReceiverSelectorCloseIfFocusLost": {
"message": "Nach Fokusverlust schließen",
"description": "Receiver selector close if focus lost option checkbox label."
},
"optionsSiteWhitelistCategoryName": {
"message": "Useragent-Whitelist",
"description": "Options page whitelist category title."
},
"optionsSiteWhitelistCategoryDescription": {
"message": "Seiten auf denen der Useragent aus Kompatibilitätsgründen mit einer Chrome-Version ersetzt wird. Suchmuster müssen gültig sein.",
"description": "Options page whitelist category description."
},
"optionsSiteWhitelistEnabled": {
"message": "Webseiten-Whitelist aktivieren",
"description": "Whitelist enabled checkbox label."
},
"optionsSiteWhitelistRestrictedEnabled": {
"message": "Eingeschränkten Modus aktivieren",
"description": "Whitelist restricted mode enabled checkbox label."
},
"optionsSiteWhitelistRestrictedEnabledDescription": {
"message": "Whitelist-Einschränkungen auch auf Seiten anwenden, die unabhängig vom aktuellen Useragent versuchen Stream-Funktionen zu laden.",
"description": "Whitelist restricted mode enabled description."
},
"optionsSiteWhitelistContent": {
"message": "Suchmuster:",
"description": "Match patterns editor widget label."
},
"optionsSiteWhitelistBasicView": {
"message": "Einfache Ansicht",
"description": "Switch to basic view button title."
},
"optionsSiteWhitelistRawView": {
"message": "Rohdatenansicht",
"description": "Switch to raw view button title."
},
"optionsSiteWhitelistSaveRaw": {
"message": "Rohdaten speichern",
"description": "Save raw view edits button title."
},
"optionsSiteWhitelistAddItem": {
"message": "Eintrag hinzufügen",
"description": "Add new whitelist item button title."
},
"optionsSiteWhitelistEditItem": {
"message": "Bearbeiten",
"description": "Edit whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistRemoveItem": {
"message": "Entfernen",
"description": "Remove whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistInvalidMatchPattern": {
"message": "Ungültiges Suchmuster $matchPattern$",
"description": "Error displayed by input indicating an invalid match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "http://example"
}
}
},
"optionsMirroringCategoryName": {
"message": "Bildschirm duplizieren",
"description": "Options page mirroring category name."
},
"optionsMirroringCategoryDescription": {
"message": "Bildschirm/Tab an eine Chromecast-Empfänger-App duplizieren.",
"description": "Options page mirroring category description."
},
"optionsMirroringEnabled": {
"message": "Bildschirm duplizieren aktivieren",
"description": "Mirroring enabled checkbox label."
},
"optionsMirroringAppId": {
"message": "Empfänger-App-ID:",
"description": "Mirroring app ID input label."
},
"optionsMirroringAppIdDescription": {
"message": "App-ID einer registrierten Chromecast-Empfängeranwendung. Nur für fortgeschrittene Anwender. Muss mit der Standard-App kompatibel sein (siehe GitHub-Repository).",
"description": "Mirroring app ID option description."
},
"optionsOptionRecommended": {
"message": "Empfohlen",
"description": "Badge next to option label indicating boolean option is recommended enabled."
},
"optionsReset": {
"message": "Standardwerte wiederherstellen",
"description": "Restore default options button label."
},
"optionsSave": {
"message": "Speichern",
"description": "Save options button label."
},
"optionsSaved": {
"message": "Gespeichert!",
"description": "Status text displayed by save button once options have been successfully saved."
}
}

View File

@@ -0,0 +1,638 @@
{
"extensionName": {
"message": "fx_cast",
"description": "Name of the extension and the native receiver selector window title."
},
"extensionDescription": {
"message": "Enables Chromecast support for casting web apps (like Netflix or BBC iPlayer), HTML5 video and screen sharing.",
"description": "Description of the extension shown in the add-ons manager."
},
"actionTitleDefault": {
"message": "fx_cast",
"description": "Title for toolbar button in default state."
},
"actionTitleConnecting": {
"message": "fx_cast: Connecting",
"description": "Title for toolbar button whilst connecting."
},
"actionTitleConnected": {
"message": "fx_cast: Connected",
"description": "Title for toolbar button whilst connected."
},
"popupBridgeErrorBanner": {
"message": "There is a problem with the bridge!",
"description": "Bridge error banner message."
},
"popupBridgeErrorBannerOptions": {
"message": "More Information",
"description": "Bridge error banner button label."
},
"popupWhitelistNotWhitelisted": {
"message": "$appName$ is not whitelisted",
"description": "Receiver selector whitelist suggestion banner label.",
"placeholders": {
"appName": {
"content": "$1",
"example": "Netflix"
}
}
},
"popupWhitelistAddToWhitelist": {
"message": "Add to Whitelist",
"description": "Receiver selector whitelist suggestion banner button label."
},
"popupMediaTypeApp": {
"message": "this site's app",
"description": "Receiver selector media type <option> text for current site's sender application."
},
"popupMediaTypeAppNotFound": {
"message": "this site's app (not found)",
"description": "Receiver selector media type <option> text for current site's sender application if none found."
},
"popupMediaTypeAppMedia": {
"message": "this media",
"description": "Receiver selector media type <option> text for media casting."
},
"popupMediaTypeScreen": {
"message": "Screen",
"description": "Receiver selector media type <option> text for screen."
},
"popupMediaTypeFile": {
"message": "Browse...",
"description": "Receiver selector media type <option> text for opening a file selector dialog."
},
"popupMediaSelectCastLabel": {
"message": "Cast",
"description": "(Cast) <select> to:"
},
"popupMediaSelectToLabel": {
"message": "to:",
"description": "Cast <select> (to:)"
},
"popupNoReceiversFound": {
"message": "No receiver devices found",
"description": "Message displayed in the receiver selector if there are no available receivers."
},
"popupCastButtonTitle": {
"message": "Cast",
"description": "Button text for each receiver entry in the receiver selector."
},
"popupCastingButtonTitle": {
"message": "Casting$ellipsis$",
"description": "Button text while establishing a session in the receiver selector. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": "..."
}
}
},
"popupStopButtonTitle": {
"message": "Stop",
"description": "Alternate action button text displayed instead of popupCastButtonTitle."
},
"popupCastMenuTitle": {
"message": "Cast to $deviceName$",
"description": "Menu text for cast item in context menu for receivers in receiver selector.",
"placeholders": {
"deviceName": {
"content": "$1",
"example": "Living Room TV"
}
}
},
"popupStopMenuTitle": {
"message": "Stop \"$appName$\" on $deviceName$",
"description": "Menu text for stop item in context menu for receivers in receiver selector.",
"placeholders": {
"appName": {
"content": "$1",
"example": "Netflix"
},
"deviceName": {
"content": "$2",
"example": "Living Room TV"
}
}
},
"popupShowDetailsTitle": {
"message": "Show details",
"description": "Receiver device expand button title."
},
"popupMediaPlay": {
"message": "Play",
"description": "Media controls play button title."
},
"popupMediaPause": {
"message": "Pause",
"description": "Media controls pause button title."
},
"popupMediaSkipPrevious": {
"message": "Skip previous",
"description": "Media controls skip previous button title."
},
"popupMediaSkipNext": {
"message": "Skip next",
"description": "Media controls skip next button title."
},
"popupMediaSeek": {
"message": "Seek position",
"description": "Media controls seek bar accessibility label."
},
"popupMediaSeekBackward": {
"message": "Seek backwards",
"description": "Media controls seek backward button title."
},
"popupMediaSeekForward": {
"message": "Seek forwards",
"description": "Media controls seek forward button title."
},
"popupMediaSubtitlesCaptions": {
"message": "Subtitles/captions",
"description": "Media controls subtitles/cc button title."
},
"popupMediaSubtitlesCaptionsOff": {
"message": "Off",
"description": "Media controls subtitles/cc button title."
},
"popupMediaMute": {
"message": "Mute",
"description": "Media controls mute button title."
},
"popupMediaUnmute": {
"message": "Unmute",
"description": "Media controls unmute button title."
},
"popupMediaVolume": {
"message": "Unmute",
"description": "Media controls volume slider accessibility label."
},
"popupMediaLive": {
"message": "Live",
"description": "Media controls label displayed for live streams."
},
"popupSearch": {
"message": "Search devices",
"description": "Media devices search title."
},
"popupSearchClear": {
"message": "Clear Search",
"description": "Media devices search clear button title."
},
"popupSearchNoDevicesFound": {
"message": "No devices found for \"$searchTerm$\".",
"description": "Message displayed when no receiver devices are found for a given search term.",
"placeholders": {
"searchTerm": {
"content": "$1",
"example": "Living Room"
}
}
},
"contextCast": {
"message": "Cast...",
"description": "Main context menu item title. Ellipsis indicates additional information required as it triggers opening of receiver selector."
},
"contextAddToWhitelist": {
"message": "Add to Whitelist",
"description": "Top-level whitelist context menu item title."
},
"contextAddToWhitelistRecommended": {
"message": "Add $matchPattern$ (Recommended)",
"description": "Context menu item title for recomended match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "https://example.com/*"
}
}
},
"contextAddToWhitelistAdvancedAdd": {
"message": "Add $matchPattern$",
"description": "Context menu item title for all other match patterns.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "*://*.example.com/*"
}
}
},
"optionsBridgeLoading": {
"message": "Loading bridge info...",
"description": "Loading placeholder text for bridge section on options page."
},
"optionsBridgeFoundStatusTitle": {
"message": "Bridge found",
"description": "Bridge OK status title text."
},
"optionsBridgeIssueStatusTitle": {
"message": "Bridge issue",
"description": "Bridge error status title text."
},
"optionsBridgeIssueStatusTextTimedOut": {
"message": "Connection timed out.",
"description": "Bridge timed out issue additional description text."
},
"optionsBridgeIssueStatusTextAuthentication": {
"message": "Failed to authenticate connection.",
"description": "Bridge authentication issue additional description text."
},
"optionsBridgeNotFoundStatusTitle": {
"message": "Bridge not found",
"description": "Bridge missing status title text."
},
"optionsBridgeNotFoundStatusText": {
"message": "Try downloading and installing the latest version.",
"description": "Bridge not found additional description text"
},
"optionsBridgeStatsName": {
"message": "Name:",
"description": "Bridge stats name row title."
},
"optionsBridgeStatsVersion": {
"message": "Version:",
"description": "Bridge stats version row title."
},
"optionsBridgeStatsExpectedVersion": {
"message": "Expected version:",
"description": "Bridge stats expected version row title."
},
"optionsBridgeStatsCompatibility": {
"message": "Compatibility:",
"description": "Bridge stats compatibility row title."
},
"optionsBridgeStatsRecommendedAction": {
"message": "Recommended action:",
"description": "Bridge stats recommended action row title."
},
"optionsBridgeCompatible": {
"message": "Compatible",
"description": "Compatibility status is definitely compatible."
},
"optionsBridgeLikelyCompatible": {
"message": "Likely compatible",
"description": "Compatibility status is probably compatible."
},
"optionsBridgeIncompatible": {
"message": "Incompatible",
"description": "Compatibility status is definitely incompatible."
},
"optionsBridgeOlderAction": {
"message": "Bridge version older than expected, try updating bridge to the latest version.",
"description": "Recommended action for when the installed bridge version is older than the installed extension version."
},
"optionsBridgeNewerAction": {
"message": "Bridge version newer than expected, try updating extension to the latest version.",
"description": "Recommended action for when the installed bridge version is newer than the installed extension version."
},
"optionsBridgeNoAction": {
"message": "No action needed.",
"description": "Recommended action for when both bridge and extension versions are compatible or likely compatible."
},
"optionsBridgeRefresh": {
"message": "Refresh bridge status",
"description": "Bridge status refresh button title."
},
"optionsBridgeUpdateCheck": {
"message": "Check for Updates",
"description": "Update check button title."
},
"optionsBridgeUpdateChecking": {
"message": "Checking for Updates$ellipsis$",
"description": "Update check button title while in progress. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": ".."
}
}
},
"optionsBridgeUpdateStatusNoUpdates": {
"message": "No updates available",
"description": "Update status if no updates are found."
},
"optionsBridgeUpdateStatusError": {
"message": "Error checking for updates",
"description": "Update status if an error was encountered checking for updates."
},
"optionsBridgeUpdateAvailable": {
"message": "An update is available:",
"description": "Update status if an update was found."
},
"optionsBridgeUpdate": {
"message": "Update Now...",
"description": "Update now button title. Ellipsis indicates additional information as it triggers an update window popup."
},
"optionsBridgeBackupEnabled": {
"message": "Enable backup daemon connection on $hostPort$",
"description": "Backup daemon checkbox label. Host/port inputs are inserted inline at the hostPort substitution.",
"placeholders": {
"hostPort": {
"content": "$1"
}
}
},
"optionsBridgeBackupEnabledDescription": {
"message": "If the regular bridge connection fails, attempt to connect to a bridge running in daemon mode.",
"description": "Backup daemon checkbox description."
},
"optionsBridgeBackupSecure": {
"message": "Use a secure connection",
"description": "Daemon secure option checkbox label."
},
"optionsBridgeBackupSecureDescription": {
"message": "Connects to the daemon via HTTPS instead of HTTP. Requires additional configuration.",
"description": "Daemon secure option description."
},
"optionsBridgeBackupPassword": {
"message": "Password:",
"description": "Daemon password option label."
},
"optionsBridgeBackupPasswordDescription": {
"message": "The optional password configured for the daemon connections.",
"description": "Daemon password option description."
},
"optionsMediaCategoryName": {
"message": "Media casting",
"description": "Options page media casting category title."
},
"optionsMediaCategoryDescription": {
"message": "HTML5 video/audio media casting.",
"description": "Options page media casting category description."
},
"optionsMediaEnabled": {
"message": "Enable media casting",
"description": "Media casting enabled checkbox label."
},
"optionsMediaSyncElement": {
"message": "Sync receiver state with media element",
"description": "Media casting sync checkbox label."
},
"optionsMediaSyncElementDescription": {
"message": "Synchronize state (playback, volume, captions, etc...) between the media element and the receiver device.",
"description": "Media casting sync option description."
},
"optionsMediaStopOnUnload": {
"message": "Stop receiver playback on page unload",
"description": "Media stop on unload checkbox label."
},
"optionsLocalMediaCategoryName": {
"message": "Local media casting",
"description": "Options page local media category title."
},
"optionsLocalMediaCategoryDescription": {
"message": "HTTP server started by the bridge app to stream local media files to the cast receiver.",
"description": "Options page local media category description."
},
"optionsLocalMediaEnabled": {
"message": "Enable local media casting",
"description": "Local media enabled checkbox label."
},
"optionsLocalMediaServerPort": {
"message": "HTTP server port:",
"description": "HTTP server port input label."
},
"optionsReceiverSelectorCategoryName": {
"message": "Receiver selector",
"description": "Options page receiver selector category title."
},
"optionsReceiverSelectorCategoryDescription": {
"message": "Receiver device selection UI.",
"description": "Options page receiver selector category description."
},
"optionsReceiverSelectorWaitForConnection": {
"message": "Wait for connection",
"description": "Receiver selector wait for connection option checkbox label."
},
"optionsReceiverSelectorWaitForConnectionDescription": {
"message": "Keep receiver selector open until the session is established or connection fails.",
"description": "Receiver selector wait for connection option description."
},
"optionsReceiverSelectorCloseIfFocusLost": {
"message": "Close after losing focus",
"description": "Receiver selector close if focus lost option checkbox label."
},
"optionsReceiverSelectorExpandActive": {
"message": "Auto-expand media controls for connected devices",
"description": "Receiver selector expand active checkbox label."
},
"optionsReceiverSelectorShowMediaImages": {
"message": "Show media images",
"description": "Receiver selector show media images checkbox label."
},
"optionsReceiverSelectorShowMediaImagesDescription": {
"message": "Loads media thumbnail/branding images from remote servers.",
"description": "Receiver selector show media images option description."
},
"optionsSiteWhitelistCategoryName": {
"message": "Site whitelist",
"description": "Options page whitelist category title."
},
"optionsSiteWhitelistCategoryDescription": {
"message": "Sites where cast functionality will be enabled and the user agent string will be replaced with a Chrome version for compatibility.",
"description": "Options page whitelist category description."
},
"optionsSiteWhitelistEnabled": {
"message": "Enable site whitelist",
"description": "Whitelist enabled checkbox label."
},
"optionsSiteWhitelistEnabledDescription": {
"message": "Disabling this option will enable cast functionality on any site, but the user agent string will not be replaced.",
"description": "Whitelist restricted mode enabled description."
},
"optionsSiteWhitelistContent": {
"message": "Match patterns:",
"description": "Match patterns editor widget label."
},
"optionsSiteWhitelistAddItem": {
"message": "Add Item",
"description": "Add new whitelist item button title."
},
"optionsSiteWhitelistEditItem": {
"message": "Edit",
"description": "Tooltip text for whitelist item's edit button."
},
"optionsSiteWhitelistRemoveItem": {
"message": "Remove",
"description": "Tooltip text for whitelist item's remove button."
},
"optionsSiteWhitelistItemShowOptions": {
"message": "Show options",
"description": "Tooltip text for whitelist item's show options button."
},
"optionsSiteWhitelistItemHideOptions": {
"message": "Hide options",
"description": "Tooltip text for whitelist item's hide options button."
},
"optionsSiteWhitelistInvalidDuplicatePattern": {
"message": "Match pattern already exists!",
"description": "Error displayed by input indicating a duplicate match pattern."
},
"optionsSiteWhitelistKnownAppsCustomApp": {
"message": "Custom",
"description": "Default <option> for knownApps <select>."
},
"optionsSiteWhitelistCustomUserAgent": {
"message": "User agent:",
"description": "Custom user agent option label."
},
"optionsSiteWhitelistCustomUserAgentDescription": {
"message": "If specified, a custom user agent string to use for whitelisted sites.",
"description": "Custom user agent option description."
},
"optionsSiteWhitelistUserAgentDisabled": {
"message": "Disable user agent",
"description": "Whitelist item user agent disabled checkbox label."
},
"optionsSiteWhitelistUserAgentDisabledDescription": {
"message": "Entirely disable user agent replacement for sites matching this pattern.",
"description": "Whitelist item user agent disabled checkbox description."
},
"optionsSiteWhitelistSiteSpecificUserAgent": {
"message": "User agent:",
"description": "Whitelist item user agent option label."
},
"optionsSiteWhitelistSiteSpecificUserAgentDescription": {
"message": "If specified, a custom user agent string to use specifically for sites matching this pattern.",
"description": "Whitelist item user agent option label."
},
"optionsSiteWhitelistItemEnabled": {
"message": "Enable whitelist item",
"description": "Whitelist item enabled checkbox title."
},
"optionsSiteWhitelistItemPattern": {
"message": "Match pattern",
"description": "Whitelist item pattern edit input title."
},
"optionsMirroringCategoryName": {
"message": "Screen mirroring",
"description": "Options page mirroring category name."
},
"optionsMirroringCategoryDescription": {
"message": "Mirroring to a Chromecast receiver app.",
"description": "Options page mirroring category description."
},
"optionsMirroringEnabled": {
"message": "Enable screen mirroring (experimental)",
"description": "Mirroring enabled checkbox label."
},
"optionsMirroringAppId": {
"message": "Mirroring app ID:",
"description": "Mirroring app ID input label."
},
"optionsMirroringAppIdDescription": {
"message": "App ID for a registered Chromecast receiver application. Advanced use only. Must be compatible with the default app (see GitHub repo).",
"description": "Mirroring app ID option description."
},
"optionsMirroringStreamOptions": {
"message": "Stream encoding options",
"description": "Options page mirroring category description."
},
"optionsMirroringStreamFrameRate": {
"message": "Max frame rate:",
"description": "Mirroring stream max frame rate option label."
},
"optionsMirroringStreamMaxBitRate": {
"message": "Max bitrate:",
"description": "Mirroring stream max bit rate option label."
},
"optionsMirroringStreamMaxBitRateDescription": {
"message": "Maximum bitrate in bits per second.",
"description": "Mirroring stream max bit rate option description."
},
"optionsMirroringStreamDownscaleFactor": {
"message": "Downscale factor:",
"description": "Mirroring stream downscale factor option label."
},
"optionsMirroringStreamDownscaleFactorDescription": {
"message": "Factor by which to scale down the video stream e.g. a factor of 2.0 would result in a video 1/4 the size. ",
"description": "Mirroring stream downscale factor option description."
},
"optionsMirroringStreamMaxResolution": {
"message": "Limit resolution to",
"description": "Mirroring stream resolution option label."
},
"optionsMirroringStreamMaxResolutionDescription": {
"message": "Limits the maximum video stream resolution while maintaining the source aspect ratio.",
"description": "Mirroring stream resolution option description."
},
"optionsMirroringStreamMaxResolutionWidthPlaceholder": {
"message": "Width",
"description": "Max resolution width input placeholder."
},
"optionsMirroringStreamMaxResolutionHeightPlaceholder": {
"message": "Height",
"description": "Max resolution height input placeholder."
},
"optionsOptionRecommended": {
"message": "recommended",
"description": "Badge next to option label indicating boolean option is recommended enabled."
},
"optionsReset": {
"message": "Restore Defaults",
"description": "Restore default options button label."
},
"optionsSave": {
"message": "Save",
"description": "Save options button label."
},
"optionsSaved": {
"message": "Saved!",
"description": "Status text displayed by save button once options have been successfully saved."
},
"optionsShowAdvancedOptions": {
"message": "Show advanced options",
"description": "Show advanced options checkbox label."
},
"mirroringPopupTitle": {
"message": "Mirroring",
"description": "Mirroring popup window title."
},
"mirroringPopupWaitingForConnection": {
"message": "Waiting for connection",
"description": "Mirroring popup loading text."
},
"mirroringPopupConnectedTo": {
"message": "Connected to $deviceName$",
"description": "Mirroring popup label displayed whilst session connected before mirroring.",
"placeholders": {
"deviceName": {
"content": "$1"
}
}
},
"mirroringPopupMirroringTo": {
"message": "Mirroring to $deviceName$",
"description": "Mirroring popup label displayed whilst mirroring.",
"placeholders": {
"deviceName": {
"content": "$1"
}
}
},
"mirroringPopupChooseSource": {
"message": "Choose Source",
"description": "Mirroring popup choose media source button label."
},
"mirroringPopupStopMirroring": {
"message": "Stop Mirroring",
"description": "Mirroring popup stop mirroring button label."
}
}

View File

@@ -0,0 +1,370 @@
{
"extensionDescription": {
"message": "Habilita el soporte de Chromecast para transmitir aplicaciones web (como Netflix y BBC iPlayer), video HTML5 y enviar pantalla/pestañas.",
"description": "Description of the extension shown in the add-ons manager."
},
"popupWhitelistNotWhitelisted": {
"message": "$appName$ no está en la lista blanca",
"description": "Receiver selector whitelist suggestion banner label.",
"placeholders": {
"appName": {
"content": "$1",
"example": "Netflix"
}
}
},
"popupWhitelistAddToWhitelist": {
"message": "Añadir a la lista blanca",
"description": "Receiver selector whitelist suggestion banner button label."
},
"popupMediaTypeApp": {
"message": "la aplicación de este sitio",
"description": "Receiver selector media type <option> text for current site's sender application."
},
"popupMediaTypeAppNotFound": {
"description": "Receiver selector media type <option> text for current site's sender application if none found.",
"message": "la aplicación de este sitio (no encontrada)"
},
"popupMediaTypeAppMedia": {
"message": "este medio",
"description": "Receiver selector media type <option> text for media casting."
},
"popupMediaTypeScreen": {
"message": "Pantalla",
"description": "Receiver selector media type <option> text for screen."
},
"popupMediaTypeFile": {
"message": "Examinar...",
"description": "Receiver selector media type <option> text for opening a file selector dialog."
},
"popupMediaSelectCastLabel": {
"message": "Transmitir",
"description": "(Cast) <select> to:"
},
"popupMediaSelectToLabel": {
"message": "a:",
"description": "Cast <select> (to:)"
},
"popupNoReceiversFound": {
"message": "No se encontraron dispositivos receptores",
"description": "Message displayed in the receiver selector if there are no available receivers."
},
"popupCastButtonTitle": {
"message": "Transmitir",
"description": "Button text for each receiver entry in the receiver selector."
},
"popupCastingButtonTitle": {
"message": "Transmitiendo$ellipsis$",
"description": "Button text while establishing a session in the receiver selector. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": "..."
}
}
},
"popupStopButtonTitle": {
"message": "Detener",
"description": "Alternate action button text displayed instead of popupCastButtonTitle."
},
"contextCast": {
"message": "Transmitir a...",
"description": "Main context menu item title. Ellipsis indicates additional information required as it triggers opening of receiver selector."
},
"contextAddToWhitelist": {
"message": "Añadir a la lista blanca",
"description": "Top-level whitelist context menu item title."
},
"contextAddToWhitelistRecommended": {
"message": "Añadir $matchPattern$ (Recomendado)",
"description": "Context menu item title for recomended match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "https://example.com/*"
}
}
},
"contextAddToWhitelistAdvancedAdd": {
"message": "Añadir $matchPattern$",
"description": "Context menu item title for all other match patterns.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "*://*.example.com/*"
}
}
},
"optionsBridgeLoading": {
"message": "Cargando información de la aplicación puente...",
"description": "Loading placeholder text for bridge section on options page."
},
"optionsBridgeFoundStatusTitle": {
"message": "Aplicación puente encontrada",
"description": "Bridge OK status title text."
},
"optionsBridgeIssueStatusTitle": {
"message": "Problema con aplicación puente",
"description": "Bridge error status title text."
},
"optionsBridgeNotFoundStatusTitle": {
"message": "Aplicación puente no encontrada",
"description": "Bridge missing status title text."
},
"optionsBridgeNotFoundStatusText": {
"message": "Intente descargar e instalar la última versión.",
"description": "Bridge not found additional description text"
},
"optionsBridgeStatsName": {
"message": "Nombre:",
"description": "Bridge stats name row title."
},
"optionsBridgeStatsVersion": {
"message": "Versión:",
"description": "Bridge stats version row title."
},
"optionsBridgeStatsExpectedVersion": {
"message": "Versión esperada:",
"description": "Bridge stats expected version row title."
},
"optionsBridgeStatsCompatibility": {
"message": "Compatibilidad:",
"description": "Bridge stats compatibility row title."
},
"optionsBridgeStatsRecommendedAction": {
"message": "Acción recomendada:",
"description": "Bridge stats recommended action row title."
},
"optionsBridgeCompatible": {
"message": "Compatible",
"description": "Compatibility status is definitely compatible."
},
"optionsBridgeLikelyCompatible": {
"message": "Probablemente compatible",
"description": "Compatibility status is probably compatible."
},
"optionsBridgeIncompatible": {
"message": "Incompatible",
"description": "Compatibility status is definitely incompatible."
},
"optionsBridgeOlderAction": {
"message": "Versión de la aplicación puente inferior a la esperada, intente actualizarla a la última versión.",
"description": "Recommended action for when the installed bridge version is older than the installed extension version."
},
"optionsBridgeNewerAction": {
"message": "Versión de la aplicación puente superior a la esperada, intente actualizar la extensión a la última versión.",
"description": "Recommended action for when the installed bridge version is newer than the installed extension version."
},
"optionsBridgeNoAction": {
"message": "No se requieren acciones.",
"description": "Recommended action for when both bridge and extension versions are compatible or likely compatible."
},
"optionsBridgeUpdateCheck": {
"message": "Buscar actualizaciones",
"description": "Update check button title."
},
"optionsBridgeUpdateChecking": {
"message": "Buscando actualizaciones para $ellipsis$",
"description": "Update check button title while in progress. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": ".."
}
}
},
"optionsBridgeUpdateStatusNoUpdates": {
"message": "No hay actualizaciones disponibles",
"description": "Update status if no updates are found."
},
"optionsBridgeUpdateStatusError": {
"message": "Error al buscar actualizaciones",
"description": "Update status if an error was encountered checking for updates."
},
"optionsBridgeUpdateAvailable": {
"message": "Hay una actualización disponible:",
"description": "Update status if an update was found."
},
"optionsBridgeUpdate": {
"message": "Actualizar ahora...",
"description": "Update now button title. Ellipsis indicates additional information as it triggers an update window popup."
},
"optionsBridgeBackupEnabled": {
"message": "Activar conexión de demonio de respaldo en $hostPort$",
"description": "Backup daemon checkbox label. Host/port inputs are inserted inline at the hostPort substitution.",
"placeholders": {
"hostPort": {
"content": "$1"
}
}
},
"optionsBridgeBackupEnabledDescription": {
"message": "Si la conexión de puente regular falla, intente conectar a un puente ejecutándose en modo demonio.",
"description": "Backup daemon checkbox description."
},
"optionsMediaCategoryName": {
"message": "Transmisión de contenidos",
"description": "Options page media casting category title."
},
"optionsMediaCategoryDescription": {
"message": "Transmisión de video/audio HTML5.",
"description": "Options page media casting category description."
},
"optionsMediaEnabled": {
"message": "Activar transmisión de contenidos",
"description": "Media casting enabled checkbox label."
},
"optionsMediaSyncElement": {
"message": "Sincronizar estado del receptor con el contenido",
"description": "Media casting sync checkbox label."
},
"optionsMediaSyncElementDescription": {
"message": "Sincroniza el estado (minuto de reproducción, volumen, subtítulos, etc...) entre el contenido y el dispositivo receptor.",
"description": "Media casting sync option description."
},
"optionsMediaStopOnUnload": {
"message": "Detener reproducción del receptor al cerrar la página",
"description": "Media stop on unload checkbox label."
},
"optionsLocalMediaCategoryName": {
"message": "Transmisión de contenidos locales",
"description": "Options page local media category title."
},
"optionsLocalMediaCategoryDescription": {
"message": "Servidor HTTP iniciado por la aplicación puente para transmitir archivos de medios locales al receptor.",
"description": "Options page local media category description."
},
"optionsLocalMediaEnabled": {
"message": "Activar transmisión de medios locales",
"description": "Local media enabled checkbox label."
},
"optionsLocalMediaServerPort": {
"message": "Puerto del servidor HTTP:",
"description": "HTTP server port input label."
},
"optionsReceiverSelectorCategoryName": {
"message": "Selector de receptor",
"description": "Options page receiver selector category title."
},
"optionsReceiverSelectorCategoryDescription": {
"message": "Interfaz de selección del dispositivo receptor.",
"description": "Options page receiver selector category description."
},
"optionsReceiverSelectorWaitForConnection": {
"message": "Esperar conexión",
"description": "Receiver selector wait for connection option checkbox label."
},
"optionsReceiverSelectorWaitForConnectionDescription": {
"message": "Mantiene el selector de receptor abierto hasta que la sesión sea establecida o falle la conexión.",
"description": "Receiver selector wait for connection option description."
},
"optionsReceiverSelectorCloseIfFocusLost": {
"message": "Cerrar al perder el foco",
"description": "Receiver selector close if focus lost option checkbox label."
},
"optionsSiteWhitelistCategoryName": {
"message": "Lista blanca de agentes de usuario",
"description": "Options page whitelist category title."
},
"optionsSiteWhitelistCategoryDescription": {
"message": "Sitios en los cuales reemplazar el agente de usuario con una versión de Chrome para compatibilidad. Deben ser patrones de coincidencia válidos.",
"description": "Options page whitelist category description."
},
"optionsSiteWhitelistEnabled": {
"message": "Activar lista blanca de sitios",
"description": "Whitelist enabled checkbox label."
},
"optionsSiteWhitelistRestrictedEnabled": {
"message": "Activar modo restringido",
"description": "Whitelist restricted mode enabled checkbox label."
},
"optionsSiteWhitelistRestrictedEnabledDescription": {
"message": "También aplica restricciones de la lista blanca a sitios intentando cargar la funcionalidad de transmisión sin importar el agente de usuario actual.",
"description": "Whitelist restricted mode enabled description."
},
"optionsSiteWhitelistContent": {
"message": "Patrones de coincidencia:",
"description": "Match patterns editor widget label."
},
"optionsSiteWhitelistBasicView": {
"message": "Vista básica",
"description": "Switch to basic view button title."
},
"optionsSiteWhitelistRawView": {
"message": "Vista en bruto",
"description": "Switch to raw view button title."
},
"optionsSiteWhitelistSaveRaw": {
"message": "Guardar archivo en bruto",
"description": "Save raw view edits button title."
},
"optionsSiteWhitelistAddItem": {
"message": "Añadir elemento",
"description": "Add new whitelist item button title."
},
"optionsSiteWhitelistEditItem": {
"message": "Editar",
"description": "Edit whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistRemoveItem": {
"message": "Eliminar",
"description": "Remove whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistInvalidMatchPattern": {
"message": "Patrón de coincidencia $matchPattern$ inválido",
"description": "Error displayed by input indicating an invalid match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "http://example"
}
}
},
"optionsMirroringCategoryName": {
"message": "Enviar pantalla/pestaña",
"description": "Options page mirroring category name."
},
"optionsMirroringCategoryDescription": {
"message": "Enviar pantalla a la aplicación receptora de Chromecast.",
"description": "Options page mirroring category description."
},
"optionsMirroringEnabled": {
"message": "Activar envío de pantalla/pestaña (experimental)",
"description": "Mirroring enabled checkbox label."
},
"optionsMirroringAppId": {
"message": "ID de aplicación receptora:",
"description": "Mirroring app ID input label."
},
"optionsMirroringAppIdDescription": {
"message": "ID de aplicación para una aplicación receptora de Chromecast registrada. Solo para uso avanzado. Debe ser compatible con la app predeterminada (véase el repositorio de GitHub).",
"description": "Mirroring app ID option description."
},
"optionsOptionRecommended": {
"message": "recomendado",
"description": "Badge next to option label indicating boolean option is recommended enabled.",
"hash": "cb7a5f6614a279d34d8bb57dae48bdbd"
},
"optionsReset": {
"message": "Restaurar predeterminados",
"description": "Restore default options button label."
},
"optionsSave": {
"message": "Guardar",
"description": "Save options button label."
},
"optionsSaved": {
"message": "¡Guardado!",
"description": "Status text displayed by save button once options have been successfully saved."
}
}

View File

@@ -0,0 +1,689 @@
{
"extensionDescription": {
"message": "Mengaktifkan dukungan Chromecast untuk mentransmisikan aplikasi web (seperti Netflix atau BBC iPlayer), video HTML5 dan berbagi layar.",
"description": "Description of the extension shown in the add-ons manager.",
"hash": "28e706bff5afebcbea0771d2fefce33c"
},
"actionTitleConnecting": {
"message": "fx_cast: Menghubungkan",
"description": "Title for toolbar button whilst connecting.",
"hash": "c09ff6e51063941fd2f028f7d6eb1d95"
},
"actionTitleConnected": {
"message": "fx_cast: Terhubung",
"description": "Title for toolbar button whilst connected.",
"hash": "5945eeeaf7bb83f9fba000df92446d12"
},
"popupBridgeErrorBanner": {
"message": "Ada masalah dengan bridge!",
"description": "Bridge error banner message.",
"hash": "a2fa863ad2a93b3380fc0db038e019dc"
},
"popupBridgeErrorBannerOptions": {
"message": "Informasi lebih lanjut",
"description": "Bridge error banner button label.",
"hash": "c16dea13f2e801809f1db437153dee02"
},
"popupWhitelistNotWhitelisted": {
"message": "$appName$ tidak ada di daftar putih",
"description": "Receiver selector whitelist suggestion banner label.",
"placeholders": {
"appName": { "content": "$1", "example": "Netflix" }
},
"hash": "7699bc5bc233b6cffd70552dca888941"
},
"popupWhitelistAddToWhitelist": {
"message": "Tambahkan ke daftar putih",
"description": "Receiver selector whitelist suggestion banner button label.",
"hash": "f62a2993e590b730340f5f377408b2f9"
},
"popupMediaTypeApp": {
"message": "situs ini",
"description": "Receiver selector media type <option> text for current site's sender application.",
"hash": "447054ca93cd9c437b3ff558d7fed68b"
},
"popupMediaTypeAppNotFound": {
"message": "situs ini (tidak ditemukan)",
"description": "Receiver selector media type <option> text for current site's sender application if none found.",
"hash": "c5b67276069722dbfb56c6a2b3ceb370"
},
"popupMediaTypeAppMedia": {
"message": "media ini",
"description": "Receiver selector media type <option> text for media casting.",
"hash": "a153436182910fbadae78621faa67395"
},
"popupMediaTypeScreen": {
"message": "Layar",
"description": "Receiver selector media type <option> text for screen.",
"hash": "ac9859bb6d9cb5098a9e455667f57c5c"
},
"popupMediaTypeFile": {
"message": "Telusuri...",
"description": "Receiver selector media type <option> text for opening a file selector dialog.",
"hash": "070e2a9fa21ab2abab971be668738e1e"
},
"popupMediaSelectCastLabel": {
"message": "Transmisikan",
"description": "(Cast) <select> to:",
"hash": "2697d6a1da20b8baa9c3c866983b195c"
},
"popupMediaSelectToLabel": {
"message": "ke:",
"description": "Cast <select> (to:)",
"hash": "16b67fe728a86deffba17241c6bd5eaf"
},
"popupNoReceiversFound": {
"message": "Tidak ada perangkat penerima yang ditemukan",
"description": "Message displayed in the receiver selector if there are no available receivers.",
"hash": "3ce2f57b41fe59bf2709b1d7ad6ec0e6"
},
"popupCastButtonTitle": {
"message": "Transmisikan",
"description": "Button text for each receiver entry in the receiver selector.",
"hash": "2697d6a1da20b8baa9c3c866983b195c"
},
"popupCastingButtonTitle": {
"message": "Mentransmisikan$ellipsis$",
"description": "Button text while establishing a session in the receiver selector. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": { "content": "$1", "example": "..." }
},
"hash": "3a13ea12ce352174fc7628f6ce954a53"
},
"popupStopButtonTitle": {
"message": "Berhenti",
"description": "Alternate action button text displayed instead of popupCastButtonTitle.",
"hash": "2c9e6b261d6a925c3acc4fa478be4f02"
},
"popupCastMenuTitle": {
"message": "Transmisikan ke $deviceName$",
"description": "Menu text for cast item in context menu for receivers in receiver selector.",
"placeholders": {
"deviceName": { "content": "$1", "example": "Living Room TV" }
},
"hash": "da6800a599084a9f971214faa56d3806"
},
"popupStopMenuTitle": {
"message": "Hentikan \"$appName$\" di $deviceName$",
"description": "Menu text for stop item in context menu for receivers in receiver selector.",
"placeholders": {
"appName": { "content": "$1", "example": "Netflix" },
"deviceName": { "content": "$2", "example": "Living Room TV" }
},
"hash": "34f1812668ef443ca94b8fdd59f277ad"
},
"popupShowDetailsTitle": {
"message": "Tampilkan detail",
"description": "Receiver device expand button title.",
"hash": "bca0de3c08c9efbfde9b210731d8e300"
},
"popupMediaPlay": {
"message": "Putar",
"description": "Media controls play button title.",
"hash": "e8836bb3401b7918d1f663d902728df4"
},
"popupMediaPause": {
"message": "Jeda",
"description": "Media controls pause button title.",
"hash": "7a53c125ad76bfc91d19f685b9feae50"
},
"popupMediaSkipPrevious": {
"message": "Skip sebelumnya",
"description": "Media controls skip previous button title.",
"hash": "60b2f75cad3736292b53452594f5798f"
},
"popupMediaSkipNext": {
"message": "Skip setelahnya",
"description": "Media controls skip next button title.",
"hash": "f11a30ae2d31facfbc43716c63edee0f"
},
"popupMediaSeek": {
"message": "Atur posisi",
"description": "Media controls seek bar accessibility label.",
"hash": "bc03f4d070364cec72647b465c3d3818"
},
"popupMediaSeekBackward": {
"message": "Mundurkan",
"description": "Media controls seek backward button title.",
"hash": "1b7ba493964cf798c81dc9e213636b5c"
},
"popupMediaSeekForward": {
"message": "Majukan",
"description": "Media controls seek forward button title.",
"hash": "4ea30d709af336bf20d8dcbfae6e9353"
},
"popupMediaSubtitlesCaptions": {
"message": "Subtitel/teks tertutup",
"description": "Media controls subtitles/cc button title.",
"hash": "2a7e8cc0225f48bf497518baddc235d6"
},
"popupMediaSubtitlesCaptionsOff": {
"message": "Nonaktif",
"description": "Media controls subtitles/cc button title.",
"hash": "68031547056d63979c1bacf256ac7fd5"
},
"popupMediaMute": {
"message": "Bisukan",
"description": "Media controls mute button title.",
"hash": "213eb933ad370f5dfa8098020f110bff"
},
"popupMediaUnmute": {
"message": "Bunyikan",
"description": "Media controls unmute button title.",
"hash": "7361ce0749dfbfb7d06ef2e0ca0aa1eb"
},
"popupMediaVolume": {
"message": "Bunyikan",
"description": "Media controls volume slider accessibility label.",
"hash": "7361ce0749dfbfb7d06ef2e0ca0aa1eb"
},
"popupMediaLive": {
"message": "Siaran Langsung",
"description": "Media controls label displayed for live streams.",
"hash": "3ee73aedcd12602aeb3f8746e95070aa"
},
"contextCast": {
"message": "Transmisikan...",
"description": "Main context menu item title. Ellipsis indicates additional information required as it triggers opening of receiver selector.",
"hash": "148ea8aea16251530ad77bc1d24253cb"
},
"contextAddToWhitelist": {
"message": "Tambahkan ke Daftar putih",
"description": "Top-level whitelist context menu item title.",
"hash": "f62a2993e590b730340f5f377408b2f9"
},
"contextAddToWhitelistRecommended": {
"message": "Tambahkan $matchPattern$ (disarankan)",
"description": "Context menu item title for recomended match pattern.",
"placeholders": {
"matchPattern": { "content": "$1", "example": "https://example.com/*" }
},
"hash": "395caf736ead76c1a7340850424e2c5b"
},
"contextAddToWhitelistAdvancedAdd": {
"message": "Tambahkan $matchPattern$",
"description": "Context menu item title for all other match patterns.",
"placeholders": {
"matchPattern": { "content": "$1", "example": "*://*.example.com/*" }
},
"hash": "a79d67f700965693261f2f38f97fd870"
},
"optionsBridgeLoading": {
"message": "Memuat info bridge...",
"description": "Loading placeholder text for bridge section on options page.",
"hash": "da533bfa70f4359693c6194f601faeeb"
},
"optionsBridgeFoundStatusTitle": {
"message": "Bridge ditemukan",
"description": "Bridge OK status title text.",
"hash": "e8ea7c0de859a99248eab9b0a8c56e0e"
},
"optionsBridgeIssueStatusTitle": {
"message": "Masalah bridge",
"description": "Bridge error status title text.",
"hash": "91ca475ef1fd61142c50e14159241986"
},
"optionsBridgeIssueStatusTextTimedOut": {
"message": "Waktu koneksi berakhir.",
"description": "Bridge timed out issue additional description text.",
"hash": "522040158e825389dd4afe9bee3de5ef"
},
"optionsBridgeIssueStatusTextAuthentication": {
"message": "Gagal mengautentikasi koneksi.",
"description": "Bridge authentication issue additional description text.",
"hash": "48874ba669a8985a0a09e2d702b66c86"
},
"optionsBridgeNotFoundStatusTitle": {
"message": "Bridge tidak ditemukan",
"description": "Bridge missing status title text.",
"hash": "ac23e6d80564cb2e6b4358421e498f5d"
},
"optionsBridgeNotFoundStatusText": {
"message": "Coba unduh dan instal versi terbaru.",
"description": "Bridge not found additional description text",
"hash": "a6cae425dc794e2798c50c914f060af1"
},
"optionsBridgeStatsName": {
"message": "Nama:",
"description": "Bridge stats name row title.",
"hash": "bcc9f177b0a20c395aac47bd88204419"
},
"optionsBridgeStatsVersion": {
"message": "Versi:",
"hash": "eb089a4ff5e2c6afc41bd4a81a4dade6"
},
"optionsBridgeStatsExpectedVersion": {
"message": "Versi yang diharapkan:",
"description": "Bridge stats expected version row title.",
"hash": "e8d73707f7a8e5c340b1ab52abf6793a"
},
"optionsBridgeStatsCompatibility": {
"message": "Kompatibilitas:",
"description": "Bridge stats compatibility row title.",
"hash": "834a9ddcab7c18521a58522adabffb1f"
},
"optionsBridgeStatsRecommendedAction": {
"message": "Tindakan yang direkomendasikan:",
"description": "Bridge stats recommended action row title.",
"hash": "967cea6c06bb9309f3cac58e0accdab1"
},
"optionsBridgeCompatible": {
"message": "Kompatibel",
"description": "Compatibility status is definitely compatible.",
"hash": "7976aeb05daa7e3ab6ff5ebc8d74b0f9"
},
"optionsBridgeLikelyCompatible": {
"message": "Mungkin kompatibel",
"description": "Compatibility status is probably compatible.",
"hash": "a08be1061026eeb504cdf08f4b7c864e"
},
"optionsBridgeIncompatible": {
"message": "Tidak kompatibel",
"description": "Compatibility status is definitely incompatible.",
"hash": "28453c055bb3d322bb9a6ff2ba430ea2"
},
"optionsBridgeOlderAction": {
"message": "Versi bridge lebih tua dari yang diharapkan, coba perbarui bridge ke versi terbaru.",
"description": "Recommended action for when the installed bridge version is older than the installed extension version.",
"hash": "401dc978f73e5452ae3ac0622a65fd05"
},
"optionsBridgeNewerAction": {
"message": "Versi bridge lebih baru dari yang diharapkan, coba perbarui ekstensi ke versi terbaru.",
"description": "Recommended action for when the installed bridge version is newer than the installed extension version.",
"hash": "1579f4717e682022f947d9f169ccad55"
},
"optionsBridgeNoAction": {
"message": "Tidak perlu tindakan.",
"description": "Recommended action for when both bridge and extension versions are compatible or likely compatible.",
"hash": "c5ba416a736b041c1f96cee49830e4a4"
},
"optionsBridgeRefresh": {
"message": "Perbarui status bridge",
"description": "Bridge status refresh button title.",
"hash": "8f1b5aa483d9cb3cc652e28fa6c8310b"
},
"optionsBridgeUpdateCheck": {
"message": "Periksa Pembaruan",
"description": "Update check button title.",
"hash": "f712c081e2e0ff4d83ab9b8c2cb226fa"
},
"optionsBridgeUpdateChecking": {
"message": "Periksa Pembaruan$ellipsis$",
"description": "Update check button title while in progress. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": { "content": "$1", "example": ".." }
},
"hash": "282dc18c1fe1de587133edd5d050b624"
},
"optionsBridgeUpdateStatusNoUpdates": {
"message": "Tidak ada pembaruan yang tersedia",
"description": "Update status if no updates are found.",
"hash": "93c0b86f72daf273a6066d9525aad1c1"
},
"optionsBridgeUpdateStatusError": {
"message": "Kesalahan memeriksa pembaruan",
"description": "Update status if an error was encountered checking for updates.",
"hash": "a885c6a90ca732276ab09e972d9c3232"
},
"optionsBridgeUpdateAvailable": {
"message": "Pembaruan tersedia:",
"description": "Update status if an update was found.",
"hash": "62f83435cf64c57e719fa05fb4cd39aa"
},
"optionsBridgeUpdate": {
"message": "Perbarui sekarang...",
"description": "Update now button title. Ellipsis indicates additional information as it triggers an update window popup.",
"hash": "19da82f32535564dc116b89e9b9839e8"
},
"optionsBridgeBackupEnabled": {
"message": "Aktifkan koneksi daemon cadangan di $hostPort$",
"description": "Backup daemon checkbox label. Host/port inputs are inserted inline at the hostPort substitution.",
"placeholders": {
"hostPort": { "content": "$1" }
},
"hash": "923eff866d306017aa4f5223e07cfcd5"
},
"optionsBridgeBackupEnabledDescription": {
"message": "Sambungkan ke bridge yang berjalan dalam mode daemon, jika koneksi bridge biasa gagal",
"description": "Backup daemon checkbox description.",
"hash": "0ce9582305f68542d131499559616155"
},
"optionsBridgeBackupSecure": {
"message": "Gunakan koneksi yang aman",
"description": "Daemon secure option checkbox label.",
"hash": "21678e1e5fde5b36c1f6be28e266ec1f"
},
"optionsBridgeBackupSecureDescription": {
"message": "Sambungkan ke daemon melalui HTTPS dari pada HTTP. Membutuhkan konfigurasi tambahan.",
"description": "Daemon secure option description.",
"hash": "3236dda1229cc1bf89b6dccf4344d9bc"
},
"optionsBridgeBackupPassword": {
"message": "Sandi:",
"description": "Daemon password option label.",
"hash": "783de8262aef12ba1de87a8277a68ddf"
},
"optionsBridgeBackupPasswordDescription": {
"message": "Kata sandi opsional yang dikonfigurasi untuk koneksi daemon.",
"description": "Daemon password option description.",
"hash": "851571617af8552047283f89f2e880ac"
},
"optionsMediaCategoryName": {
"message": "Transmisi media",
"description": "Options page media casting category title.",
"hash": "b68324d8f3c48698008bf4ea67a9843c"
},
"optionsMediaCategoryDescription": {
"message": "Transmisi media video/audio HTML5.",
"description": "Options page media casting category description.",
"hash": "6b1a30506d1324ab3dfb340bdca684d7"
},
"optionsMediaEnabled": {
"message": "Aktifkan transmisi media",
"description": "Media casting enabled checkbox label.",
"hash": "c9071e46ce057bdc8ac472b061ed62e9"
},
"optionsMediaSyncElement": {
"message": "Sinkronkan status penerima dengan elemen media",
"description": "Media casting sync checkbox label.",
"hash": "2b61bd9c36dc09cc4d67b62e63e8439b"
},
"optionsMediaSyncElementDescription": {
"message": "Sinkronkan status (pemutaran, volume, keterangan, dll...) antara elemen media dan perangkat penerima.",
"description": "Media casting sync option description.",
"hash": "156ea206867c07cf80bbcd8b8f955d89"
},
"optionsMediaStopOnUnload": {
"message": "Hentikan pemutaran penerima saat tidak memuat halaman",
"description": "Media stop on unload checkbox label.",
"hash": "a60732963c6bf8ba5273a977cfc53e20"
},
"optionsLocalMediaCategoryName": {
"message": "Transmisi media lokal",
"description": "Options page local media category title.",
"hash": "555b3dda3d57b31268b2fd4e2148f0a1"
},
"optionsLocalMediaCategoryDescription": {
"message": "Server HTTP dimulai oleh aplikasi bridge untuk streaming file media lokal ke penerima transmisi.",
"description": "Options page local media category description.",
"hash": "e00f4b7c8c6455740bb5aa5ca48f94ce"
},
"optionsLocalMediaEnabled": {
"message": "Aktifkan transmisi media lokal",
"description": "Local media enabled checkbox label.",
"hash": "dddd9796b68b17067a304f4560223cdc"
},
"optionsLocalMediaServerPort": {
"message": "Port server HTTP:",
"description": "HTTP server port input label.",
"hash": "adba96abd8f0ed467bb7146315dd8b90"
},
"optionsReceiverSelectorCategoryName": {
"message": "Pemilih perangkat penerima",
"description": "Options page receiver selector category title.",
"hash": "5f4dc0204ad9cf9bf062b9ddbbd48f71"
},
"optionsReceiverSelectorCategoryDescription": {
"message": "UI pemilihan perangkat penerima.",
"description": "Options page receiver selector category description.",
"hash": "b990cd04c332de84ad96f13ada59539f"
},
"optionsReceiverSelectorWaitForConnection": {
"message": "Menunggu koneksi",
"description": "Receiver selector wait for connection option checkbox label.",
"hash": "2d7e179330799e7187908f0adb832798"
},
"optionsReceiverSelectorWaitForConnectionDescription": {
"message": "Tetap buka pemilih perangkat penerima hingga sesi dibuat atau koneksi gagal.",
"description": "Receiver selector wait for connection option description.",
"hash": "a093f20a6ac41312a874b14fdba7a3f0"
},
"optionsReceiverSelectorCloseIfFocusLost": {
"message": "Tutup setelah kehilangan fokus",
"description": "Receiver selector close if focus lost option checkbox label.",
"hash": "670ee2676ef52e4b399937991458ebef"
},
"optionsReceiverSelectorExpandActive": {
"message": "Perluas kontrol media secara otomatis untuk perangkat yang terhubung",
"description": "Receiver selector expand active checkbox label.",
"hash": "6010e2f6cc8dd276cf5678ee70ae8523"
},
"optionsReceiverSelectorShowMediaImages": {
"message": "Tampilkan gambar media",
"description": "Receiver selector show media images checkbox label.",
"hash": "5451dabe85152fef98feda4591737871"
},
"optionsReceiverSelectorShowMediaImagesDescription": {
"message": "Memuat media thumbnail/gambar merk dari server jarak jauh.",
"description": "Receiver selector show media images option description.",
"hash": "9ea74badb4387d204c42be2111925344"
},
"optionsSiteWhitelistCategoryName": {
"message": "Situs daftar putih",
"description": "Options page whitelist category title.",
"hash": "b4c29508b1076fc8d70c0d0a3b9bcfc7"
},
"optionsSiteWhitelistCategoryDescription": {
"message": "Situs dimana fungsi transmisi akan diaktifkan dan string User Agent akan diganti dengan versi Chrome untuk kompatibilitas.",
"description": "Options page whitelist category description.",
"hash": "50289390d9ba15f54b7fd1538adc5067"
},
"optionsSiteWhitelistEnabled": {
"message": "Aktifkan situs daftar putih",
"description": "Whitelist enabled checkbox label.",
"hash": "e2a983568c921d33169acb2285112e3a"
},
"optionsSiteWhitelistEnabledDescription": {
"message": "Menonaktifkan opsi ini akan mengaktifkan fungsionalitas transmisi di situs mana pun, tetapi string User Agent tidak akan diganti.",
"description": "Whitelist restricted mode enabled description.",
"hash": "0793ff98663df82bb12a5b59c9744740"
},
"optionsSiteWhitelistContent": {
"message": "Pola pencocokan situs:",
"description": "Match patterns editor widget label.",
"hash": "649cd2a5977b6f2760aa4a0b7e8094c5"
},
"optionsSiteWhitelistAddItem": {
"message": "Tambahkan Situs",
"description": "Add new whitelist item button title.",
"hash": "7f3dd0607a364c07977651e77888485a"
},
"optionsSiteWhitelistEditItem": {
"message": "Ubah",
"description": "Tooltip text for whitelist item's edit button.",
"hash": "07fa34db2b1ae6134e7ea08b4c43b447"
},
"optionsSiteWhitelistRemoveItem": {
"message": "Hapus",
"description": "Tooltip text for whitelist item's remove button.",
"hash": "3908a268c854fe64242e38845dbb7c81"
},
"optionsSiteWhitelistItemShowOptions": {
"message": "Tampilkan opsi",
"description": "Tooltip text for whitelist item's show options button.",
"hash": "54b0919c482e66be5dd0af67ace666c7"
},
"optionsSiteWhitelistItemHideOptions": {
"message": "Sembunyikan opsi",
"description": "Tooltip text for whitelist item's hide options button.",
"hash": "893b6ef0fe464c26e743028c1e1ab998"
},
"optionsSiteWhitelistInvalidDuplicatePattern": {
"message": "Pola pencocokan situs sudah ada!",
"description": "Error displayed by input indicating a duplicate match pattern.",
"hash": "2df401191e509fcc99fd6340347ad0f4"
},
"optionsSiteWhitelistKnownAppsCustomApp": {
"message": "Kustom",
"description": "Default <option> for knownApps <select>.",
"hash": "1685452bc68395b620b0ce1fb9b76da5"
},
"optionsSiteWhitelistCustomUserAgentDescription": {
"message": "Jika ditentukan, string User Agent khusus yang akan digunakan untuk situs yang masuk daftar putih.",
"description": "Custom user agent option description.",
"hash": "75a715d0fdb4cee2273d3c13bf9a4bab"
},
"optionsSiteWhitelistUserAgentDisabled": {
"message": "Nonaktifkan User Agent",
"description": "Whitelist item user agent disabled checkbox label.",
"hash": "43140519e27bbe3665b09ce574dfecb7"
},
"optionsSiteWhitelistUserAgentDisabledDescription": {
"message": "Nonaktifkan sepenuhnya penggantian User Agent untuk situs yang cocok dengan pola ini.",
"description": "Whitelist item user agent disabled checkbox description.",
"hash": "acb6f7918125ad114de9cbec08a942d9"
},
"optionsSiteWhitelistSiteSpecificUserAgentDescription": {
"message": "Jika ditentukan, string User Agent khusus yang akan digunakan untuk situs yang masuk daftar putih.",
"description": "Whitelist item user agent option label.",
"hash": "253942e6e08a6e2f0e03de2380c0fcea"
},
"optionsSiteWhitelistItemEnabled": {
"message": "Aktifkan item daftar putih",
"description": "Whitelist item enabled checkbox title.",
"hash": "41d0a73fac7f0fb38e7201a3140deae1"
},
"optionsSiteWhitelistItemPattern": {
"message": "Pola pencocokan situs",
"description": "Whitelist item pattern edit input title.",
"hash": "a46b7d8eb955f4e50d345eb66b8267d8"
},
"optionsMirroringCategoryName": {
"message": "Mentransmisikan layar",
"description": "Options page mirroring category name.",
"hash": "c97bb91cfa508a7400db67093429405c"
},
"optionsMirroringCategoryDescription": {
"message": "Mentransmisikan layar ke aplikasi penerima Chromecast.",
"description": "Options page mirroring category description.",
"hash": "e6c1ef9e8b168f0d9049aacc984c22fd"
},
"optionsMirroringEnabled": {
"message": "Aktifkan transmisi layar (eksperimental)",
"description": "Mirroring enabled checkbox label.",
"hash": "69d00f1674ec817c6b3b5a0e9c4e8061"
},
"optionsMirroringAppId": {
"message": "ID aplikasi transmisi layar:",
"description": "Mirroring app ID input label.",
"hash": "3416cc2f16a3a9dd2f6dc43f3e609cb6"
},
"optionsMirroringAppIdDescription": {
"message": "ID Aplikasi untuk aplikasi penerima Chromecast yang terdaftar. Hanya penggunaan tingkat lanjut. Harus kompatibel dengan aplikasi default (lihat repo di GitHub).",
"description": "Mirroring app ID option description.",
"hash": "90e7943c57f0bcaa04ce8e55795d9d0c"
},
"optionsMirroringStreamOptions": {
"message": "Opsi enkode stream",
"description": "Options page mirroring category description.",
"hash": "150cf1291af3a86ca42168befe2a4751"
},
"optionsMirroringStreamFrameRate": {
"message": "Maks kecepatan frame:",
"description": "Mirroring stream max frame rate option label.",
"hash": "080d81431268f644afe7d241bc88ebbc"
},
"optionsMirroringStreamMaxBitRate": {
"message": "Maks kecepatan bit",
"description": "Mirroring stream max bit rate option label.",
"hash": "3259b72367ab1f152a5a5c6cf4143ea3"
},
"optionsMirroringStreamMaxBitRateDescription": {
"message": "Maksimum kecepatan bit dalam bit per detik.",
"description": "Mirroring stream max bit rate option description.",
"hash": "59df8cacd361cee2ba4c9d37a89786ce"
},
"optionsMirroringStreamDownscaleFactor": {
"message": "Faktor penurunan:",
"description": "Mirroring stream downscale factor option label.",
"hash": "8c950afc5a3249832c757a804f3c0e41"
},
"optionsMirroringStreamDownscaleFactorDescription": {
"message": "Faktor yang digunakan untuk memperkecil stream video, co. faktor 2.0 akan menghasilkan video berukuran 1/4.",
"description": "Mirroring stream downscale factor option description.",
"hash": "9135a6b22ce55ee7ecf30e7c74c836d3"
},
"optionsMirroringStreamMaxResolution": {
"message": "Batasi resolusi hingga",
"description": "Mirroring stream resolution option label.",
"hash": "574f9b6e388ec0311b18502d16ea22f7"
},
"optionsMirroringStreamMaxResolutionDescription": {
"message": "Membatasi resolusi streaming video maksimum sambil mempertahankan aspek rasio sumber.",
"description": "Mirroring stream resolution option description.",
"hash": "d9e946e544eb7c6b02bd30f3d2c618c6"
},
"optionsMirroringStreamMaxResolutionWidthPlaceholder": {
"message": "Lebar",
"description": "Max resolution width input placeholder.",
"hash": "f6322e65aa252c2bfebeef3642eda579"
},
"optionsMirroringStreamMaxResolutionHeightPlaceholder": {
"message": "Tinggi",
"description": "Max resolution height input placeholder.",
"hash": "8413a498a0ddfc0947e8ad46a2ff186f"
},
"optionsOptionRecommended": {
"message": "disarankan",
"description": "Badge next to option label indicating boolean option is recommended enabled.",
"hash": "cb7a5f6614a279d34d8bb57dae48bdbd"
},
"optionsReset": {
"message": "Kembalikan Default",
"description": "Restore default options button label.",
"hash": "368002c153ff9aeda930d97821631f0c"
},
"optionsSave": {
"message": "Simpan",
"description": "Save options button label.",
"hash": "aa14043bd4e6f79db542868539be9220"
},
"optionsSaved": {
"message": "Tersimpan!",
"description": "Status text displayed by save button once options have been successfully saved.",
"hash": "d2f31b10edbccfb9147336aa30b5bb08"
},
"optionsShowAdvancedOptions": {
"message": "Tampilkan opsi lanjutan",
"description": "Show advanced options checkbox label.",
"hash": "47f7601f490ff7de924c9167538a25b6"
},
"mirroringPopupTitle": {
"message": "Mentransmisikan Layar",
"description": "Mirroring popup window title.",
"hash": "092bd4419cff5b6b4e365f36fc4f9c50"
},
"mirroringPopupWaitingForConnection": {
"message": "Menunggu koneksi",
"description": "Mirroring popup loading text.",
"hash": "79e6d3d1169bbc652acecf34dce6b82e"
},
"mirroringPopupConnectedTo": {
"message": "Terhubung ke $deviceName$",
"description": "Mirroring popup label displayed whilst session connected before mirroring.",
"placeholders": {
"deviceName": { "content": "$1" }
},
"hash": "bd3d58015008ac8b9dcded600e05a744"
},
"mirroringPopupMirroringTo": {
"message": "Mentransmisikan Layar ke $deviceName$",
"description": "Mirroring popup label displayed whilst mirroring.",
"placeholders": {
"deviceName": { "content": "$1" }
},
"hash": "13dd028ed9d37902f92977622241d0f9"
},
"mirroringPopupChooseSource": {
"message": "Pilih Sumber",
"description": "Mirroring popup choose media source button label.",
"hash": "040e46e866e781425c429261eef824d3"
},
"mirroringPopupStopMirroring": {
"message": "Hentikan transmisi layar",
"description": "Mirroring popup stop mirroring button label.",
"hash": "fade5f575be1a34437a60036bcd96bb3"
},
"__WET_LOCALE__": { "message": "id" }
}

View File

@@ -0,0 +1,283 @@
{
"extensionDescription": {
"message": "Fornisce il supporto a web app Chromecast (come Netflix o BBC iPlayer), video HTML5 e condivisione tab/schermo. ",
"description": "Description of the extension shown in the add-ons manager."
},
"popupWhitelistNotWhitelisted": {
"message": "$appName$ non è nella Whitelist",
"description": "Receiver selector whitelist suggestion banner label.",
"placeholders": {
"appName": {
"content": "$1",
"example": "Netflix"
}
}
},
"popupWhitelistAddToWhitelist": {
"message": "Aggiungi alla Whitelist",
"description": "Receiver selector whitelist suggestion banner button label."
},
"popupMediaTypeApp": {
"message": "l'app del sito corrente",
"description": "Receiver selector media type <option> text for current site's sender application."
},
"popupMediaTypeAppNotFound": {
"message": "l'app del sito corrente (non trovato)",
"description": "Receiver selector media type <option> text for current site's sender application if none found."
},
"popupMediaTypeAppMedia": {
"message": "questo media ",
"description": "Receiver selector media type <option> text for media casting."
},
"popupMediaTypeScreen": {
"message": "Schermo",
"description": "Receiver selector media type <option> text for screen."
},
"popupMediaSelectCastLabel": {
"message": "Cast",
"description": "(Cast) <select> to:"
},
"popupMediaSelectToLabel": {
"message": "a:",
"description": "Cast <select> (to:)"
},
"popupNoReceiversFound": {
"message": "Nessun ricevitore trovato",
"description": "Message displayed in the receiver selector if there are no available receivers."
},
"popupCastButtonTitle": {
"message": "Cast",
"description": "Button text for each receiver entry in the receiver selector."
},
"popupCastMenuTitle": {
"message": "Cast a \"$deviceName$\"",
"description": "Menu text for cast item in context menu for receivers in receiver selector.",
"placeholders": {
"deviceName": {
"content": "$1",
"example": "Living Room TV"
}
}
},
"popupStopMenuTitle": {
"message": "Stop \"$appName$\" su \"$deviceName$\"",
"description": "Menu text for stop item in context menu for receivers in receiver selector.",
"placeholders": {
"appName": {
"content": "$1",
"example": "Netflix"
},
"deviceName": {
"content": "$2",
"example": "Living Room TV"
}
}
},
"contextAddToWhitelist": {
"message": "Aggiungi alla Whitelist",
"description": "Top-level whitelist context menu item title."
},
"contextAddToWhitelistRecommended": {
"message": "Aggiungi $matchPattern$ (Raccomandato)",
"description": "Context menu item title for recomended match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "https://example.com/*"
}
}
},
"contextAddToWhitelistAdvancedAdd": {
"message": "Aggiungi $matchPattern$",
"description": "Context menu item title for all other match patterns.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "*://*.example.com/*"
}
}
},
"optionsBridgeLoading": {
"message": "Caricamento info bridge...",
"description": "Loading placeholder text for bridge section on options page."
},
"optionsBridgeFoundStatusTitle": {
"message": "Bridge trovato",
"description": "Bridge OK status title text."
},
"optionsBridgeIssueStatusTitle": {
"message": "Problema con il bridge",
"description": "Bridge error status title text."
},
"optionsBridgeNotFoundStatusTitle": {
"message": "Bridge non trovato",
"description": "Bridge missing status title text."
},
"optionsBridgeNotFoundStatusText": {
"message": "Prova a scaricare e installare l'ultima versione.",
"description": "Bridge not found additional description text"
},
"optionsBridgeStatsName": {
"message": "Nome:",
"description": "Bridge stats name row title."
},
"optionsBridgeStatsVersion": {
"message": "Versione:",
"description": "Bridge stats version row title."
},
"optionsBridgeStatsCompatibility": {
"message": "Compatibilità:",
"description": "Bridge stats compatibility row title."
},
"optionsBridgeStatsRecommendedAction": {
"message": "Azione raccomandata:",
"description": "Bridge stats recommended action row title."
},
"optionsBridgeCompatible": {
"message": "Compatibile:",
"description": "Compatibility status is definitely compatible."
},
"optionsBridgeLikelyCompatible": {
"message": "Probabilmente compatibile:",
"description": "Compatibility status is probably compatible."
},
"optionsBridgeIncompatible": {
"message": "Incompatibile:",
"description": "Compatibility status is definitely incompatible."
},
"optionsBridgeOlderAction": {
"message": "Versione Bridge più vecchia di quella richiesta, prova ad aggiornare il Bridge all'ultima versione",
"description": "Recommended action for when the installed bridge version is older than the installed extension version."
},
"optionsBridgeNewerAction": {
"message": "Versione Bridge più nuova di quella richiesta, prova ad aggiornare l'estensione all'ultima versione",
"description": "Recommended action for when the installed bridge version is newer than the installed extension version."
},
"optionsBridgeNoAction": {
"message": "Nessuna azione richiesta:",
"description": "Recommended action for when both bridge and extension versions are compatible or likely compatible."
},
"optionsBridgeUpdateCheck": {
"message": "Verifica aggiornamenti",
"description": "Update check button title."
},
"optionsBridgeUpdateChecking": {
"message": "Verifica aggiornamenti in corso$ellipsis$",
"description": "Update check button title while in progress. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": ".."
}
}
},
"optionsBridgeUpdateStatusNoUpdates": {
"message": "Nessun aggiornamento disponibile",
"description": "Update status if no updates are found."
},
"optionsBridgeUpdateStatusError": {
"message": "Errore durante la ricerca degli aggiornamenti",
"description": "Update status if an error was encountered checking for updates."
},
"optionsBridgeUpdateAvailable": {
"message": "Aggiornamento disponibile:",
"description": "Update status if an update was found."
},
"optionsBridgeUpdate": {
"message": "Aggiorna ora...",
"description": "Update now button title. Ellipsis indicates additional information as it triggers an update window popup."
},
"optionsBridgeBackupEnabled": {
"message": "Abilita connessione demone di backup su $hostPort$",
"description": "Backup daemon checkbox label. Host/port inputs are inserted inline at the hostPort substitution.",
"placeholders": {
"hostPort": {
"content": "$1"
}
}
},
"optionsBridgeBackupEnabledDescription": {
"message": "Se la normale connessione al Bridge fallisce, prova a connettersi al Bridge in modalità demone",
"description": "Backup daemon checkbox description."
},
"optionsLocalMediaServerPort": {
"message": "Porta server HTTP:",
"description": "HTTP server port input label."
},
"optionsReceiverSelectorCategoryName": {
"message": "Selettore ricevitore",
"description": "Options page receiver selector category title."
},
"optionsReceiverSelectorCategoryDescription": {
"message": "Interfaccia selezione dispositivo ricevitore.",
"description": "Options page receiver selector category description."
},
"optionsReceiverSelectorWaitForConnection": {
"message": "In attesa di connessione",
"description": "Receiver selector wait for connection option checkbox label."
},
"optionsReceiverSelectorWaitForConnectionDescription": {
"message": "Mantieni il selettore del ricevitore finché la connessione non è stata stabilita o la connessione fallisce",
"description": "Receiver selector wait for connection option description."
},
"optionsReceiverSelectorCloseIfFocusLost": {
"message": "Chiudi alla perdita del focus",
"description": "Receiver selector close if focus lost option checkbox label."
},
"optionsSiteWhitelistCategoryName": {
"message": "Siti nella whitelist",
"description": "Options page whitelist category title."
},
"optionsSiteWhitelistCategoryDescription": {
"message": "I siti in cui verrà abilitata la funzionalità casting e verrà sostituita la stringa user agent con una versione per Chrome per compatibilità",
"description": "Options page whitelist category description."
},
"optionsSiteWhitelistEnabled": {
"message": "Abilita whitelist siti",
"description": "Whitelist enabled checkbox label."
},
"optionsSiteWhitelistEnabledDescription": {
"message": "Disabilitando questa opzione la funzionalità di casting verrà abilitata su qualsiasi sito, ma la string user agent non verrà sostituita.",
"description": "Whitelist restricted mode enabled description."
},
"optionsSiteWhitelistAddItem": {
"message": "Aggiungi elemento",
"description": "Add new whitelist item button title."
},
"optionsSiteWhitelistEditItem": {
"message": "Modifica",
"description": "Edit whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistRemoveItem": {
"message": "Rimuovi",
"description": "Remove whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistKnownAppsCustomApp": {
"message": "Personalizzato",
"description": "Default <option> for knownApps <select>."
},
"optionsMirroringCategoryName": {
"message": "Casting schermo/tab",
"description": "Options page mirroring category name."
},
"optionsMirroringEnabled": {
"message": "Abilita schermo/tab casting (experimental)",
"description": "Mirroring enabled checkbox label."
},
"optionsOptionRecommended": {
"message": "raccomandato",
"description": "Badge next to option label indicating boolean option is recommended enabled."
},
"optionsReset": {
"message": "Ripristina Predefiniti",
"description": "Restore default options button label."
},
"optionsSave": {
"message": "Salva",
"description": "Save options button label."
},
"optionsSaved": {
"message": "Salvato!",
"description": "Status text displayed by save button once options have been successfully saved."
}
}

View File

@@ -0,0 +1,324 @@
{
"extensionDescription": {
"message": "",
"description": "Description of the extension shown in the add-ons manager."
},
"popupMediaTypeApp": {
"message": "Deze site zijn app",
"description": "Receiver selector media type <option> text for current site's sender application."
},
"popupMediaTypeAppMedia": {
"message": "Deze media",
"description": "Receiver selector media type <option> text for media casting."
},
"popupMediaTypeScreen": {
"message": "Scherm",
"description": "Receiver selector media type <option> text for screen."
},
"popupMediaTypeFile": {
"message": "Bladeren...",
"description": "Receiver selector media type <option> text for opening a file selector dialog."
},
"popupMediaSelectCastLabel": {
"message": "Cast",
"description": "(Cast) <select> to:"
},
"popupMediaSelectToLabel": {
"message": "to:",
"description": "Cast <select> (to:)"
},
"popupNoReceiversFound": {
"message": "Geen ontvangende apparaten gevonden",
"description": "Message displayed in the receiver selector if there are no available receivers."
},
"popupCastButtonTitle": {
"message": "Casten",
"description": "Button text for each receiver entry in the receiver selector."
},
"popupCastingButtonTitle": {
"message": "Aan het casten$ellipsis$",
"description": "Button text while establishing a session in the receiver selector. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": "..."
}
}
},
"popupStopButtonTitle": {
"message": "Stop",
"description": "Alternate action button text displayed instead of popupCastButtonTitle."
},
"contextCast": {
"message": "Bezig met casten...",
"description": "Main context menu item title. Ellipsis indicates additional information required as it triggers opening of receiver selector."
},
"contextAddToWhitelist": {
"message": "Voeg aan whitelist toe",
"description": "Top-level whitelist context menu item title."
},
"contextAddToWhitelistRecommended": {
"message": "Voeg $matchPattern$ toe (Aangeraden)",
"description": "Context menu item title for recomended match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "https://example.com/*"
}
}
},
"contextAddToWhitelistAdvancedAdd": {
"message": "Voeg $matchPattern$ toe",
"description": "Context menu item title for all other match patterns.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "*://*.example.com/*"
}
}
},
"optionsBridgeLoading": {
"message": "Bezig met laden van bridge-informatie...",
"description": "Loading placeholder text for bridge section on options page."
},
"optionsBridgeFoundStatusTitle": {
"message": "Bridge aangetroffen",
"description": "Bridge OK status title text."
},
"optionsBridgeIssueStatusTitle": {
"message": "Probleem met bridge",
"description": "Bridge error status title text."
},
"optionsBridgeNotFoundStatusTitle": {
"message": "Geen bridge aangetroffen",
"description": "Bridge missing status title text."
},
"optionsBridgeNotFoundStatusText": {
"message": "Probeer de nieuwste versie te installeren.",
"description": "Bridge not found additional description text"
},
"optionsBridgeStatsName": {
"message": "Naam:",
"description": "Bridge stats name row title."
},
"optionsBridgeStatsVersion": {
"message": "Versie:",
"description": "Bridge stats version row title."
},
"optionsBridgeStatsExpectedVersion": {
"message": "Verwachte versie:",
"description": "Bridge stats expected version row title."
},
"optionsBridgeStatsCompatibility": {
"message": "Compatibiliteit:",
"description": "Bridge stats compatibility row title."
},
"optionsBridgeStatsRecommendedAction": {
"message": "Aanbevolen actie:",
"description": "Bridge stats recommended action row title."
},
"optionsBridgeCompatible": {
"message": "COMPATIBEL",
"description": "Compatibility status is definitely compatible."
},
"optionsBridgeLikelyCompatible": {
"message": "WAARSCHIJNLIJK COMPATIBEL",
"description": "Compatibility status is probably compatible."
},
"optionsBridgeIncompatible": {
"message": "INCOMPATIBEL",
"description": "Compatibility status is definitely incompatible."
},
"optionsBridgeOlderAction": {
"message": "De bridge-versie is ouder dan verwacht. Installeer de nieuwste bridge-versie.",
"description": "Recommended action for when the installed bridge version is older than the installed extension version."
},
"optionsBridgeNewerAction": {
"message": "De bridge-versie is nieuwer dan verwacht. Installeer de nieuwste versie van de extensie.",
"description": "Recommended action for when the installed bridge version is newer than the installed extension version."
},
"optionsBridgeNoAction": {
"message": "Geen actie benodigd.",
"description": "Recommended action for when both bridge and extension versions are compatible or likely compatible."
},
"optionsBridgeUpdateCheck": {
"message": "Controleer op updates",
"description": "Update check button title."
},
"optionsBridgeUpdateChecking": {
"message": "Bezig met controleren op updates$ellipsis$",
"description": "Update check button title while in progress. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": ".."
}
}
},
"optionsBridgeUpdateStatusNoUpdates": {
"message": "Geen updates beschikbaar",
"description": "Update status if no updates are found."
},
"optionsBridgeUpdateStatusError": {
"message": "Er is een fout opgetreden tijdens het controleren op updates",
"description": "Update status if an error was encountered checking for updates."
},
"optionsBridgeUpdateAvailable": {
"message": "Er is een update beschikbaar:",
"description": "Update status if an update was found."
},
"optionsBridgeUpdate": {
"message": "Nu bijwerken...",
"description": "Update now button title. Ellipsis indicates additional information as it triggers an update window popup."
},
"optionsBridgeBackupEnabled": {
"message": "Inschakelen van backup daemon verbinding",
"description": "Backup daemon checkbox label."
},
"optionsBridgeBackupEnabledDescription": {
"message": "Als de brigde verbinding regelmatig verbreekt, probeer dan de bridge in daemon mode te starten.",
"description": "Backup daemon checkbox description."
},
"optionsMediaCategoryName": {
"message": "Mediacasten",
"description": "Options page media casting category title."
},
"optionsMediaCategoryDescription": {
"message": "HTML5 video/audio media casten.",
"description": "Options page media casting category description."
},
"optionsMediaEnabled": {
"message": "Mediacasten ingeschakeld",
"description": "Media casting enabled checkbox label."
},
"optionsMediaSyncElement": {
"message": "Ontvangerstatus synchroniseren met media-element",
"description": "Media casting sync checkbox label."
},
"optionsMediaSyncElementDescription": {
"message": "Synchroniseer status (afspelen, volume, ondertiteling, etc...) tussen het media element en de ontvanger.",
"description": "Media casting sync option description."
},
"optionsMediaStopOnUnload": {
"message": "Afspelen stoppen na sluiten van pagina",
"description": "Media stop on unload checkbox label."
},
"optionsLocalMediaCategoryName": {
"message": "Lokale media casten",
"description": "Options page local media category title."
},
"optionsLocalMediaCategoryDescription": {
"message": "HTTP-server gestart door de bridge-app om lokale mediabestanden te streamen naar de ontvanger.",
"description": "Options page local media category description."
},
"optionsLocalMediaEnabled": {
"message": "Lokaal mediacasten ingeschakeld",
"description": "Local media enabled checkbox label."
},
"optionsLocalMediaServerPort": {
"message": "HTTP-serverpoort:",
"description": "HTTP server port input label."
},
"optionsReceiverSelectorCategoryName": {
"message": "Ontvanger selectie",
"description": "Options page receiver selector category title."
},
"optionsReceiverSelectorCategoryDescription": {
"message": "Ontvangstapparaat selectie interface",
"description": "Options page receiver selector category description."
},
"optionsReceiverSelectorWaitForConnection": {
"message": "Wachten op verbinding",
"description": "Receiver selector wait for connection option checkbox label."
},
"optionsReceiverSelectorWaitForConnectionDescription": {
"message": "Hou de ontvanger selectie open totdat er verbinding is gemaakt of het maken van de verbinding mislukt.",
"description": "Receiver selector wait for connection option description."
},
"optionsReceiverSelectorCloseIfFocusLost": {
"message": "Sluit na het verliezen van de focus",
"description": "Receiver selector close if focus lost option checkbox label."
},
"optionsSiteWhitelistCategoryName": {
"message": "Gebruikersagent - Whitelist",
"description": "Options page whitelist category title."
},
"optionsSiteWhitelistCategoryDescription": {
"message": "Websites waarvan de gebruikersagent omwille van compatibiliteit moet worden ingesteld op Chrome. De patronen moeten geldig zijn.",
"description": "Options page whitelist category description."
},
"optionsSiteWhitelistEnabled": {
"message": "Whitelist ingeschakeld",
"description": "Whitelist enabled checkbox label."
},
"optionsSiteWhitelistContent": {
"message": "Patronen:",
"description": "Match patterns editor widget label."
},
"optionsSiteWhitelistBasicView": {
"message": "Basisweergave",
"description": "Switch to basic view button title."
},
"optionsSiteWhitelistRawView": {
"message": "Ruwe weergave",
"description": "Switch to raw view button title."
},
"optionsSiteWhitelistSaveRaw": {
"message": "Ruwe weergave opslaan",
"description": "Save raw view edits button title."
},
"optionsSiteWhitelistAddItem": {
"message": "Voeg toe",
"description": "Add new whitelist item button title."
},
"optionsSiteWhitelistEditItem": {
"message": "Bewerken",
"description": "Edit whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistRemoveItem": {
"message": "Verwijderen",
"description": "Remove whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistInvalidMatchPattern": {
"message": "Ongeldig patroon $matchPattern$",
"description": "Error displayed by input indicating an invalid match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "http://example"
}
}
},
"optionsMirroringCategoryName": {
"message": "Beeldschermspiegeling",
"description": "Options page mirroring category name."
},
"optionsMirroringCategoryDescription": {
"message": "Beeldscherm-/tabbladspiegeling naar een Chromecast-ontvanger.",
"description": "Options page mirroring category description."
},
"optionsMirroringEnabled": {
"message": "Beeldschermspiegeling ingeschakeld",
"description": "Mirroring enabled checkbox label."
},
"optionsMirroringAppId": {
"message": "App ID van ontvanger:",
"description": "Mirroring app ID input label."
},
"optionsMirroringAppIdDescription": {
"message": "App ID for a registered Chromecast receiver application. Advanced use only. Must be compatible with the default app (see GitHub repo).",
"description": "Mirroring app ID option description."
},
"optionsReset": {
"message": "Standaardwaarden herstellen",
"description": "Restore default options button label."
},
"optionsSave": {
"message": "Opslaan",
"description": "Save options button label."
},
"optionsSaved": {
"message": "Opgeslagen!",
"description": "Status text displayed by save button once options have been successfully saved."
}
}

View File

@@ -0,0 +1,359 @@
{
"extensionDescription": {
"message": "Skrur på Chromecast for å caste apper på nettet (som Netflix eller BBC iPlayer), HTML5 video og skjerm/fane-deling",
"description": "Description of the extension shown in the add-ons manager."
},
"popupMediaTypeApp": {
"message": "denne sidens applikasjon",
"description": "Receiver selector media type <option> text for current site's sender application."
},
"popupMediaTypeAppNotFound": {
"message": "denne sidens applikasjon (ikke funnet)",
"description": "Receiver selector media type <option> text for current site's sender application if none found."
},
"popupMediaTypeAppMedia": {
"message": "dette medium",
"description": "Receiver selector media type <option> text for media casting."
},
"popupMediaTypeScreen": {
"message": "Skjerm",
"description": "Receiver selector media type <option> text for screen."
},
"popupMediaTypeFile": {
"message": "Søk...",
"description": "Receiver selector media type <option> text for opening a file selector dialog."
},
"popupMediaSelectCastLabel": {
"message": "Cast",
"description": "(Cast) <select> to:"
},
"popupMediaSelectToLabel": {
"message": "til:",
"description": "Cast <select> (to:)"
},
"popupNoReceiversFound": {
"message": "Ingen mottagere funnet",
"description": "Message displayed in the receiver selector if there are no available receivers."
},
"popupCastButtonTitle": {
"message": "Cast",
"description": "Button text for each receiver entry in the receiver selector."
},
"popupCastingButtonTitle": {
"message": "Caster$ellipsis$",
"description": "Button text while establishing a session in the receiver selector. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": "..."
}
}
},
"popupStopButtonTitle": {
"message": "Stopp",
"description": "Alternate action button text displayed instead of popupCastButtonTitle."
},
"contextCast": {
"message": "Cast...",
"description": "Main context menu item title. Ellipsis indicates additional information required as it triggers opening of receiver selector."
},
"contextAddToWhitelist": {
"message": "Legg til i Whitelist",
"description": "Top-level whitelist context menu item title."
},
"contextAddToWhitelistRecommended": {
"message": "Legg til $matchPattern$ (anbefalt)",
"description": "Context menu item title for recomended match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "https://example.com/*"
}
}
},
"contextAddToWhitelistAdvancedAdd": {
"message": "Legg til $matchPattern$",
"description": "Context menu item title for all other match patterns.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "*://*.example.com/*"
}
}
},
"optionsBridgeLoading": {
"message": "Laster bro-info",
"description": "Loading placeholder text for bridge section on options page."
},
"optionsBridgeFoundStatusTitle": {
"message": "Bro funnet",
"description": "Bridge OK status title text."
},
"optionsBridgeIssueStatusTitle": {
"message": "Problem med bro",
"description": "Bridge error status title text."
},
"optionsBridgeNotFoundStatusTitle": {
"message": "Bro ikke netfun",
"description": "Bridge missing status title text."
},
"optionsBridgeNotFoundStatusText": {
"message": "Prøv å last ned nyeste versjon",
"description": "Bridge not found additional description text"
},
"optionsBridgeStatsName": {
"message": "Navn:",
"description": "Bridge stats name row title."
},
"optionsBridgeStatsVersion": {
"message": "Versjon:",
"description": "Bridge stats version row title."
},
"optionsBridgeStatsExpectedVersion": {
"message": "Forventet versjon:",
"description": "Bridge stats expected version row title."
},
"optionsBridgeStatsCompatibility": {
"message": "Kompatibilitet:",
"description": "Bridge stats compatibility row title."
},
"optionsBridgeStatsRecommendedAction": {
"message": "Anbefalt handling:",
"description": "Bridge stats recommended action row title."
},
"optionsBridgeCompatible": {
"message": "Kompatibel",
"description": "Compatibility status is definitely compatible."
},
"optionsBridgeLikelyCompatible": {
"message": "Sannsynlig kompatibel",
"description": "Compatibility status is probably compatible."
},
"optionsBridgeIncompatible": {
"message": "Ukompatibel",
"description": "Compatibility status is definitely incompatible."
},
"optionsBridgeOlderAction": {
"message": "Broversjon er eldre enn forventet, prøv å oppgradere bro til nyeste versjon",
"description": "Recommended action for when the installed bridge version is older than the installed extension version."
},
"optionsBridgeNewerAction": {
"message": "Broversjon er nyere enn forventet, prøv å nedgradere bro til en eldre versjon",
"description": "Recommended action for when the installed bridge version is newer than the installed extension version."
},
"optionsBridgeNoAction": {
"message": "Ingen handling nødvendig.",
"description": "Recommended action for when both bridge and extension versions are compatible or likely compatible."
},
"optionsBridgeUpdateCheck": {
"message": "Sjekker etter Oppdateringer",
"description": "Update check button title."
},
"optionsBridgeUpdateChecking": {
"message": "Sjekker etter Oppdateringer$ellipsis$",
"description": "Update check button title while in progress. Ellipsis cycles (. → .. → ...) as loading indicator.",
"placeholders": {
"ellipsis": {
"content": "$1",
"example": ".."
}
}
},
"optionsBridgeUpdateStatusNoUpdates": {
"message": "Ingen oppdateringer tilgjengelig",
"description": "Update status if no updates are found."
},
"optionsBridgeUpdateStatusError": {
"message": "Klarte ikke finne oppdateringer",
"description": "Update status if an error was encountered checking for updates."
},
"optionsBridgeUpdateAvailable": {
"message": "En ny oppdatering er tilgjengelig:",
"description": "Update status if an update was found."
},
"optionsBridgeUpdate": {
"message": "Oppdater nå...",
"description": "Update now button title. Ellipsis indicates additional information as it triggers an update window popup."
},
"optionsBridgeBackupEnabled": {
"message": "Skru på backup daemon tilkobling på $hostPort$",
"description": "Backup daemon checkbox label. Host/port inputs are inserted inline at the hostPort substitution.",
"placeholders": {
"hostPort": {
"content": "$1"
}
}
},
"optionsBridgeBackupEnabledDescription": {
"message": "Hvis den vanlige bro-tilkoblingen mislykkes, prøv å koble til en bro som kjører i daemon-modus",
"description": "Backup daemon checkbox description."
},
"optionsMediaCategoryName": {
"message": "Media-casting",
"description": "Options page media casting category title."
},
"optionsMediaCategoryDescription": {
"message": "HTML5 video/lyd-casting",
"description": "Options page media casting category description."
},
"optionsMediaEnabled": {
"message": "Skru på media-casting",
"description": "Media casting enabled checkbox label."
},
"optionsMediaSyncElement": {
"message": "Synkroniser mottager med mediaelement",
"description": "Media casting sync checkbox label."
},
"optionsMediaSyncElementDescription": {
"message": "Synkroniser tilstand (playback, volum, undertekster, etc...) mellom mediaelement og mottagerdingsen.",
"description": "Media casting sync option description."
},
"optionsMediaStopOnUnload": {
"message": "Stop spilling på mottager når siden lukkes",
"description": "Media stop on unload checkbox label."
},
"optionsLocalMediaCategoryName": {
"message": "Lokal media-casting",
"description": "Options page local media category title."
},
"optionsLocalMediaCategoryDescription": {
"message": "HTTP server startet via bro-appen til å steame lokale mediafiler til mottageren",
"description": "Options page local media category description."
},
"optionsLocalMediaEnabled": {
"message": "Skru på casting av lokal media",
"description": "Local media enabled checkbox label."
},
"optionsLocalMediaServerPort": {
"message": "HTTP-server port:",
"description": "HTTP server port input label."
},
"optionsReceiverSelectorCategoryName": {
"message": "Velg mottager",
"description": "Options page receiver selector category title."
},
"optionsReceiverSelectorCategoryDescription": {
"message": "Grensesnitt for valg av mottager",
"description": "Options page receiver selector category description."
},
"optionsReceiverSelectorWaitForConnection": {
"message": "Vent på tilkobling",
"description": "Receiver selector wait for connection option checkbox label."
},
"optionsReceiverSelectorWaitForConnectionDescription": {
"message": "Hold mottagervelgeren åpen til sessjonen er etablert eller tilkoblingen mislykkes.",
"description": "Receiver selector wait for connection option description."
},
"optionsReceiverSelectorCloseIfFocusLost": {
"message": "Lukk når fokus mistes",
"description": "Receiver selector close if focus lost option checkbox label."
},
"optionsSiteWhitelistCategoryName": {
"message": "Brukeragent whitelist",
"description": "Options page whitelist category title."
},
"optionsSiteWhitelistCategoryDescription": {
"message": "Sider hvor man kan erstatte brukeragent med en Chrome-versjon for kompatibilitet. Må være et gjenkjennbart mønster.",
"description": "Options page whitelist category description."
},
"optionsSiteWhitelistEnabled": {
"message": "Skru på whitelist",
"description": "Whitelist enabled checkbox label."
},
"optionsSiteWhitelistRestrictedEnabled": {
"message": "Skru på ",
"description": "Whitelist restricted mode enabled checkbox label."
},
"optionsSiteWhitelistRestrictedEnabledDescription": {
"message": "Legg også til whitelist-begrensninger til side smo prøvde å laste cast-funksjonalitet uavhengig av nåværende brukeragent.",
"description": "Whitelist restricted mode enabled description."
},
"optionsSiteWhitelistContent": {
"message": "Match mønster",
"description": "Match patterns editor widget label."
},
"optionsSiteWhitelistBasicView": {
"message": "Standard visning",
"description": "Switch to basic view button title."
},
"optionsSiteWhitelistRawView": {
"message": "Rå visning",
"description": "Switch to raw view button title."
},
"optionsSiteWhitelistSaveRaw": {
"message": "Lagre rå",
"description": "Save raw view edits button title."
},
"optionsSiteWhitelistAddItem": {
"message": "Legg til",
"description": "Add new whitelist item button title."
},
"optionsSiteWhitelistEditItem": {
"message": "Rediger",
"description": "Edit whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistRemoveItem": {
"message": "Fjern",
"description": "Remove whitelist item button title. Displayed on each item."
},
"optionsSiteWhitelistInvalidMatchPattern": {
"message": "Ugyldig mønster $matchPattern$",
"description": "Error displayed by input indicating an invalid match pattern.",
"placeholders": {
"matchPattern": {
"content": "$1",
"example": "http://example"
}
}
},
"optionsMirroringCategoryName": {
"message": "Skjerm/fane-casting",
"description": "Options page mirroring category name."
},
"optionsMirroringCategoryDescription": {
"message": "Speiler til en Chromecast mottagerapp.",
"description": "Options page mirroring category description."
},
"optionsMirroringEnabled": {
"message": "Skru på skjerm/fane-casting (eksperimentell)",
"description": "Mirroring enabled checkbox label."
},
"optionsMirroringAppId": {
"message": "Speilingsapp-ID",
"description": "Mirroring app ID input label."
},
"optionsMirroringAppIdDescription": {
"message": "App ID for en registrert Chromecast-mottager applikasjon. Kun for viderekommende. Må være kompatibel med standard-appen (se Github repo)",
"description": "Mirroring app ID option description."
},
"optionsOptionRecommended": {
"message": "anbefalt",
"description": "Badge next to option label indicating boolean option is recommended enabled."
},
"optionsReset": {
"message": "Tilbakestill verdier",
"description": "Restore default options button label."
},
"optionsSave": {
"message": "Lagre",
"description": "Save options button label."
},
"optionsSaved": {
"message": "Lagret",
"description": "Status text displayed by save button once options have been successfully saved."
}
}

View File

@@ -0,0 +1,297 @@
import logger from "../lib/logger";
import messaging, { Port, Message } from "../messaging";
import options from "../lib/options";
import { TypedEventTarget } from "../lib/TypedEventTarget";
import type { SenderMediaMessage, SenderMessage } from "../cast/sdk/types";
import type {
ReceiverDevice,
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "../types";
const POPUP_URL = browser.runtime.getURL("ui/popup/index.html");
export interface ReceiverSelection {
device: ReceiverDevice;
mediaType: ReceiverSelectorMediaType;
}
export interface ReceiverSelectorReceiverMessage {
deviceId: string;
message: SenderMessage;
}
export interface ReceiverSelectorMediaMessage {
deviceId: string;
message: SenderMediaMessage;
}
interface ReceiverSelectorEvents {
selected: ReceiverSelection;
cancelled: void;
stop: { deviceId: string };
error: string;
close: void;
receiverMessage: ReceiverSelectorReceiverMessage;
mediaMessage: ReceiverSelectorMediaMessage;
}
/**
* Manages the receiver selector popup window and communication with the
* extension page hosted within.
*/
export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorEvents> {
/** Popup window ID. */
private windowId?: number;
/** Message port to extension page. */
private messagePort?: Port;
private messagePortDisconnected?: boolean;
private devices?: ReceiverDevice[];
private defaultMediaType?: ReceiverSelectorMediaType;
private availableMediaTypes?: ReceiverSelectorMediaType;
private wasReceiverSelected = false;
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
constructor(private isBridgeCompatible: boolean) {
super();
this.onConnect = this.onConnect.bind(this);
this.onPopupMessage = this.onPopupMessage.bind(this);
this.onWindowsRemoved = this.onWindowsRemoved.bind(this);
this.onWindowsFocusChanged = this.onWindowsFocusChanged.bind(this);
browser.windows.onRemoved.addListener(this.onWindowsRemoved);
browser.windows.onFocusChanged.addListener(this.onWindowsFocusChanged);
/**
* Handle incoming message channel connection from popup
* window script.
*/
messaging.onConnect.addListener(this.onConnect);
}
/** Is receiver selector window currently open. */
get isOpen() {
return this.windowId !== undefined;
}
/**
* Creates and opens a receiver selector window.
*/
public async open(opts: {
devices: ReceiverDevice[];
defaultMediaType: ReceiverSelectorMediaType;
availableMediaTypes: ReceiverSelectorMediaType;
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
}) {
this.appInfo = opts.appInfo;
this.pageInfo = opts.pageInfo;
// If popup already exists, close it
if (this.windowId) {
await browser.windows.remove(this.windowId);
}
this.devices = opts.devices;
this.defaultMediaType = opts.defaultMediaType;
this.availableMediaTypes = opts.availableMediaTypes;
const popupSizePosition = {
width: 400,
height: 200,
left: 100,
top: 100
};
/**
* Get current browser window and calculate relative centered
* left/top positions for the popup.
*/
const refWin = await browser.windows.getCurrent();
if (refWin.width && refWin.height && refWin.left && refWin.top) {
const centerX = refWin.left + refWin.width / 2;
const centerY = refWin.top + refWin.height / 3;
popupSizePosition.left = Math.floor(
centerX - popupSizePosition.width / 2
);
popupSizePosition.top = Math.floor(
centerY - popupSizePosition.height / 2
);
} else {
logger.log("Reference window missing positional properties.");
}
// Create popup window
const popup = await browser.windows.create({
url: POPUP_URL,
type: "popup",
...popupSizePosition
});
if (popup?.id === undefined) {
throw logger.error("Failed to create receiver selector popup.");
}
// Size/position not set correctly on creation (bug 1396881)
await browser.windows.update(popup.id, {
...popupSizePosition
});
this.windowId = popup.id;
}
/** Updates receiver devices displayed in the receiver selector. */
public update(
devices: ReceiverDevice[],
isBridgeCompatible: boolean,
connectedSessionIds: string[]
) {
this.devices = devices;
this.messagePort?.postMessage({
subject: "popup:update",
data: { devices, isBridgeCompatible, connectedSessionIds }
});
}
/** Closes the receiver selector (if open). */
public async close() {
if (this.windowId) {
await browser.windows.remove(this.windowId);
}
if (this.messagePort && !this.messagePortDisconnected) {
this.messagePort.disconnect();
}
}
/**
* Handles incoming port connection from the extension page and
* sends init data.
*/
private onConnect(port: Port) {
// Keep history state clean
browser.history.deleteUrl({ url: POPUP_URL });
if (port.name !== "popup") {
return;
}
this.messagePort?.disconnect();
this.messagePort = port;
this.messagePort.onMessage.addListener(this.onPopupMessage);
this.messagePort.onDisconnect.addListener(() => {
this.messagePortDisconnected = true;
});
if (
this.devices === undefined ||
this.defaultMediaType === undefined ||
this.availableMediaTypes === undefined
) {
this.dispatchEvent(
new CustomEvent("error", {
detail: "Popup receiver data not found."
})
);
return;
}
this.messagePort.postMessage({
subject: "popup:init",
data: {
appInfo: this.appInfo,
pageInfo: this.pageInfo
}
});
this.messagePort.postMessage({
subject: "popup:update",
data: {
devices: this.devices,
isBridgeCompatible: this.isBridgeCompatible,
defaultMediaType: this.defaultMediaType,
availableMediaTypes: this.availableMediaTypes
}
});
}
/** Handles messages from the popup extension page. */
private onPopupMessage(message: Message) {
switch (message.subject) {
case "main:receiverSelected":
this.wasReceiverSelected = true;
this.dispatchEvent(
new CustomEvent("selected", { detail: message.data })
);
break;
case "main:receiverStopped":
this.dispatchEvent(
new CustomEvent("stop", { detail: message.data })
);
break;
case "main:sendReceiverMessage":
this.dispatchEvent(
new CustomEvent("receiverMessage", { detail: message.data })
);
break;
case "main:sendMediaMessage":
this.dispatchEvent(
new CustomEvent("mediaMessage", { detail: message.data })
);
break;
}
}
/**
* Handles cancellation state where the popup window is closed
* before a receiver is selected.
*/
private onWindowsRemoved(windowId: number) {
// Only care about popup window
if (windowId !== this.windowId) {
return;
}
browser.windows.onRemoved.removeListener(this.onWindowsRemoved);
browser.windows.onFocusChanged.removeListener(
this.onWindowsFocusChanged
);
if (!this.wasReceiverSelected) {
this.dispatchEvent(new CustomEvent("cancelled"));
}
this.dispatchEvent(new CustomEvent("close"));
delete this.windowId;
}
/**
* Closes popup window if another browser window is brought into
* focus. Doesn't apply if no window is focused `WINDOW_ID_NONE`
* or if the popup window is re-focused.
*/
private async onWindowsFocusChanged(windowId: number) {
if (!this.windowId) return;
if (
windowId !== browser.windows.WINDOW_ID_NONE &&
windowId !== this.windowId
) {
if (await options.get("receiverSelectorCloseIfFocusLost")) {
browser.windows.remove(this.windowId);
}
}
}
}

View File

@@ -0,0 +1,60 @@
import logger from "../lib/logger";
import castManager from "./castManager";
const _ = browser.i18n.getMessage;
const ACTION_ICON_DEFAULT_DARK = "icons/cast-default-dark.svg";
const ACTION_ICON_DEFAULT_LIGHT = "icons/cast-default-light.svg";
const ACTION_ICON_CONNECTING_DARK = "icons/cast-connecting-dark.svg";
const ACTION_ICON_CONNECTING_LIGHT = "icons/cast-connecting-light.svg";
const ACTION_ICON_CONNECTED = "icons/cast-connected.svg";
const isDarkTheme = window.matchMedia("(prefers-color-scheme: dark)").matches;
export enum ActionState {
Default,
Connecting,
Connected
}
/** Updates action details depending on given state. */
export function updateActionState(state: ActionState, tabId?: number) {
let title: string;
let path = isDarkTheme
? ACTION_ICON_DEFAULT_LIGHT
: ACTION_ICON_DEFAULT_DARK;
switch (state) {
case ActionState.Default:
title = _("actionTitleDefault");
break;
case ActionState.Connecting:
title = _("actionTitleConnecting");
path = isDarkTheme
? ACTION_ICON_CONNECTING_LIGHT
: ACTION_ICON_CONNECTING_DARK;
break;
case ActionState.Connected:
title = _("actionTitleConnected");
path = ACTION_ICON_CONNECTED;
break;
}
browser.browserAction.setTitle({ tabId, title });
browser.browserAction.setIcon({ tabId, path });
}
export function initAction() {
logger.info("init (action)");
updateActionState(ActionState.Default);
browser.browserAction.onClicked.addListener(async tab => {
if (tab.id === undefined) {
logger.error("Tab ID not found in browser action handler.");
return;
}
castManager.triggerCast(tab.id);
});
}

View File

@@ -0,0 +1,147 @@
import logger from "../lib/logger";
import options from "../lib/options";
import bridge, { BridgeInfo } from "../lib/bridge";
import { baseConfigStorage, fetchBaseConfig } from "../lib/chromecastConfigApi";
import defaultOptions from "../defaultOptions";
import messaging from "../messaging";
import castManager from "./castManager";
import deviceManager from "./deviceManager";
import { initAction } from "./action";
import { initMenus } from "./menus";
import { initWhitelist } from "./whitelist";
const _ = browser.i18n.getMessage;
/**
* On install, set the default options before initializing the
* extension. On update, handle any unset values and set to the new
* defaults.
*/
browser.runtime.onInstalled.addListener(async details => {
switch (details.reason) {
case "install": {
// Set defaults
await options.setAll(defaultOptions);
// Extension initialization
init();
break;
}
case "update": {
// Set new defaults
await options.update(defaultOptions);
break;
}
}
});
/**
* Checks whether the bridge can be reached and is compatible with the
* current version of the extension. If not, triggers a notification
* with the appropriate info.
*/
async function notifyBridgeCompat() {
logger.info("checking for bridge...");
let info: BridgeInfo;
try {
info = await bridge.getInfo();
} catch (err) {
logger.info("... bridge issue!");
return;
}
if (info.isVersionCompatible) {
logger.info("... bridge compatible!");
} else {
logger.info("... bridge incompatible!");
const updateNotificationId = await browser.notifications.create({
type: "basic",
title: `${_("extensionName")}${_(
"optionsBridgeIssueStatusTitle"
)}`,
message: info.isVersionOlder
? _("optionsBridgeOlderAction")
: _("optionsBridgeNewerAction")
});
browser.notifications.onClicked.addListener(notificationId => {
if (notificationId !== updateNotificationId) {
return;
}
browser.tabs.create({
url: `https://github.com/hensm/fx_cast/releases/tag/v${info.expectedVersion}`
});
});
}
}
/**
* Updates locally-stored base config data if never downloaded or since
* expired.
*/
async function cacheBaseConfig() {
const { baseConfigUpdated } = await baseConfigStorage.get(
"baseConfigUpdated"
);
// If never updated or updated more than 48 hours ago
if (
!baseConfigUpdated ||
(Date.now() - baseConfigUpdated) / 1000 >= 172800
) {
logger.info("Fetching updated Chromecast base config...");
const baseConfig = await fetchBaseConfig();
if (baseConfig) {
await baseConfigStorage.set({
baseConfig,
baseConfigUpdated: Date.now()
});
}
}
}
let isInitialized = false;
async function init() {
if (isInitialized) {
return;
}
/**
* If options haven't been set yet, we can't properly initialize,
* so wait until init is called again in the onInstalled listener.
*/
if (!(await options.getAll())) {
return;
}
logger.info("init");
isInitialized = true;
await notifyBridgeCompat();
await deviceManager.init();
await castManager.init();
await initAction();
await initMenus();
await initWhitelist();
messaging.onMessage.addListener(message => {
switch (message.subject) {
case "main:refreshDeviceManager":
deviceManager.refresh();
break;
}
});
}
cacheBaseConfig();
init();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
import bridge, { BridgeInfo } from "../lib/bridge";
import logger from "../lib/logger";
import { TypedEventTarget } from "../lib/TypedEventTarget";
import type { Message, Port } from "../messaging";
import type { ReceiverDevice } from "../types";
import type {
MediaStatus,
ReceiverStatus,
SenderMediaMessage,
SenderMessage
} from "../cast/sdk/types";
import { PlayerState } from "../cast/sdk/media/enums";
import { ActionState, updateActionState } from "./action";
interface EventMap {
deviceUp: { deviceInfo: ReceiverDevice };
deviceDown: { deviceId: string };
deviceUpdated: { deviceId: string; status: ReceiverStatus };
deviceMediaUpdated: { deviceId: string; status: MediaStatus };
applicationFound: { deviceId: string; appId: string };
applicationClosed: { deviceId: string; appId: string; sessionId: string };
}
export default new (class extends TypedEventTarget<EventMap> {
/**
* Map of receiver device IDs to devices. Updated as receiverDevice
* messages are received from the bridge.
*/
private receiverDevices = new Map<string, ReceiverDevice>();
private bridgePort?: Port;
private bridgeInfo?: BridgeInfo;
async init() {
if (!this.bridgePort) {
await this.refresh();
}
}
/**
* Initializes (or re-initializes) a bridge connection to start
* dispatching events.
*/
async refresh() {
this.bridgePort?.disconnect();
try {
this.bridgeInfo = await bridge.getInfo();
// eslint-disable-next-line no-empty
} catch {}
if (this.bridgeInfo?.isVersionCompatible) {
this.bridgePort = await bridge.connect();
this.bridgePort.onMessage.addListener(this.onBridgeMessage);
this.bridgePort.onDisconnect.addListener(this.onBridgeDisconnect);
this.bridgePort.postMessage({
subject: "bridge:startDiscovery",
data: {
// Also send back status messages
shouldWatchStatus: true
}
});
}
}
getBridgeInfo() {
return this.bridgeInfo;
}
/** Gets a list of receiver devices. */
getDevices() {
return Array.from(this.receiverDevices.values());
}
/** Gets a device by ID. */
getDeviceById(deviceId: string) {
return this.receiverDevices.get(deviceId);
}
/** Sends an NS_RECEIVER message to a given device. */
sendReceiverMessage(deviceId: string, message: SenderMessage) {
if (!this.bridgePort) {
logger.error(
"Failed to send receiver message (no bridge connection)"
);
return;
}
const device = this.receiverDevices.get(deviceId);
if (!device) {
logger.error(
"Failed to send receiver message (could not find device)"
);
return;
}
this.bridgePort?.postMessage({
subject: "bridge:sendReceiverMessage",
data: { deviceId, message }
});
}
/** Sends an NS_MEDIA message to a given device. */
sendMediaMessage(deviceId: string, message: SenderMediaMessage) {
if (!this.bridgePort) {
logger.error("Failed to send media message (no bridge connection)");
return;
}
const device = this.receiverDevices.get(deviceId);
if (!device) {
logger.error(
"Failed to send media message (could not find device)"
);
return;
}
this.bridgePort?.postMessage({
subject: "bridge:sendMediaMessage",
data: { deviceId, message }
});
}
private onBridgeMessage = (message: Message) => {
switch (message.subject) {
case "main:deviceUp": {
const { deviceId, deviceInfo } = message.data;
this.receiverDevices.set(deviceId, deviceInfo);
// Sort devices by friendly name
this.receiverDevices = new Map(
[...this.receiverDevices].sort(([, deviceA], [, deviceB]) =>
deviceA.friendlyName.localeCompare(deviceB.friendlyName)
)
);
this.dispatchEvent(
new CustomEvent("deviceUp", {
detail: { deviceInfo }
})
);
break;
}
case "main:deviceDown": {
const { deviceId } = message.data;
if (this.receiverDevices.has(deviceId)) {
this.receiverDevices.delete(deviceId);
}
this.dispatchEvent(
new CustomEvent("deviceDown", {
detail: { deviceId: deviceId }
})
);
break;
}
case "main:receiverDeviceStatusUpdated": {
const { deviceId, status } = message.data;
const device = this.receiverDevices.get(deviceId);
if (!device) break;
const oldApplication = device.status?.applications?.[0];
// Clear media status when app status changes
const application = status.applications?.[0];
if (!application || application.isIdleScreen) {
delete device.mediaStatus;
// Send application closed event
if (oldApplication && !oldApplication.isIdleScreen) {
this.dispatchEvent(
new CustomEvent("applicationClosed", {
detail: {
deviceId,
appId: oldApplication.appId,
sessionId: oldApplication.transportId
}
})
);
}
}
device.status = status;
this.dispatchEvent(
new CustomEvent("deviceUpdated", {
detail: {
deviceId,
status: device.status
}
})
);
// Send new application found event
if (
!oldApplication &&
application &&
!application.isIdleScreen
) {
this.dispatchEvent(
new CustomEvent("applicationFound", {
detail: { deviceId, appId: application.appId }
})
);
}
break;
}
case "main:receiverDeviceMediaStatusUpdated": {
const { deviceId, status } = message.data;
const device = this.receiverDevices.get(deviceId);
if (!device) break;
if (device.mediaStatus) {
device.mediaStatus = { ...device.mediaStatus, ...status };
if (status.playerState === PlayerState.IDLE) {
delete device.mediaStatus.media;
}
} else {
device.mediaStatus = status;
}
this.dispatchEvent(
new CustomEvent("deviceMediaUpdated", {
detail: {
deviceId,
status: device.mediaStatus
}
})
);
break;
}
}
};
private onBridgeDisconnect = () => {
const deviceIds = [...this.receiverDevices.keys()];
delete this.bridgeInfo;
this.receiverDevices.clear();
// Notify listeners of device availablility
for (const deviceId of deviceIds) {
const event = new CustomEvent("deviceDown", {
detail: { deviceId }
});
this.dispatchEvent(event);
}
/**
* Reconnect 10 seconds after disconnect if not already
* reconnected (like immediately after a refresh).
*/
window.setTimeout(() => {
if (!this.bridgeInfo) {
this.refresh();
}
}, 10000);
};
})();

View File

@@ -0,0 +1,347 @@
import logger from "../lib/logger";
import options from "../lib/options";
import { stringify } from "../lib/utils";
import * as menuIds from "../menuIds";
import castManager from "./castManager";
const _ = browser.i18n.getMessage;
const URL_PATTERN_HTTP = "http://*/*";
const URL_PATTERN_HTTPS = "https://*/*";
const URL_PATTERN_FILE = "file://*/*";
const URL_PATTERNS_REMOTE = [URL_PATTERN_HTTP, URL_PATTERN_HTTPS];
const URL_PATTERNS_ALL = [...URL_PATTERNS_REMOTE, URL_PATTERN_FILE];
type MenuId = string | number;
let menuIdCast: MenuId;
let menuIdCastMedia: MenuId;
let menuIdWhitelist: MenuId;
let menuIdWhitelistRecommended: MenuId;
/** Match patterns for the whitelist option menus. */
const whitelistChildMenuPatterns = new Map<MenuId, string>();
/** Handles initial menu setup. */
export async function initMenus() {
logger.info("init (menus)");
const opts = await options.getAll();
// Global "Cast..." menu item
menuIdCast = browser.menus.create({
contexts: ["browser_action", "page", "tools_menu"],
title: _("contextCast"),
documentUrlPatterns: ["http://*/*", "https://*/*"],
icons: { "16": "icons/icon.svg" } // browser_action context
});
// <video>/<audio> "Cast..." context menu item
menuIdCastMedia = browser.menus.create({
contexts: ["audio", "video", "image"],
title: _("contextCast"),
visible: opts.mediaEnabled,
targetUrlPatterns: opts.localMediaEnabled
? URL_PATTERNS_ALL
: URL_PATTERNS_REMOTE
});
menuIdWhitelist = browser.menus.create({
contexts: ["browser_action"],
title: _("contextAddToWhitelist"),
enabled: false
});
menuIdWhitelistRecommended = browser.menus.create({
title: _("contextAddToWhitelistRecommended"),
parentId: menuIdWhitelist
});
browser.menus.create({
type: "separator",
parentId: menuIdWhitelist
});
// Popup context menus
const createPopupMenu = (props: browser.menus._CreateCreateProperties) =>
browser.menus.create({
visible: false,
documentUrlPatterns: [`${browser.runtime.getURL("ui/popup")}/*`],
...props
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_PLAY_PAUSE,
title: _("popupMediaPlay")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_MUTE,
type: "checkbox",
title: _("popupMediaMute")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_SKIP_PREVIOUS,
title: _("popupMediaSkipPrevious")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_SKIP_NEXT,
title: _("popupMediaSkipNext")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_CC,
title: _("popupMediaSubtitlesCaptions")
});
createPopupMenu({
id: menuIds.POPUP_MEDIA_CC_OFF,
parentId: menuIds.POPUP_MEDIA_CC,
type: "radio",
title: _("popupMediaSubtitlesCaptionsOff")
});
createPopupMenu({ id: menuIds.POPUP_MEDIA_SEPARATOR, type: "separator" });
createPopupMenu({
id: menuIds.POPUP_CAST,
title: _("popupCastButtonTitle"),
icons: { 16: "icons/icon.svg" }
});
createPopupMenu({
id: menuIds.POPUP_STOP,
title: _("popupStopButtonTitle")
});
browser.menus.onShown.addListener(onMenuShown);
browser.menus.onClicked.addListener(onMenuClicked);
options.addEventListener("changed", async ev => {
const alteredOpts = ev.detail;
const newOpts = await options.getAll();
if (menuIdCastMedia && alteredOpts.includes("mediaEnabled")) {
browser.menus.update(menuIdCastMedia, {
visible: newOpts.mediaEnabled
});
}
if (menuIdCastMedia && alteredOpts.includes("localMediaEnabled")) {
browser.menus.update(menuIdCastMedia, {
targetUrlPatterns: newOpts.localMediaEnabled
? URL_PATTERNS_ALL
: URL_PATTERNS_REMOTE
});
}
});
}
/** Handle updating menus when shown. */
async function onMenuShown(info: browser.menus._OnShownInfo) {
const menuIds = info.menuIds as unknown as number[];
// Only rebuild menus if whitelist menu present
if (menuIds.includes(menuIdWhitelist as number)) {
updateWhitelistMenu(info.pageUrl);
return;
}
}
/** Handle menu click events */
async function onMenuClicked(
info: browser.menus.OnClickData,
tab?: browser.tabs.Tab
) {
// Handle whitelist menus
if (info.parentMenuItemId === menuIdWhitelist) {
const pattern = whitelistChildMenuPatterns.get(info.menuItemId);
if (!pattern) {
throw logger.error(
`Whitelist pattern not found for menu item ID ${info.menuItemId}.`
);
}
const whitelist = await options.get("siteWhitelist");
if (!whitelist.find(item => item.pattern === pattern)) {
// Add to whitelist and update options
whitelist.push({ pattern, isEnabled: true });
await options.set("siteWhitelist", whitelist);
}
return;
}
if (tab?.id === undefined) {
logger.error("Menu handler tab ID not found.");
return;
}
switch (info.menuItemId) {
case menuIdCast: {
castManager.triggerCast(tab.id, info.frameId);
break;
}
case menuIdCastMedia:
if (info.srcUrl) {
await browser.tabs.executeScript(tab.id, {
code: stringify`
window.mediaUrl = ${info.srcUrl};
window.targetElementId = ${info.targetElementId};
`,
frameId: info.frameId
});
await browser.tabs.executeScript(tab.id, {
file: "cast/senders/media.js",
frameId: info.frameId
});
}
break;
}
}
/** Handles updating the whitelist menus for a given URL */
async function updateWhitelistMenu(pageUrl?: string) {
/**
* If page URL doesn't exist, we're not on a page and have nothing
* to whitelist, so disable the menu and return.
*/
if (!pageUrl) {
browser.menus.update(menuIdWhitelist, {
enabled: false
});
browser.menus.refresh();
return;
}
const url = new URL(pageUrl);
const urlHasOrigin = url.origin !== "null";
/**
* If the page URL doesn't have an origin, we're not on a
* remote page and have nothing to whitelist, so disable the
* menu and return.
*/
if (!urlHasOrigin) {
browser.menus.update(menuIdWhitelist, {
enabled: false
});
browser.menus.refresh();
return;
}
// Enable the whitelist menu
browser.menus.update(menuIdWhitelist, {
enabled: true
});
for (const [menuId] of whitelistChildMenuPatterns) {
// Clear all page-specific temporary menus
if (menuId !== menuIdWhitelistRecommended) {
browser.menus.remove(menuId);
}
whitelistChildMenuPatterns.delete(menuId);
}
// If there is more than one subdomain, get the base domain
const baseDomain =
(url.hostname.match(/\./g) || []).length > 1
? url.hostname.substring(url.hostname.indexOf(".") + 1)
: url.hostname;
const portlessOrigin = `${url.protocol}//${url.hostname}`;
const patternRecommended = `${portlessOrigin}/*`;
const patternSearch = `${portlessOrigin}${url.pathname}${url.search}`;
const patternWildcardProtocol = `*://${url.hostname}/*`;
const patternWildcardSubdomain = `${url.protocol}//*.${baseDomain}/*`;
const patternWildcardProtocolAndSubdomain = `*://*.${baseDomain}/*`;
// Update recommended menu item
browser.menus.update(menuIdWhitelistRecommended, {
title: _("contextAddToWhitelistRecommended", patternRecommended)
});
whitelistChildMenuPatterns.set(
menuIdWhitelistRecommended,
patternRecommended
);
if (url.search) {
const whitelistSearchMenuId = browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", patternSearch),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(whitelistSearchMenuId, patternSearch);
}
/**
* Split URL path into segments and add menu items for each
* partial path as the segments are removed.
*/
{
const pathTrimmed = url.pathname.endsWith("/")
? url.pathname.substring(0, url.pathname.length - 1)
: url.pathname;
const pathSegments = pathTrimmed
.split("/")
.filter(segment => segment)
.reverse();
if (pathSegments.length) {
for (let i = 0; i < pathSegments.length; i++) {
const partialPath = pathSegments.slice(i).reverse().join("/");
const pattern = `${portlessOrigin}/${partialPath}/*`;
const partialPathMenuId = browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", pattern),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(partialPathMenuId, pattern);
}
}
}
const wildcardProtocolMenuId = browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", patternWildcardProtocol),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardProtocolMenuId,
patternWildcardProtocol
);
const wildcardSubdomainMenuId = browser.menus.create({
title: _("contextAddToWhitelistAdvancedAdd", patternWildcardSubdomain),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardSubdomainMenuId,
patternWildcardSubdomain
);
const wildcardProtocolAndSubdomainMenuId = browser.menus.create({
title: _(
"contextAddToWhitelistAdvancedAdd",
patternWildcardProtocolAndSubdomain
),
parentId: menuIdWhitelist
});
whitelistChildMenuPatterns.set(
wildcardProtocolAndSubdomainMenuId,
patternWildcardProtocolAndSubdomain
);
await browser.menus.refresh();
}

View File

@@ -0,0 +1,268 @@
import logger from "../lib/logger";
import options from "../lib/options";
import { getChromeUserAgent } from "../lib/userAgents";
import { RemoteMatchPattern } from "../lib/matchPattern";
import {
CAST_FRAMEWORK_LOADER_SCRIPT_URL,
CAST_LOADER_SCRIPT_URL
} from "../cast/urls";
// Missing on @types/firefox-webext-browser
type OnBeforeSendHeadersDetails = Parameters<
Parameters<typeof browser.webRequest.onBeforeSendHeaders.addListener>[0]
>[0] & {
frameAncestors?: Array<{ url: string; frameId: number }>;
};
type OnBeforeRequestDetails = Parameters<
Parameters<typeof browser.webRequest.onBeforeRequest.addListener>[0]
>[0] & {
frameAncestors?: Array<{ url: string; frameId: number }>;
};
export interface WhitelistItemData {
pattern: string;
isEnabled: boolean;
isUserAgentDisabled?: boolean;
customUserAgent?: string;
}
let matchPatterns: RemoteMatchPattern[] = [];
let platform: string;
let chromeUserAgent: string | undefined;
let chromeUserAgentHybrid: string | undefined;
let siteWhitelistEnabled = false;
let siteWhitelist: Nullable<WhitelistItemData[]> = null;
let customUserAgent: string | undefined;
export async function initWhitelist() {
logger.info("init (whitelist)");
if (!platform) {
// TODO: Allow hybrid UA to be configurable
platform = (await browser.runtime.getPlatformInfo()).os;
chromeUserAgent = getChromeUserAgent(platform);
chromeUserAgentHybrid = getChromeUserAgent(platform, true);
if (!chromeUserAgent) {
throw logger.error("Failed to get Chrome UA string");
}
customUserAgent = await options.get("siteWhitelistCustomUserAgent");
}
// Register on first run
await registerSiteWhitelist();
options.addEventListener("changed", async ev => {
// Update custom UA on change
if (ev.detail.includes("siteWhitelistCustomUserAgent")) {
customUserAgent = await options.get("siteWhitelistCustomUserAgent");
}
// Re-register whitelist on change
if (
ev.detail.includes("siteWhitelist") ||
ev.detail.includes("siteWhitelistEnabled")
) {
unregisterSiteWhitelist();
registerSiteWhitelist();
}
});
}
/**
* Returns the configured user agent matching the specified URL or
* undefined if the user agent is disabled.
*/
function getUserAgent(url: string, host?: string): Optional<string> {
if (!siteWhitelistEnabled || !siteWhitelist) return;
// Search site-specific user agents
const matchingItem = siteWhitelist.find(
item =>
item.customUserAgent &&
new RemoteMatchPattern(item.pattern).matches(url)
);
if (matchingItem) {
if (!matchingItem.isEnabled || matchingItem.isUserAgentDisabled) return;
return matchingItem.customUserAgent;
}
return (
customUserAgent ||
(host === "www.youtube.com" ? chromeUserAgentHybrid : chromeUserAgent)
);
}
/**
* Override User-Agent header for requests to whitelisted URLs. Sites
* with Chromecast support will usually only load the Cast SDK if they
* detect a Chrome user agent string.
*/
async function onWhitelistedBeforeSendHeaders(
details: OnBeforeSendHeadersDetails
) {
if (!details.requestHeaders) {
throw logger.error(
"OnBeforeSendHeaders handler details missing requestHeaders."
);
}
const host = details.requestHeaders.find(header => header.name === "Host");
for (const header of details.requestHeaders) {
if (header.name === "User-Agent") {
header.value = getUserAgent(details.url, host?.value);
break;
}
}
return {
requestHeaders: details.requestHeaders
};
}
/**
* Override User-Agent header for requests from child frames of
* whitelisted URLs to support embedded players on other origins (e.g.
* CDN domains).
*/
function onWhitelistedChildBeforeSendHeaders(
details: OnBeforeSendHeadersDetails
) {
if (!details.requestHeaders || !details.frameAncestors) {
return;
}
for (const ancestor of details.frameAncestors) {
// If no matching patterns
if (!matchPatterns.some(pattern => pattern.matches(ancestor.url))) {
continue;
}
// Override User-Agent header
const requestHeaders = details.requestHeaders;
for (const header of requestHeaders) {
if (header.name === "User-Agent") {
const host = requestHeaders.find(
header => header.name === "Host"
);
header.value = getUserAgent(details.url, host?.value);
break;
}
}
return { requestHeaders };
}
}
/**
* Handle requests to cast_sender.js SDK loader script and redirect to
* the extension's implementation.
*/
async function onBeforeCastSDKRequest(details: OnBeforeRequestDetails) {
if (!details.originUrl || details.tabId === -1) {
return {};
}
// Test against whitelist if enabled
if (await options.get("siteWhitelistEnabled")) {
/**
* Frame ancestor URLs (if present) or origin URL that the SDK
* is loaded from.
*/
const urls = [
...(details.frameAncestors?.map(ancestor => ancestor.url) ?? []),
details.originUrl
];
// Allow request if no whitelist matches
if (
!urls.some(url =>
matchPatterns.some(pattern => pattern.matches(url))
)
) {
return {};
}
}
await browser.tabs.executeScript(details.tabId, {
code: `
window.isFramework = ${
details.url === CAST_FRAMEWORK_LOADER_SCRIPT_URL
};
`,
frameId: details.frameId,
runAt: "document_start"
});
await browser.tabs.executeScript(details.tabId, {
file: "cast/contentBridge.js",
frameId: details.frameId,
runAt: "document_start"
});
return {
redirectUrl: browser.runtime.getURL("cast/content.js")
};
}
async function registerSiteWhitelist() {
const opts = await options.getAll();
siteWhitelist = opts.siteWhitelist;
siteWhitelistEnabled = opts.siteWhitelistEnabled;
// Parse match patterns once
matchPatterns = siteWhitelist.map(
item => new RemoteMatchPattern(item.pattern)
);
browser.webRequest.onBeforeRequest.addListener(
onBeforeCastSDKRequest,
{ urls: [CAST_LOADER_SCRIPT_URL, CAST_FRAMEWORK_LOADER_SCRIPT_URL] },
["blocking"]
);
// Skip whitelist request listeners if disabled or empty
if (!siteWhitelistEnabled || !siteWhitelist.length) {
return;
}
browser.webRequest.onBeforeSendHeaders.addListener(
onWhitelistedBeforeSendHeaders,
{
// Filter for items with UA enabled
urls: siteWhitelist.flatMap(item =>
item.isEnabled && !item.isUserAgentDisabled
? [item.pattern]
: []
)
},
["blocking", "requestHeaders"]
);
browser.webRequest.onBeforeSendHeaders.addListener(
onWhitelistedChildBeforeSendHeaders,
{ urls: ["<all_urls>"] },
["blocking", "requestHeaders"]
);
browser.contentScripts.register({
matches: siteWhitelist.map(item => item.pattern),
js: [{ file: "cast/contentInitial.js" }],
runAt: "document_start",
allFrames: true
});
}
function unregisterSiteWhitelist() {
browser.webRequest.onBeforeSendHeaders.removeListener(
onWhitelistedBeforeSendHeaders
);
browser.webRequest.onBeforeSendHeaders.removeListener(
onWhitelistedChildBeforeSendHeaders
);
browser.webRequest.onBeforeRequest.removeListener(onBeforeCastSDKRequest);
}

View File

@@ -0,0 +1,48 @@
/**
* Cast Sender SDK page script loaded in place of remote cast_sender
* script. Handles API object creation and initializes sender apps.
*/
import logger from "../lib/logger";
import { loadScript } from "../lib/utils";
import pageMessaging from "./pageMessaging";
import CastSDK from "./sdk";
import { CAST_FRAMEWORK_SCRIPT_URL } from "./urls";
// Create page-accessible API object
window.chrome.cast = new CastSDK();
let frameworkScriptPromise: Promise<HTMLScriptElement> | undefined;
// Load remote CAF script if requested in script URL params.
if (document.currentScript) {
const currentScript = document.currentScript as HTMLScriptElement;
const currentScriptParams = new URLSearchParams(
new URL(currentScript.src).search
);
if (currentScriptParams.get("loadCastFramework") === "1") {
frameworkScriptPromise = loadScript(CAST_FRAMEWORK_SCRIPT_URL);
frameworkScriptPromise.catch(() => {
logger.error("Failed to load CAF script!");
});
}
}
pageMessaging.page.addListener(async message => {
switch (message.subject) {
case "cast:instanceCreated": {
// If framework API is loading, wait until completed
await frameworkScriptPromise;
// Call page script/framework API script's init function
const initFn = window.__onGCastApiAvailable;
if (initFn && typeof initFn === "function") {
initFn(message.data.isAvailable);
}
break;
}
}
});

View File

@@ -0,0 +1,19 @@
import messaging, { Message } from "../messaging";
import pageMessaging from "./pageMessaging";
// Message port to cast manager in background script
const managerPort = messaging.connect({ name: "cast" });
const forwardToPage = (message: Message) => {
pageMessaging.extension.sendMessage(message);
};
const forwardToMain = (message: Message) => {
managerPort.postMessage(message);
};
managerPort.onMessage.addListener(forwardToPage);
pageMessaging.extension.addListener(forwardToMain);
managerPort.onDisconnect.addListener(() => {
pageMessaging.extension.close();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,367 @@
import { Logger } from "../../lib/logger";
import options from "../../lib/options";
import type { Message } from "../../messaging";
// Cast types
import { AutoJoinPolicy, ReceiverAvailability } from "../sdk/enums";
import type Session from "../sdk/Session";
import type Media from "../sdk/media/Media";
import cast, { ensureInit, CastPort } from "../export";
const logger = new Logger("fx_cast [media sender]");
interface MediaSenderOpts {
mediaUrl: string;
contextTabId?: number;
mediaElement?: HTMLMediaElement;
}
export default class MediaSender {
private port?: CastPort;
private mediaUrl: string;
private contextTabId?: number;
/** Target media element if loaded as a content script. */
private mediaElement?: HTMLMediaElement;
private isLocalMedia = false;
private isLocalMediaEnabled = false;
private wasSessionRequested = false;
// Cast API objects
private session?: Session;
private media?: Media;
constructor(opts: MediaSenderOpts) {
this.mediaUrl = opts.mediaUrl;
this.contextTabId = opts.contextTabId;
this.mediaElement = opts.mediaElement;
this.init();
}
stop() {
this.port?.postMessage({ subject: "bridge:stopMediaServer" });
this.session?.stop();
}
private async init() {
try {
this.port = await ensureInit({ contextTabId: this.contextTabId });
} catch (err) {
logger.error("Failed to initialize cast API", err);
}
window.addEventListener("beforeunload", async () => {
if (await options.get("mediaStopOnUnload")) {
this.port?.postMessage({
subject: "bridge:stopMediaServer"
});
this.session?.stop();
}
});
this.isLocalMedia = this.mediaUrl.startsWith("file://");
this.isLocalMediaEnabled = await options.get("localMediaEnabled");
if (this.isLocalMedia && !this.isLocalMediaEnabled) {
throw logger.error("Local media casting not enabled");
}
const capabilities = [cast.Capability.AUDIO_OUT];
if (
this.mediaElement instanceof HTMLVideoElement ||
this.mediaElement instanceof HTMLImageElement
) {
capabilities.push(cast.Capability.VIDEO_OUT);
}
cast.initialize(
new cast.ApiConfig(
new cast.SessionRequest(
cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
capabilities
),
this.sessionListener.bind(this),
this.receiverListener.bind(this),
AutoJoinPolicy.PAGE_SCOPED
),
undefined,
err => {
logger.error("Failed to initialize cast SDK", err);
}
);
}
private sessionListener() {
// Unused
}
private receiverListener(availability: ReceiverAvailability) {
if (this.wasSessionRequested) return;
this.wasSessionRequested = false;
if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession(
session => {
this.session = session;
this.loadMedia();
},
err => {
logger.error("Session request failed", err);
}
);
}
}
private async loadMedia() {
let mediaUrl = new URL(this.mediaUrl);
const mediaTitle = mediaUrl.pathname.slice(1);
const subtitleUrls: URL[] = [];
if (this.isLocalMedia) {
const port = await options.get("localMediaServerPort");
try {
const { localAddress, mediaPath, subtitlePaths } =
await this.startMediaServer(mediaTitle, port);
const baseUrl = new URL(`http://${localAddress}:${port}/`);
mediaUrl = new URL(mediaPath, baseUrl);
subtitleUrls.push(
...subtitlePaths.map(path => new URL(path, baseUrl))
);
} catch (err) {
throw logger.error("Failed to start media server", err);
}
}
const mediaInfo = new cast.media.MediaInfo(mediaUrl.href, "");
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
mediaInfo.metadata.title = mediaTitle;
mediaInfo.tracks = [];
const activeTrackIds: number[] = [];
let trackIndex = 0;
for (const url of subtitleUrls) {
const track = new cast.media.Track(
trackIndex++,
cast.media.TrackType.TEXT
);
track.name = url.pathname;
track.trackContentId = url.href;
track.trackContentType = "text/vtt";
track.subtype = cast.media.TextTrackType.SUBTITLES;
mediaInfo.tracks.push(track);
}
if (this.mediaElement instanceof HTMLMediaElement) {
if (this.mediaElement instanceof HTMLVideoElement) {
if (this.mediaElement.poster) {
mediaInfo.metadata.images = [
new cast.Image(this.mediaElement.poster)
];
}
}
if (this.mediaElement.textTracks.length) {
const textTracks = Array.from(this.mediaElement.textTracks);
const trackElements =
this.mediaElement.querySelectorAll("track");
let mediaTrackIndex = mediaInfo.tracks.length;
textTracks.forEach((track, index) => {
const trackElement = trackElements[index];
/**
* Create media.Track object with the index as the track ID
* and type as TrackType.TEXT.
*/
const castTrack = new cast.media.Track(
mediaTrackIndex,
cast.media.TrackType.TEXT
);
// Copy TextTrack properties
castTrack.name = track.label || `track-${mediaTrackIndex}`;
castTrack.language = track.language;
castTrack.trackContentId = trackElement.src;
castTrack.trackContentType = "text/vtt";
switch (track.kind) {
case "subtitles":
castTrack.subtype =
cast.media.TextTrackType.SUBTITLES;
break;
case "captions":
castTrack.subtype =
cast.media.TextTrackType.CAPTIONS;
break;
case "descriptions":
castTrack.subtype =
cast.media.TextTrackType.DESCRIPTIONS;
break;
case "chapters":
castTrack.subtype =
cast.media.TextTrackType.CHAPTERS;
break;
case "metadata":
castTrack.subtype =
cast.media.TextTrackType.METADATA;
break;
// Default to subtitles
default:
castTrack.subtype =
cast.media.TextTrackType.SUBTITLES;
}
// Add track to mediaInfo
mediaInfo.tracks?.push(castTrack);
// If enabled, mark as active track for load request
if (track.mode === "showing" || trackElement.default) {
activeTrackIds.push(mediaTrackIndex);
}
mediaTrackIndex++;
});
}
}
const loadRequest = new cast.media.LoadRequest(mediaInfo);
loadRequest.autoplay = true;
loadRequest.activeTrackIds = activeTrackIds;
this.session?.loadMedia(loadRequest, async media => {
this.media = media;
if (
(await options.get("mediaSyncElement")) &&
this.mediaElement instanceof HTMLMediaElement
) {
this.addMediaElementListeners(this.mediaElement);
}
});
}
private addMediaElementListeners(mediaElement: HTMLMediaElement) {
this.session?.addUpdateListener(isAlive => {
if (!isAlive) return;
// Update volume level
const volume = this.session?.receiver.volume;
if (!volume) return;
if (
volume?.level !== null &&
volume.level !== mediaElement.volume
) {
mediaElement.volume = volume.level;
}
// Update muted state
if (volume?.muted !== null && volume.muted !== mediaElement.muted) {
mediaElement.muted = volume.muted;
}
});
this.media?.addUpdateListener(isAlive => {
if (!isAlive || !this.media) return;
/**
* If media element time and estimated time are off by more
* than two seconds, set the media element time to the
* estimated time.
*/
const estimatedTime = this.media.getEstimatedTime();
if (Math.abs(mediaElement.currentTime - estimatedTime) > 2) {
mediaElement.currentTime = estimatedTime;
}
const mediaElementPlayerState = mediaElement.paused
? cast.media.PlayerState.PAUSED
: cast.media.PlayerState.PLAYING;
if (mediaElementPlayerState !== this.media.playerState) {
switch (this.media.playerState) {
case cast.media.PlayerState.PLAYING:
mediaElement.play();
break;
case cast.media.PlayerState.PAUSED:
case cast.media.PlayerState.BUFFERING:
case cast.media.PlayerState.IDLE:
mediaElement.pause();
break;
}
}
});
}
private startMediaServer(
filePath: string,
port: number
): Promise<{
mediaPath: string;
subtitlePaths: string[];
localAddress: string;
}> {
return new Promise((resolve, reject) => {
if (!this.port) {
reject();
return;
}
this.port.postMessage({
subject: "bridge:startMediaServer",
data: {
filePath: decodeURI(filePath),
port: port
}
});
const onMessage = (ev: MessageEvent<Message>) => {
const message = ev.data;
if (message.subject.startsWith("mediaCast:mediaServer")) {
this.port?.removeEventListener("message", onMessage);
}
switch (message.subject) {
case "mediaCast:mediaServerStarted":
resolve(message.data);
break;
case "mediaCast:mediaServerError":
reject(message.data);
break;
}
};
this.port.addEventListener("message", onMessage);
this.port.start();
});
}
}
/**
* If loaded as a content script, opts are stored on the window object.
*/
if (window.location.protocol !== "moz-extension:") {
const window_ = window as any;
let mediaElement: Optional<HTMLMediaElement>;
if (window_.targetElementId) {
mediaElement = browser.menus.getTargetElement(
window_.targetElementId
) as HTMLMediaElement;
}
new MediaSender({
mediaUrl: window_.mediaUrl,
mediaElement
});
}

View File

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

View File

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

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

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

View File

@@ -0,0 +1,109 @@
import type { WhitelistItemData } from "./background/whitelist";
export interface Options {
/** Native messaging host name. */
bridgeApplicationName: string;
/** Attempt to connect to daemon if native messaging fails. */
bridgeBackupEnabled: boolean;
/** Daemon WebSocket server host. */
bridgeBackupHost: string;
/** Daemon WebSocket server port. */
bridgeBackupPort: number;
/** Whether daemon WebSocket server uses HTTPS. */
bridgeBackupSecure: boolean;
/** Daemon password. */
bridgeBackupPassword: string;
/** HTML5 media/image casting. */
mediaEnabled: boolean;
/** Sync media element state with remote media. */
mediaSyncElement: boolean;
/** Stop media cast session if page is closed. */
mediaStopOnUnload: boolean;
/** Casting for media on local filesystem. */
localMediaEnabled: boolean;
/** HTTP server port for local media. */
localMediaServerPort: number;
/** Screen mirroring casting. */
mirroringEnabled: boolean;
/** Chromecast receiver app ID for mirroring. */
mirroringAppId: string;
/** Max frame rate for mirroring WebRTC media stream. */
mirroringStreamMaxFrameRate: number;
/** Max bitrate for mirroring WebRTC media stream. */
mirroringStreamMaxBitRate: number;
/**
* Base `scaleResolutionDownBy` parameter for mirroring WebRTC media
* stream.
*/
mirroringStreamDownscaleFactor: number;
/** Max width/height to use for calculating final
* `scaleResolutionDownBy` parameter for mirroring WebRTC media
* stream.
*/
mirroringStreamMaxResolution: { width?: number; height?: number };
/** Whether to apply max resolution limits to mirroring WebRTC media
* stream.
*/
mirroringStreamUseMaxResolution: boolean;
/**
* Close receiver selector popup if another browser window is
* focused.
*/
receiverSelectorCloseIfFocusLost: boolean;
/** Close receiver selector after a session is established. */
receiverSelectorWaitForConnection: boolean;
/** Auto-expand active sessions managed by the extension. */
receiverSelectorExpandActive: boolean;
/** Show media images in receiver selector. */
receiverSelectorShowMediaImages: boolean;
/** User agent replacement whitelist enabled. */
siteWhitelistEnabled: boolean;
/** User agent replacement whitelist items data. */
siteWhitelist: WhitelistItemData[];
/** Custom user agent string for whitelist. */
siteWhitelistCustomUserAgent: string;
/** Show advanced options on options page. */
showAdvancedOptions: boolean;
[key: string]: Options[keyof Options];
}
export default {
bridgeApplicationName: BRIDGE_NAME,
bridgeBackupEnabled: false,
bridgeBackupHost: "localhost",
bridgeBackupPort: 9556,
bridgeBackupSecure: false,
bridgeBackupPassword: "",
mediaEnabled: true,
mediaSyncElement: false,
mediaStopOnUnload: false,
localMediaEnabled: true,
localMediaServerPort: 9555,
mirroringEnabled: false,
mirroringAppId: MIRRORING_APP_ID,
mirroringStreamMaxFrameRate: 15,
mirroringStreamMaxBitRate: 1000000,
mirroringStreamDownscaleFactor: 1.0,
mirroringStreamMaxResolution: { width: 1920, height: 1080 },
mirroringStreamUseMaxResolution: true,
receiverSelectorCloseIfFocusLost: true,
receiverSelectorWaitForConnection: true,
receiverSelectorExpandActive: true,
receiverSelectorShowMediaImages: false,
siteWhitelistEnabled: true,
siteWhitelist: [{ pattern: "https://www.netflix.com/*", isEnabled: true }],
siteWhitelistCustomUserAgent: "",
showAdvancedOptions: false
} as Options;

100
extension/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,100 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
declare const BRIDGE_VERSION: string;
declare const BRIDGE_NAME: string;
declare const MIRRORING_APP_ID: string;
declare type Nullable<T> = T | null;
declare type Optional<T> = T | undefined;
declare type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never;
declare interface CanvasRenderingContext2D {
DRAWWINDOW_DRAW_CARET: 0x01;
DRAWWINDOW_DO_NOT_FLUSH: 0x02;
DRAWWINDOW_DRAW_VIEW: 0x04;
DRAWWINDOW_USE_WIDGET_LAYERS: 0x08;
DRAWWINDOW_ASYNC_DECODE_IMAGES: 0x10;
drawWindow(
window: Window,
x: number,
y: number,
w: number,
h: number,
bgColor: string,
flags: number
): void;
}
declare interface HTMLCanvasElement {
captureStream(frameRate?: number): MediaStream;
}
declare interface MediaTrackConstraints {
cursor: "always" | "motion" | "never";
}
declare interface RTCPeerConnection {
addStream(mediaStream: MediaStream): void;
}
declare interface MediaDevices {
getDisplayMedia(constraints: MediaStreamConstraints): Promise<MediaStream>;
}
interface CloneIntoOptions {
cloneFunctions?: boolean;
wrapReflectors?: boolean;
}
declare function cloneInto<T>(
obj: T,
targetScope: Window,
options?: CloneIntoOptions
): T;
interface ExportFunctionOptions {
defineAs: string;
allowCallbacks?: boolean;
allowCrossOriginArguments?: boolean;
}
type ExportFunctionFunc = (...args: any[]) => any;
declare function exportFunction(
func: ExportFunctionFunc,
targetScope: Window,
options?: ExportFunctionOptions
): ExportFunctionFunc;
// Fix issues with @types/firefox-webext-browser
declare namespace browser.events {
/**
* Shouldn't enforce limited function signature across all
* event types.
*/
interface Event {
addListener(...args: any[]): void | Promise<void>;
removeListener(...args: any[]): void | Promise<void>;
}
}
declare namespace browser.runtime {
interface Port {
error?: { message: string };
/**
* https://git.io/fjmzb
* addListener cb `() => void` is wrong
*/
onMessage: browser.events.Event;
}
function connect(connectInfo: {
name?: string;
includeTlsChannelId?: boolean;
}): browser.runtime.Port;
}

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 125 125" fill="white">
<path d="M43.5 84.1l1.3-1.5c.3-.3.3-.8 0-1.1-10.5-9.7-11.2-26.2-1.4-36.7s26.2-11.2 36.7-1.4 11.2 26.2 1.4 36.7c-.5.5-.9 1-1.4 1.4-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.3 1.1.1 12-11.1 12.7-29.7 1.7-41.7-11.1-12-29.7-12.7-41.7-1.7s-12.7 29.7-1.7 41.7c.5.6 1.1 1.1 1.7 1.7.3.2.7.2 1-.1z"/>
<path d="M44.8 62.5c0-9.7 7.9-17.6 17.6-17.6S80 52.9 80 62.6c0 4.8-2 9.5-5.5 12.8-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.4 1.1.1 8.5-8 8.9-21.3 1-29.8s-21.3-8.9-29.8-1-9 21.2-1.1 29.7c.3.3.6.7 1 1 .3.3.8.3 1.1 0l1.3-1.5c.3-.3.3-.8 0-1.1-3.5-3.3-5.6-8-5.6-12.9z"/>
<path d="M53.2 62.5c0-5.1 4.1-9.2 9.2-9.2s9.2 4.1 9.2 9.2c0 2.5-1 4.8-2.8 6.6-.3.3-.3.8 0 1.1l1.3 1.5c.3.3.8.3 1.1 0 5-4.9 5.2-12.9.3-18s-12.9-5.2-18-.3-5.2 12.9-.3 18l.3.3c.3.3.8.3 1.1 0l1.3-1.5c.3-.3.3-.8 0-1.1-1.7-1.7-2.7-4.1-2.7-6.6z"/>
<path d="M80.9 89.1L63.5 69.3c-.5-.6-1.3-.6-1.9-.1l-.1.1-17.6 19.8c-.4.5-.4 1.2.1 1.7.2.2.5.3.7.3H80c.6 0 1.2-.5 1.2-1.2 0-.3-.1-.6-.3-.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 1016 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 125 125" fill="white">
<path d="M81 88.8c.4.5.4 1.3-.1 1.7-.2.2-.5.3-.8.3H44.9c-.7 0-1.2-.5-1.2-1.2 0-.3.1-.6.3-.8l17.5-20.1c.5-.6 1.3-.6 1.9-.1l.1.1L81 88.8zm-4.1-11.1l-2.8-3.3h10.5c.9.1 1.7-.1 2.5-.4.5-.3 1-.7 1.2-1.2.4-.8.5-1.7.4-2.5V45.8c.1-.9-.1-1.7-.4-2.5-.3-.5-.7-1-1.2-1.2-.8-.4-1.7-.5-2.5-.4h-44c-.9-.1-1.7.1-2.5.4-.5.3-1 .7-1.2 1.2-.4.8-.5 1.7-.4 2.5v24.4c-.1.9.1 1.7.4 2.5.3.5.7 1 1.2 1.2.8.4 1.7.5 2.5.4h10.5l-2.8 3.3h-6.7c-3 0-4-.3-5-.9-1.1-.6-1.9-1.4-2.5-2.5-.6-1.1-.9-2.1-.9-5V46.7c0-3 .3-4 .9-5.1.6-1.1 1.4-1.9 2.5-2.5 1.1-.6 2.1-.9 5-.9h42.2c3 0 4 .3 5.1.9 1.1.6 1.9 1.4 2.5 2.5.6 1.1.9 2.1.9 5.1v22.5c0 3-.3 4-.9 5-.6 1.1-1.4 1.9-2.5 2.5-1.1.6-2.1.9-5.1.9l-6.9.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="#0a84ff">
<path d="M4 1S1 1 1 4v2a9 9 0 0 1 1.25.088V4A1.75 1.75 0 0 1 4 2.25h8A1.75 1.75 0 0 1 13.75 4v8A1.75 1.75 0 0 1 12 13.75H9.912A9 9 0 0 1 10 15h2s3 0 3-3V4s0-3-3-3H4z"/>
<path d="M5 4a1 1 0 0 0-1 1v1.516a9 9 0 0 1 5.563 5.734H11a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5z"/>
<path d="M1 13.75V15h1.25A1.25 1.25 0 0 0 1 13.75Z"/>
<path d="M1 10.75v1.244A3.005 3.005 0 0 1 4.006 15H5.25A4.25 4.25 0 0 0 1 10.75z"/>
<path d="M1 7.75V9a6 6 0 0 1 6 6h1.25A7.25 7.25 0 0 0 1 7.75z"/>
</svg>

After

Width:  |  Height:  |  Size: 570 B

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="rgb(91, 91, 102)">
<style>
@keyframes blink {
0% { opacity: 1 }
}
.wave {
animation-name: blink;
animation-duration: 1500ms;
animation-iteration-count: infinite;
animation-timing-function: ease-out;
opacity: 0.3;
}
.wave-1 { animation-delay: 500ms; }
.wave-2 { animation-delay: 1000ms; }
.wave-3 { animation-delay: 1500ms; }
</style>
<path d="M4 1S1 1 1 4v2a9 9 0 0 1 1.25.088V4A1.75 1.75 0 0 1 4 2.25h8A1.75 1.75 0 0 1 13.75 4v8A1.75 1.75 0 0 1 12 13.75H9.912A9 9 0 0 1 10 15h2s3 0 3-3V4s0-3-3-3H4z"/>
<path class="wave wave-1" d="M1 13.75V15h1.25A1.25 1.25 0 0 0 1 13.75Z"/>
<path class="wave wave-2" d="M1 10.75v1.244A3.005 3.005 0 0 1 4.006 15H5.25A4.25 4.25 0 0 0 1 10.75z"/>
<path class="wave wave-3" d="M1 7.75V9a6 6 0 0 1 6 6h1.25A7.25 7.25 0 0 0 1 7.75z"/>
</svg>

After

Width:  |  Height:  |  Size: 994 B

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="rgb(251, 251, 254)">
<style>
@keyframes blink {
0% { opacity: 1 }
}
.wave {
animation-name: blink;
animation-duration: 1500ms;
animation-iteration-count: infinite;
animation-timing-function: ease-out;
opacity: 0.3;
}
.wave-1 { animation-delay: 500ms; }
.wave-2 { animation-delay: 1000ms; }
.wave-3 { animation-delay: 1500ms; }
</style>
<path d="M4 1S1 1 1 4v2a9 9 0 0 1 1.25.088V4A1.75 1.75 0 0 1 4 2.25h8A1.75 1.75 0 0 1 13.75 4v8A1.75 1.75 0 0 1 12 13.75H9.912A9 9 0 0 1 10 15h2s3 0 3-3V4s0-3-3-3H4z"/>
<path class="wave wave-1" d="M1 13.75V15h1.25A1.25 1.25 0 0 0 1 13.75Z"/>
<path class="wave wave-2" d="M1 10.75v1.244A3.005 3.005 0 0 1 4.006 15H5.25A4.25 4.25 0 0 0 1 10.75z"/>
<path class="wave wave-3" d="M1 7.75V9a6 6 0 0 1 6 6h1.25A7.25 7.25 0 0 0 1 7.75z"/>
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="rgb(91, 91, 102)">
<path d="M4 1S1 1 1 4v2a9 9 0 0 1 1.25.088V4A1.75 1.75 0 0 1 4 2.25h8A1.75 1.75 0 0 1 13.75 4v8A1.75 1.75 0 0 1 12 13.75H9.912A9 9 0 0 1 10 15h2s3 0 3-3V4s0-3-3-3H4z"/>
<path d="M1 13.75V15h1.25A1.25 1.25 0 0 0 1 13.75Z"/>
<path d="M1 10.75v1.244A3.005 3.005 0 0 1 4.006 15H5.25A4.25 4.25 0 0 0 1 10.75z"/>
<path d="M1 7.75V9a6 6 0 0 1 6 6h1.25A7.25 7.25 0 0 0 1 7.75z"/>
</svg>

After

Width:  |  Height:  |  Size: 480 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="rgb(251, 251, 254)">
<path d="M4 1S1 1 1 4v2a9 9 0 0 1 1.25.088V4A1.75 1.75 0 0 1 4 2.25h8A1.75 1.75 0 0 1 13.75 4v8A1.75 1.75 0 0 1 12 13.75H9.912A9 9 0 0 1 10 15h2s3 0 3-3V4s0-3-3-3H4z"/>
<path d="M1 13.75V15h1.25A1.25 1.25 0 0 0 1 13.75Z"/>
<path d="M1 10.75v1.244A3.005 3.005 0 0 1 4.006 15H5.25A4.25 4.25 0 0 0 1 10.75z"/>
<path d="M1 7.75V9a6 6 0 0 1 6 6h1.25A7.25 7.25 0 0 0 1 7.75z"/>
</svg>

After

Width:  |  Height:  |  Size: 482 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="#0a84ff">
<path d="M4 1S1 1 1 4v2a9 9 0 0 1 1.25.088V4A1.75 1.75 0 0 1 4 2.25h8A1.75 1.75 0 0 1 13.75 4v8A1.75 1.75 0 0 1 12 13.75H9.912A9 9 0 0 1 10 15h2s3 0 3-3V4s0-3-3-3H4z"/>
<path d="M1 13.75V15h1.25A1.25 1.25 0 0 0 1 13.75Z"/>
<path d="M1 10.75v1.244A3.005 3.005 0 0 1 4.006 15H5.25A4.25 4.25 0 0 0 1 10.75z"/>
<path d="M1 7.75V9a6 6 0 0 1 6 6h1.25A7.25 7.25 0 0 0 1 7.75z"/>
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@@ -0,0 +1,32 @@
interface TypedEvents {
[key: string]: any;
}
/**
* Provides a typed interface to EventTarget objects.
*/
export class TypedEventTarget<T extends TypedEvents> extends EventTarget {
// @ts-ignore
public addEventListener<K extends keyof T>(
type: K,
listener: (ev: CustomEvent<T[K]>) => void,
options?: boolean | AddEventListenerOptions
): void {
// @ts-ignore
super.addEventListener(type as string, listener, options);
}
// @ts-ignore
public removeEventListener<K extends keyof T>(
type: K,
listener: (ev: CustomEvent<T[K]>) => void,
options?: boolean | EventListenerOptions
): void {
// @ts-ignore
super.removeEventListener(type, listener, options);
}
public dispatchEvent<K extends keyof T>(ev: CustomEvent<T[K]>): boolean {
return super.dispatchEvent(ev);
}
}

View File

@@ -0,0 +1,7 @@
/**
* Provides a typed interface to MessagePort objects.
*/
export interface TypedMessagePort<T> extends MessagePort {
postMessage(message: T, transfer: Transferable[]): void;
postMessage(message: T, options?: StructuredSerializeOptions): void;
}

View File

@@ -0,0 +1,22 @@
/**
* Provides a typed interface to runtime.Port objects.
*/
export interface TypedPort<T>
extends Omit<
browser.runtime.Port,
"onDisconnect" | "onMessage" | "postMessage"
> {
onDisconnect: {
addListener(cb: (port: TypedPort<T>) => void): void | Promise<void>;
removeListener(cb: (port: TypedPort<T>) => void): void | Promise<void>;
hasListener(cb: (port: TypedPort<T>) => void): boolean;
hasListeners(): boolean;
};
onMessage: {
addListener(cb: (message: T) => void): void | Promise<void>;
removeListener(cb: (message: T) => void): void | Promise<void>;
hasListener(cb: (message: T) => void): boolean;
hasListeners(): boolean;
};
postMessage(message: T): void;
}

View File

@@ -0,0 +1,42 @@
/**
* Allows typed access to a StorageArea.
*
* Provide a string-keyed schema as a type parameter with
* the specified storage area.
*/
export class TypedStorageArea<Schema extends { [key: string]: any }> {
private storageArea: any;
constructor(storageArea: browser.storage.StorageArea) {
this.storageArea = storageArea;
}
public async get<
SchemaKey extends keyof Schema,
SchemaPartial extends Partial<Schema>
>(
keys?: SchemaKey | SchemaKey[] | SchemaPartial | null | undefined
): Promise<Pick<Schema, Extract<keyof SchemaPartial, SchemaKey>>> {
return await this.storageArea.get(keys);
}
public async getBytesInUse<SchemaKey extends keyof Schema>(
keys?: Schema | SchemaKey[]
): Promise<number> {
return await this.storageArea.getBytesInUse(keys);
}
public async set(keys: Partial<Schema>): Promise<void> {
await this.storageArea.set(keys);
}
public async remove<SchemaKey extends keyof Schema>(
keys: SchemaKey | SchemaKey[]
): Promise<void> {
await this.storageArea.remove(keys);
}
public async clear(): Promise<void> {
await this.storageArea.clear();
}
}

134
extension/src/lib/bridge.ts Normal file
View File

@@ -0,0 +1,134 @@
import semver from "semver";
import logger from "./logger";
import type { Port } from "../messaging";
import * as nativeMessaging from "./nativeMessaging";
import options from "./options";
export const BRIDGE_TIMEOUT = 5000;
/**
* Creates a bridge instance and returns a message port.
*/
async function connect(): Promise<Port> {
const applicationName = await options.get("bridgeApplicationName");
const bridgePort = nativeMessaging.connectNative(
applicationName
) as unknown as Port;
bridgePort.onDisconnect.addListener(() => {
if (bridgePort.error) {
console.error(
`${applicationName} disconnected:`,
bridgePort.error.message
);
} else {
console.info(`${applicationName} disconnected`);
}
});
return bridgePort;
}
export interface BridgeInfo {
name: string;
version: string;
expectedVersion: string;
isVersionExact: boolean;
isVersionCompatible: boolean;
isVersionOlder: boolean;
isVersionNewer: boolean;
}
export class BridgeConnectionError extends Error {}
export class BridgeTimedOutError extends Error {}
export class BridgeAuthenticationError extends Error {}
/**
* Creates a temporary bridge to query the version info,
* compares the version to the extension version using semver
* rules to determine compatiblity, then returns a
* BridgeInfo object.
*/
const getInfo = () =>
new Promise<BridgeInfo>(async (resolve, reject) => {
const applicationName = await options.get("bridgeApplicationName");
if (!applicationName) {
reject(logger.error("Bridge application name not found."));
return;
}
const bridgeTimeoutId = setTimeout(() => {
logger.error("Bridge timed out.");
reject(new BridgeTimedOutError());
}, BRIDGE_TIMEOUT);
let applicationVersion: string;
try {
const { version } = browser.runtime.getManifest();
applicationVersion = await nativeMessaging.sendNativeMessage(
applicationName,
{ subject: "bridge:/getInfo", data: version }
);
} catch (err) {
if (err === 401) {
reject(new BridgeAuthenticationError());
} else {
logger.error("Bridge connection failed.");
reject(new BridgeConnectionError());
}
clearTimeout(bridgeTimeoutId);
return;
}
clearTimeout(bridgeTimeoutId);
const extensionVersion = browser.runtime.getManifest().version;
const extensionVersionMajor = semver.major(extensionVersion);
const versionDiff = semver.diff(applicationVersion, extensionVersion);
/**
* If the target version is above 0.x.x range, API is stable
* and versions with minor or patch level changes should be
* compatible.
*/
const isVersionCompatible =
semver.eq(applicationVersion, extensionVersion) ||
(versionDiff !== "major" && extensionVersionMajor !== 0) ||
(versionDiff === "patch" && extensionVersionMajor === 0);
const isVersionExact = semver.eq(applicationVersion, extensionVersion);
const isVersionOlder = semver.lt(applicationVersion, extensionVersion);
const isVersionNewer = semver.gt(applicationVersion, extensionVersion);
// Print compatibility info to console
if (!isVersionCompatible) {
logger.error(
`Expecting ${applicationName} v${BRIDGE_VERSION}, found v${applicationVersion}. ${
isVersionOlder
? "Try updating the native app to the latest version."
: "Try updating the extension to the latest version"
}`
);
}
resolve({
name: applicationName,
version: applicationVersion,
expectedVersion: BRIDGE_VERSION,
// Version info
isVersionExact,
isVersionCompatible,
isVersionOlder,
isVersionNewer
});
});
export default {
connect,
getInfo
};

View File

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

View File

@@ -0,0 +1,42 @@
export class Logger {
constructor(private prefix: string) {}
public log(message: string, data?: unknown) {
const formattedMessage = `${this.prefix} (Log): ${message}`;
if (data) {
// eslint-disable-next-line no-console
console.log(formattedMessage, data);
} else {
// eslint-disable-next-line no-console
console.log(formattedMessage);
}
}
public info(message: string, data?: unknown) {
const formattedMessage = `${this.prefix} (Info): ${message}`;
if (data) {
console.info(formattedMessage, data);
} else {
console.info(formattedMessage);
}
}
public warn(message: string, data?: unknown) {
const formattedMessage = `${this.prefix} (Warning): ${message}`;
if (data) {
console.warn(formattedMessage, data);
} else {
console.warn(formattedMessage);
}
}
public error(message: string, data?: unknown) {
const formattedMessage = `${this.prefix} (Error): ${message}`;
if (data) {
console.error(formattedMessage, data);
} else {
console.error(formattedMessage);
}
return new Error(formattedMessage);
}
}
export default new Logger("fx_cast");

View File

@@ -0,0 +1,134 @@
const WILDCARD_SCHEMES = ["http", "https", "ws", "wss"];
export const REMOTE_MATCH_PATTERN_REGEX =
/^(?:(?:(\*|https?|wss?|ftp):\/\/(\*|(?:\*\.(?:[^\/\*:]\.?)+(?:[^\.])|[^\/\*:]*))?)(\/.*)|<all_urls>)$/;
/**
* Partial implementation of WebExtension match patterns. Only handles
* remote patterns, as we don't need local matching and it's more
* complex to implement.
*/
export class RemoteMatchPattern {
private partScheme: string;
private partHost: string;
private partPath: string;
/** Matching schemes */
private schemes: string[] = [];
/** Base domain for subdomain matching */
private domain?: string;
/** Host part includes subdomain wildcard */
private matchSubdomains = false;
constructor(public pattern: string) {
// Parse match pattern parts
const matches = pattern.match(REMOTE_MATCH_PATTERN_REGEX);
if (!matches) {
throw new Error("Invalid match pattern");
}
[, this.partScheme, this.partHost, this.partPath] = matches;
if (pattern === "<all_urls>") {
this.schemes = WILDCARD_SCHEMES;
return;
}
// Scheme
this.schemes =
this.partScheme === "*" ? WILDCARD_SCHEMES : [this.partScheme];
// Host
if (this.partHost.startsWith("*.")) {
this.domain = this.partHost.slice(2);
this.matchSubdomains = true;
} else if (this.partHost !== "*") {
this.domain = this.partHost;
}
}
/**
* Test domain string against match pattern.
*/
private matchesDomain(domain: string) {
// If wildcard or exact match
if (this.partHost === "*" || this.domain === domain) {
return true;
}
if (this.matchSubdomains) {
// Should exist here
if (!this.domain) return false;
// Starting offset of pattern in url host string
const offset = domain.length - this.domain.length;
if (
offset > 0 &&
domain[offset - 1] === "." &&
domain.slice(offset) === this.domain
) {
return true;
}
}
return false;
}
/**
* Tests URL string against match pattern and returns boolean
* result.
*/
matches(urlString: string) {
let url: URL;
try {
url = new URL(urlString);
} catch (err) {
return false;
}
// If URL does not have a matching scheme
if (!this.schemes.includes(url.protocol.slice(0, -1))) {
return false;
}
// If pattern host is not a wildcard
if (!this.matchesDomain(url.hostname)) {
return false;
}
const urlPath = `${url.pathname}${url.search}`;
// If pattern path is not a wildcard
if (this.partPath !== "/*") {
// And if paths don't match
if (this.partPath !== urlPath) {
const specialChars = ".+*?^${}()|[]\\";
/**
* Create regular expression from pattern path, escaping
* any special characters.
*/
let pathRegexString = "";
for (const c of this.partPath) {
if (c === "*") {
pathRegexString += ".*";
} else {
if (specialChars.includes(c)) {
pathRegexString += "\\";
}
pathRegexString += c;
}
}
// Test compiled expression against path
if (!new RegExp(`^${pathRegexString}$`).test(urlPath)) {
return false;
}
}
}
return true;
}
}

View File

@@ -0,0 +1,214 @@
import logger from "./logger";
import options from "./options";
import type { Message, Port } from "../messaging";
type DisconnectListener = (port: Port) => void;
type MessageListener = (message: Message) => void;
/**
* Create backup server URL from configured options.
*/
async function getBackupServerUrl() {
const {
bridgeBackupHost,
bridgeBackupPort,
bridgeBackupSecure,
bridgeBackupPassword
} = await options.getAll();
const url = new URL(
`${bridgeBackupSecure ? "wss" : "ws"}://${
// Handle IPv6 address formatting
bridgeBackupHost.includes(":")
? `[${bridgeBackupHost}]`
: bridgeBackupHost
}`
);
url.port = bridgeBackupPort.toString();
if (bridgeBackupPassword) {
url.searchParams.append("password", bridgeBackupPassword);
}
return url;
}
/**
* `browser.runtime.connectNative()` wrapper.
*/
export function connectNative(application: string): Port {
/** Whether native host or backup is ready for messages. */
let isNativeHostReady = false;
let backupSocket: Nullable<WebSocket> = null;
let backupMessageQueue: Message[] = [];
// Make initial connection to native host
const port = browser.runtime.connectNative(application); //
const messageListeners = new Set<MessageListener>();
const disconnectListeners = new Set<DisconnectListener>();
const portObject: Port = {
name: "",
onDisconnect: {
addListener(cb: DisconnectListener) {
disconnectListeners.add(cb);
},
removeListener(cb: DisconnectListener) {
disconnectListeners.delete(cb);
},
hasListener(cb: DisconnectListener) {
return disconnectListeners.has(cb);
},
hasListeners() {
return disconnectListeners.size > 0;
}
},
onMessage: {
addListener(cb: MessageListener) {
messageListeners.add(cb);
},
removeListener(cb: MessageListener) {
messageListeners.delete(cb);
},
hasListener(cb: MessageListener) {
return messageListeners.has(cb);
},
hasListeners() {
return messageListeners.size > 0;
}
},
disconnect() {
if (backupSocket) {
backupSocket.close();
} else {
port.disconnect();
}
},
postMessage(message) {
if (!isNativeHostReady) {
// Queue messages until ready
backupMessageQueue.push(message);
} else if (backupSocket) {
backupSocket.send(JSON.stringify(message));
return;
}
port.postMessage(message);
}
};
port.onDisconnect.addListener(async () => {
const bridgeBackupEnabled = await options.get("bridgeBackupEnabled");
if (!bridgeBackupEnabled) {
portObject.error = { message: "" };
for (const listener of disconnectListeners) {
listener(portObject);
}
throw logger.error(
"Bridge connection failed and backup not enabled."
);
}
/**
* If port disconnected because of an error and native host
* status had not already been resolved.
*/
if (port.error && !isNativeHostReady) {
backupSocket = new WebSocket(await getBackupServerUrl());
backupSocket.addEventListener("open", () => {
isNativeHostReady = true;
// Send all messages in queue
while (backupMessageQueue.length) {
backupSocket?.send(
JSON.stringify(backupMessageQueue.shift())
);
}
});
backupSocket.addEventListener("message", ev => {
for (const listener of messageListeners) {
listener(JSON.parse(ev.data));
}
});
backupSocket.addEventListener("close", ev => {
// If not a normal closure, set error message
if (ev.code !== 1000) {
portObject.error = { message: ev.reason };
}
for (const listener of disconnectListeners) {
listener(portObject);
}
});
}
});
port.onMessage.addListener((message: Message) => {
if (!isNativeHostReady) {
isNativeHostReady = true;
backupMessageQueue = [];
}
for (const listener of messageListeners) {
listener(message);
}
});
return portObject;
}
/**
* `browser.runtime.sendNativeMessage()` wrapper.
*/
export async function sendNativeMessage(application: string, message: Message) {
try {
return await browser.runtime.sendNativeMessage(application, message);
} catch {
const { bridgeBackupEnabled, bridgeBackupSecure } =
await options.getAll();
if (!bridgeBackupEnabled) {
throw logger.error(
"Bridge connection failed and backup not enabled."
);
}
const backupServerUrl = await getBackupServerUrl();
const backupServerHttpUrl = new URL(backupServerUrl);
backupServerHttpUrl.protocol = bridgeBackupSecure ? "https" : "http";
// Send HTTP request to check authentication
if ((await fetch(backupServerHttpUrl)).status === 401) {
logger.error(
"Bridge daemon connection failed due to authentication error."
);
throw 401;
}
return await new Promise((resolve, reject) => {
const backupSocket = new WebSocket(backupServerUrl);
backupSocket.addEventListener("open", () => {
backupSocket.send(JSON.stringify(message));
});
backupSocket.addEventListener("message", ev => {
backupSocket.close();
resolve(JSON.parse(ev.data));
});
backupSocket.addEventListener("error", () => {
logger.error("Bridge daemon connection error.");
reject();
});
});
}
}

View File

@@ -0,0 +1,145 @@
import defaultOptions, { Options } from "../defaultOptions";
export { Options };
import logger from "./logger";
import { TypedEventTarget } from "./TypedEventTarget";
import { TypedStorageArea } from "./TypedStorageArea";
const storageArea = new TypedStorageArea<{
options: Options;
}>(browser.storage.sync);
interface EventMap {
changed: Array<keyof Options>;
}
export default new (class extends TypedEventTarget<EventMap> {
constructor() {
super();
this.onStorageChanged = this.onStorageChanged.bind(this);
browser.storage.onChanged.addListener(this.onStorageChanged);
// Supresses sendRemoveListener closed conduit error
window.addEventListener("unload", () => {
browser.storage.onChanged.removeListener(this.onStorageChanged);
});
}
private onStorageChanged(
changes: { [key: string]: browser.storage.StorageChange },
areaName: string
) {
if (areaName !== "sync") {
return;
}
if ("options" in changes) {
const { oldValue, newValue } = changes.options;
const changedKeys = [];
for (const key of Object.keys(newValue)) {
if (oldValue) {
// Don't track added keys
if (!(key in oldValue)) {
continue;
}
const oldKeyValue = oldValue[key];
const newKeyValue = newValue[key];
// Equality comparison
if (oldKeyValue === newKeyValue) {
continue;
}
// Array comparison
if (
oldKeyValue instanceof Array &&
newKeyValue instanceof Array
) {
if (
oldKeyValue.length === newKeyValue.length &&
oldKeyValue.every(
(value, index) => value === newKeyValue[index]
)
) {
continue;
}
}
}
changedKeys.push(key);
}
this.dispatchEvent(
new CustomEvent("changed", {
detail: changedKeys as Array<keyof Options>
})
);
}
}
/**
* Fetches `options` key from storage and returns it as
* Options interface type.
*/
public async getAll(): Promise<Options> {
const { options } = await storageArea.get("options");
return options;
}
/**
* Takes Options object and sets to `options` storage key.
* Returns storage promise.
*/
public async setAll(options: Options): Promise<void> {
return storageArea.set({ options });
}
/**
* Gets specific option from storage and returns it as its
* type from Options interface type.
*/
public async get<T extends keyof Options>(name: T): Promise<Options[T]> {
const options = await this.getAll();
if (options.hasOwnProperty(name)) {
return options[name];
} else {
throw logger.error(`Failed to find option ${name} in storage.`);
}
}
/**
* Sets specific option to storage. Returns storage
* promise.
*/
public async set<T extends keyof Options>(
name: T,
value: Options[T]
): Promise<void> {
const options = await this.getAll();
options[name] = value;
return this.setAll(options);
}
/**
* Gets existing options from storage and compares it
* against defaults. Any options in defaults and not in
* storage are set. Does not override any existing options.
*/
public async update(defaults = defaultOptions): Promise<void> {
const newOpts = await this.getAll();
// Find options not already in storage
for (const [optName, optVal] of Object.entries(defaults)) {
if (!newOpts.hasOwnProperty(optName)) {
newOpts[optName] = optVal;
}
}
// Update storage with default values of new options
return this.setAll(newOpts);
}
})();

View File

@@ -0,0 +1,25 @@
const PLATFORM_MAC = "Macintosh; Intel Mac OS X 12_5";
const PLATFORM_MAC_HYBRID = "Macintosh; Intel Mac OS X 12_5; rv:103.0";
const PLATFORM_WIN = "Windows NT 10.0; Win64; x64";
const PLATFORM_LINUX = "X11; Linux x86_64";
const UA_CHROME =
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36";
const UA_HYBRID = "Chrome/104.0.0.0 Gecko/20100101 Firefox/103.0";
export function getChromeUserAgent(platform: string, hybrid = false) {
let platformComponent: string;
if (platform === "mac") {
platformComponent = hybrid ? PLATFORM_MAC_HYBRID : PLATFORM_MAC;
} else if (platform === "win") {
platformComponent = PLATFORM_WIN;
} else if (platform === "linux") {
platformComponent = PLATFORM_LINUX;
} else {
return;
}
const browserComponent = hybrid ? UA_HYBRID : UA_CHROME;
return `Mozilla/5.0 (${platformComponent}) ${browserComponent}`;
}

View File

@@ -0,0 +1,42 @@
export function getNextEllipsis(ellipsis: string): string {
if (ellipsis === "") return ".";
if (ellipsis === ".") return "..";
if (ellipsis === "..") return "...";
if (ellipsis === "...") return "";
return "";
}
/**
* Template literal tag function, JSON-encodes substitutions.
*/
export function stringify(
templateStrings: TemplateStringsArray,
...substitutions: unknown[]
) {
let formattedString = "";
for (const templateString of templateStrings) {
if (formattedString) {
formattedString += JSON.stringify(substitutions.shift());
}
formattedString += templateString;
}
return formattedString;
}
export function loadScript(
scriptUrl: string,
doc: Document = document
): Promise<HTMLScriptElement> {
return new Promise((resolve, reject) => {
const scriptEl = doc.createElement("script");
scriptEl.src = scriptUrl;
(doc.head || doc.documentElement).append(scriptEl);
scriptEl.addEventListener("load", () => resolve(scriptEl));
scriptEl.addEventListener("error", () => reject());
});
}

57
extension/src/manifest.json Executable file
View File

@@ -0,0 +1,57 @@
{
"name": "__MSG_extensionName__",
"description": "__MSG_extensionDescription__",
"version": "0.3.1",
"developer": {
"name": "Matt Hensman",
"url": "https://matt.tf/"
},
"applications": {
"gecko": {
"id": "fx_cast@matt.tf",
"strict_min_version": "64.0",
"update_url": "https://hensm.github.io/fx_cast/updates.json"
}
},
"browser_action": {
"theme_icons": [
{
"light": "icons/cast-default-light.svg",
"dark": "icons/cast-default-dark.svg",
"size": 32
}
]
},
"background": {
"scripts": ["background/background.js"]
},
"default_locale": "en",
"icons": {
"48": "icons/icon.svg",
"96": "icons/icon.svg"
},
"manifest_version": 2,
"options_ui": {
"page": "ui/options/index.html"
},
"permissions": [
"history",
"menus",
"menus.overrideContext",
"nativeMessaging",
"notifications",
"storage",
"tabs",
"webNavigation",
"webRequest",
"webRequestBlocking",
"<all_urls>"
],
"web_accessible_resources": ["cast/content.js"]
}

30
extension/src/menuIds.ts Normal file
View File

@@ -0,0 +1,30 @@
export const POPUP_CAST = "popupCastMenuId";
export const POPUP_STOP = "popupStopMenuId";
export const POPUP_MEDIA_SEPARATOR = "popupMediaSeparatorMenuId";
export const POPUP_MEDIA_PLAY_PAUSE = "popupMediaPlayPauseMenuId";
export const POPUP_MEDIA_MUTE = "popupMediaMuteMenuId";
export const POPUP_MEDIA_SKIP_PREVIOUS = "popupMediaSkipPreviousMenuId";
export const POPUP_MEDIA_SKIP_NEXT = "popupMediaSkipNextMenuId";
export const POPUP_MEDIA_CC = "popupMediaSubtitlesCaptionsMenuId";
export const POPUP_MEDIA_CC_OFF = "popupMediaSubtitlesCaptionsOffMenuId";
export const receiverMenuIds = [
POPUP_CAST,
POPUP_STOP,
POPUP_MEDIA_SEPARATOR,
POPUP_MEDIA_PLAY_PAUSE,
POPUP_MEDIA_MUTE,
POPUP_MEDIA_SKIP_PREVIOUS,
POPUP_MEDIA_SKIP_NEXT,
POPUP_MEDIA_CC
];
export const mediaMenuIds = [
POPUP_MEDIA_SEPARATOR,
POPUP_MEDIA_PLAY_PAUSE,
POPUP_MEDIA_MUTE,
POPUP_MEDIA_SKIP_PREVIOUS,
POPUP_MEDIA_SKIP_NEXT,
POPUP_MEDIA_CC
];

357
extension/src/messaging.ts Normal file
View File

@@ -0,0 +1,357 @@
import type { TypedPort } from "./lib/TypedPort";
import type {
ReceiverSelection,
ReceiverSelectorMediaMessage,
ReceiverSelectorReceiverMessage
} from "./background/receiverSelector";
import type {
CastSessionCreatedDetails,
CastSessionUpdatedDetails,
MediaStatus,
ReceiverStatus,
SenderMediaMessage,
SenderMessage
} from "./cast/sdk/types";
import type { ApiConfig, Receiver, SessionRequest } from "./cast/sdk/classes";
import type {
ReceiverDevice,
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "./types";
import type { ReceiverAction } from "./cast/sdk/enums";
/**
* Messages are JSON objects with a `subject` string key and a
* generic `data` key:
* { subject: "...", data: ... }
*
* Message subjects may include an optional destination and
* response name formatted like this:
* ^(destination:)?messageName(\/responseName)?$
*
* Message formats are specified with subject as a key and data
* as the value in the message tables.
*/
/**
* Messages exclusively used internally between extension
* components.
*/
type ExtensionMessageDefinitions = {
/** Initial data to send to selector popup. */
"popup:init": {
appInfo?: ReceiverSelectorAppInfo;
pageInfo?: ReceiverSelectorPageInfo;
};
/** Updates selector popup with new data. */
"popup:update": {
devices: ReceiverDevice[];
isBridgeCompatible: boolean;
connectedSessionIds?: string[];
defaultMediaType?: ReceiverSelectorMediaType;
availableMediaTypes?: ReceiverSelectorMediaType;
};
/**
* Sent from the selector popup when a receiver has been
* selected.
*/
"main:receiverSelected": ReceiverSelection;
/**
* Sent from the selector popup when a receiver has been
* stopped. Used to provide cast API receiver action updates.
*/
"main:receiverStopped": { deviceId: string };
/**
* Tells the cast manager to provide the cast API instance with
* receiver data.
*/
"main:initializeCastSdk": { apiConfig: ApiConfig };
"cast:initialized": { isAvailable: boolean };
/**
* Sent to the cast API when a session is requested or stopped via
* the extension UI.
*/
"cast:receiverAction": { receiver: Receiver; action: ReceiverAction };
/**
* Sent from the cast API to trigger receiver selection on session
* request.
*/
"main:requestSession": {
sessionRequest: SessionRequest;
/** Skip receiver selection (allowed for trusted instances only). */
receiverDevice?: ReceiverDevice;
};
/** Return message to the cast API when a selection is cancelled. */
"cast:sessionRequestCancelled": undefined;
"main:requestSessionById": { sessionId: string };
"main:leaveSession": void;
"cast:instanceCreated": { isAvailable: boolean };
"cast:receiverAvailabilityUpdated": { isAvailable: boolean };
"cast:sessionCreated": CastSessionCreatedDetails & {
receiver: Receiver;
media?: MediaStatus;
};
"cast:sessionUpdated": CastSessionUpdatedDetails;
"cast:sessionDisconnected": { sessionId: string };
/** Allows the selector popup to send cast NS_RECEIVER messages. */
"main:sendReceiverMessage": ReceiverSelectorReceiverMessage;
/** Allows the selector popup to send cast NS_MEDIA messages. */
"main:sendMediaMessage": ReceiverSelectorMediaMessage;
/**
* Tells the device manager to clear its device list and re-connect
* to the bridge.
*/
"main:refreshDeviceManager": void;
"mirroringPopup:init": { device: ReceiverDevice };
};
/**
* IMPORTANT:
* Messages that cross the native messaging channel. MUST keep
* in-sync with the bridge's version at:
* app/src/bridge/messaging.ts > MessageDefinitions
*/
type BridgeMessageDefinitions = {
/**
* First message sent by the extension to the bridge.
* Includes extension version string. Responds directly with version
* string of the bridge to compare.
*
* Still uses `:/` message separator for compat talking to older
* bridge versions.
*/
"bridge:getInfo": string;
"bridge:/getInfo": string;
/**
* Tells a bridge to begin service discovery (and whether to
* establish connections to monitor the status of the receiver
* devices).
*/
"bridge:startDiscovery": {
shouldWatchStatus: boolean;
};
/**
* Sent to extension from the bridge whenever a receiver device is
* found.
*/
"main:deviceUp": { deviceId: string; deviceInfo: ReceiverDevice };
/**
* Sent to extension from the bridge whenever a previously found
* receiver device is lost.
*/
"main:deviceDown": { deviceId: string };
/**
* Sent to the extension from the bridge whenever a
* `RECEIVER_STATUS` message (`NS_RECEIVER`) is received.
*/
"main:receiverDeviceStatusUpdated": {
deviceId: string;
status: ReceiverStatus;
};
/**
* Sent to the extension from the bridge whenever a
* `MEDIA_STATUS` message (`NS_RECEIVER`) is received.
*/
"main:receiverDeviceMediaStatusUpdated": {
deviceId: string;
status: MediaStatus;
};
/**
* Sent to the bridge when non-session related receiver messages
* need to be sent (e.g. volume control, application stop, etc...).
*/
"bridge:sendReceiverMessage": {
deviceId: string;
message: SenderMessage;
};
/**
* Sent to the bridge when the receiver selector media UI is used
* to control media playback.
*/
"bridge:sendMediaMessage": {
deviceId: string;
message: SenderMediaMessage;
};
/**
* Sent to bridge from cast API instance when a session request is
* initiated.
*/
"bridge:createCastSession": {
appId: string;
receiverDevice: ReceiverDevice;
};
/**
* Connects to, and sends a `STOP` message on the `NS_RECEIVER`
* channel for the given receiver device.
*/
"bridge:stopCastSession": {
receiverDevice: ReceiverDevice;
};
/**
* Sent to cast API instances whenever a session is created or
* updates. Updated details is a mutable subset of session details
* otherwise fixed on creation.
*/
"main:castSessionCreated": CastSessionCreatedDetails;
"main:castSessionUpdated": CastSessionUpdatedDetails;
/**
* Sent to cast API instances whenever a session is stopped.
*/
"cast:sessionStopped": {
sessionId: string;
};
/**
* Sent to bridge from cast API instance whenever an `NS_RECEIVER`
* message needs to be sent.
*/
"bridge:sendCastReceiverMessage": {
sessionId: string;
messageData: SenderMessage;
messageId: string;
};
/**
* Sent to bridge from cast API instance whenever a application
* session message needs to be sent (via
* `chrome.cast.Session#sendMessage`).
*/
"bridge:sendCastSessionMessage": {
sessionId: string;
namespace: string;
messageData: object | string;
messageId: string;
};
/**
* Sent to cast API instance from bridge when session message
* received from a receiver device.
*/
"cast:sessionMessageReceived": {
sessionId: string;
namespace: string;
messageData: string;
};
/**
* Sent to cast API instance from bridge whenever a message
* operation is completed. If an error ocurred, an error string will
* be passed as the `error` data property.
*/
"cast:impl_sendMessage": {
sessionId: string;
messageId: string;
error?: string;
};
/**
* Sent to the bridge to start an HTTP media server at a given file
* path on the given port.
*/
"bridge:startMediaServer": {
filePath: string;
port: number;
};
/**
* Sent to media sender from bridge when the media server is ready
* to serve files.
*/
"mediaCast:mediaServerStarted": {
mediaPath: string;
subtitlePaths: string[];
localAddress: string;
};
/**
* Sent to bridge to stop HTTP media server.
*/
"bridge:stopMediaServer": undefined;
/**
* Sent to media sender from bridge when the media server has
* stopped.
*/
"mediaCast:mediaServerStopped": undefined;
/**
* Sent to media sender from bridge when the media server has
* encountered an error.
*/
"mediaCast:mediaServerError": string;
};
type MessageDefinitions = ExtensionMessageDefinitions &
BridgeMessageDefinitions;
interface MessageBase<K extends keyof MessageDefinitions> {
subject: K;
data: MessageDefinitions[K];
}
type Messages = {
[K in keyof MessageDefinitions]: MessageBase<K>;
};
/**
* Make message data key optional if specified as blank or with
* all-optional keys.
*/
type NarrowedMessage<L extends MessageBase<keyof MessageDefinitions>> =
L extends unknown
? undefined extends L["data"]
? Omit<L, "data"> & Partial<L>
: L
: never;
export type Port = TypedPort<Message>;
export type Message = NarrowedMessage<Messages[keyof Messages]>;
/**
* Typed WebExtension-style messaging utility class.
*/
export default new (class Messenger {
connect(connectInfo: { name: string }) {
return browser.runtime.connect(connectInfo) as Port;
}
connectTab(tabId: number, connectInfo: { name: string; frameId: number }) {
return browser.tabs.connect(tabId, connectInfo) as Port;
}
sendMessage(
message: Message,
options?: browser.runtime._SendMessageOptions
): Promise<any>;
sendMessage(
extensionId: string,
options?: browser.runtime._SendMessageOptions
): Promise<any>;
sendMessage(
messageOrExtensionId: string | Message,
options?: browser.runtime._SendMessageOptions
) {
return browser.runtime.sendMessage(messageOrExtensionId, options);
}
onConnect = browser.runtime.onConnect as WebExtEvent<(port: Port) => void>;
onMessage = browser.runtime.onMessage as WebExtEvent<
(message: Message, sender: browser.runtime.MessageSender) => void
>;
})();

41
extension/src/types.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { SessionRequest } from "./cast/sdk/classes";
import type { MediaStatus, ReceiverStatus } from "./cast/sdk/types";
export enum ReceiverDeviceCapabilities {
NONE = 0,
VIDEO_OUT = 1,
VIDEO_IN = 2,
AUDIO_OUT = 4,
AUDIO_IN = 8,
MULTIZONE_GROUP = 32
}
export interface ReceiverDevice {
id: string;
friendlyName: string;
modelName: string;
capabilities: ReceiverDeviceCapabilities;
host: string;
port: number;
status?: ReceiverStatus;
mediaStatus?: MediaStatus;
}
export enum ReceiverSelectorMediaType {
None = 0,
App = 1,
Tab = 2,
Screen = 4
}
export interface ReceiverSelectorAppInfo {
sessionRequest: SessionRequest;
isRequestAppAudioCompatible?: boolean;
}
/** Info about sender page context. */
export interface ReceiverSelectorPageInfo {
url: string;
tabId: number;
frameId: number;
}

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { onMount } from "svelte";
function getNextEllipsis(ellipsis: string): string {
if (ellipsis === "") return ".";
if (ellipsis === ".") return "..";
if (ellipsis === "..") return "...";
if (ellipsis === "...") return "";
return "";
}
export let interval = 500;
let ellipsis = "";
let intervalId: number;
onMount(() => {
intervalId = window.setInterval(() => {
ellipsis = getNextEllipsis(ellipsis);
}, interval);
return () => {
window.clearInterval(intervalId);
};
});
</script>
<span class="indicator">
<slot />{ellipsis}
</span>

View File

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path>
</svg>

After

Width:  |  Height:  |  Size: 666 B

View File

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View File

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M6.586 8l-2.293 2.293a1 1 0 0 0 1.414 1.414L8 9.414l2.293 2.293a1 1 0 0 0 1.414-1.414L9.414 8l2.293-2.293a1 1 0 1 0-1.414-1.414L8 6.586 5.707 4.293a1 1 0 0 0-1.414 1.414L6.586 8zM8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0z"></path>
</svg>

After

Width:  |  Height:  |  Size: 761 B

View File

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M8 1a7 7 0 1 0 7 7 7.008 7.008 0 0 0-7-7zm0 13a6 6 0 1 1 6-6 6.007 6.007 0 0 1-6 6zm0-7a1 1 0 0 0-1 1v3a1 1 0 1 0 2 0V8a1 1 0 0 0-1-1zm0-3.188A1.188 1.188 0 1 0 9.188 5 1.188 1.188 0 0 0 8 3.812z"></path>
</svg>

After

Width:  |  Height:  |  Size: 746 B

View File

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M14.742 12.106L9.789 2.2a2 2 0 0 0-3.578 0l-4.953 9.91A2 2 0 0 0 3.047 15h9.905a2 2 0 0 0 1.79-2.894zM7 5a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0zm1 8.25A1.25 1.25 0 1 1 9.25 12 1.25 1.25 0 0 1 8 13.25z"></path>
</svg>

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import MirroringSender from "../../cast/senders/mirroring";
import logger from "../../lib/logger";
import options, { Options } from "../../lib/options";
import messaging, { Port } from "../../messaging";
import type { ReceiverDevice } from "../../types";
import LoadingIndicator from "../LoadingIndicator.svelte";
const _ = browser.i18n.getMessage;
document.title = _("mirroringPopupTitle");
let port: Optional<Port>;
let opts: Optional<Options>;
let device: ReceiverDevice;
let mirroringSender: Optional<MirroringSender>;
let isSessionCreated = false;
let isMirroringConnected = false;
onMount(async () => {
port = messaging.connect({ name: "mirroring" });
port.onMessage.addListener(message => {
switch (message.subject) {
case "mirroringPopup:init":
device = message.data.device;
mirroringSender = new MirroringSender({
receiverDevice: device,
onSessionCreated() {
isSessionCreated = true;
},
onMirroringConnected() {
isMirroringConnected = true;
},
onMirroringStopped() {
window.close();
}
});
break;
}
});
opts = await options.getAll();
});
onDestroy(() => {
mirroringSender?.stop();
port?.disconnect();
});
async function requestDisplayMedia() {
if (!mirroringSender) return;
try {
mirroringSender.createMirroringConnection(
await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: "motion",
frameRate: opts?.mirroringStreamMaxFrameRate
},
// Currently not implemented in Firefox
audio: true
})
);
} catch (err) {
logger.error("Failed to create mirroring connection!", err);
}
}
</script>
{#if isSessionCreated}
<div class="mirroring-status">
{#if isMirroringConnected}
<p>
{_("mirroringPopupMirroringTo", device.friendlyName)}
</p>
<button on:click={() => window.close()}>
{_("mirroringPopupStopMirroring")}
</button>
{:else}
<p>
{_("mirroringPopupConnectedTo", device.friendlyName)}
</p>
<button on:click={requestDisplayMedia} class="button">
{_("mirroringPopupChooseSource")}
</button>
{/if}
</div>
{:else}
<p>
<LoadingIndicator
>{_("mirroringPopupWaitingForConnection")}</LoadingIndicator
>
</p>
{/if}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="../photon-colors.css" />
<link rel="stylesheet" href="../photon-widgets.css" />
<link rel="stylesheet" href="style.css" />
<script src="index.js" defer></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,9 @@
import MirroringPopup from "./MirroringPopup.svelte";
const target = document.getElementById("root");
if (target) {
const mirroringPopup = new MirroringPopup({ target });
window.addEventListener("beforeunload", () => {
mirroringPopup.$destroy();
});
}

View File

@@ -0,0 +1,30 @@
body,
html {
height: 100%;
width: 100%;
}
body {
background: var(--box-background);
color: var(--box-color);
font: message-box;
font-size: 15px;
margin: initial;
}
#root {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
}
.mirroring-status {
align-items: center;
display: flex;
flex-direction: column;
gap: 15px;
}
.mirroring-status > p {
margin: initial;
}

View File

@@ -0,0 +1,369 @@
<script lang="ts">
import semver from "semver";
import { onMount } from "svelte";
import LoadingIndicator from "../LoadingIndicator.svelte";
import bridge, {
BridgeInfo,
BridgeTimedOutError,
BridgeAuthenticationError
} from "../../lib/bridge";
import logger from "../../lib/logger";
import type { Options } from "../../lib/options";
import messaging from "../../messaging";
const _ = browser.i18n.getMessage;
export let opts: Options;
let bridgeInfo: Nullable<BridgeInfo> = null;
let bridgeInfoError: Nullable<Error> = null;
let isLoadingInfo = true;
// Status
let statusIcon: string;
let statusTitle: string;
let statusText: Nullable<string> = null;
async function updateBridgeStatus() {
// Reset state
bridgeInfo = null;
bridgeInfoError = null;
isLoadingInfo = true;
statusText = null;
try {
bridgeInfo = await bridge.getInfo();
} catch (err) {
logger.error("Failed to fetch bridge/platform info.");
if (err instanceof Error) {
bridgeInfoError = err;
}
}
isLoadingInfo = false;
if (!bridgeInfo) {
if (
bridgeInfoError instanceof BridgeTimedOutError ||
bridgeInfoError instanceof BridgeAuthenticationError
) {
statusIcon = "assets/icons8-warn-120.png";
statusTitle = _("optionsBridgeIssueStatusTitle");
if (bridgeInfoError instanceof BridgeTimedOutError) {
statusText = _("optionsBridgeIssueStatusTextTimedOut");
} else if (
bridgeInfoError instanceof BridgeAuthenticationError
) {
statusText = _(
"optionsBridgeIssueStatusTextAuthentication"
);
}
} else {
statusIcon = "assets/icons8-cancel-120.png";
statusTitle = _("optionsBridgeNotFoundStatusTitle");
statusText = _("optionsBridgeNotFoundStatusText");
}
} else {
if (bridgeInfo.isVersionCompatible) {
statusIcon = "assets/icons8-ok-120.png";
statusTitle = _("optionsBridgeFoundStatusTitle");
} else {
statusIcon = "assets/icons8-warn-120.png";
statusTitle = _("optionsBridgeIssueStatusTitle");
}
}
}
onMount(() => {
updateBridgeStatus();
});
// Updates
let updateData: Nullable<GitHubRelease> = null;
let updateStatus: Nullable<string> = null;
let updateStatusTimeout: number;
let isCheckingUpdate = false;
let isUpdateAvailable = false;
interface GitHubRelease {
url: string;
tag_name: string;
html_url: string;
assets: Array<{
content_type: string;
html_url: string;
}>;
}
async function checkUpdate() {
isCheckingUpdate = true;
let releases: GitHubRelease[];
try {
releases = await fetch(
"https://api.github.com/repos/hensm/fx_cast/releases"
).then(res => res.json());
} catch (err) {
isCheckingUpdate = false;
updateStatus = _("optionsBridgeUpdateStatusError");
return;
}
// Ensure valid response
if (!Array.isArray(releases)) {
throw logger.error("Check update response is not array.", releases);
}
// First non-extension-only release
const latestBridgeRelease = releases.find(release =>
release.assets.find(
asset => asset.content_type !== "application/x-xpinstall"
)
);
if (!latestBridgeRelease) {
throw logger.error(
"Check update response does not contain release info."
);
}
/**
* Update available if no bridge found or bridge version lower
* than fetched release version.
*/
isUpdateAvailable =
!bridgeInfo ||
semver.lt(bridgeInfo.version, latestBridgeRelease.tag_name);
if (isUpdateAvailable) {
updateData = latestBridgeRelease;
} else {
updateStatus = _("optionsBridgeUpdateStatusNoUpdates");
}
isCheckingUpdate = false;
if (updateStatusTimeout) {
window.clearTimeout(updateStatusTimeout);
}
updateStatusTimeout = window.setTimeout(() => {
updateStatus = null;
}, 1500);
}
function getUpdate() {
// Open downloads page
if (updateData?.html_url) {
browser.tabs.create({ url: updateData.html_url });
}
}
const [backupMessageStart, backupMessageEnd] = _(
"optionsBridgeBackupEnabled",
"\0"
).split("\0");
</script>
<div class="bridge">
{#if isLoadingInfo}
<div class="bridge__loading">
{_("optionsBridgeLoading")}
<progress />
</div>
{:else}
<div
class="bridge__info"
class:bridge__info--found={!!bridgeInfo}
class:bridge__info--error={!bridgeInfo}
>
<div class="bridge__status">
<img
class="bridge__status-icon"
width="60"
height="60"
src={statusIcon}
alt="icon, bridge status"
/>
<h2 class="bridge__status-title">{statusTitle}</h2>
{#if statusText}
<p class="bridge__status-text">{statusText}</p>
{/if}
<button
type="button"
class="ghost bridge__refresh"
title={_("optionsBridgeRefresh")}
on:click={() => {
if (bridgeInfo && !bridgeInfoError) {
messaging.sendMessage({
subject: "main:refreshDeviceManager"
});
}
updateBridgeStatus();
}}
/>
</div>
{#if bridgeInfo}
<table class="bridge__stats">
<tr>
<th>{_("optionsBridgeStatsName")}</th>
<td>{bridgeInfo.name}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsVersion")}</th>
<td>{bridgeInfo.version}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsExpectedVersion")}</th>
<td>{bridgeInfo.expectedVersion}</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsCompatibility")}</th>
<td>
{bridgeInfo.isVersionCompatible
? bridgeInfo.isVersionExact
? _("optionsBridgeCompatible")
: _("optionsBridgeLikelyCompatible")
: _("optionsBridgeIncompatible")}
</td>
</tr>
<tr>
<th>{_("optionsBridgeStatsRecommendedAction")}</th>
<td>
{bridgeInfo.isVersionCompatible
? _("optionsBridgeNoAction")
: bridgeInfo.isVersionOlder
? _("optionsBridgeOlderAction")
: bridgeInfo.isVersionNewer
? _("optionsBridgeNewerAction")
: _("optionsBridgeNoAction")}
</td>
</tr>
</table>
{/if}
</div>
{/if}
<div class="bridge__options">
<div class="option option--inline">
<div class="option__control">
<input
name="bridgeBackupEnabled"
id="bridgeBackupEnabled"
type="checkbox"
bind:checked={opts.bridgeBackupEnabled}
/>
</div>
<label class="option__label" for="bridgeBackupEnabled">
{backupMessageStart}
<input
class="bridge__backup-host"
name="bridgeBackupHost"
type="text"
required
bind:value={opts.bridgeBackupHost}
/>
:
<input
class="bridge__backup-port"
name="bridgeBackupPort"
type="number"
required
min="1025"
max="65535"
bind:value={opts.bridgeBackupPort}
/>
{backupMessageEnd}
</label>
<div class="option__description">
{_("optionsBridgeBackupEnabledDescription")}
</div>
</div>
{#if opts.showAdvancedOptions}
<fieldset
class="bridge__daemon-options"
disabled={!opts.bridgeBackupEnabled}
>
<div class="option option--inline">
<div class="option__control">
<input
id="bridgeBackupSecure"
type="checkbox"
bind:checked={opts.bridgeBackupSecure}
/>
</div>
<label class="option__label" for="bridgeBackupSecure">
{_("optionsBridgeBackupSecure")}
</label>
<div class="option__description">
{_("optionsBridgeBackupSecureDescription")}
</div>
</div>
<div class="option">
<label class="option__label" for="bridgeBackupPassword">
{_("optionsBridgeBackupPassword")}
</label>
<div class="option__control">
<input
id="bridgeBackupPassword"
placeholder="Password"
type="password"
bind:value={opts.bridgeBackupPassword}
/>
<div class="option__description">
{_("optionsBridgeBackupPasswordDescription")}
</div>
</div>
</div>
</fieldset>
{/if}
</div>
{#if !isLoadingInfo}
<div class="bridge__update-info">
{#if isUpdateAvailable}
<div class="bridge__update">
<p class="bridge__update-label">
{_("optionsBridgeUpdateAvailable")}
</p>
<div class="bridge__update-options">
<button
class="bridge__update-start"
type="button"
on:click={getUpdate}
>
{_("optionsBridgeUpdate")}
</button>
</div>
</div>
{:else}
<button
class="bridge__update-check"
type="button"
disabled={isCheckingUpdate}
on:click={checkUpdate}
>
{#if isCheckingUpdate}
{_("optionsBridgeUpdateChecking", "")}<LoadingIndicator
/>
{:else}
{_("optionsBridgeUpdateCheck")}
{/if}
</button>
{/if}
{#if updateStatus && !isUpdateAvailable}
<div class="bridge--update-status">
{updateStatus}
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,513 @@
<script lang="ts">
import { afterUpdate, onMount } from "svelte";
import Bridge from "./Bridge.svelte";
import Whitelist from "./Whitelist.svelte";
void Bridge, Whitelist;
import logger from "../../lib/logger";
import options, { Options } from "../../lib/options";
import defaultOptions from "../../defaultOptions";
import { getChromeUserAgent } from "../../lib/userAgents";
const _ = browser.i18n.getMessage;
let formElement: HTMLFormElement;
let isFormValid = true;
let isSavedIndicatorVisible = false;
let defaultUserAgent: Optional<string>;
let opts: Options | undefined;
onMount(async () => {
const platform = (await browser.runtime.getPlatformInfo()).os;
defaultUserAgent = getChromeUserAgent(platform);
opts = await options.getAll();
options.addEventListener("changed", async () => {
opts = await options.getAll();
});
});
afterUpdate(() => {
isFormValid = formElement?.checkValidity();
});
/** Saves options and show indicator. */
async function onFormSubmit() {
formElement.reportValidity();
if (!opts) return;
try {
// Remove implicit whitelist item props
for (const item of opts.siteWhitelist) {
if (item.isUserAgentDisabled === false) {
delete item.isUserAgentDisabled;
}
if (item.customUserAgent === "") {
delete item.customUserAgent;
}
}
await options.setAll(opts);
// 1s long saved indicator
isSavedIndicatorVisible = true;
setTimeout(() => {
isSavedIndicatorVisible = false;
}, 1000);
} catch (err) {
logger.error("Failed to save options!");
}
}
function onFormInput() {
isFormValid = formElement.checkValidity();
}
function resetForm() {
if (!opts) return;
opts = {
...JSON.parse(JSON.stringify(defaultOptions)),
// Retain advanced options shown state
showAdvancedOptions: opts.showAdvancedOptions
};
}
</script>
{#if opts}
<form
class="form"
bind:this={formElement}
on:input={onFormInput}
on:submit|preventDefault={onFormSubmit}
>
<Bridge bind:opts />
<fieldset class="category">
<legend class="category__name">
<h2>{_("optionsMediaCategoryName")}</h2>
</legend>
<p class="category__description">
{_("optionsMediaCategoryDescription")}
</p>
<div class="option option--inline">
<div class="option__control">
<input
id="mediaEnabled"
type="checkbox"
bind:checked={opts.mediaEnabled}
/>
</div>
<label class="option__label" for="mediaEnabled">
{_("optionsMediaEnabled")}
</label>
</div>
{#if opts.showAdvancedOptions}
<div class="option option--inline">
<div class="option__control">
<input
id="mediaSyncElement"
type="checkbox"
bind:checked={opts.mediaSyncElement}
/>
</div>
<label class="option__label" for="mediaSyncElement">
{_("optionsMediaSyncElement")}
</label>
<div class="option__description">
{_("optionsMediaSyncElementDescription")}
</div>
</div>
{/if}
<div class="option option--inline">
<div class="option__control">
<input
id="mediaStopOnUnload"
type="checkbox"
bind:checked={opts.mediaStopOnUnload}
/>
</div>
<label class="option__label" for="mediaStopOnUnload">
{_("optionsMediaStopOnUnload")}
</label>
</div>
<hr />
<div class="option option--inline">
<div class="option__control">
<input
id="localMediaEnabled"
type="checkbox"
bind:checked={opts.localMediaEnabled}
/>
</div>
<label class="option__label" for="localMediaEnabled">
{_("optionsLocalMediaEnabled")}
</label>
<div class="option__description">
{_("optionsLocalMediaCategoryDescription")}
</div>
</div>
<div class="option">
<label class="option__label" for="localMediaServerPort">
{_("optionsLocalMediaServerPort")}
</label>
<div class="option__control">
<input
id="localMediaServerPort"
type="number"
required
min="1025"
max="65535"
bind:value={opts.localMediaServerPort}
/>
</div>
</div>
</fieldset>
{#if opts.showAdvancedOptions}
<fieldset class="category">
<legend class="category__name">
<h2>{_("optionsMirroringCategoryName")}</h2>
</legend>
<p class="category__description">
{_("optionsMirroringCategoryDescription")}
</p>
<div class="option option--inline">
<div class="option__control">
<input
id="mirroringEnabled"
type="checkbox"
bind:checked={opts.mirroringEnabled}
/>
</div>
<label class="option__label" for="mirroringEnabled">
{_("optionsMirroringEnabled")}
</label>
</div>
<div class="option">
<label class="option__label" for="mirroringAppId">
{_("optionsMirroringAppId")}
</label>
<div class="option__control">
<input
id="mirroringAppId"
type="text"
required
bind:value={opts.mirroringAppId}
/>
<div class="option__description">
{_("optionsMirroringAppIdDescription")}
</div>
</div>
</div>
<details class="mirroring-stream">
<summary>
{_("optionsMirroringStreamOptions")}
</summary>
<div class="mirroring-stream__options">
<div class="option option--inline scaling-resolution">
<div class="option__control">
<input
type="checkbox"
name="scaling"
id="mirroringStreamUseMaxResolution"
bind:checked={opts.mirroringStreamUseMaxResolution}
/>
</div>
<label
class="option__label"
for="mirroringStreamUseMaxResolution"
>
{_("optionsMirroringStreamMaxResolution")}
<input
type="number"
min="1"
placeholder={_(
"optionsMirroringStreamMaxResolutionWidthPlaceholder"
)}
bind:value={opts
.mirroringStreamMaxResolution.width}
/>
×
<input
type="number"
min="1"
placeholder={_(
"optionsMirroringStreamMaxResolutionHeightPlaceholder"
)}
bind:value={opts
.mirroringStreamMaxResolution.height}
/>
</label>
<p class="option__description">
{_(
"optionsMirroringStreamMaxResolutionDescription"
)}
</p>
</div>
<div class="option scaling-downscale">
<label
class="option__label"
for="mirroringStreamDownscaleFactor"
>
{_("optionsMirroringStreamDownscaleFactor")}
</label>
<div class="option__control">
<input
id="mirroringStreamDownscaleFactor"
type="number"
required
min="1"
step="any"
bind:value={opts.mirroringStreamDownscaleFactor}
/>
<p class="option__description">
{_(
"optionsMirroringStreamDownscaleFactorDescription"
)}
</p>
</div>
</div>
<div class="option">
<label
class="option__label"
for="mirroringStreamMaxFrameRate"
>
{_("optionsMirroringStreamFrameRate")}
</label>
<div class="option__control">
<input
id="mirroringStreamMaxFrameRate"
type="number"
required
min="1"
bind:value={opts.mirroringStreamMaxFrameRate}
/>
</div>
</div>
<div class="option">
<label
class="option__label"
for="mirroringStreamMaxBitRate"
>
{_("optionsMirroringStreamMaxBitRate")}
</label>
<div class="option__control">
<input
id="mirroringStreamMaxBitRate"
type="number"
required
min="1"
bind:value={opts.mirroringStreamMaxBitRate}
/>
<p class="option__description">
{_(
"optionsMirroringStreamMaxBitRateDescription"
)}
</p>
</div>
</div>
</div>
</details>
</fieldset>
{/if}
{#if opts.showAdvancedOptions}
<fieldset class="category">
<legend class="category__name">
<h2>{_("optionsReceiverSelectorCategoryName")}</h2>
</legend>
<p class="category__description">
{_("optionsReceiverSelectorCategoryDescription")}
</p>
<!--
<div class="option option--inline">
<div class="option__control">
<input
id="receiverSelectorWaitForConnection"
type="checkbox"
bind:checked={opts.receiverSelectorWaitForConnection}
/>
</div>
<label
class="option__label"
for="receiverSelectorWaitForConnection"
>
{_("optionsReceiverSelectorWaitForConnection")}
</label>
<div class="option__description">
{_(
"optionsReceiverSelectorWaitForConnectionDescription"
)}
</div>
</div>
-->
<div class="option option--inline">
<div class="option__control">
<input
id="receiverSelectorExpandActive"
type="checkbox"
bind:checked={opts.receiverSelectorExpandActive}
/>
</div>
<label
class="option__label"
for="receiverSelectorExpandActive"
>
{_("optionsReceiverSelectorExpandActive")}
</label>
</div>
<div class="option option--inline">
<div class="option__control">
<input
id="receiverSelectorShowMediaImages"
type="checkbox"
bind:checked={opts.receiverSelectorShowMediaImages}
/>
</div>
<label
class="option__label"
for="receiverSelectorShowMediaImages"
>
{_("optionsReceiverSelectorShowMediaImages")}
</label>
<div class="option__description">
{_("optionsReceiverSelectorShowMediaImagesDescription")}
</div>
</div>
<div class="option option--inline">
<div class="option__control">
<input
id="receiverSelectorCloseIfFocusLost"
type="checkbox"
bind:checked={opts.receiverSelectorCloseIfFocusLost}
/>
</div>
<label
class="option__label"
for="receiverSelectorCloseIfFocusLost"
>
{_("optionsReceiverSelectorCloseIfFocusLost")}
</label>
</div>
</fieldset>
{/if}
<fieldset class="category">
<legend class="category__name">
<h2>{_("optionsSiteWhitelistCategoryName")}</h2>
</legend>
<p class="category__description">
{_("optionsSiteWhitelistCategoryDescription")}
</p>
{#if opts.showAdvancedOptions}
<div class="option option--inline">
<div class="option__control">
<input
id="siteWhitelistEnabled"
type="checkbox"
bind:checked={opts.siteWhitelistEnabled}
/>
</div>
<label class="option__label" for="siteWhitelistEnabled">
{_("optionsSiteWhitelistEnabled")}
<span class="option__recommended">
{_("optionsOptionRecommended")}
</span>
</label>
<div class="option__description">
{_("optionsSiteWhitelistEnabledDescription")}
</div>
</div>
<div class="option">
<label
class="option__label"
for="siteWhitelistCustomUserAgent"
>
{_("optionsSiteWhitelistCustomUserAgent")}
</label>
<div class="option__control">
<input
id="siteWhitelistCustomUserAgent"
type="text"
bind:value={opts.siteWhitelistCustomUserAgent}
placeholder={defaultUserAgent}
/>
<div class="option__description">
{_(
"optionsSiteWhitelistCustomUserAgentDescription"
)}
</div>
</div>
</div>
{/if}
<div class="option">
<div class="option__label">
{_("optionsSiteWhitelistContent")}
</div>
<div class="option__control">
<Whitelist
bind:items={opts.siteWhitelist}
{opts}
{defaultUserAgent}
/>
</div>
</div>
</fieldset>
<div class="form__footer">
<div class="option option--inline">
<div class="option__control">
<input
id="showAdvancedOptions"
type="checkbox"
bind:checked={opts.showAdvancedOptions}
/>
</div>
<label class="option__label" for="showAdvancedOptions">
{_("optionsShowAdvancedOptions")}
</label>
</div>
<div class="form__buttons">
{#if isSavedIndicatorVisible}
<div class="form__status-line">
{_("optionsSaved")}
</div>
{/if}
<button on:click={resetForm} type="button">
{_("optionsReset")}
</button>
<button type="submit" default disabled={!isFormValid}>
{_("optionsSave")}
</button>
</div>
</div>
</form>
{/if}

View File

@@ -0,0 +1,294 @@
<script lang="ts">
import { tick } from "svelte";
import type { WhitelistItemData } from "../../background/whitelist";
import { REMOTE_MATCH_PATTERN_REGEX } from "../../lib/matchPattern";
import type { Options } from "../../lib/options";
import knownApps, { KnownApp } from "../../cast/knownApps";
const _ = browser.i18n.getMessage;
/** Whitelist items to display. */
export let items: WhitelistItemData[];
export let opts: Options;
export let defaultUserAgent: Optional<string>;
let isEditing = false;
let isEditingValid = false;
let editingIndex: number;
let editingInput: HTMLInputElement;
let editingValue: string;
let expandedItemIndices = new Set<number>();
let knownAppToAdd: Nullable<KnownApp> = null;
$: filteredKnownApps = Object.values(knownApps).filter(app => {
// If no pattern or name matches default media sender
if (!app.matches || app.name === _("popupMediaTypeAppMedia")) {
return false;
}
// Filter if pattern already in whitelist
return !items.find(item => item.pattern === app.matches);
});
async function beginEditing(index: number) {
if (isEditing) return;
editingIndex = index;
editingValue = items[index].pattern;
isEditing = true;
await tick();
isEditingValid = editingInput.validity.valid;
editingInput.focus();
editingInput.select();
}
function finishEditing() {
if (!isEditing || !isEditingValid) return;
isEditing = false;
items[editingIndex].pattern = editingValue;
}
async function onEditKeydown(ev: KeyboardEvent) {
key: switch (ev.key) {
// Finish editing on enter
case "Enter":
finishEditing();
break;
// Cancel editing (or adding new item) on escape
case "Escape": {
const originalValue = items[editingIndex];
switch (originalValue.pattern) {
case "":
removeItem(editingIndex);
break key;
case editingValue:
finishEditing();
break key;
}
editingValue = originalValue.pattern;
await tick();
isEditingValid = editingInput.validity.valid;
editingInput.select();
break;
}
}
}
function onEditInput() {
editingInput.setCustomValidity(
// Has duplicate pattern
items.some(
(item, index) =>
index !== editingIndex && item.pattern === editingValue
)
? _("optionsSiteWhitelistInvalidDuplicatePattern")
: ""
);
isEditingValid = editingInput.validity.valid;
}
function addItem() {
if (isEditing) return;
if (knownAppToAdd?.matches) {
items = [
...items,
{ pattern: knownAppToAdd.matches, isEnabled: true }
];
knownAppToAdd = null;
} else {
items = [...items, { pattern: "", isEnabled: true }];
beginEditing(items.length - 1);
}
}
function removeItem(index: number) {
if (isEditing) {
if (index !== editingIndex) return;
isEditing = false;
}
expandedItemIndices.delete(index);
expandedItemIndices = expandedItemIndices;
items.splice(index, 1);
items = items;
}
</script>
<div class="whitelist">
<ul class="whitelist__items">
{#each items as item, i}
{@const isEditingItem = isEditing && editingIndex === i}
{@const isItemExpanded = expandedItemIndices.has(i)}
<li
class="whitelist__item"
class:whitelist__item--selected={isEditingItem}
class:whitelist__item--expanded={isItemExpanded}
class:whitelist__item--disabled={!item.isEnabled}
class:whitelist__item--editing={isEditingItem}
>
{#if !isEditingItem}
<input
type="checkbox"
title={_("optionsSiteWhitelistItemEnabled")}
bind:checked={item.isEnabled}
/>
{/if}
<div
class="whitelist__title"
on:dblclick={() => beginEditing(i)}
>
{#if isEditingItem}
<input
type="text"
class="whitelist__input-pattern"
pattern={REMOTE_MATCH_PATTERN_REGEX.source}
required
title={_("optionsSiteWhitelistItemPattern")}
bind:this={editingInput}
bind:value={editingValue}
on:input={onEditInput}
on:keydown={onEditKeydown}
on:blur={finishEditing}
/>
{:else}
{@const knownApp = Object.values(knownApps).find(
app => app.matches === item.pattern
)}
<div class="whitelist__pattern">
{item.pattern}
</div>
{#if knownApp}
<div class="whitelist__known-app">
&nbsp;({knownApp.name})
</div>
{/if}
{/if}
</div>
{#if !isEditingItem}
<button
type="button"
class="whitelist__edit-button ghost"
title={_("optionsSiteWhitelistEditItem")}
disabled={isEditing && !isEditingValid}
on:click={() => beginEditing(i)}
/>
{/if}
<button
type="button"
class="whitelist__remove-button ghost"
title={_("optionsSiteWhitelistRemoveItem")}
disabled={isEditing && !isEditingItem && !isEditingValid}
on:click={() => removeItem(i)}
/>
{#if !isEditingItem && opts.showAdvancedOptions}
<button
type="button"
class="whitelist__expand-button ghost"
title={_(
isItemExpanded
? "optionsSiteWhitelistItemHideOptions"
: "optionsSiteWhitelistItemShowOptions"
)}
on:click={() => {
// Toggle expanded state
if (isItemExpanded) {
expandedItemIndices.delete(i);
} else {
expandedItemIndices.add(i);
}
expandedItemIndices = expandedItemIndices;
}}
/>
{#if isItemExpanded}
<div class="whitelist__expanded">
<div class="option option--inline">
<div class="option__control">
<input
id="isUserAgentDisabled-{i}"
type="checkbox"
bind:checked={item.isUserAgentDisabled}
/>
</div>
<label
class="option__label"
for="isUserAgentDisabled-{i}"
>
{_("optionsSiteWhitelistUserAgentDisabled")}
</label>
<div class="option__description">
{_(
"optionsSiteWhitelistUserAgentDisabledDescription"
)}
</div>
</div>
<div class="option">
<label
class="option__label"
for="customUserAgentString-{i}"
>
{_(
"optionsSiteWhitelistSiteSpecificUserAgent"
)}
</label>
<div class="option__control">
<input
id="customUserAgentString-{i}"
type="text"
bind:value={item.customUserAgent}
placeholder={opts.siteWhitelistCustomUserAgent ||
defaultUserAgent}
/>
<div class="option__description">
{_(
"optionsSiteWhitelistSiteSpecificUserAgentDescription"
)}
</div>
</div>
</div>
</div>
{/if}
{/if}
</li>
{/each}
</ul>
<hr />
<div class="whitelist__view-actions">
<div class="select-wrapper">
<select bind:value={knownAppToAdd}>
<option value={null}>
{_("optionsSiteWhitelistKnownAppsCustomApp")}
</option>
{#each filteredKnownApps as knownApp}
<option value={knownApp}>
{knownApp.name}
</option>
{/each}
</select>
</div>
<button
class="whitelist__add-button ghost"
title={_("optionsSiteWhitelistAddItem")}
on:click={addItem}
type="button"
/>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,17 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M6.5 12a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5zm2 0a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5zm2 0a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5z"></path>
<path d="M14 2h-3.05a2.5 2.5 0 0 0-4.9 0H3a1 1 0 0 0 0 2v9a3 3 0 0 0 3 3h5a3 3 0 0 0 3-3V4a1 1 0 0 0 0-2zM8.5 1a1.489 1.489 0 0 1 1.391 1H7.109A1.489 1.489 0 0 1 8.5 1zM12 13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4h7z"></path>
</svg>

After

Width:  |  Height:  |  Size: 951 B

View File

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M14.354 2.353l-.708-.707a2.007 2.007 0 0 0-2.828 0l-.379.379a.5.5 0 0 0 0 .707l2.829 2.829a.5.5 0 0 0 .707 0l.379-.379a2.008 2.008 0 0 0 0-2.829zM9.732 3.439a.5.5 0 0 0-.707 0L3.246 9.218a1.986 1.986 0 0 0-.452.712l-1.756 4.39A.5.5 0 0 0 1.5 15a.5.5 0 0 0 .188-.037l4.382-1.752a1.966 1.966 0 0 0 .716-.454l5.779-5.778a.5.5 0 0 0 0-.707zM5.161 12.5l-2.549 1.02a.1.1 0 0 1-.13-.13L3.5 10.831a.1.1 0 0 1 .16-.031l1.54 1.535a.1.1 0 0 1-.039.165z"></path>
</svg>

After

Width:  |  Height:  |  Size: 992 B

View File

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M14 7H9V2a1 1 0 0 0-2 0v5H2a1 1 0 1 0 0 2h5v5a1 1 0 0 0 2 0V9h5a1 1 0 0 0 0-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,16 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 726 B

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../photon-colors.css">
<link rel="stylesheet" href="../photon-widgets.css">
<link rel="stylesheet" href="styles/index.css">
<script src="index.js" defer></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,23 @@
import Options from "./Options.svelte";
// macOS styles
browser.runtime.getPlatformInfo().then(platformInfo => {
const link = document.createElement("link");
link.rel = "stylesheet";
switch (platformInfo.os) {
case "mac": {
link.href = "styles/mac.css";
break;
}
}
if (link.href) {
document.head.appendChild(link);
}
});
const target = document.getElementById("root");
if (target) {
new Options({ target });
}

View File

@@ -0,0 +1,482 @@
#root {
--overlay-color: rgba(255, 255, 255, 0.05);
}
@media (prefers-color-scheme: dark) {
:root {
--overlay-color: rgba(0, 0, 0, 0.1);
}
}
body {
background: var(--box-background) !important;
color: var(--box-color) !important;
font-size: 13px;
overflow-y: hidden;
padding: 20px 10px;
}
a {
color: var(--blue-40);
}
a:hover {
color: var(--blue-50);
}
a:hover:active {
color: var(--blue-60);
}
input:placeholder-shown {
text-overflow: ellipsis;
}
.form {
display: flex;
flex-direction: column;
}
.form__footer {
align-items: center;
display: flex;
margin-top: 20px;
}
.form__buttons {
align-items: center;
display: flex;
gap: 5px;
margin-inline-start: auto;
}
.form__status-line {
color: var(--secondary-color);
}
.bridge {
border-bottom: 1px solid var(--border-color);
margin-bottom: 10px;
order: -1;
padding-bottom: 20px;
}
.bridge,
.bridge__loading {
display: flex;
flex-direction: column;
}
.bridge__loading {
align-items: center;
align-self: center;
font-size: 1.25em;
font-weight: 300;
width: 30%;
}
.bridge__loading progress {
margin-top: 5px;
width: 100%;
}
.bridge__info {
display: flex;
padding-inline-start: 25px;
position: relative;
}
.bridge__info--error {
padding-inline-end: 25px;
}
.bridge__info--found .bridge__status {
border-inline-end: 1px solid var(--border-color);
padding-inline-end: 25px;
}
.bridge__info--error .bridge__status {
display: grid;
grid-template-columns: min-content 1fr;
grid-template-rows: min-content 1fr;
grid-template-areas:
"status-icon status-title"
"status-icon status-text";
gap: 5px 20px;
}
.bridge__info--found .bridge__status-icon {
margin-block-end: 5px;
}
.bridge__info--error .bridge__status-icon {
grid-area: status-icon;
}
.bridge__info--error .bridge__status-title {
grid-area: status-title;
white-space: normal;
}
.bridge__info--error .bridge__status-text {
grid-area: status-text;
margin-top: initial;
align-self: start;
}
.bridge__status {
align-items: center;
display: flex;
flex-direction: column;
margin-inline-end: 25px;
}
.bridge__status-title {
margin: initial;
font-weight: 600;
font-size: 1.5em;
white-space: nowrap;
}
.bridge__status-text {
margin: initial;
margin-top: 5px;
font-size: 1.15em;
text-align: center;
}
.bridge__stats {
border-collapse: collapse;
border-spacing: 0;
}
.bridge__stats th {
font-weight: 600;
padding-inline-end: 10px;
text-align: end;
vertical-align: top;
white-space: nowrap;
}
.bridge__refresh {
background-image: url("../assets/photon_refresh.svg");
position: absolute;
right: 0;
top: 0;
}
.bridge__options {
margin-top: 30px;
}
.bridge__daemon-options {
column-gap: 10px;
border: initial;
display: flex;
flex-direction: column;
row-gap: 5px;
margin-left: 20px;
}
.bridge__daemon-options > .option:not(.option--inline) {
display: flex;
gap: inherit;
}
.bridge__update-info {
align-items: center;
display: flex;
margin-top: 10px;
}
.bridge__update-label {
display: inline-block;
margin: initial;
}
.bridge__update-options {
display: inline-flex;
flex-direction: column;
margin-left: 10px;
}
.bridge__update-start {
align-self: flex-end;
margin-top: 5px;
}
.bridge--update-status {
margin-left: 10px;
}
.bridge__backup-host,
.bridge__backup-port {
margin-left: 2px;
margin-right: 2px;
}
.bridge__backup-host {
width: 125px;
}
.bridge__backup-port {
width: 75px;
}
.bridge__backup-password {
display: block;
margin-left: 20px;
margin-top: 5px;
}
.category {
border: initial;
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
grid-column-gap: 10px;
grid-row-gap: 5px;
margin: initial;
padding: 10px 0;
}
.form > .category {
border-bottom: 1px solid var(--border-color);
}
.category > hr {
border: initial;
border-top: 1px solid var(--border-color);
grid-column: span 2;
width: 100%;
}
.category > details {
display: contents;
}
.category > details > summary {
grid-column: 2;
margin-top: 5px;
}
.category > details:not([open]) > summary ~ * {
display: none;
}
.category > details:not([open]) > summary,
.category > details[open] > :last-child {
margin-bottom: 5px;
}
.category__name {
float: left;
}
.category__name > h2 {
font-size: 1.15em;
font-weight: 600;
margin: initial;
}
.category__description {
color: var(--secondary-color);
margin-top: initial;
max-width: 60ch;
}
.category__name,
.category__description,
.category .category {
grid-column: span 2;
}
.option {
display: contents;
}
.option--inline {
align-items: baseline;
column-gap: 4px;
display: grid;
grid-column-start: 2;
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content;
grid-template-areas:
"input label"
". description";
}
.option--inline > input {
grid-area: input;
width: 16px;
}
.option--inline > .option__label {
grid-area: label;
text-align: initial;
}
.option--inline > .option__description {
grid-area: description;
}
.option__label {
text-align: right;
display: inline-block;
}
fieldset:disabled .option__label,
fieldset:disabled .option__description {
opacity: 0.65;
}
.option__recommended {
background-color: var(--blue-60);
border-radius: 2px;
color: white;
font-size: smaller;
margin-inline-start: 5px;
padding: 0 5px;
}
.option__description {
color: var(--secondary-color);
font-size: smaller;
grid-column: span 2;
margin: 5px 0;
max-width: 60ch;
}
.option > input,
.option > select {
align-self: center;
justify-self: flex-start;
margin-inline-start: initial;
}
.mirroring-stream > summary {
color: var(--secondary-color);
}
.mirroring-stream__options {
background-color: var(--overlay-color);
border-radius: 4px;
column-gap: 10px;
display: flex;
flex-direction: column;
grid-column: 2;
padding: 10px 20px;
row-gap: 5px;
}
.mirroring-stream__options > .option:not(.option--inline) {
display: flex;
gap: inherit;
}
.mirroring-stream__options .option__label {
flex-shrink: 0;
}
.whitelist {
background-color: var(--box-background);
border: 1px solid var(--border-color);
color: var(--box-color);
justify-content: end;
padding: 5px;
}
.whitelist__view-actions {
align-items: center;
display: flex;
gap: 5px;
justify-content: end;
}
.whitelist hr {
border: initial;
border-top: 1px solid var(--border-color);
margin: 5px 0;
}
.whitelist__items {
display: flex;
flex-direction: column;
margin: initial;
margin-inline-start: -5px;
padding: initial;
width: calc(100% + 10px);
}
.whitelist__item {
align-items: center;
display: flex;
flex-wrap: wrap;
column-gap: 5px;
padding: 0 10px;
}
.whitelist__item:nth-child(even) {
background-color: var(--overlay-color);
}
.whitelist__item--selected {
background-color: var(--blue-50-a30) !important;
}
.whitelist__item--disabled:not(.whitelist__item--editing) .whitelist__title {
opacity: 0.65;
}
.whitelist__title {
display: flex;
align-items: center;
flex: 1;
min-height: 34px;
min-width: 0;
padding: 4px;
white-space: nowrap;
}
.whitelist__known-app {
opacity: 0.5;
}
.whitelist__pattern {
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
}
.whitelist__input-pattern {
font: inherit;
width: -moz-available;
}
.whitelist__edit-button {
background-image: url("../assets/photon_edit.svg");
}
.whitelist__remove-button {
background-image: url("../assets/photon_delete.svg");
}
.whitelist__expand-button {
background-image: url("../../assets/photon_arrowhead_down.svg");
}
.whitelist__item--expanded .whitelist__expand-button {
background-image: url("../../assets/photon_arrowhead_up.svg");
}
.whitelist__add-button {
background-image: url("../assets/photon_new.svg");
margin-inline-end: auto;
}
.whitelist__expanded {
border-top: 1px solid var(--border-color);
display: grid;
grid-template-columns: max-content 1fr;
grid-column-gap: 10px;
grid-row-gap: 5px;
padding: 10px;
width: 100%;
}
.whitelist__expanded .option:not(.option--inline) .option__label {
max-width: 80px;
}
.translator__tag {
color: #0a84ff;
display: inline-block;
font-size: 80%;
font-weight: bold;
margin-inline-start: 2px;
text-transform: uppercase;
vertical-align: text-top;
}
/* Option specific styles */
#siteWhitelistCustomUserAgent,
input[id^="customUserAgentString-"] {
width: -moz-available;
}
.scaling-resolution input[type="number"],
.scaling-downscale input[type="number"] {
width: 75px;
}

View File

@@ -0,0 +1,22 @@
body {
font-size: initial;
font: menu;
}
button,
select,
input {
font: inherit;
}
input[type="checkbox"],
input[type="radio"] {
height: 16px;
margin-bottom: 1px;
margin-top: 1px;
width: 16px;
}
input[type="number"] {
padding-inline-end: 4px;
}

View File

@@ -0,0 +1,94 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* Photon Colors CSS Variables v3.2.0 */
:root {
--magenta-50: #ff1ad9;
--magenta-60: #ed00b5;
--magenta-70: #b5007f;
--magenta-80: #7d004f;
--magenta-90: #440027;
--purple-30: #c069ff;
--purple-40: #ad3bff;
--purple-50: #9400ff;
--purple-60: #8000d7;
--purple-70: #6200a4;
--purple-80: #440071;
--purple-90: #25003e;
--blue-40: #45a1ff;
--blue-50: #0a84ff;
--blue-50-a30: #0a84ff4d;
--blue-60: #0060df;
--blue-70: #003eaa;
--blue-80: #002275;
--blue-90: #000f40;
--teal-50: #00feff;
--teal-60: #00c8d7;
--teal-70: #008ea4;
--teal-80: #005a71;
--teal-90: #002d3e;
--green-50: #30e60b;
--green-60: #12bc00;
--green-70: #058b00;
--green-80: #006504;
--green-90: #003706;
--yellow-50: #ffe900;
--yellow-60: #d7b600;
--yellow-60-a30: #d7b6004d;
--yellow-70: #a47f00;
--yellow-80: #715100;
--yellow-90: #3e2800;
--red-50: #ff0039;
--red-60: #d70022;
--red-60-a30: #d700225d;
--red-70: #a4000f;
--red-80: #5a0002;
--red-90: #3e0200;
--orange-50: #ff9400;
--orange-60: #d76e00;
--orange-70: #a44900;
--orange-80: #712b00;
--orange-90: #3e1300;
--grey-10: #f9f9fa;
--grey-10-a10: rgba(249, 249, 250, 0.1);
--grey-10-a20: rgba(249, 249, 250, 0.2);
--grey-10-a30: rgba(249, 249, 250, 0.3);
--grey-10-a40: rgba(249, 249, 250, 0.4);
--grey-10-a60: rgba(249, 249, 250, 0.6);
--grey-10-a80: rgba(249, 249, 250, 0.8);
--grey-20: #ededf0;
--grey-30: #d7d7db;
--grey-40: #b1b1b3;
--grey-50: #737373;
--grey-60: #4a4a4f;
--grey-70: #38383d;
--grey-80: #2a2a2e;
--grey-90: #0c0c0d;
--grey-90-a05: rgba(12, 12, 13, 0.05);
--grey-90-a10: rgba(12, 12, 13, 0.1);
--grey-90-a20: rgba(12, 12, 13, 0.2);
--grey-90-a30: rgba(12, 12, 13, 0.3);
--grey-90-a40: rgba(12, 12, 13, 0.4);
--grey-90-a50: rgba(12, 12, 13, 0.5);
--grey-90-a60: rgba(12, 12, 13, 0.6);
--grey-90-a70: rgba(12, 12, 13, 0.7);
--grey-90-a80: rgba(12, 12, 13, 0.8);
--grey-90-a90: rgba(12, 12, 13, 0.9);
--ink-70: #363959;
--ink-80: #202340;
--ink-90: #0f1126;
--white-100: #ffffff;
}

View File

@@ -0,0 +1,198 @@
:root {
--shadow-10: 0 1px 4px rgba(12, 12, 13, 0.1);
--shadow-20: 0 2px 8px rgba(12, 12, 13, 0.1);
--shadow-30: 0 4px 16px rgba(12, 12, 13, 0.1);
--focus-border-color: var(--blue-50);
--box-background: var(--white-100);
--box-color: var(--grey-90);
--focus-box-shadow: 0 0 0 1px var(--focus-border-color);
--button-background: var(--grey-90-a10);
--button-background-hover: var(--grey-90-a20);
--button-background-active: var(--grey-90-a30);
--button-background-primary: var(--blue-60);
--button-background-primary-hover: var(--blue-70);
--button-background-primary-active: var(--blue-80);
--button-color: var(--grey-90);
--button-color-primary: var(--white-100);
--field-background: var(--box-background);
--field-color: var(--box-color);
--field-placeholder-color: var(--grey-50);
--field-border-color: var(--grey-90-a20);
--field-border-color-hover: var(--grey-90-a30);
--field-box-shadow-warning: 0 0 0 1px var(--yellow-60),
0 0 0 4px var(--yellow-60-a30);
--field-box-shadow-error: 0 0 0 1px var(--red-60),
0 0 0 4px var(--red-60-a30);
--border-color: var(--grey-90-a20);
--secondary-color: rgb(125, 125, 125);
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--box-background: rgb(35, 34, 43);
--box-color: var(--white-100);
--button-background: rgb(71, 70, 79);
--button-background-hover: rgb(80, 79, 88);
--button-background-active: rgb(92, 91, 100);
--button-color: var(--white-100);
--field-placeholder-color: var(--grey-30);
--field-border-color: var(--grey-10-a20);
--field-border-color-hover: var(--grey-10-a30);
--border-color: var(--grey-10-a20);
--secondary-color: var(--grey-10-a60);
}
}
* {
box-sizing: border-box;
}
button,
input,
textarea,
select {
border: 1px solid transparent;
border-radius: 2px;
}
button,
select {
background-color: var(--button-background);
color: var(--button-color);
}
button:not(:disabled):hover {
background-color: var(--button-background-hover);
}
button:not(:disabled):active {
background-color: var(--button-background-active);
}
input,
textarea,
select {
background-color: var(--field-background);
border: 1px solid var(--field-border-color);
color: var(--field-color);
}
input:hover,
textarea:hover,
select:hover {
border-color: var(--field-border-color-hover);
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
border-color: var(--focus-border-color) !important;
box-shadow: var(--focus-box-shadow);
outline: initial;
}
button::-moz-focus-inner,
input::-moz-focus-inner,
textarea::-moz-focus-inner,
select::-moz-focus-inner {
border: initial;
}
input:invalid,
textarea:invalid {
box-shadow: var(--field-box-shadow-error) !important;
border-color: var(--red-60) !important;
}
button:disabled,
input:disabled,
textarea:disabled,
select:disabled {
opacity: 0.35;
}
button,
input,
textarea,
select {
padding: 4px 8px;
font: inherit;
}
/* No inset for spinbox control */
input[type="number"] {
padding-inline-end: 2px;
}
button:default {
background-color: var(--button-background-primary);
color: white !important;
}
button:default:hover {
background-color: var(--button-background-primary-hover);
}
button:default:hover:active {
background-color: var(--button-background-primary-active);
}
.ghost {
align-items: center;
background-position: center center;
background-repeat: no-repeat;
display: flex;
height: 24px !important;
justify-content: center;
padding: initial;
width: 24px !important;
}
.ghost:not(:hover),
.ghost:disabled {
background-color: initial;
}
.select-wrapper {
--arrow-width: 16px;
position: relative;
display: inline-block;
}
.select-wrapper::after {
align-items: center;
background-image: url("assets/photon_arrowhead_down.svg");
background-position: center center;
background-repeat: no-repeat;
background-size: 80%;
content: "";
display: flex;
height: 100%;
justify-content: center;
margin-right: 4px;
opacity: 0.5;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
width: var(--arrow-width);
}
.select-wrapper--disabled::after {
opacity: 0.25;
}
select {
-moz-appearance: none;
padding-right: calc(8px + var(--arrow-width));
}
option:disabled {
opacity: 0.35;
}

View File

@@ -0,0 +1,493 @@
<script lang="ts">
import { afterUpdate, onDestroy, onMount, tick } from "svelte";
import fuzzysort from "fuzzysort";
import messaging, { Message, Port } from "../../messaging";
import options, { Options } from "../../lib/options";
import { RemoteMatchPattern } from "../../lib/matchPattern";
import { receiverMenuIds } from "../../menuIds";
import {
ReceiverDevice,
ReceiverDeviceCapabilities,
ReceiverSelectorAppInfo,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} from "../../types";
import knownApps, { KnownApp } from "../../cast/knownApps";
import { hasRequiredCapabilities } from "../../cast/utils";
import Receiver from "./Receiver.svelte";
const _ = browser.i18n.getMessage;
/** Currently selected media type. */
let mediaType = ReceiverSelectorMediaType.App;
/** Media types available to select. */
let availableMediaTypes = ReceiverSelectorMediaType.App;
/** Whether to show bridge warning banner. */
let isBridgeCompatible = true;
/** Devices to display. */
let devices: ReceiverDevice[] = [];
/** IDs of sessions connected by this extension. */
let connectedSessionIds: string[] = [];
/** Sender app info (if available). */
let appInfo: Optional<ReceiverSelectorAppInfo>;
/** Page info (if launched from page context). */
let pageInfo: Optional<ReceiverSelectorPageInfo>;
/** App details (if matches known app). */
let knownApp: Nullable<KnownApp> = null;
/** Whether current page URL matches a whitelist pattern. */
let isPageWhitelisted = false;
/** Whether casting to a device been initiated from this selector. */
let isConnecting = false;
/** Extension options */
let opts: Nullable<Options> = null;
$: isMediaTypeAvailable = !!(availableMediaTypes & mediaType);
$: isAppMediaTypeAvailable = !!(
availableMediaTypes & ReceiverSelectorMediaType.App
);
/** Whether to display whitelist suggestion banner. */
$: shouldSuggestWhitelist =
// If we know the app
knownApp &&
// If the whitelist is enabled
opts?.siteWhitelistEnabled &&
// If the page is not whitelisted
!isPageWhitelisted &&
// If an app is not already loaded on the page
!(availableMediaTypes & ReceiverSelectorMediaType.App);
/**
* Checks if device is compatible with the requested app and
* capabilities.
*/
function isDeviceCompatible(
mediaType: ReceiverSelectorMediaType,
device: ReceiverDevice
) {
switch (mediaType) {
case ReceiverSelectorMediaType.App:
// If device is audio-only, check app's audio support flag
if (
!(
device.capabilities &
ReceiverDeviceCapabilities.VIDEO_OUT
) &&
appInfo?.isRequestAppAudioCompatible === false
) {
return false;
}
return hasRequiredCapabilities(
device,
appInfo?.sessionRequest?.capabilities
);
/** Mirroring requires video output capability. */
case ReceiverSelectorMediaType.Screen:
return !!(
device.capabilities & ReceiverDeviceCapabilities.VIDEO_OUT
);
}
return false;
}
let port: Nullable<Port> = null;
let browserWindow: Nullable<browser.windows.Window> = null;
let resizeObserver = new ResizeObserver(() => fitWindowHeight());
window.addEventListener("resize", fitWindowHeight);
onMount(async () => {
port = messaging.connect({ name: "popup" });
port.onMessage.addListener(onMessage);
browserWindow = await browser.windows.getCurrent();
opts = await options.getAll();
options.addEventListener("changed", async ev => {
opts = await options.getAll();
/**
* Update available media types and ensure selected media
* type is valid.
*/
if (ev.detail.includes("mirroringEnabled")) {
if (!opts.mirroringEnabled) {
availableMediaTypes &= ~ReceiverSelectorMediaType.Screen;
} else {
availableMediaTypes |= ReceiverSelectorMediaType.Screen;
}
if (!(availableMediaTypes & mediaType)) {
if (availableMediaTypes & ReceiverSelectorMediaType.App) {
mediaType = ReceiverSelectorMediaType.App;
} else if (
availableMediaTypes & ReceiverSelectorMediaType.Screen
) {
mediaType = ReceiverSelectorMediaType.Screen;
} else {
mediaType = ReceiverSelectorMediaType.App;
}
}
}
});
updateKnownApp();
resizeObserver.observe(document.documentElement);
browser.menus.onShown.addListener(onMenuShown);
});
onDestroy(() => {
port?.disconnect();
resizeObserver.disconnect();
browser.menus.onShown.removeListener(onMenuShown);
});
afterUpdate(async () => {
await tick();
fitWindowHeight();
});
function onMessage(message: Message) {
switch (message.subject) {
case "popup:init":
appInfo = message.data.appInfo;
pageInfo = message.data.pageInfo;
break;
case "popup:update": {
isBridgeCompatible = message.data.isBridgeCompatible;
updateKnownApp();
if (
message.data.availableMediaTypes !== undefined &&
message.data.defaultMediaType !== undefined
) {
availableMediaTypes = message.data.availableMediaTypes;
if (availableMediaTypes & message.data.defaultMediaType) {
mediaType = message.data.defaultMediaType;
}
}
devices = message.data.devices;
if (message.data.connectedSessionIds) {
connectedSessionIds = message.data.connectedSessionIds;
}
break;
}
}
}
/** Resize browser window to fit content height. */
function fitWindowHeight() {
if (browserWindow?.id === undefined) return;
browser.windows.update(browserWindow.id, {
height: Math.ceil(
document.body.clientHeight +
(window.outerHeight - window.innerHeight)
)
});
}
function updateKnownApp() {
let newKnownApp: Nullable<KnownApp> = null;
/**
* Check knownApps for an app with an ID matching the registered
* app on the target page.
*/
if (isAppMediaTypeAvailable && appInfo?.sessionRequest.appId) {
newKnownApp = knownApps[appInfo.sessionRequest.appId];
} else if (pageInfo) {
const pageUrl = pageInfo.url;
/**
* Or if there isn't an registered app, check for an app
* with a match pattern matching the target page URL.
*/
for (const [, app] of Object.entries(knownApps)) {
if (!app.matches) {
continue;
}
const pattern = new RemoteMatchPattern(app.matches);
if (pattern.matches(pageUrl)) {
newKnownApp = app;
break;
}
}
}
// Check if target page URL is whitelisted.
if (pageInfo && opts?.siteWhitelist) {
for (const item of opts.siteWhitelist) {
const pattern = new RemoteMatchPattern(item.pattern);
if (pattern.matches(pageInfo.url)) {
isPageWhitelisted = true;
break;
}
}
}
knownApp = newKnownApp;
}
async function addToWhitelist(
app: KnownApp,
pageInfo: ReceiverSelectorPageInfo
) {
if (!app.matches) {
return;
}
const whitelist = await options.get("siteWhitelist");
if (!whitelist.find(item => item.pattern === app.matches)) {
whitelist.push({ pattern: app.matches, isEnabled: true });
await options.set("siteWhitelist", whitelist);
await browser.tabs.reload(pageInfo.tabId);
window.close();
}
}
/** Device ID associated with the last receiver menu that was shown. */
let lastMenuShownDeviceId: string;
/** Handle show events for receiver context menus. */
function onMenuShown(info: browser.menus._OnShownInfo) {
// Only handle menu events on this page
if (info.pageUrl !== window.location.href) return;
if (!info.targetElementId) return;
const targetElement = browser.menus.getTargetElement(
info.targetElementId
);
if (!targetElement) return;
const receiverElement = targetElement.closest(".receiver");
if (!receiverElement) {
for (const menuId of receiverMenuIds) {
browser.menus.update(menuId, { visible: false });
}
browser.menus.refresh();
}
}
function onReceiverCast(device: ReceiverDevice) {
isConnecting = true;
port?.postMessage({
subject: "main:receiverSelected",
data: { device, mediaType }
});
}
function onReceiverStop(device: ReceiverDevice) {
port?.postMessage({
subject: "main:sendReceiverMessage",
data: {
deviceId: device.id,
message: { requestId: 0, type: "STOP" }
}
});
port?.postMessage({
subject: "main:receiverStopped",
data: { deviceId: device.id }
});
}
function openOptionsPage() {
browser.runtime.openOptionsPage();
}
/** Search input element. */
let searchInput: HTMLInputElement | undefined;
/** Current search term. */
let searchTerm: string | undefined;
let isSearching = false;
/** Results of current search term. */
let searchResults: Fuzzysort.KeyResults<ReceiverDevice> | undefined;
async function handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
handleSearchClear();
return;
}
if (!isSearching && ev.key.length === 1) {
isSearching = true;
}
await tick();
searchInput?.focus();
}
function handleSearchInput() {
// Clear search on empty string
if (!searchTerm) {
handleSearchClear();
return;
}
searchResults = fuzzysort.go(searchTerm, devices, {
key: "friendlyName"
});
}
function handleSearchClear() {
isSearching = false;
searchTerm = undefined;
}
</script>
<svelte:window on:keydown={handleKeyDown} />
{#if !isBridgeCompatible}
<div class="banner banner--warn">
{_("popupBridgeErrorBanner")}
<button on:click={openOptionsPage}>
{_("popupBridgeErrorBannerOptions")}
</button>
</div>
{/if}
{#if shouldSuggestWhitelist}
<div class="banner banner--info">
{_("popupWhitelistNotWhitelisted", knownApp?.name)}
<button
on:click={() => {
if (!knownApp || !pageInfo) return;
addToWhitelist(knownApp, pageInfo);
}}
>
{_("popupWhitelistAddToWhitelist")}
</button>
</div>
{/if}
{#if availableMediaTypes !== ReceiverSelectorMediaType.None}
<div class="media-type-select">
<div class="media-type-select__label-cast">
{_("popupMediaSelectCastLabel")}
</div>
<div class="select-wrapper">
<select class="media-type-select__dropdown" bind:value={mediaType}>
<option
value={ReceiverSelectorMediaType.App}
disabled={!isAppMediaTypeAvailable}
>
{knownApp?.name ?? _("popupMediaTypeApp")}
</option>
{#if opts?.mirroringEnabled}
<option
value={ReceiverSelectorMediaType.Screen}
disabled={!(
availableMediaTypes &
ReceiverSelectorMediaType.Screen
)}
>
{_("popupMediaTypeScreen")}
</option>
{/if}
</select>
</div>
<div class="media-type-select__label-to">
{_("popupMediaSelectToLabel")}
</div>
</div>
{/if}
{#if isSearching}
<div class="search">
<input
type="text"
class="search-input"
bind:this={searchInput}
bind:value={searchTerm}
on:input={handleSearchInput}
title={_("popupSearch")}
/>
<button
class="search-clear ghost"
title={_("popupSearchClear")}
on:click={handleSearchClear}
/>
</div>
{/if}
<ul class="receiver-list">
{#if searchTerm && searchResults}
{#if !searchResults.length}
<div class="receiver-list__not-found">
No devices found for "{searchTerm}"
</div>
{/if}
{#each searchResults as result}
{@const device = devices.find(
device => device.id === result.obj.id
)}
{#if device}
<Receiver
{opts}
{port}
{device}
{result}
{connectedSessionIds}
{isMediaTypeAvailable}
isAnyMediaTypeAvailable={availableMediaTypes !==
ReceiverSelectorMediaType.None &&
isDeviceCompatible(mediaType, device)}
isAnyConnecting={isConnecting}
bind:lastMenuShownDeviceId
on:cast={ev => onReceiverCast(ev.detail.device)}
on:stop={ev => onReceiverStop(ev.detail.device)}
/>
{/if}
{/each}
{:else if !devices.length}
<div class="receiver-list__not-found">
{_("popupNoReceiversFound")}
</div>
{:else}
{#each devices as device}
<Receiver
{opts}
{port}
{device}
{connectedSessionIds}
{isMediaTypeAvailable}
isAnyMediaTypeAvailable={availableMediaTypes !==
ReceiverSelectorMediaType.None &&
isDeviceCompatible(mediaType, device)}
isAnyConnecting={isConnecting}
bind:lastMenuShownDeviceId
on:cast={ev => onReceiverCast(ev.detail.device)}
on:stop={ev => onReceiverStop(ev.detail.device)}
/>
{/each}
{/if}
</ul>

View File

@@ -0,0 +1,501 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import fuzzysort from "fuzzysort";
import type { Options } from "../../lib/options";
import { ReceiverDevice, ReceiverDeviceCapabilities } from "../../types";
import type { Port } from "../../messaging";
import * as menuIds from "../../menuIds";
import type { Volume } from "../../cast/sdk/classes";
import { PlayerState, TrackType } from "../../cast/sdk/media/enums";
import {
SenderMediaMessage,
SenderMessage,
_MediaCommand
} from "../../cast/sdk/types";
import LoadingIndicator from "../LoadingIndicator.svelte";
import ReceiverMedia from "./ReceiverMedia.svelte";
const _ = browser.i18n.getMessage;
const dispatch = createEventDispatcher<{
cast: { device: ReceiverDevice };
stop: { device: ReceiverDevice };
}>();
export let port: Nullable<Port>;
/** Whether there are sessions being established for any receiver. */
export let isAnyConnecting: boolean;
/** Whether the selected media type is available for this receiver. */
export let isMediaTypeAvailable: boolean;
/** Whether any media types are available for this receiver. */
export let isAnyMediaTypeAvailable: boolean;
/** Device to display. */
export let device: ReceiverDevice;
export let connectedSessionIds: string[];
/** Result object if this receiver is displayed in a search results list. */
export let result: Nullable<Fuzzysort.KeyResult<ReceiverDevice>> = null;
export let opts: Nullable<Options>;
/** Current receiver application (if available) */
$: application = device.status?.applications?.[0];
/** Current media status (if available) */
$: mediaStatus = device.mediaStatus;
export let lastMenuShownDeviceId: string;
$: if (lastMenuShownDeviceId === device.id) {
void device.mediaStatus;
updateMediaMenus();
browser.menus.refresh();
}
const languageNames = new Intl.DisplayNames(
[browser.i18n.getUILanguage()],
{ type: "language" }
);
// Subtitle/caption tracks
$: textTracks = mediaStatus?.media?.tracks
?.filter(track => track.type === TrackType.TEXT)
.map(track => {
/**
* If track has no name, but does have a language, get a
* display name for the language.
*/
if (!track.name && track.language) {
try {
const displayName = languageNames.of(track.language);
if (displayName) {
track.name = displayName;
}
// eslint-disable-next-line no-empty
} catch (err) {}
}
return track;
});
$: activeTextTrackId = mediaStatus?.activeTrackIds?.find(trackId =>
textTracks?.find(track => track.trackId === trackId)
);
/** Whether media controls are shown. */
let isExpanded = false;
let isExpandedUserModified = false;
// Unexpand if media status disappears
$: if (!device.mediaStatus) {
isExpanded = false;
} else if (
// If app is running
application &&
// And user hasn't manually changed the expanded state
!isExpandedUserModified &&
// And auto-expansion is enabled
opts?.receiverSelectorExpandActive
) {
isExpanded = connectedSessionIds.includes(application.transportId);
}
/** Whether a session request is in progress for this receiver.. */
let isConnecting = false;
function sendReceiverMessage(
partialMessage: DistributiveOmit<SenderMessage, "requestId">
) {
const message: SenderMessage = {
...partialMessage,
requestId: 0
};
port?.postMessage({
subject: "main:sendReceiverMessage",
data: { deviceId: device.id, message }
});
}
function sendMediaMessage(
partialMessage: DistributiveOmit<
SenderMediaMessage,
"requestId" | "mediaSessionId"
>
) {
if (!device.mediaStatus) return;
const message: SenderMediaMessage = {
...(partialMessage as any),
requestId: 0,
mediaSessionId: device.mediaStatus.mediaSessionId
};
port?.postMessage({
subject: "main:sendMediaMessage",
data: { deviceId: device.id, message }
});
}
let receiverElement: HTMLLIElement;
function isTarget(
info?: browser.menus._OnShownInfo | browser.menus.OnClickData
) {
// Only handle menu events on this page
if (info?.pageUrl !== window.location.href) return false;
if (!info.targetElementId) return false;
const targetElement = browser.menus.getTargetElement(
info.targetElementId
);
if (!targetElement) return false;
return (
targetElement === receiverElement ||
receiverElement.contains(targetElement)
);
}
// Map of menu IDs to track IDs
const captionSubmenus = new Map<number | string, number>();
function onMenuShown(info: browser.menus._OnShownInfo) {
if (!isTarget(info)) {
return;
}
lastMenuShownDeviceId = device.id;
browser.menus.update(menuIds.POPUP_CAST, {
visible: true,
title: _("popupCastMenuTitle", device.friendlyName),
enabled:
// Not already connecting to a receiver
!isConnecting &&
!isAnyConnecting &&
// Selected media type available
isMediaTypeAvailable &&
isAnyMediaTypeAvailable
});
browser.menus.update(menuIds.POPUP_STOP, {
visible: !!application && !application.isIdleScreen,
title: application?.displayName
? _("popupStopMenuTitle", [
application.displayName,
device.friendlyName
])
: ""
});
updateMediaMenus(info.menuIds as (string | number)[]);
browser.menus.refresh();
}
function handleMediaPlayPause() {
switch (mediaStatus?.playerState) {
case PlayerState.PLAYING:
sendMediaMessage({ type: "PAUSE" });
break;
case PlayerState.PAUSED:
sendMediaMessage({ type: "PLAY" });
break;
}
}
function handleMediaSkipPrevious() {
sendMediaMessage({
type: "QUEUE_UPDATE",
jump: -1
});
}
function handleMediaSkipNext() {
sendMediaMessage({
type: "QUEUE_UPDATE",
jump: 1
});
}
function handleMediaTrackChange(activeTrackIds: number[]) {
sendMediaMessage({
type: "EDIT_TRACKS_INFO",
activeTrackIds: activeTrackIds
});
}
function handleVolumeChange(volume: Partial<Volume>) {
sendReceiverMessage({
type: "SET_VOLUME",
volume
});
}
function onMenuClicked(info: browser.menus.OnClickData) {
if (!isTarget(info)) return;
switch (info.menuItemId) {
case menuIds.POPUP_MEDIA_PLAY_PAUSE:
handleMediaPlayPause();
break;
case menuIds.POPUP_MEDIA_MUTE:
if (
!device.status?.volume.muted &&
device.status?.volume.level === 0
) {
handleVolumeChange({ level: 1 });
} else {
handleVolumeChange({ muted: !device.status?.volume.muted });
}
break;
case menuIds.POPUP_MEDIA_SKIP_PREVIOUS:
handleMediaSkipPrevious();
break;
case menuIds.POPUP_MEDIA_SKIP_NEXT:
handleMediaSkipNext();
break;
case menuIds.POPUP_CAST:
isConnecting = true;
dispatch("cast", { device });
break;
case menuIds.POPUP_STOP:
dispatch("stop", { device });
break;
}
// Handle caption submenu items
if (info.parentMenuItemId === menuIds.POPUP_MEDIA_CC) {
// Filter and append active track IDs array
if (!mediaStatus?.activeTrackIds) return;
const activeTrackIds = mediaStatus.activeTrackIds.filter(
activeTrackId => activeTrackId !== activeTextTrackId
);
const trackId = captionSubmenus.get(info.menuItemId);
if (trackId) {
activeTrackIds.push(trackId);
}
handleMediaTrackChange(activeTrackIds);
}
}
function onContextMenu() {
browser.menus.overrideContext({ showDefaults: false });
}
/** Updates media menu items from media status. */
function updateMediaMenus(shownMenuIds: (number | string)[] = []) {
// Clear caption submenu for re-build
if (captionSubmenus.size) {
for (const menuId of captionSubmenus.keys()) {
browser.menus.remove(menuId);
}
captionSubmenus.clear();
} else {
// Clear caption submenus from previous instances
for (const menuId of shownMenuIds as string[] | number[]) {
if (
typeof menuId === "string" &&
menuId.startsWith("subtitle-")
) {
browser.menus.remove(menuId);
}
}
}
// Hide all media menu items if no media status
if (!mediaStatus) {
for (const menuId of menuIds.mediaMenuIds) {
browser.menus.update(menuId, { visible: false });
}
return;
}
browser.menus.update(menuIds.POPUP_MEDIA_SEPARATOR, {
visible: true
});
// Play/pause menu item
if (mediaStatus.supportedMediaCommands & _MediaCommand.PAUSE) {
browser.menus.update(menuIds.POPUP_MEDIA_PLAY_PAUSE, {
visible: true,
title:
mediaStatus.playerState === PlayerState.PLAYING ||
mediaStatus.playerState === PlayerState.BUFFERING
? _("popupMediaPause")
: _("popupMediaPlay"),
enabled:
mediaStatus.playerState === PlayerState.PLAYING ||
mediaStatus.playerState === PlayerState.PAUSED
});
} else {
browser.menus.update(menuIds.POPUP_MEDIA_PLAY_PAUSE, {
visible: false
});
}
// Mute/unmute menu item
if (device.status?.volume) {
const volume = device.status.volume;
browser.menus.update(menuIds.POPUP_MEDIA_MUTE, {
visible: true,
title: _("popupMediaMute"),
checked: volume.muted || volume.level === 0,
enabled: "muted" in volume
});
} else {
browser.menus.update(menuIds.POPUP_MEDIA_MUTE, {
visible: false
});
}
browser.menus.update(menuIds.POPUP_MEDIA_SKIP_PREVIOUS, {
visible: !!(
mediaStatus.supportedMediaCommands & _MediaCommand.QUEUE_PREV
)
});
browser.menus.update(menuIds.POPUP_MEDIA_SKIP_NEXT, {
visible: !!(
mediaStatus.supportedMediaCommands & _MediaCommand.QUEUE_NEXT
)
});
// Build captions submenu from text tracks
if (
textTracks?.length &&
mediaStatus.supportedMediaCommands & _MediaCommand.EDIT_TRACKS
) {
browser.menus.update(menuIds.POPUP_MEDIA_CC, { visible: true });
browser.menus.update(menuIds.POPUP_MEDIA_CC_OFF, {
visible: true,
checked: activeTextTrackId === undefined
});
for (const track of textTracks) {
const menuId = browser.menus.create({
id: `subtitle-${track.trackId}`,
title: track.name ?? track.trackId.toString(),
parentId: menuIds.POPUP_MEDIA_CC,
type: "radio",
checked: track.trackId === activeTextTrackId
});
captionSubmenus.set(menuId, track.trackId);
}
} else {
browser.menus.update(menuIds.POPUP_MEDIA_CC, {
visible: false
});
}
}
onMount(() => {
sendMediaMessage({
type: "GET_STATUS"
});
browser.menus.onShown.addListener(onMenuShown);
browser.menus.onClicked.addListener(onMenuClicked);
return () => {
browser.menus.onShown.removeListener(onMenuShown);
browser.menus.onClicked.removeListener(onMenuClicked);
};
});
</script>
<li
class="receiver"
class:receiver--result={!!result}
bind:this={receiverElement}
on:contextmenu={onContextMenu}
>
<img
class="receiver__icon"
src="icons/{device.capabilities & ReceiverDeviceCapabilities.VIDEO_OUT
? 'device-video.svg'
: 'device-audio.svg'}"
alt=""
height="24"
width="24"
/>
<div class="receiver__details">
<div class="receiver__name">
{#if result}
{@html fuzzysort.highlight(result)}
{:else}
{device.friendlyName}
{/if}
</div>
{#if application && !application.isIdleScreen}
<div class="receiver__status">
<span class="receiver__app-name">
{application.displayName}
</span>
{#if application.statusText !== application.displayName}
· {application.statusText}
{/if}
</div>
{/if}
</div>
{#if application && !application.isIdleScreen}
<button
class="receiver__stop-button"
on:click={() => dispatch("stop", { device })}
>
{_("popupStopButtonTitle")}
</button>
{:else if isAnyMediaTypeAvailable}
<button
class="receiver__cast-button"
disabled={isConnecting || isAnyConnecting || !isMediaTypeAvailable}
on:click={() => {
isConnecting = true;
dispatch("cast", { device });
}}
>
{#if isConnecting}
{_("popupCastingButtonTitle", "")}<LoadingIndicator />
{:else}
{_("popupCastButtonTitle")}
{/if}
</button>
{/if}
<button
type="button"
class="receiver__expand-button ghost"
class:receiver__expand-button--expanded={isExpanded && mediaStatus}
title={_("popupShowDetailsTitle")}
disabled={!mediaStatus}
on:click={() => {
isExpanded = !isExpanded;
isExpandedUserModified = true;
}}
/>
{#if isExpanded && mediaStatus}
<div class="receiver__expanded">
<ReceiverMedia
status={mediaStatus}
showImage={opts?.receiverSelectorShowMediaImages}
{device}
{textTracks}
on:togglePlayback={() => handleMediaPlayPause()}
on:previous={() => handleMediaSkipPrevious()}
on:next={() => handleMediaSkipNext()}
on:seek={ev => {
sendMediaMessage({
type: "SEEK",
currentTime: ev.detail.position
});
}}
on:trackChanged={ev =>
handleMediaTrackChange(ev.detail.activeTrackIds)}
on:volumeChanged={ev => handleVolumeChange(ev.detail)}
/>
</div>
{/if}
</li>

View File

@@ -0,0 +1,394 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import type { ReceiverDevice } from "../../types";
import { MediaStatus, _MediaCommand } from "../../cast/sdk/types";
import type { Volume } from "../../cast/sdk/classes";
import {
MetadataType,
PlayerState,
StreamType
} from "../../cast/sdk/media/enums";
import type { Track } from "../../cast/sdk/media/classes";
import { getEstimatedTime } from "../../cast/utils";
const _ = browser.i18n.getMessage;
const dispatch = createEventDispatcher<{
togglePlayback: void;
seek: { position: number };
previous: void;
next: void;
trackChanged: { activeTrackIds: number[] };
volumeChanged: Partial<Volume>;
}>();
export let status: MediaStatus;
export let device: ReceiverDevice;
export let textTracks: Track[] = [];
export let showImage = false;
$: isPlayingOrPaused =
status.playerState === PlayerState.PLAYING ||
status.playerState === PlayerState.PAUSED;
$: hasDuration = status.media?.duration && status.media?.duration > 0;
$: isSeekable = status.supportedMediaCommands & _MediaCommand.SEEK;
$: isLive = status.media?.streamType === StreamType.LIVE;
let mediaTitle: Optional<string>;
let mediaSubtitle: Optional<string>;
let mediaImageSet: Optional<string>;
// Choose subset of metadata depending on metadata type
$: {
const metadata = status?.media?.metadata;
mediaTitle = metadata?.title;
mediaSubtitle = undefined;
if (metadata) {
switch (metadata.metadataType) {
case MetadataType.AUDIOBOOK_CHAPTER:
if (metadata.bookTitle) {
metadata.title = metadata.bookTitle;
}
metadata.subtitle = metadata.chapterTitle;
break;
case MetadataType.MUSIC_TRACK:
mediaSubtitle = metadata.artist;
break;
case MetadataType.TV_SHOW:
if (metadata.seriesTitle) {
mediaTitle = metadata.seriesTitle;
mediaSubtitle = metadata.title;
}
break;
case MetadataType.MOVIE:
case MetadataType.GENERIC:
mediaSubtitle = metadata.subtitle;
}
}
if (showImage && metadata?.images?.length) {
let imageSet: string[] = [];
for (const image of metadata.images) {
let sizeString = image.url;
if (image.width) sizeString += ` ${image.width}w`;
imageSet.push(sizeString);
}
mediaImageSet = imageSet.join(",");
} else {
mediaImageSet = undefined;
}
}
// Keep track of update times for currentTime estimations
let lastUpdateTime = 0;
let lastCurrentTime = 0;
let currentTime = getEstimatedMediaTime();
$: if (
device.mediaStatus?.currentTime &&
device.mediaStatus.currentTime !== lastCurrentTime
) {
lastUpdateTime = Date.now();
currentTime = device.mediaStatus.currentTime;
lastCurrentTime = currentTime;
}
// Update estimated time every second
onMount(() => {
const intervalId = window.setInterval(() => {
if (currentTime !== getEstimatedMediaTime()) {
currentTime = getEstimatedMediaTime();
}
}, 1000);
return () => {
window.clearInterval(intervalId);
};
});
/**
* Estimates the current playback position based on the last status
* update.
*/
function getEstimatedMediaTime() {
if (!status.currentTime || !lastUpdateTime) return 0;
if (status.playerState === PlayerState.PLAYING) {
return getEstimatedTime({
currentTime: status.currentTime,
lastUpdateTime,
duration: status.media?.duration
});
}
return status.currentTime;
}
/** Formats seconds into HH:MM:SS */
function formatTime(seconds: number) {
const date = new Date(seconds * 1000);
const hours = date.getUTCHours();
let ret = "";
if (hours) ret += `${hours}:`;
ret += `${date
.getUTCMinutes()
.toString()
.padStart(hours ? 2 : 1, "0")}:`;
ret += date.getUTCSeconds().toString().padStart(2, "0");
return ret;
}
let seekHoverPosition: Nullable<number> = null;
function onSeekMouseMove(node: HTMLInputElement) {
if (node.type !== "range") {
throw new Error("Wrong type of input!");
}
function onMouseMove(ev: MouseEvent) {
const clientRect = node.getBoundingClientRect();
seekHoverPosition =
((ev.clientX - clientRect.left) / clientRect.width) * 100;
}
const onMouseLeave = () => (seekHoverPosition = null);
node.addEventListener("mousemove", onMouseMove);
node.addEventListener("mouseleave", onMouseLeave);
return {
destroy() {
seekHoverPosition = null;
node.removeEventListener("mousemove", onMouseMove);
node.removeEventListener("mouseleave", onMouseLeave);
}
};
}
</script>
<div class="media">
{#if mediaTitle}
<div class="media__metadata">
{#if mediaImageSet}
<img class="media__image" srcset={mediaImageSet} alt="" />
{/if}
<div class="media__metadata-text">
<div class="media__title" title={mediaTitle}>
{mediaTitle}
</div>
{#if mediaSubtitle}
<div class="media__subtitle">
{mediaSubtitle}
</div>
{/if}
</div>
</div>
{/if}
<div class="media__controls">
<!-- Seek bar -->
{#if status.media && status.media?.duration && hasDuration && isSeekable}
<div class="media__seek">
{#if isLive}
<span class="media__live">
{_("popupMediaLive")}
</span>
{/if}
<span class="media__current-time">
{formatTime(currentTime)}
</span>
<div class="media__seek-bar-container">
<input
type="range"
class="slider media__seek-bar"
class:slider--indeterminate={status.playerState ===
PlayerState.BUFFERING}
aria-label={_("popupMediaSeek")}
max={status.media.duration ?? currentTime}
value={currentTime}
on:change={ev => {
if (seekHoverPosition) {
ev.preventDefault();
return;
}
dispatch("seek", {
position: ev.currentTarget.valueAsNumber
});
}}
on:click={() => {
if (seekHoverPosition && status.media?.duration) {
dispatch("seek", {
position:
status.media.duration *
(seekHoverPosition / 100)
});
}
}}
use:onSeekMouseMove
/>
{#if seekHoverPosition}
<div
class="media__seek-tooltip"
style:--seek-hover-position="{seekHoverPosition}%"
>
{formatTime(
status.media.duration *
(seekHoverPosition / 100)
)}
</div>
{/if}
</div>
<span class="media__remaining-time">
-{formatTime(status.media.duration - currentTime)}
</span>
</div>
{/if}
<div class="media__buttons">
{#if status.supportedMediaCommands & _MediaCommand.QUEUE_PREV}
<button
class="media__previous-button ghost"
title={_("popupMediaSkipPrevious")}
on:click={() => dispatch("previous")}
/>
{/if}
{#if status.supportedMediaCommands & _MediaCommand.SEEK}
<button
class="media__backward-button ghost"
title={_("popupMediaSeekBackward")}
disabled={status.playerState === PlayerState.IDLE}
on:click={() =>
dispatch("seek", { position: currentTime - 5 })}
/>
{/if}
{#if status.supportedMediaCommands & _MediaCommand.PAUSE}
<button
class={`ghost ${
status.playerState === PlayerState.PLAYING ||
status.playerState === PlayerState.BUFFERING
? "media__pause-button"
: "media__play-button"
}`}
title={isPlayingOrPaused &&
status.playerState === PlayerState.PLAYING
? _("popupMediaPause")
: _("popupMediaPlay")}
on:click={() => dispatch("togglePlayback")}
/>
{/if}
{#if status.supportedMediaCommands & _MediaCommand.SEEK}
<button
class="media__forward-button ghost"
disabled={status.playerState === PlayerState.IDLE}
title={_("popupMediaSeekForward")}
on:click={() =>
dispatch("seek", { position: currentTime + 5 })}
/>
{/if}
{#if status.supportedMediaCommands & _MediaCommand.QUEUE_NEXT}
<button
class="media__next-button ghost"
title={_("popupMediaSkipNext")}
on:click={() => dispatch("next")}
/>
{/if}
{#if textTracks?.length && status.supportedMediaCommands & _MediaCommand.EDIT_TRACKS}
{@const activeTextTrackId = status.activeTrackIds?.find(
trackId =>
textTracks?.find(track => track.trackId === trackId)
)}
<select
class="media__cc-button ghost"
class:media__cc-button--off={activeTextTrackId ===
undefined}
title={_("popupMediaSubtitlesCaptions")}
value={activeTextTrackId}
on:change={ev => {
if (!status.activeTrackIds) return;
let activeTrackIds = status.activeTrackIds.filter(
trackId => trackId !== activeTextTrackId
);
const trackId = parseInt(ev.currentTarget.value);
if (!Number.isNaN(trackId)) {
activeTrackIds.push(trackId);
}
dispatch("trackChanged", { activeTrackIds });
}}
>
<option value={undefined}>
{_("popupMediaSubtitlesCaptionsOff")}
</option>
{#each textTracks as track}
<option value={track.trackId}>
{track.name ?? track.trackId}
</option>
{/each}
</select>
{/if}
{#if isLive && !isSeekable}
<span class="media__live">
{_("popupMediaLive")}
</span>
{/if}
{#if device.status?.volume}
{@const volume = device.status?.volume}
{@const isMuted = volume.muted || volume.level === 0}
<div class="media__volume">
<button
class="media__mute-button ghost"
class:media__mute-button--muted={isMuted}
disabled={!("muted" in volume)}
title={isMuted
? _("popupMediaUnmute")
: _("popupMediaMute")}
on:click={() => {
/**
* If not muted and volume is at 0, max out
* volume instead of flipping mute value.
*/
if (!volume.muted && volume.level === 0) {
dispatch("volumeChanged", {
level: 1
});
} else {
dispatch("volumeChanged", {
muted: !volume.muted
});
}
}}
/>
<input
type="range"
class="slider media__volume-slider"
aria-label={_("popupMediaVolume")}
disabled={!("level" in volume)}
step="0.05"
max={1}
value={volume.muted ? 0 : volume.level}
on:change={ev => {
dispatch("volumeChanged", {
level: ev.currentTarget.valueAsNumber
});
}}
/>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,19 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="m11 4.149 0 4.181 1.775 1.775c.3-.641.475-1.35.475-2.105a4.981 4.981 0 0 0-1.818-3.851l-.432 0z" />
<path d="M2.067 1.183a.626.626 0 0 0-.885.885L4.115 5 2 5a2 2 0 0 0-2 2l0 2a2 2 0 0 0 2 2l2.117 0 3.128 3.65C7.848 15.353 9 14.927 9 14l0-4.116 3.317 3.317c-.273.232-.56.45-.873.636a.624.624 0 0 0-.218.856.621.621 0 0 0 .856.219 7.58 7.58 0 0 0 1.122-.823l.729.729a.626.626 0 0 0 .884-.886L2.067 1.183z" />
<path d="M9 2c0-.926-1.152-1.352-1.755-.649L5.757 3.087 9 6.33 9 2z" />
<path d="M11.341 2.169a6.767 6.767 0 0 1 3.409 5.864 6.732 6.732 0 0 1-.83 3.217l.912.912A7.992 7.992 0 0 0 16 8.033a8.018 8.018 0 0 0-4.04-6.95.625.625 0 0 0-.619 1.086z" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,18 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M14.901,3.571l-4.412,3.422V1.919L6.286,5.46H4.869c-1.298,0-2.36,1.062-2.36,2.36v2.36
c0,1.062,0.708,1.888,1.652,2.242l-2.242,1.77l1.18,1.416L16.081,4.987L14.901,3.571z M10.489,16.081V11.36l-2.669,2.36
L10.489,16.081z" />
</svg>

After

Width:  |  Height:  |  Size: 788 B

View File

@@ -0,0 +1,18 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M7.245 1.35 4.117 5 2 5a2 2 0 0 0-2 2l0 2a2 2 0 0 0 2 2l2.117 0 3.128 3.65C7.848 15.353 9 14.927 9 14L9 2c0-.927-1.152-1.353-1.755-.65z" />
<path d="M11.764 15a.623.623 0 0 1-.32-1.162 6.783 6.783 0 0 0 3.306-5.805 6.767 6.767 0 0 0-3.409-5.864.624.624 0 1 1 .619-1.085A8.015 8.015 0 0 1 16 8.033a8.038 8.038 0 0 1-3.918 6.879c-.1.06-.21.088-.318.088z" />
<path d="M11.434 11.85A4.982 4.982 0 0 0 13.25 8a4.982 4.982 0 0 0-1.819-3.852l-.431 0 0 7.702.434 0z" />
</svg>

After

Width:  |  Height:  |  Size: 1011 B

View File

@@ -0,0 +1,17 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M15.996 3.995c0-.55-.386-.753-.857-.46l-6.284 3.93c-.473.295-.47.776 0 1.07l6.287 3.93c.474.296.858.08.858-.46v-8.01Z" />
<path d="M7.495 3.995c0-.55-.386-.753-.857-.46L.354 7.465c-.473.295-.47.776 0 1.07l6.287 3.93c.474.296.858.08.858-.46v-8.01Z" />
</svg>

After

Width:  |  Height:  |  Size: 797 B

View File

@@ -0,0 +1,23 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path fill-rule="evenodd" d="M16.531,16.107H5.267l1.982-2H15c0.6,0,1-0.4,1-1V5.274
l1.946-1.964C17.963,3.399,18,3.483,18,3.576v11.031C18,15.407,17.331,16.107,16.531,16.107z M14.016,8.506h-1.218l1.005-1.014
C13.913,7.789,13.984,8.128,14.016,8.506z M11.786,12.361c-0.828,0-1.476-0.326-1.913-0.902l1.09-1.101
c0.136,0.323,0.374,0.541,0.796,0.541c0.514,0,0.695-0.44,0.756-1.014h1.535C13.908,11.43,13.071,12.361,11.786,12.361z
M1.496,16.106C0.697,16.104,0,15.406,0,14.607V3.576c0-0.8,0.7-1.5,1.5-1.5h12.846L16.299,0l1.316,1.283L2.615,17.13L1.496,16.106
z M3,4.107c-0.6,0-1,0.4-1,1v8c0,0.6,0.4,1,1,1h0.029l2.031-2.16c-0.757-0.503-1.191-1.457-1.191-2.744
c0-1.936,1.069-3.14,2.428-3.14c1.357,0,2.136,0.76,2.361,2.059l3.777-4.016H3z M8.298,8.506H7.355
c-0.047-0.623-0.49-1.23-0.99-1.23c-0.561,0-1.337,0.84-1.337,1.995c0,0.674,0.381,1.427,0.95,1.702L8.298,8.506z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,24 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18">
<style>
path {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M16.531,1.984H1.5c-0.8,0-1.5,0.7-1.5,1.5v11.031c0,0.8,0.7,1.5,1.5,1.5h15.031
c0.8,0,1.469-0.7,1.469-1.5V3.484C18,2.684,17.331,1.984,16.531,1.984z
M16,13.016c0,0.6-0.4,1-1,1H3c-0.6,0-1-0.4-1-1v-8c0-0.6,0.4-1,1-1h12c0.6,0,1,0.4,1,1V13.016z
M6.426,10.807c-0.811,0-0.96-0.789-0.96-1.628c0-1.155,0.338-1.745,0.899-1.745c0.5,0,0.818,0.357,0.866,0.98
h1.484C8.585,6.877,7.785,5.972,6.297,5.972c-1.359,0-2.428,1.205-2.428,3.14c0,1.944,0.974,3.157,2.583,3.157
c1.285,0,2.153-0.93,2.295-2.476H7.244C7.183,10.367,6.94,10.807,6.426,10.807z
M11.759,10.807c-0.811,0-0.96-0.789-0.96-1.628c0-1.155,0.338-1.745,0.899-1.745c0.5,0,0.756,0.357,0.803,0.98h1.515
c-0.129-1.537-0.898-2.443-2.385-2.443c-1.359,0-2.396,1.205-2.396,3.14c0,1.944,0.943,3.157,2.552,3.157
c1.285,0,2.122-0.93,2.264-2.476h-1.535C12.454,10.367,12.273,10.807,11.759,10.807z" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<style>
path, circle {
fill: rgba(12, 12, 13, .8);
}
@media (prefers-color-scheme: dark) {
path, circle {
fill: rgba(249, 249, 250, .8);
}
}
</style>
<path d="M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Zm6 2a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2 2 2 0 0 1 2-2zm0 6a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5zm0 3a2 2 0 0 0-2 2 2 2 0 0 0 2 2 2 2 0 0 0 2-2 2 2 0 0 0-2-2z" />
</svg>

After

Width:  |  Height:  |  Size: 602 B

Some files were not shown because too many files have changed in this diff Show More